Hi
This commit is contained in:
parent
e7ad26ba33
commit
6bb60149ce
18 changed files with 3351 additions and 1 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
/target
|
||||||
|
|
||||||
|
sdk.db
|
||||||
|
sdk_server.toml
|
2334
Cargo.lock
generated
Normal file
2334
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
30
Cargo.toml
Normal file
30
Cargo.toml
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
[package]
|
||||||
|
name = "hoyo-sdk"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { version = "1.43.0", features = ["full"] }
|
||||||
|
axum = "0.8.1"
|
||||||
|
sqlx = { version = "0.8.3", features = ["sqlite", "runtime-tokio"] }
|
||||||
|
|
||||||
|
rsa = "0.9.7"
|
||||||
|
password-hash = { version = "0.5.0", features = ["alloc", "rand_core"] }
|
||||||
|
pbkdf2 = { version = "0.12.2", features = ["simple"] }
|
||||||
|
rand = "0.8.5"
|
||||||
|
|
||||||
|
serde = { version = "1.0.217", features = ["derive"] }
|
||||||
|
serde_json = "1.0.135"
|
||||||
|
toml = "0.8.19"
|
||||||
|
rbase64 = "2.0.3"
|
||||||
|
thiserror = "2.0.11"
|
||||||
|
|
||||||
|
regex = "1.11.1"
|
||||||
|
tracing = "0.1.41"
|
||||||
|
tracing-subscriber = "0.3.19"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true # Automatically strip symbols from the binary.
|
||||||
|
lto = true # Link-time optimization.
|
||||||
|
opt-level = 3 # Optimize for speed.
|
||||||
|
codegen-units = 1 # Maximum size reduction optimizations.
|
|
@ -1,3 +1,10 @@
|
||||||
# hoyo-sdk
|
# hoyo-sdk
|
||||||
|
|
||||||
Hoyo SDK server emulator
|
Portable and lightweight SDK server implementation in Rust.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
Just compile and run. The server uses sqlite, so no additional database setup required.
|
||||||
|
|
||||||
|
### Register an account
|
||||||
|
By default, you can register an account here: http://127.0.0.1:20100/account/register
|
||||||
|
(assuming you're running server with the default configuration)
|
||||||
|
|
191
html/register.html
Normal file
191
html/register.html
Normal file
|
@ -0,0 +1,191 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta http-equiv="pragma" content="no-cache" />
|
||||||
|
<meta http-equiv="cache-control" content="no-cache" />
|
||||||
|
<meta http-equiv="x-ua-compatible" content="IE=edge" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<title>Register account</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: hidden;
|
||||||
|
overflow-x: auto;
|
||||||
|
background-color: #fff;
|
||||||
|
font-size: 100px;
|
||||||
|
color: #333;
|
||||||
|
font-family:
|
||||||
|
Microsoft YaHei,
|
||||||
|
Arial,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #4ea4dc;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #6cf;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: auto;
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
position: relative;
|
||||||
|
font-size: 0.14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 0.04rem;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 70%;
|
||||||
|
margin: 0.3rem auto auto;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
font-size: 0.2rem;
|
||||||
|
line-height: 0.6rem;
|
||||||
|
text-align: center;
|
||||||
|
background-color: #4ea4dc;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .flash {
|
||||||
|
margin: 0.2rem;
|
||||||
|
padding: 0.1rem;
|
||||||
|
width: 55%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.15rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.content .flash.error {
|
||||||
|
background: #e7746b;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.content .flash.success {
|
||||||
|
background: #8bc34a;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.content .flash.warning {
|
||||||
|
background: #ff9800;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content form {
|
||||||
|
padding: 0.2rem;
|
||||||
|
width: 55%;
|
||||||
|
}
|
||||||
|
.content form input {
|
||||||
|
padding: 0 20px;
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e6e6e6;
|
||||||
|
color: #333;
|
||||||
|
outline: none;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.content form input:focus {
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.content form button {
|
||||||
|
padding: 0 20px;
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
background: #4ea4dc;
|
||||||
|
border-radius: 0.04rem;
|
||||||
|
border: 1px solid #4ea4dc;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.2rem;
|
||||||
|
}
|
||||||
|
.content form button:hover {
|
||||||
|
background-color: #6cf;
|
||||||
|
border-color: #6cf;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .crumbs {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 50%;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 1400px) {
|
||||||
|
.content .flash {
|
||||||
|
width: 75%;
|
||||||
|
}
|
||||||
|
.content form {
|
||||||
|
width: 75%;
|
||||||
|
}
|
||||||
|
.content .crumbs {
|
||||||
|
width: 65%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (max-width: 800px) {
|
||||||
|
.container {
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
.content .flash {
|
||||||
|
width: 95%;
|
||||||
|
}
|
||||||
|
.content form {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.content .crumbs {
|
||||||
|
width: 85%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root">
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">Register account</div>
|
||||||
|
<div class="content">
|
||||||
|
<form method="post">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
required=""
|
||||||
|
placeholder="Username"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
required=""
|
||||||
|
placeholder="Password"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password_v2"
|
||||||
|
required=""
|
||||||
|
placeholder="Confirm Password"
|
||||||
|
/>
|
||||||
|
<button type="submit">Register</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
171
html/result.html
Normal file
171
html/result.html
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta http-equiv="pragma" content="no-cache" />
|
||||||
|
<meta http-equiv="cache-control" content="no-cache" />
|
||||||
|
<meta http-equiv="x-ua-compatible" content="IE=edge" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<title>Register account</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: hidden;
|
||||||
|
overflow-x: auto;
|
||||||
|
background-color: #fff;
|
||||||
|
font-size: 100px;
|
||||||
|
color: #333;
|
||||||
|
font-family:
|
||||||
|
Microsoft YaHei,
|
||||||
|
Arial,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #4ea4dc;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #6cf;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: auto;
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
position: relative;
|
||||||
|
font-size: 0.14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 0.04rem;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 70%;
|
||||||
|
margin: 0.3rem auto auto;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
font-size: 0.2rem;
|
||||||
|
line-height: 0.6rem;
|
||||||
|
text-align: center;
|
||||||
|
background-color: #4ea4dc;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .flash {
|
||||||
|
margin: 0.2rem;
|
||||||
|
padding: 0.1rem;
|
||||||
|
width: 55%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.15rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.content .flash.error {
|
||||||
|
background: #e7746b;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.content .flash.success {
|
||||||
|
background: #8bc34a;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.content .flash.warning {
|
||||||
|
background: #ff9800;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content form {
|
||||||
|
padding: 0.2rem;
|
||||||
|
width: 55%;
|
||||||
|
}
|
||||||
|
.content form input {
|
||||||
|
padding: 0 20px;
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e6e6e6;
|
||||||
|
color: #333;
|
||||||
|
outline: none;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.content form input:focus {
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.content form button {
|
||||||
|
padding: 0 20px;
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
background: #4ea4dc;
|
||||||
|
border-radius: 0.04rem;
|
||||||
|
border: 1px solid #4ea4dc;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.2rem;
|
||||||
|
}
|
||||||
|
.content form button:hover {
|
||||||
|
background-color: #6cf;
|
||||||
|
border-color: #6cf;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .crumbs {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 50%;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 1400px) {
|
||||||
|
.content .flash {
|
||||||
|
width: 75%;
|
||||||
|
}
|
||||||
|
.content form {
|
||||||
|
width: 75%;
|
||||||
|
}
|
||||||
|
.content .crumbs {
|
||||||
|
width: 65%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (max-width: 800px) {
|
||||||
|
.container {
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
.content .flash {
|
||||||
|
width: 95%;
|
||||||
|
}
|
||||||
|
.content form {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.content .crumbs {
|
||||||
|
width: 85%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root">
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">Register account</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="flash %RESULT%">%MESSAGE%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
BIN
rsa/private_key.der
Normal file
BIN
rsa/private_key.der
Normal file
Binary file not shown.
2
sdk.default.toml
Normal file
2
sdk.default.toml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
http_addr = "127.0.0.1:20100"
|
||||||
|
db_file = "sdk.db"
|
19
src/config.rs
Normal file
19
src/config.rs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
const DEFAULT: &str = include_str!("../sdk.default.toml");
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SdkConfig {
|
||||||
|
pub http_addr: String,
|
||||||
|
pub db_file: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_or_create(path: &str) -> SdkConfig {
|
||||||
|
std::fs::read_to_string(path).map_or_else(
|
||||||
|
|_| {
|
||||||
|
std::fs::write(path, DEFAULT).unwrap();
|
||||||
|
toml::from_str(DEFAULT).unwrap()
|
||||||
|
},
|
||||||
|
|data| toml::from_str(&data).unwrap(),
|
||||||
|
)
|
||||||
|
}
|
83
src/database/mod.rs
Normal file
83
src/database/mod.rs
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
use rand::distributions::{Alphanumeric, DistString};
|
||||||
|
use schema::{DbSdkAccountRow, Password, Username};
|
||||||
|
use sqlx::{migrate::MigrateDatabase, Sqlite, SqlitePool};
|
||||||
|
|
||||||
|
pub mod schema;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct DbContext(SqlitePool);
|
||||||
|
type Result<T> = std::result::Result<T, sqlx::Error>;
|
||||||
|
|
||||||
|
impl DbContext {
|
||||||
|
pub async fn connect(db_file: &str) -> Result<Self> {
|
||||||
|
let db_url = format!("sqlite://{db_file}");
|
||||||
|
|
||||||
|
if !Sqlite::database_exists(&db_url).await.unwrap_or(false) {
|
||||||
|
Sqlite::create_database(&db_url).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = Self(SqlitePool::connect(&db_url).await?);
|
||||||
|
db.prepare_tables().await?;
|
||||||
|
|
||||||
|
Ok(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prepare_tables(&self) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS t_sdk_account (
|
||||||
|
uid INTEGER PRIMARY KEY,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(&self.0)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_account(
|
||||||
|
&self,
|
||||||
|
username: Username,
|
||||||
|
password: Password,
|
||||||
|
) -> Result<Option<DbSdkAccountRow>> {
|
||||||
|
if self
|
||||||
|
.get_account_by_name(username.as_str().to_string())
|
||||||
|
.await?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = Alphanumeric.sample_string(&mut rand::thread_rng(), 64);
|
||||||
|
Ok(Some(sqlx::query_as(
|
||||||
|
"INSERT INTO t_sdk_account (token, username, password) VALUES ($1, $2, $3) RETURNING *",
|
||||||
|
)
|
||||||
|
.bind(token)
|
||||||
|
.bind(username.as_str())
|
||||||
|
.bind(password.as_hash_str())
|
||||||
|
.fetch_one(&self.0)
|
||||||
|
.await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_account_by_name(&self, username: String) -> Result<Option<DbSdkAccountRow>> {
|
||||||
|
const SELECT_BY_NAME_QUERY: &str = r#"SELECT * FROM t_sdk_account WHERE username = ($1)"#;
|
||||||
|
|
||||||
|
sqlx::query_as(SELECT_BY_NAME_QUERY)
|
||||||
|
.bind(&username)
|
||||||
|
.fetch_optional(&self.0)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_account_by_uid(&self, uid: i32) -> Result<Option<DbSdkAccountRow>> {
|
||||||
|
const SELECT_BY_UID_QUERY: &str = r#"SELECT * FROM t_sdk_account WHERE uid = ($1)"#;
|
||||||
|
|
||||||
|
sqlx::query_as(SELECT_BY_UID_QUERY)
|
||||||
|
.bind(uid)
|
||||||
|
.fetch_optional(&self.0)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
90
src/database/schema.rs
Normal file
90
src/database/schema.rs
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::util;
|
||||||
|
|
||||||
|
#[derive(sqlx::Encode, sqlx::Decode)]
|
||||||
|
pub struct Username(String);
|
||||||
|
|
||||||
|
impl Username {
|
||||||
|
pub fn parse(username: String) -> Option<Self> {
|
||||||
|
static ALLOWED_USERNAME_REGEX: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new("^[a-zA-Z0-9._@-]{6,25}$").unwrap());
|
||||||
|
|
||||||
|
ALLOWED_USERNAME_REGEX
|
||||||
|
.is_match(&username)
|
||||||
|
.then_some(Self(username))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
self.0.as_str()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl sqlx::Type<sqlx::Sqlite> for Username {
|
||||||
|
fn type_info() -> <sqlx::Sqlite as sqlx::Database>::TypeInfo {
|
||||||
|
<String as sqlx::Type<sqlx::Sqlite>>::type_info()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compatible(ty: &<sqlx::Sqlite as sqlx::Database>::TypeInfo) -> bool {
|
||||||
|
<String as sqlx::Type<sqlx::Sqlite>>::compatible(ty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum PasswordError {
|
||||||
|
#[error("password pair mismatch")]
|
||||||
|
PairMismatch,
|
||||||
|
#[error("user input doesn't meet requirements")]
|
||||||
|
RequirementsMismatch,
|
||||||
|
#[error("failed to generate password hash: {0}")]
|
||||||
|
HashFailed(pbkdf2::password_hash::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::Encode, sqlx::Decode)]
|
||||||
|
pub struct Password(String);
|
||||||
|
|
||||||
|
impl Password {
|
||||||
|
pub fn new(password: String, password_v2: String) -> Result<Self, PasswordError> {
|
||||||
|
(password == password_v2)
|
||||||
|
.then_some(())
|
||||||
|
.ok_or(PasswordError::PairMismatch)?;
|
||||||
|
|
||||||
|
matches!(password.len(), 8..30)
|
||||||
|
.then_some(())
|
||||||
|
.ok_or(PasswordError::RequirementsMismatch)?;
|
||||||
|
|
||||||
|
let hash = util::hash_string(&password).map_err(|err| PasswordError::HashFailed(err))?;
|
||||||
|
Ok(Self(hash))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify(&self, password: &str) -> bool {
|
||||||
|
util::verify_hash(password, &self.0).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_hash_str(&self) -> &str {
|
||||||
|
self.0.as_str()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl sqlx::Type<sqlx::Sqlite> for Password {
|
||||||
|
fn type_info() -> <sqlx::Sqlite as sqlx::Database>::TypeInfo {
|
||||||
|
<String as sqlx::Type<sqlx::Sqlite>>::type_info()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compatible(ty: &<sqlx::Sqlite as sqlx::Database>::TypeInfo) -> bool {
|
||||||
|
<String as sqlx::Type<sqlx::Sqlite>>::compatible(ty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow)]
|
||||||
|
pub struct DbSdkAccountRow {
|
||||||
|
pub uid: i32,
|
||||||
|
pub token: String,
|
||||||
|
pub username: Username,
|
||||||
|
pub password: Password,
|
||||||
|
}
|
60
src/handlers/combo_granter.rs
Normal file
60
src/handlers/combo_granter.rs
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RequestData {
|
||||||
|
pub uid: String,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GranterTokenRequest {
|
||||||
|
pub data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppStateRef> {
|
||||||
|
Router::new().route(
|
||||||
|
"/{product_name}/combo/granter/login/v2/login",
|
||||||
|
post(login_v2),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ResponseData {
|
||||||
|
pub account_type: u32,
|
||||||
|
pub combo_id: String,
|
||||||
|
pub combo_token: String,
|
||||||
|
pub data: &'static str,
|
||||||
|
pub heartbeat: bool,
|
||||||
|
pub open_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login_v2(
|
||||||
|
state: State<AppStateRef>,
|
||||||
|
request: Json<GranterTokenRequest>,
|
||||||
|
) -> Json<Response<ResponseData>> {
|
||||||
|
let Ok(data) = serde_json::from_str::<RequestData>(&request.data) else {
|
||||||
|
return Json(Response::error(-101, "Account token error"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(uid) = data.uid.parse() else {
|
||||||
|
return Json(Response::error(-101, "Account token error"));
|
||||||
|
};
|
||||||
|
|
||||||
|
match state.db.get_account_by_uid(uid).await {
|
||||||
|
Ok(Some(account)) if account.token == data.token => {
|
||||||
|
success_rsp(data.uid.clone(), account.token)
|
||||||
|
}
|
||||||
|
_ => Json(Response::error(-101, "Account token error")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn success_rsp(uid: String, token: String) -> Json<Response<ResponseData>> {
|
||||||
|
Json(Response::new(ResponseData {
|
||||||
|
account_type: 1,
|
||||||
|
combo_id: uid.clone(),
|
||||||
|
combo_token: token,
|
||||||
|
data: r#"{"guest":false}"#,
|
||||||
|
heartbeat: false,
|
||||||
|
open_id: uid,
|
||||||
|
}))
|
||||||
|
}
|
117
src/handlers/mdk_shield_api.rs
Normal file
117
src/handlers/mdk_shield_api.rs
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppStateRef> {
|
||||||
|
Router::new()
|
||||||
|
.route("/{product_name}/mdk/shield/api/login", post(login))
|
||||||
|
.route("/{product_name}/mdk/shield/api/verify", post(verify))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LoginRequest {
|
||||||
|
pub account: String,
|
||||||
|
pub password: String,
|
||||||
|
pub is_crypto: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct VerifyRequest {
|
||||||
|
pub uid: String,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Default)]
|
||||||
|
struct ResponseData {
|
||||||
|
pub account: ResponseAccountData,
|
||||||
|
pub device_grant_required: bool,
|
||||||
|
pub reactive_required: bool,
|
||||||
|
pub realperson_required: bool,
|
||||||
|
pub safe_mobile_required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Default)]
|
||||||
|
struct ResponseAccountData {
|
||||||
|
pub area_code: String,
|
||||||
|
pub email: String,
|
||||||
|
pub country: String,
|
||||||
|
pub is_email_verify: String,
|
||||||
|
pub token: String,
|
||||||
|
pub uid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login(
|
||||||
|
state: State<AppStateRef>,
|
||||||
|
request: Json<LoginRequest>,
|
||||||
|
) -> Json<Response<ResponseData>> {
|
||||||
|
if !request.is_crypto {
|
||||||
|
return Json(Response::error(
|
||||||
|
-10,
|
||||||
|
"Invalid account format: unencrypted passwords are disabled by SDK security policy",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(password) = crate::util::rsa_decrypt(&request.password) else {
|
||||||
|
return Json(Response::error(-10, "Your patch is outdated, get a new one at https://discord.gg/reversedrooms (Password decryption failed)"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let account = match state.db.get_account_by_name(request.account.clone()).await {
|
||||||
|
Ok(Some(account)) => account,
|
||||||
|
Ok(None) => return Json(Response::error(-101, "Account or password error")),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!("database error: {err}");
|
||||||
|
return Json(Response::error(-1, "Internal server error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !account.password.verify(&password) {
|
||||||
|
return Json(Response::error(-101, "Account or password error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(Response::new(ResponseData {
|
||||||
|
account: ResponseAccountData {
|
||||||
|
area_code: String::from("**"),
|
||||||
|
email: account.username.as_str().to_string(),
|
||||||
|
country: String::from("RU"),
|
||||||
|
is_email_verify: String::from("1"),
|
||||||
|
uid: account.uid.to_string(),
|
||||||
|
token: account.token,
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify(
|
||||||
|
state: State<AppStateRef>,
|
||||||
|
request: Json<VerifyRequest>,
|
||||||
|
) -> Json<Response<ResponseData>> {
|
||||||
|
let Ok(uid) = request.uid.parse() else {
|
||||||
|
return Json(Response::error(-101, "Account cache error"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let account = match state.db.get_account_by_uid(uid).await {
|
||||||
|
Ok(Some(account)) => account,
|
||||||
|
Ok(None) => return Json(Response::error(-101, "Account cache error")),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!("database error: {err}");
|
||||||
|
return Json(Response::error(-1, "Internal server error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if account.token == request.token {
|
||||||
|
Json(Response::new(ResponseData {
|
||||||
|
account: ResponseAccountData {
|
||||||
|
area_code: String::from("**"),
|
||||||
|
email: account.username.as_str().to_string(),
|
||||||
|
country: String::from("RU"),
|
||||||
|
is_email_verify: String::from("1"),
|
||||||
|
uid: account.uid.to_string(),
|
||||||
|
token: account.token,
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
Json(Response::error(
|
||||||
|
-101,
|
||||||
|
"For account safety, please log in again",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
38
src/handlers/mod.rs
Normal file
38
src/handlers/mod.rs
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
use crate::AppStateRef;
|
||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
response::Html,
|
||||||
|
routing::{get, post},
|
||||||
|
Form, Json, Router,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub mod combo_granter;
|
||||||
|
pub mod mdk_shield_api;
|
||||||
|
pub mod register;
|
||||||
|
pub mod risky_api;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Response<T> {
|
||||||
|
data: Option<T>,
|
||||||
|
message: String,
|
||||||
|
retcode: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Response<T> {
|
||||||
|
pub fn new(data: T) -> Self {
|
||||||
|
Self {
|
||||||
|
data: Some(data),
|
||||||
|
message: String::from("OK"),
|
||||||
|
retcode: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error(retcode: i32, message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
data: None,
|
||||||
|
message: message.to_string(),
|
||||||
|
retcode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
82
src/handlers/register.rs
Normal file
82
src/handlers/register.rs
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
use super::*;
|
||||||
|
use crate::database::schema::{Password, PasswordError, Username};
|
||||||
|
|
||||||
|
const REGISTER_PAGE: Html<&str> = Html(include_str!("../../html/register.html"));
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppStateRef> {
|
||||||
|
Router::new()
|
||||||
|
.route("/account/register", get(|| async { REGISTER_PAGE }))
|
||||||
|
.route("/account/register", post(process_register_request))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RegisterForm {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub password_v2: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_register_request(
|
||||||
|
State(state): State<AppStateRef>,
|
||||||
|
Form(form): Form<RegisterForm>,
|
||||||
|
) -> Html<String> {
|
||||||
|
let Some(username) = Username::parse(form.username) else {
|
||||||
|
return html_result(ResultStatus::Failure, "Invalid username format; should consists of characters [A-Za-z0-9_] and be at least 6 characters long.");
|
||||||
|
};
|
||||||
|
|
||||||
|
let password = match Password::new(form.password, form.password_v2) {
|
||||||
|
Ok(password) => password,
|
||||||
|
Err(PasswordError::PairMismatch) => {
|
||||||
|
return html_result(ResultStatus::Failure, "Passwords pair doesn't match")
|
||||||
|
}
|
||||||
|
Err(PasswordError::RequirementsMismatch) => {
|
||||||
|
return html_result(
|
||||||
|
ResultStatus::Failure,
|
||||||
|
"Password should contain at least 8 and not more than 30 characters",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(PasswordError::HashFailed(err)) => {
|
||||||
|
tracing::error!("failed to hash password, err: {err}");
|
||||||
|
return html_result(ResultStatus::Failure, "Internal server error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match state.db.create_account(username, password).await {
|
||||||
|
Ok(Some(_)) => html_result(
|
||||||
|
ResultStatus::Success,
|
||||||
|
"Account successfully registered, now you can use in-game login",
|
||||||
|
),
|
||||||
|
Ok(None) => html_result(
|
||||||
|
ResultStatus::Failure,
|
||||||
|
"Account with specified username already exists",
|
||||||
|
),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!("database operation error: {err}");
|
||||||
|
html_result(ResultStatus::Failure, "Internal server error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ResultStatus {
|
||||||
|
Failure,
|
||||||
|
Success,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResultStatus {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Failure => "error",
|
||||||
|
Self::Success => "success",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn html_result(result: ResultStatus, message: &str) -> Html<String> {
|
||||||
|
static RESULT_HTML: &str = include_str!("../../html/result.html");
|
||||||
|
|
||||||
|
Html(
|
||||||
|
RESULT_HTML
|
||||||
|
.replace("%RESULT%", result.as_str())
|
||||||
|
.replace("%MESSAGE%", message),
|
||||||
|
)
|
||||||
|
}
|
9
src/handlers/risky_api.rs
Normal file
9
src/handlers/risky_api.rs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppStateRef> {
|
||||||
|
Router::new().route("/account/risky/api/check", post(risky_api_check))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn risky_api_check(_: String) -> &'static str {
|
||||||
|
r#"{"data":{},message:"OK",retcode:0}"#
|
||||||
|
}
|
67
src/main.rs
Normal file
67
src/main.rs
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
use std::{
|
||||||
|
process::ExitCode,
|
||||||
|
sync::{LazyLock, OnceLock},
|
||||||
|
};
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use config::SdkConfig;
|
||||||
|
use database::DbContext;
|
||||||
|
use handlers::{combo_granter, mdk_shield_api, register, risky_api};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod database;
|
||||||
|
mod handlers;
|
||||||
|
mod util;
|
||||||
|
|
||||||
|
struct AppState {
|
||||||
|
db: DbContext,
|
||||||
|
#[expect(dead_code)]
|
||||||
|
config: &'static SdkConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppStateRef = &'static AppState;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> ExitCode {
|
||||||
|
static CONFIG: LazyLock<SdkConfig> =
|
||||||
|
LazyLock::new(|| config::load_or_create("sdk_server.toml"));
|
||||||
|
static STATE: OnceLock<AppState> = OnceLock::new();
|
||||||
|
|
||||||
|
println!("
|
||||||
|
_____ _ _____ \n | __ \\ | | __ \\ \n | |__) |_____ _____ _ __ ___ ___ __| | |__) |___ ___ _ __ ___ ___ \n | _ // _ \\ \\ / / _ \\ '__/ __|/ _ \\/ _` | _ // _ \\ / _ \\| '_ ` _ \\/ __|\n | | \\ \\ __/\\ V / __/ | \\__ \\ __/ (_| | | \\ \\ (_) | (_) | | | | | \\__ \\\n |_| \\_\\___| \\_/ \\___|_| |___/\\___|\\__,_|_| \\_\\___/ \\___/|_| |_| |_|___/");
|
||||||
|
|
||||||
|
init_tracing();
|
||||||
|
let db = match DbContext::connect(&CONFIG.db_file).await {
|
||||||
|
Ok(db) => db,
|
||||||
|
Err(err) => {
|
||||||
|
error!("Failed to open SQLite database. Error: {err}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = STATE.set(AppState {
|
||||||
|
db,
|
||||||
|
config: &CONFIG,
|
||||||
|
});
|
||||||
|
|
||||||
|
let router = Router::new()
|
||||||
|
.merge(risky_api::routes())
|
||||||
|
.merge(register::routes())
|
||||||
|
.merge(mdk_shield_api::routes())
|
||||||
|
.merge(combo_granter::routes())
|
||||||
|
.with_state(STATE.get().unwrap());
|
||||||
|
|
||||||
|
let listener = TcpListener::bind(&CONFIG.http_addr)
|
||||||
|
.await
|
||||||
|
.expect("TcpListener::bind failed. Is another instance of this server already running?");
|
||||||
|
|
||||||
|
axum::serve(listener, router).await.unwrap();
|
||||||
|
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_tracing() {
|
||||||
|
tracing_subscriber::fmt().without_time().init();
|
||||||
|
}
|
46
src/util.rs
Normal file
46
src/util.rs
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
use rsa::{Pkcs1v15Encrypt, RsaPrivateKey};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use password_hash::{PasswordHash, PasswordHasher, SaltString};
|
||||||
|
use pbkdf2::Pbkdf2;
|
||||||
|
const SDK_PRIVATE_KEY: &[u8] = include_bytes!("../rsa/private_key.der");
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum CryptoError {
|
||||||
|
#[error("failed to decrypt: {0}")]
|
||||||
|
Decrypt(#[from] rsa::Error),
|
||||||
|
#[error("failed to decode base64 string")]
|
||||||
|
FromBase64,
|
||||||
|
#[error("from_utf8 failed: {0}")]
|
||||||
|
FromUtf8(#[from] std::string::FromUtf8Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rsa_decrypt(cipher: &str) -> Result<String, CryptoError> {
|
||||||
|
let private_key: RsaPrivateKey = rsa::pkcs8::DecodePrivateKey::from_pkcs8_der(SDK_PRIVATE_KEY)
|
||||||
|
.expect("failed to decode private key");
|
||||||
|
let payload = private_key.decrypt(
|
||||||
|
Pkcs1v15Encrypt,
|
||||||
|
&rbase64::decode(cipher).map_err(|_| CryptoError::FromBase64)?,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(String::from_utf8(payload)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hash_string(content: &str) -> Result<String, pbkdf2::password_hash::Error> {
|
||||||
|
const HASH_PARAMS: pbkdf2::Params = pbkdf2::Params {
|
||||||
|
rounds: 10_000,
|
||||||
|
output_length: 32,
|
||||||
|
};
|
||||||
|
|
||||||
|
let salt = SaltString::generate(rand::thread_rng());
|
||||||
|
let hash =
|
||||||
|
Pbkdf2.hash_password_customized(content.as_bytes(), None, None, HASH_PARAMS, &salt)?;
|
||||||
|
|
||||||
|
Ok(hash.serialize().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn verify_hash(content: &str, hash_str: &str) -> Option<()> {
|
||||||
|
let hash = PasswordHash::new(hash_str).ok()?;
|
||||||
|
hash.verify_password(&[&Pbkdf2], content).ok()
|
||||||
|
}
|
Loading…
Reference in a new issue