feat: a bunch of things
This commit is contained in:
parent
2cd0311a20
commit
2e2b01990e
20 changed files with 16216 additions and 137 deletions
1378
Cargo.lock
generated
1378
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
13
Cargo.toml
13
Cargo.toml
|
|
@ -10,4 +10,15 @@ log = "0.4.26"
|
|||
quick-xml = { version = "0.37.2", features = ["serialize"] }
|
||||
tokio = "1.43.0"
|
||||
dotenvy = "0.15.7"
|
||||
diesel = { version = "2.2.7", features = ["mysql"] }
|
||||
once_cell = "1.20.3"
|
||||
serde_json = "1.0.139"
|
||||
chrono = { version = "0.4.39", features = ["serde"] }
|
||||
rust-s3 = "0.35.1"
|
||||
argon2 = "0.5.3"
|
||||
sha2 = "0.10.8"
|
||||
bytemuck = "1.21.0"
|
||||
base64 = "0.22.1"
|
||||
hex = "0.4.3"
|
||||
thiserror = "2.0.11"
|
||||
sqlx = { version = "0.8", features = [ "runtime-tokio", "tls-native-tls", "postgres" ] }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
# For documentation on how to configure this file,
|
||||
# see https://diesel.rs/guides/configuring-diesel-cli
|
||||
|
||||
[print_schema]
|
||||
file = "src/db.rs"
|
||||
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
|
||||
|
||||
[migrations_directory]
|
||||
dir = "/home/tv/RustProjects/account/migrations"
|
||||
1
res/agreement/DE.xml
Normal file
1
res/agreement/DE.xml
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0"?><agreements><agreement><country>US</country><language>en</language><language_name>English</language_name><publish_date>2014-09-29T20:07:35</publish_date><texts xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="chunkedStoredAgreementText"><main_title><![CDATA[Splatfestival Network Services Agreement]]></main_title><agree_text><![CDATA[I Accept]]></agree_text><non_agree_text><![CDATA[I Decline]]></non_agree_text><main_text index="1"><![CDATA[Welcome to the Splatfestival Network! If you are seeing this, you have correctly installed the environment! Please note that we have rules to follow. You only have one warning. The rules are the following: Do not harrass people. Do not advertise your own servers and such. Do not use ANY hacks. That includes Silverlight, you will be banned. Do not try and stress test the server. Do not DDoS the server. If we detect a pirated copy of Splatoon, you will be banned without appeal. Do not impersonate staff members. If you have any questions, please contact andrea (at) perditum (dot) com.]]></main_text><sub_title><![CDATA[SPFN Privacy Policy]]></sub_title><sub_text index="1"><![CDATA[Please note that we will store the following: Email Address, IP Address, birthdate and timezone. These are required for the following purposes: Email is required to validate you as a real person. It will only be stored for the purpose of sending you a validation email. Your IP address is required to make sure you do not bypass any bans and store your current connection to the server so that you cannot connect twice. Your birthdate is required to make sure you are old enough to access our services and your timezone is required to have a valid created date for your account.]]></sub_text></texts><type>NINTENDO-NETWORK-EULA</type><version>0300</version></agreement><agreement><country>US</country><language>en</language><language_name>Español</language_name><publish_date>2014-09-29T20:07:35</publish_date><texts xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="chunkedStoredAgreementText"><main_title><![CDATA[Acuerdo de servicios de red de Splatfestival]]></main_title><agree_text><![CDATA[I Accept]]></agree_text><non_agree_text><![CDATA[I Decline]]></non_agree_text><main_text index="1"><![CDATA[¡Bienvenido a la red Splatfestival! Si ves esto, ¡has instalado el entorno correctamente! Ten en cuenta que tenemos reglas que seguir. Solo tienes una advertencia. Las reglas son las siguientes: No acoses a la gente. No hagas publicidad de tus propios servidores ni nada parecido. No uses NINGÚN hack. Eso incluye Silverlight, serás baneado. No intentes poner a prueba el servidor. No hagas DDoS en el servidor. Si detectamos una copia pirateada de Splatoon, serás baneado sin posibilidad de apelación. No te hagas pasar por miembros del personal. Si tienes alguna pregunta, ponte en contacto con toskaandrea (at) gmail (dot) com.]]></main_text><sub_title><![CDATA[Política de Privacidad]]></sub_title><sub_text index="1"><![CDATA[Tenga en cuenta que almacenaremos lo siguiente: d* Connection #0 to host account.spfn.cc left intactirección de correo electrónico, dirección IP, fecha de nacimiento y zona horaria. Estos son necesarios para los siguientes fines: el correo electrónico es necesario para validarlo como una persona real. Solo se almacenará con el fin de enviarle un correo electrónico de validación. Su dirección IP es necesaria para asegurarnos de que no eluda ninguna prohibición y para almacenar su conexión actual al servidor para que no pueda conectarse dos veces. Su fecha de nacimiento es necesaria para asegurarnos de que tiene la edad suficiente para acceder a nuestros servicios y su zona horaria es necesaria para tener una fecha de creación válida para su cuenta.]]></sub_text></texts><type>NINTENDO-NETWORK-EULA</type><version>0300</version></agreement></agreements>
|
||||
1
res/agreement/DEFAULT.xml
Normal file
1
res/agreement/DEFAULT.xml
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0"?><agreements><agreement><country>US</country><language>en</language><language_name>English</language_name><publish_date>2014-09-29T20:07:35</publish_date><texts xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="chunkedStoredAgreementText"><main_title><![CDATA[Splatfestival Network Services Agreement]]></main_title><agree_text><![CDATA[I Accept]]></agree_text><non_agree_text><![CDATA[I Decline]]></non_agree_text><main_text index="1"><![CDATA[Welcome to the Splatfestival Network! If you are seeing this, you have correctly installed the environment! Please note that we have rules to follow. You only have one warning. The rules are the following: Do not harrass people. Do not advertise your own servers and such. Do not use ANY hacks. That includes Silverlight, you will be banned. Do not try and stress test the server. Do not DDoS the server. If we detect a pirated copy of Splatoon, you will be banned without appeal. Do not impersonate staff members. If you have any questions, please contact andrea (at) perditum (dot) com.]]></main_text><sub_title><![CDATA[SPFN Privacy Policy]]></sub_title><sub_text index="1"><![CDATA[Please note that we will store the following: Email Address, IP Address, birthdate and timezone. These are required for the following purposes: Email is required to validate you as a real person. It will only be stored for the purpose of sending you a validation email. Your IP address is required to make sure you do not bypass any bans and store your current connection to the server so that you cannot connect twice. Your birthdate is required to make sure you are old enough to access our services and your timezone is required to have a valid created date for your account.]]></sub_text></texts><type>NINTENDO-NETWORK-EULA</type><version>0300</version></agreement><agreement><country>US</country><language>en</language><language_name>Español</language_name><publish_date>2014-09-29T20:07:35</publish_date><texts xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="chunkedStoredAgreementText"><main_title><![CDATA[Acuerdo de servicios de red de Splatfestival]]></main_title><agree_text><![CDATA[I Accept]]></agree_text><non_agree_text><![CDATA[I Decline]]></non_agree_text><main_text index="1"><![CDATA[¡Bienvenido a la red Splatfestival! Si ves esto, ¡has instalado el entorno correctamente! Ten en cuenta que tenemos reglas que seguir. Solo tienes una advertencia. Las reglas son las siguientes: No acoses a la gente. No hagas publicidad de tus propios servidores ni nada parecido. No uses NINGÚN hack. Eso incluye Silverlight, serás baneado. No intentes poner a prueba el servidor. No hagas DDoS en el servidor. Si detectamos una copia pirateada de Splatoon, serás baneado sin posibilidad de apelación. No te hagas pasar por miembros del personal. Si tienes alguna pregunta, ponte en contacto con toskaandrea (at) gmail (dot) com.]]></main_text><sub_title><![CDATA[Política de Privacidad]]></sub_title><sub_text index="1"><![CDATA[Tenga en cuenta que almacenaremos lo siguiente: d* Connection #0 to host account.spfn.cc left intactirección de correo electrónico, dirección IP, fecha de nacimiento y zona horaria. Estos son necesarios para los siguientes fines: el correo electrónico es necesario para validarlo como una persona real. Solo se almacenará con el fin de enviarle un correo electrónico de validación. Su dirección IP es necesaria para asegurarnos de que no eluda ninguna prohibición y para almacenar su conexión actual al servidor para que no pueda conectarse dos veces. Su fecha de nacimiento es necesaria para asegurarnos de que tiene la edad suficiente para acceder a nuestros servicios y su zona horaria es necesaria para tener una fecha de creación válida para su cuenta.]]></sub_text></texts><type>NINTENDO-NETWORK-EULA</type><version>0300</version></agreement></agreements>
|
||||
14121
res/timezones.json
Normal file
14121
res/timezones.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,158 @@
|
|||
use diesel::{Queryable, Selectable};
|
||||
use std::io::Write;
|
||||
use argon2::{Algorithm, Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use argon2::password_hash::rand_core::OsRng;
|
||||
use argon2::password_hash::SaltString;
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use bytemuck::bytes_of;
|
||||
use log::{error, warn};
|
||||
use rocket::http::Status;
|
||||
use rocket::{async_trait, Request};
|
||||
use rocket::request::{FromRequest, Outcome};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sha2::digest::FixedOutput;
|
||||
use crate::error::{Error, Errors};
|
||||
use crate::Pool;
|
||||
|
||||
#[derive(Queryable, Selectable)]
|
||||
#[diesel(table_name = crate::db::user)]
|
||||
#[diesel(check_for_backend(diesel::mysql::Mysql))]
|
||||
macro_rules! request_try {
|
||||
($expression:expr) => {
|
||||
match $expression{
|
||||
Ok(v) => v,
|
||||
Err(e) => return Outcome::Error((Status::BadRequest, e))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const INVALID_TOKEN_ERRORS: Errors<'static> = Errors{
|
||||
error: &[
|
||||
Error{
|
||||
message: "Invalid access token",
|
||||
code: "0005"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// optimization note: add token caching
|
||||
pub struct User {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub hashed_pw: String
|
||||
pub password: String,
|
||||
pub birthdate: chrono::NaiveDate,
|
||||
pub timezone: String,
|
||||
pub email: String,
|
||||
pub gender: String,
|
||||
pub country: String,
|
||||
pub language: String,
|
||||
pub marketing_allowed: bool,
|
||||
pub region: i32,
|
||||
pub mii_identifier: String
|
||||
}
|
||||
|
||||
fn generate_nintendo_hash(pid: i32, text_password: &str) -> String{
|
||||
let mut sha = Sha256::new();
|
||||
|
||||
sha.write_all(&bytes_of(&pid)).unwrap();
|
||||
sha.write_all(&[0x02, 0x65, 0x43 ,0x46]).unwrap();
|
||||
sha.write_all(text_password.as_bytes()).unwrap();
|
||||
|
||||
hex::encode(&sha.finalize()[..])
|
||||
}
|
||||
|
||||
impl User{
|
||||
fn generate_nintendo_hash(&self, text_password: &str) -> String{
|
||||
generate_nintendo_hash(self.id, text_password)
|
||||
}
|
||||
|
||||
pub fn verify_password(&self, cleartext_password: &str) -> (bool, bool){
|
||||
let Ok(hash) = PasswordHash::new(&self.password) else {
|
||||
error!("invalid password in database for user with pid: {}", self.id);
|
||||
|
||||
if self.password == self.generate_nintendo_hash(cleartext_password){
|
||||
return (true, true)
|
||||
}
|
||||
|
||||
return (false, false)
|
||||
};
|
||||
|
||||
let argon = Argon2::default();
|
||||
|
||||
(argon.verify_password(cleartext_password.as_bytes(), &hash).is_ok(), false)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_password(pid: i32, cleartext_password: &str) -> Option<String>{
|
||||
let password = generate_nintendo_hash(pid, cleartext_password);
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon = Argon2::default();
|
||||
|
||||
let pw = argon.hash_password(password.as_bytes(), &salt).ok()?;
|
||||
|
||||
Some(pw.to_string())
|
||||
}
|
||||
|
||||
|
||||
pub async fn read_basic_auth_token(connection: &mut AsyncMysqlConnection, token: &str) -> Option<User> {
|
||||
let data = BASE64_STANDARD.decode(&token).ok()?;
|
||||
|
||||
let decoded_basic_token = String::from_utf8(data).ok()?;
|
||||
|
||||
let (login_username, login_password) = decoded_basic_token.split_once(' ')?;
|
||||
|
||||
|
||||
|
||||
let mut user: User = users
|
||||
.filter(name.eq(login_username))
|
||||
.select(User::as_select())
|
||||
.first(connection)
|
||||
.await.ok()?;
|
||||
|
||||
let (password_valid, upgrade_password) = user.verify_password(&login_password);
|
||||
|
||||
if password_valid{
|
||||
if upgrade_password{
|
||||
user.password = generate_password(&login_password).unwrap();
|
||||
|
||||
user = connection.update_and_fetch(&user).await.ok()?;
|
||||
}
|
||||
|
||||
Some(user)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_bearer_auth_token(connection: &mut AsyncMysqlConnection, token: &str) -> Option<User> {
|
||||
let data = BASE64_STANDARD.decode(&token).ok()?;
|
||||
|
||||
warn!("bearer token login currently unsupported");
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<'r> FromRequest<'r> for User{
|
||||
type Error = Errors<'static>;
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let pool: &Pool = request.rocket().state().unwrap();
|
||||
|
||||
let mut connection = pool.get().await.unwrap();
|
||||
|
||||
let auth = request_try!(request.headers().get("Authorization").next().ok_or(INVALID_TOKEN_ERRORS));
|
||||
|
||||
let (auth_type, token) = request_try!(auth.split_once(' ').ok_or(INVALID_TOKEN_ERRORS));
|
||||
|
||||
let user = match auth_type{
|
||||
"Basic" => read_basic_auth_token(&mut connection, token).await,
|
||||
"Bearer" => read_bearer_auth_token(&mut connection, token).await,
|
||||
_ => return Outcome::Error((Status::BadRequest, INVALID_TOKEN_ERRORS)),
|
||||
};
|
||||
|
||||
let Some(user) = user else {
|
||||
return Outcome::Error((Status::BadRequest, INVALID_TOKEN_ERRORS));
|
||||
};
|
||||
|
||||
Outcome::Success(user)
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
mod account;
|
||||
pub mod account;
|
||||
16
src/dsresponse.rs
Normal file
16
src/dsresponse.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
use std::marker::PhantomData;
|
||||
use rocket::{Request, Response};
|
||||
use rocket::http::{Header, Status};
|
||||
use rocket::response::Responder;
|
||||
use crate::error::Errors;
|
||||
use crate::xml::Xml;
|
||||
|
||||
pub struct Ds<T>(pub T);
|
||||
|
||||
impl<'r, 'o: 'r, T: Responder<'r, 'o>> Responder<'r, 'o> for Ds<T> {
|
||||
fn respond_to(self, request: &'r Request<'_>) -> rocket::response::Result<'o> {
|
||||
Response::build_from(self.0.respond_to(request)?)
|
||||
.header(Header::new("Server", "Nintendo 3DS (http)"))
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
28
src/error.rs
Normal file
28
src/error.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
use log::error;
|
||||
use rocket::http::Status;
|
||||
use rocket::{Request, Response};
|
||||
use rocket::response::content::RawXml;
|
||||
use rocket::response::Responder;
|
||||
use rocket::serde::Serialize;
|
||||
use crate::nnid::timezones::Timezone;
|
||||
use crate::xml::{serialize_with_version, Xml};
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct Error<'a>{
|
||||
pub code: &'a str,
|
||||
pub message: &'a str
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(rename(serialize = "errors"))]
|
||||
pub struct Errors<'a>{
|
||||
pub error: &'a [Error<'a>],
|
||||
}
|
||||
|
||||
impl<'r, 'o: 'r> Responder<'r, 'o> for Errors<'r> {
|
||||
fn respond_to(self, request: &'r Request<'_>) -> rocket::response::Result<'o> {
|
||||
Response::build_from(Xml(self).respond_to(request)?)
|
||||
.status(Status::BadRequest)
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
46
src/main.rs
46
src/main.rs
|
|
@ -1,26 +1,60 @@
|
|||
use std::env;
|
||||
use diesel::{Connection, MysqlConnection};
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use dotenvy::dotenv;
|
||||
use log::info;
|
||||
use rocket::fairing::AdHoc;
|
||||
use rocket::http::Header;
|
||||
use rocket::routes;
|
||||
use sqlx::Postgres;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
|
||||
mod xml;
|
||||
mod conntest;
|
||||
mod db;
|
||||
mod nnid;
|
||||
mod account;
|
||||
mod error;
|
||||
mod dsresponse;
|
||||
|
||||
type Pool = sqlx::Pool<Postgres>;
|
||||
|
||||
#[rocket::launch]
|
||||
async fn launch() -> _ {
|
||||
dotenv().ok();
|
||||
|
||||
let act_database_url = env::var("ACCOUNT_DATABASE_URL").expect("account database url is not set");
|
||||
|
||||
let conn = MysqlConnection::establish(&act_database_url).expect("unable to connect to database");
|
||||
|
||||
let act_database_url = env::var("DATABASE_URL").expect("account database url is not set");
|
||||
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&act_database_url).await
|
||||
.expect("unable to create pool");
|
||||
|
||||
rocket::build()
|
||||
.manage(pool)
|
||||
.attach(AdHoc::on_response("org", |_, response| Box::pin(async move {
|
||||
response.adjoin_header(Header::new("x-organization", "Nintendo"));
|
||||
//response.adjoin_header(Header::new("x-organization", "Nintendo"));
|
||||
response.adjoin_header(Header::new("x-nintendo-date", SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis()
|
||||
.to_string()
|
||||
));
|
||||
|
||||
response.adjoin_header(Header::new("Content-Type", "text/xml; charset=utf-8"));
|
||||
|
||||
|
||||
response.remove_header("x-content-type-options");
|
||||
response.remove_header("x-frame-options");
|
||||
response.remove_header("permissions-policy");
|
||||
})))
|
||||
.mount("/", routes![conntest::conntest])
|
||||
.mount("/", routes![
|
||||
conntest::conntest,
|
||||
nnid::devices::current_device_status,
|
||||
nnid::agreements::get_agreement,
|
||||
nnid::timezones::get_timezone,
|
||||
nnid::person_exists::person_exists,
|
||||
nnid::email::validate
|
||||
])
|
||||
}
|
||||
|
|
|
|||
45
src/nnid/agreements.rs
Normal file
45
src/nnid/agreements.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use std::{env, fs, io};
|
||||
use rocket::fs::NamedFile;
|
||||
use rocket::get;
|
||||
use rocket::response::content::RawXml;
|
||||
use tokio::fs::try_exists;
|
||||
use crate::dsresponse::Ds;
|
||||
use crate::nnid::devices::Device;
|
||||
use crate::xml::Xml;
|
||||
|
||||
#[get("/v1/api/content/agreements/Nintendo-Network-EULA/<lang>/@latest")]
|
||||
pub async fn get_agreement(lang: &str) -> io::Result<Ds<RawXml<NamedFile>>>{
|
||||
let base_path = {
|
||||
// if this crashes then something is wrong with the server setup so crashing here is fine imo
|
||||
let mut path = env::current_dir().unwrap();
|
||||
|
||||
path.push("res");
|
||||
path.push("agreement");
|
||||
|
||||
path
|
||||
};
|
||||
|
||||
let requested_file_path = {
|
||||
let mut path = base_path.clone();
|
||||
|
||||
path.push(format!("{}.xml", lang));
|
||||
|
||||
path
|
||||
};
|
||||
|
||||
|
||||
|
||||
if try_exists(&requested_file_path).await.is_ok_and(|v| v == true){
|
||||
Ok(Ds(RawXml(NamedFile::open(&requested_file_path).await?)))
|
||||
} else {
|
||||
let fallback_path = {
|
||||
let mut path = base_path;
|
||||
|
||||
path.push("DEFAULT.xml");
|
||||
|
||||
path
|
||||
};
|
||||
|
||||
Ok(Ds(RawXml(NamedFile::open(&fallback_path).await?)))
|
||||
}
|
||||
}
|
||||
176
src/nnid/create_account.rs
Normal file
176
src/nnid/create_account.rs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
use chrono::NaiveDate;
|
||||
use rocket::{post, State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::account::account::{generate_password, User};
|
||||
use crate::error::Errors;
|
||||
use crate::Pool;
|
||||
use crate::xml::{Xml, YesNoVal};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Email{
|
||||
address: Box<str>
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct Mii{
|
||||
name: Box<str>,
|
||||
primary: YesNoVal,
|
||||
data: Box<str>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename(serialize = "person"))]
|
||||
struct AccountCreationData{
|
||||
birth_date: NaiveDate,
|
||||
user_id: Box<str>,
|
||||
password: Box<str>,
|
||||
country: Box<str>,
|
||||
language: Box<str>,
|
||||
tz_name: Box<str>,
|
||||
email: Email,
|
||||
gender: Box<str>,
|
||||
marketing_flag: YesNoVal,
|
||||
region: i32
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename(serialize = "person"))]
|
||||
struct AccountCreationResponseData{
|
||||
pid: i32
|
||||
}
|
||||
|
||||
#[post("/v1/api/people", data="<data>")]
|
||||
async fn create_account(database: &State<Pool>, data: Xml<AccountCreationData>) -> Result<Xml<AccountCreationResponseData>, Errors>{
|
||||
let database = database.inner();
|
||||
|
||||
let AccountCreationData {
|
||||
user_id,
|
||||
password,
|
||||
birth_date,
|
||||
tz_name,
|
||||
language,
|
||||
email: Email{
|
||||
address
|
||||
},
|
||||
marketing_flag,
|
||||
gender,
|
||||
region,
|
||||
country,
|
||||
..
|
||||
} = *data;
|
||||
|
||||
|
||||
|
||||
let new_account = sqlx::query("
|
||||
INSERT INTO users.users (
|
||||
pid,
|
||||
username,
|
||||
password,
|
||||
birthdate,
|
||||
birthdate,
|
||||
timezone,
|
||||
email,
|
||||
country,
|
||||
language,
|
||||
marketing_allowed,
|
||||
off_device_allowed,
|
||||
region,
|
||||
mii_data
|
||||
) VALUES (
|
||||
?,?,?,?,?,?,?,?,?,?
|
||||
)
|
||||
");
|
||||
|
||||
|
||||
|
||||
let pid = connection.transaction::<_, diesel::result::Error, _>(|conn| Box::pin(async move{
|
||||
use crate::schema::users::dsl::*;
|
||||
|
||||
diesel::insert_into(users)
|
||||
.values(&new_account)
|
||||
.returning(User::as_returning())
|
||||
.get_result(conn)
|
||||
.await?;
|
||||
|
||||
|
||||
Ok(())
|
||||
})).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test{
|
||||
use chrono::NaiveDate;
|
||||
use crate::nnid::create_account::AccountCreationData;
|
||||
|
||||
const TEST_XML: &str =
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>
|
||||
<person>
|
||||
<birth_date>1991-02-03</birth_date>
|
||||
<user_id>testtest</user_id>
|
||||
<password>[PASSWORD]</password>
|
||||
<country>DE</country>
|
||||
<language>en</language>
|
||||
<tz_name>Europe/Berlin</tz_name>
|
||||
<agreement>
|
||||
<agreement_date>2025-02-24T19:42:45</agreement_date>
|
||||
<country>US</country>
|
||||
<location>https://account.spfn.cc/v1/api/content/agreements/Nintendo-Network-EULA/0300</location>
|
||||
<type>NINTENDO-NETWORK-EULA</type>
|
||||
<version>0300</version>
|
||||
</agreement>
|
||||
<email>
|
||||
<address>tvnebel@gmail.com</address>
|
||||
<owned>N</owned>
|
||||
<parent>N</parent>
|
||||
<primary>Y</primary>
|
||||
<validated>N</validated>
|
||||
<type>DEFAULT</type>
|
||||
</email>
|
||||
<mii>
|
||||
<name>y</name>
|
||||
<primary>Y</primary>
|
||||
<data>
|
||||
AwAAQDrPvmeBxJIQ3j+V8Ip4iCWDvgAAAEB5AAAAIABOAEEATQBFAAAAAAAAAEBAAAAhAQJoRBgm
|
||||
NEYUgRIXaA0AACkAUkhQAAAAAAAAAAAAAAAAAAAAAAAAAAAAANzO
|
||||
</data>
|
||||
</mii>
|
||||
<parental_consent>
|
||||
<scope>1</scope>
|
||||
<consent_date>2025-02-24T19:42:45</consent_date>
|
||||
<approval_id>0</approval_id>
|
||||
</parental_consent>
|
||||
<gender>M</gender>
|
||||
<region>1309343744</region>
|
||||
<marketing_flag>N</marketing_flag>
|
||||
<device_attributes>
|
||||
<device_attribute>
|
||||
<name>uuid_account</name>
|
||||
<value>55fdbad0-f2ab-11ef-b648-010144cdca06</value>
|
||||
</device_attribute>
|
||||
<device_attribute>
|
||||
<name>uuid_common</name>
|
||||
<value>898ed052-5e25-11ef-b648-010144cdca06</value>
|
||||
</device_attribute>
|
||||
<device_attribute>
|
||||
<name>persistent_id</name>
|
||||
<value>8000001d</value>
|
||||
</device_attribute>
|
||||
<device_attribute>
|
||||
<name>transferable_id_base</name>
|
||||
<value>0800000444cdca06</value>
|
||||
</device_attribute>
|
||||
<device_attribute>
|
||||
<name>transferable_id_base_common</name>
|
||||
<value>0640000444cdca06</value>
|
||||
</device_attribute>
|
||||
</device_attributes>
|
||||
<off_device_flag>N</off_device_flag>
|
||||
</person>";
|
||||
#[test]
|
||||
fn test(){
|
||||
let data: AccountCreationData = quick_xml::de::from_str(TEST_XML).unwrap();
|
||||
|
||||
assert_eq!(data.birth_date, NaiveDate::from_ymd_opt(1991,02,03).unwrap());
|
||||
assert_eq!(data.user_id.as_ref(), "testtest");
|
||||
}
|
||||
}
|
||||
28
src/nnid/devices.rs
Normal file
28
src/nnid/devices.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
use rocket::get;
|
||||
use serde::Serialize;
|
||||
use crate::xml::Xml;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename(serialize = "device"))]
|
||||
pub struct Device;
|
||||
|
||||
#[get("/v1/api/devices/@current/status")]
|
||||
pub fn current_device_status() -> Xml<Device>{
|
||||
Xml(Device)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::str::from_utf8;
|
||||
use crate::nnid::devices::Device;
|
||||
|
||||
#[test]
|
||||
fn test_device_data(){
|
||||
let text = crate::xml::serialize_with_version(&Device).unwrap();
|
||||
|
||||
|
||||
|
||||
println!("{}", text);
|
||||
|
||||
}
|
||||
}
|
||||
11
src/nnid/email.rs
Normal file
11
src/nnid/email.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
use rocket::{post, FromForm};
|
||||
use rocket::form::Form;
|
||||
|
||||
#[derive(FromForm)]
|
||||
struct ValidateEmailInput{
|
||||
email: String,
|
||||
}
|
||||
#[post("/v1/api/support/validate/email", data="<data>")]
|
||||
pub fn validate(data: Form<ValidateEmailInput>){
|
||||
|
||||
}
|
||||
6
src/nnid/mod.rs
Normal file
6
src/nnid/mod.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
pub mod devices;
|
||||
pub mod agreements;
|
||||
pub mod timezones;
|
||||
pub mod person_exists;
|
||||
pub mod email;
|
||||
mod create_account;
|
||||
58
src/nnid/person_exists.rs
Normal file
58
src/nnid/person_exists.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
use rocket::{get, State};
|
||||
use sqlx::Row;
|
||||
use crate::error::{Error, Errors};
|
||||
use crate::Pool;
|
||||
use crate::xml::Xml;
|
||||
|
||||
#[get("/v1/api/people/<username>")]
|
||||
pub async fn person_exists(database: &State<Pool>, username: &str) -> Result<(), Errors<'static>>{
|
||||
let database = database.inner();
|
||||
|
||||
let exists: bool = sqlx::query_as!(
|
||||
bool,
|
||||
"SELECT EXISTS(SELECT 1 FROM users.users WHERE username = ? )",
|
||||
username
|
||||
).fetch_one(database)
|
||||
.await
|
||||
.unwrap_or(true);
|
||||
|
||||
if exists {
|
||||
Err(
|
||||
Errors{
|
||||
error: &[
|
||||
Error{
|
||||
code: "0100",
|
||||
message: "Account ID already exists"
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test{
|
||||
use crate::error::{Error, Errors};
|
||||
use crate::xml::serialize_with_version;
|
||||
|
||||
#[test]
|
||||
fn test(){
|
||||
let val = Errors{
|
||||
error: &[
|
||||
Error{
|
||||
code: "0100",
|
||||
message: "Account ID already exists"
|
||||
}
|
||||
],
|
||||
};
|
||||
|
||||
let enc = serialize_with_version(&val).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
enc.as_ref(),
|
||||
"<?xml version=\"1.0\"?><errors><error><code>0100</code><message>Account ID already exists</message></error></errors>"
|
||||
)
|
||||
}
|
||||
}
|
||||
64
src/nnid/timezones.rs
Normal file
64
src/nnid/timezones.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
use std::collections::HashMap;
|
||||
use std::{env, fs};
|
||||
use once_cell::sync::Lazy;
|
||||
use rocket::get;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::from_slice;
|
||||
use crate::xml::{serialize_with_version, Xml};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename(serialize = "timezone"))]
|
||||
pub struct Timezone{
|
||||
area: String,
|
||||
language: String,
|
||||
name: String,
|
||||
utc_offset: String,
|
||||
order: String
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename(serialize = "timezones"))]
|
||||
pub struct Timezones<'a>{
|
||||
pub timezone: &'a [Timezone],
|
||||
}
|
||||
|
||||
pub static TIMEZONES: Lazy<HashMap<String, HashMap<String, Vec<Timezone>>>> = Lazy::new(||{
|
||||
let path = {
|
||||
// if this crashes then something is wrong with the server setup so crashing here is fine imo
|
||||
let mut path = env::current_dir().unwrap();
|
||||
|
||||
path.push("res");
|
||||
path.push("timezones.json");
|
||||
|
||||
path
|
||||
};
|
||||
serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
|
||||
});
|
||||
|
||||
|
||||
#[get("/v1/api/content/time_zones/<zone>/<lang>")]
|
||||
pub fn get_timezone(zone: &str, lang: &str) -> Option<Xml<Timezones<'static>>>{
|
||||
let timezone = (&*TIMEZONES).get(zone)?.get(lang)?;
|
||||
let timezones = Timezones{ timezone };
|
||||
Some(Xml(timezones))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test{
|
||||
use crate::nnid::timezones::{Timezones, TIMEZONES};
|
||||
use crate::xml::serialize_with_version;
|
||||
|
||||
#[test]
|
||||
fn test(){
|
||||
let timezone = (&*TIMEZONES).get("DE").unwrap().get("en").unwrap();
|
||||
let timezones = Timezones{ timezone };
|
||||
let ser = serialize_with_version(&timezones).unwrap();
|
||||
|
||||
println!("{}", ser);
|
||||
|
||||
assert_eq!(
|
||||
ser.as_ref(),
|
||||
"<?xml version=\"1.0\"?><timezones><timezone><area>Europe/Berlin</area><language>en</language><name>Amsterdam, Berlin, Rome</name><utc_offset>3600</utc_offset><order>0</order></timezone></timezones>"
|
||||
)
|
||||
}
|
||||
}
|
||||
132
src/xml.rs
132
src/xml.rs
|
|
@ -1,17 +1,51 @@
|
|||
use std::fmt::Formatter;
|
||||
use std::io::Cursor;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::result;
|
||||
use rocket::http::Status;
|
||||
use rocket::Request;
|
||||
use rocket::{async_trait, Data, Request};
|
||||
use rocket::response::Responder;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use rocket::response::Result;
|
||||
use log::error;
|
||||
use quick_xml::events::{BytesDecl, Event};
|
||||
use quick_xml::se::Serializer;
|
||||
use quick_xml::{DeError, SeError};
|
||||
use rocket::data::{ByteUnit, FromData, Outcome};
|
||||
use rocket::response::content::RawXml;
|
||||
use rocket::response::status::BadRequest;
|
||||
use serde::__private::de::UntaggedUnitVisitor;
|
||||
use serde::de::{DeserializeOwned, Error, Visitor};
|
||||
use thiserror::Error;
|
||||
|
||||
pub fn serialize_with_version(serializable: &impl Serialize) -> result::Result<Box<str>, SeError>{
|
||||
let mut write_dest = "<?xml version=\"1.0\"?>".to_owned();
|
||||
|
||||
|
||||
|
||||
serializable.serialize(Serializer::new(&mut write_dest))?;
|
||||
Ok(write_dest.into_boxed_str())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Xml<T>(pub T);
|
||||
|
||||
impl<T> Deref for Xml<T>{
|
||||
type Target = T;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> DerefMut for Xml<T>{
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<'r, 'o: 'r, T: Serialize> Responder<'r, 'o> for Xml<T>{
|
||||
fn respond_to(self, request: &'r Request<'_>) -> Result<'o> {
|
||||
match quick_xml::se::to_string(&self.0){
|
||||
match serialize_with_version(&self.0){
|
||||
Ok(ser) => {
|
||||
RawXml(ser).respond_to(request)
|
||||
},
|
||||
|
|
@ -21,4 +55,96 @@ impl<'r, 'o: 'r, T: Serialize> Responder<'r, 'o> for Xml<T>{
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<'r, T: DeserializeOwned> FromData<'r> for Xml<T>{
|
||||
type Error = Option<DeError>;
|
||||
|
||||
async fn from_data(req: &'r Request<'_>, data: Data<'r>) -> Outcome<'r, Self> {
|
||||
let data = data.open(1 * ByteUnit::MB);
|
||||
|
||||
let Ok(data) = data.into_string().await else {
|
||||
return Outcome::Error((Status::BadRequest, None))
|
||||
};
|
||||
|
||||
|
||||
|
||||
match quick_xml::de::from_str(&data){
|
||||
Ok(v) => Outcome::Success(Self(v)),
|
||||
Err(e) => Outcome::Error((Status::BadRequest, Some(e)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
pub struct YesNoVal(pub bool);
|
||||
|
||||
struct YesNoVisitor;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("did not find Y or N")]
|
||||
struct NotYNError;
|
||||
|
||||
|
||||
|
||||
impl<'de> Visitor<'de> for YesNoVisitor{
|
||||
type Value = YesNoVal;
|
||||
|
||||
fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
|
||||
write!(formatter, "expected Y or N")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> result::Result<Self::Value, E>
|
||||
where
|
||||
E: Error,
|
||||
{
|
||||
match v{
|
||||
"Y" => Ok(YesNoVal(true)),
|
||||
"N" => Ok(YesNoVal(false)),
|
||||
_ => Err(E::custom("didnt get N or Y"))
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_bytes<E>(self, v: &[u8]) -> result::Result<Self::Value, E>
|
||||
where
|
||||
E: Error,
|
||||
{
|
||||
|
||||
const Y_BYTES: &[u8] = "Y".as_bytes();
|
||||
const N_BYTES: &[u8] = "N".as_bytes();
|
||||
|
||||
match v{
|
||||
Y_BYTES => Ok(YesNoVal(true)),
|
||||
N_BYTES => Ok(YesNoVal(false)),
|
||||
_ => Err(E::custom("didnt get N or Y"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for YesNoVal{
|
||||
fn deserialize<D>(deserializer: D) -> result::Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>
|
||||
{
|
||||
deserializer.deserialize_str(YesNoVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for YesNoVal{
|
||||
fn serialize<S>(&self, serializer: S) -> result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_char(
|
||||
match self.0{
|
||||
true => 'Y',
|
||||
false => 'N',
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue