diff --git a/.sqlx/query-0edf3f60bfb92912f73b6c61962e4a98b37c62d596cb0c8c3d1cc1c0199dcb2d.json b/.sqlx/query-0edf3f60bfb92912f73b6c61962e4a98b37c62d596cb0c8c3d1cc1c0199dcb2d.json new file mode 100644 index 0000000..1ae8535 --- /dev/null +++ b/.sqlx/query-0edf3f60bfb92912f73b6c61962e4a98b37c62d596cb0c8c3d1cc1c0199dcb2d.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users SET\n email = $1,\n email_verified_since = NULL,\n verification_code = $2\n WHERE pid = $3\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int4", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "0edf3f60bfb92912f73b6c61962e4a98b37c62d596cb0c8c3d1cc1c0199dcb2d" +} diff --git a/.sqlx/query-8cd49b3defe144fe73bdeee1ce000b9bb8716320c6b421395a04f12bd826a469.json b/.sqlx/query-8cd49b3defe144fe73bdeee1ce000b9bb8716320c6b421395a04f12bd826a469.json new file mode 100644 index 0000000..6ec9f3a --- /dev/null +++ b/.sqlx/query-8cd49b3defe144fe73bdeee1ce000b9bb8716320c6b421395a04f12bd826a469.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users SET\n gender = COALESCE($1, gender),\n region = COALESCE($2, region),\n country = COALESCE($3, country),\n language = COALESCE($4, language),\n timezone = COALESCE($5, timezone),\n marketing_allowed = COALESCE($6, marketing_allowed),\n off_device_allowed = COALESCE($7, off_device_allowed),\n password = COALESCE($8, password)\n WHERE pid = $9\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bpchar", + "Int4", + "Varchar", + "Varchar", + "Varchar", + "Bool", + "Bool", + "Varchar", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "8cd49b3defe144fe73bdeee1ce000b9bb8716320c6b421395a04f12bd826a469" +} diff --git a/.sqlx/query-ffef185a4db4b101477f7a78444192a7dfa8af98c00394978a9627b5ecdae9b5.json b/.sqlx/query-ffef185a4db4b101477f7a78444192a7dfa8af98c00394978a9627b5ecdae9b5.json new file mode 100644 index 0000000..e9fb825 --- /dev/null +++ b/.sqlx/query-ffef185a4db4b101477f7a78444192a7dfa8af98c00394978a9627b5ecdae9b5.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM users WHERE pid = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [] + }, + "hash": "ffef185a4db4b101477f7a78444192a7dfa8af98c00394978a9627b5ecdae9b5" +} diff --git a/res/agreement/DEFAULT.xml b/res/agreement/DEFAULT.xml index 25d2187..e6ba92a 100644 --- a/res/agreement/DEFAULT.xml +++ b/res/agreement/DEFAULT.xml @@ -1 +1 @@ -USenEnglish2014-09-29T20:07:35NINTENDO-NETWORK-EULA0300USenEspañol2014-09-29T20:07:35NINTENDO-NETWORK-EULA0300 \ No newline at end of file +USenEnglish2014-09-29T20:07:35NINTENDO-NETWORK-EULA0300USenEspañol2014-09-29T20:07:35NINTENDO-NETWORK-EULA0300 \ No newline at end of file diff --git a/res/email/confirmationTemplate.html b/res/email/confirmationTemplate.html index 63753f8..ecb52b3 100644 --- a/res/email/confirmationTemplate.html +++ b/res/email/confirmationTemplate.html @@ -38,7 +38,7 @@ color: #B60000 !important; } img.logo { - content: url("https://spfn.net/res/img/spfn.png") !important; + content: url("https://spfn.spbr.net/res/img/spfn.png") !important; } } @media (prefers-color-scheme: dark) { @@ -91,8 +91,8 @@ @@ -122,19 +122,6 @@ Your Splatfestival Network ID activation is almost complete. - - - - - - - - - - - - -
- - + +
 
 
Enter the following 6-digit code on your console: diff --git a/res/email/resetTemplate.html b/res/email/resetTemplate.html new file mode 100644 index 0000000..84b899a --- /dev/null +++ b/res/email/resetTemplate.html @@ -0,0 +1,190 @@ + + + + + + + + +
Hello {{username}}. Your Splatfestival Network ID password reset has been processed. You may use the temporary password below to log into your account.
+ + + + +
+ + + + +
+ + + + + + +
  + + + + + + + +
 
+ + + + + + + + + + + + + + + + + + + +
+ + + +
 
+ + + + + + +
  + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
+ Hello {{username}}. +
 
+ Your Splatfestival Network ID password reset has been processed. +
+ You may use the following temporary password to login: +
 
+ {{password}} +
 
+ Please change your password as soon as you can on your Wii U. (Click your mii on the top-left of the Wii U Menu and scroll to "Change Password") +
 
+ The SPFN team +
 
+
 
+
 
+ Note: this email message was auto-generated, please do not respond. For further assistance, please join our Discord server. +
 
+
+
 
+
+
+ + \ No newline at end of file diff --git a/src/email.rs b/src/email.rs index 255fe49..4008994 100644 --- a/src/email.rs +++ b/src/email.rs @@ -36,3 +36,37 @@ pub async fn send_verification_email(to: &str, code: i32, username: &str) -> Res Ok(()) } + +pub async fn send_reset_email(to: &str, pwd: &str, username: &str) -> Result<(), String> { + let smtp_user = env::var("SMTP_USER").map_err(|_| "SMTP_USER not set".to_string())?; + let smtp_pass = env::var("SMTP_PASS").map_err(|_| "SMTP_PASS not set".to_string())?; + let smtp_server = env::var("SMTP_SERVER").map_err(|_| "SMTP_SERVER not set".to_string())?; + + // Load template + let template = fs::read_to_string("res/email/resetTemplate.html") + .map_err(|e| format!("Failed to read email template: {}", e))?; + + // Replace placeholders + let body = template + .replace("{{username}}", username) + .replace("{{password}}", pwd); + + let email = Message::builder() + .from(smtp_user.parse().unwrap()) + .to(to.parse().unwrap()) + .subject("Password Reset for SPFN") + .header(lettre::message::header::ContentType::TEXT_HTML) + .body(body) + .map_err(|e| e.to_string())?; + + let creds = Credentials::new(smtp_user, smtp_pass); + + let mailer = SmtpTransport::relay(&smtp_server) + .map_err(|e| e.to_string())? + .credentials(creds) + .build(); + + mailer.send(&email).map_err(|e| e.to_string())?; + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 1ad5ca5..cee7cc5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -101,11 +101,19 @@ async fn launch() -> _ { nnid::support::validate, nnid::support::verify_email, nnid::support::resend_email, + nnid::support::forgotten_password, nnid::people::create_account, nnid::people::get_own_profile, nnid::people::get_device_owner, nnid::people::get_own_device, nnid::people::change_mii, + nnid::people::update_account, + nnid::people::delete_account, + nnid::people::get_user_devices, + nnid::people::get_device_status, + nnid::people::inactivate_current_device, + nnid::people::get_own_emails, + nnid::people::update_primary_email, nnid::miis::get_miis, nnid::oauth::generate_token::generate_token, nnid::provider::get_nex_token, diff --git a/src/nnid/people.rs b/src/nnid/people.rs index 8154392..5f45c08 100644 --- a/src/nnid/people.rs +++ b/src/nnid/people.rs @@ -13,6 +13,7 @@ use nex_account::grpc::{ActStageInfo, ActStageReturn}; use nex_account::grpc_client; use rand::prelude::*; use rocket::serde::{Deserialize, Serialize}; +use rocket::request::{FromRequest, Outcome, Request}; use rocket::{State, get, post, put}; const DATABASE_ERROR: Errors = Errors { @@ -64,6 +65,67 @@ pub struct AccountCreationResponseData { pid: i32, } +#[derive(Serialize)] +#[serde(rename = "device")] +pub struct DeviceInfo { + pub device_id: String, + pub language: String, + pub updated: NaiveDateTime, + pub pid: i32, + pub platform_id: String, + pub region: String, + pub serial_number: String, + pub status: String, + pub system_version: String, + pub r#type: String, + pub updated_by: String, +} + +#[derive(Serialize)] +pub struct DevicesWrapper { + #[serde(rename = "device")] + pub devices: Vec, +} + +#[derive(Serialize)] +pub struct EmptyDeviceResponse { + pub device: String, +} + +#[derive(Deserialize)] +#[serde(rename = "person")] +pub struct UpdateAccountData { + pub gender: Option>, + pub region: Option, + pub country: Option>, + pub language: Option>, + pub tz_name: Option>, + pub marketing_flag: Option, + pub off_device_flag: Option, + pub password: Option>, +} + +#[derive(Serialize)] +pub struct EmailWrapper { + pub email: EmailInfoOwnProfileData, +} + +#[derive(Serialize)] +pub struct EmailsWrapper { + #[serde(rename = "email")] + pub emails: Vec, +} + +#[derive(Deserialize)] +pub struct UpdateEmailData { + pub address: Box, +} + +#[derive(Deserialize)] +pub struct UpdateEmailRequest { + pub email: UpdateEmailData, +} + #[post("/v1/api/people", data = "")] pub async fn create_account( database: &State, @@ -174,17 +236,17 @@ pub async fn create_account( // } #[derive(Serialize)] -struct EmailInfoOwnProfileData { - address: String, - id: u32, - parent: YesNoVal, - primary: YesNoVal, - reachable: YesNoVal, +pub struct EmailInfoOwnProfileData { + pub address: String, + pub id: u32, + pub parent: YesNoVal, + pub primary: YesNoVal, + pub reachable: YesNoVal, #[serde(rename = "type")] - email_type: String, - updated_by: String, - validated: YesNoVal, - validated_date: Option, + pub email_type: String, + pub updated_by: String, + pub validated: YesNoVal, + pub validated_date: Option, } #[derive(Serialize)] @@ -525,6 +587,214 @@ pub async fn thing( println!("Failed to update EULA version for PID {}: {:?}", pid, e); return Err(Some(DATABASE_ERROR)); } + + Ok(()) +} + +pub struct DeviceHeaders { + pub device_id: String, + pub accept_language: String, + pub platform_id: String, + pub region: String, + pub serial_number: String, + pub system_version: String, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for DeviceHeaders { + type Error = Errors<'static>; + + async fn from_request(req: &'r Request<'_>) -> Outcome { + let headers = req.headers(); + + let get_h = |key: &str| headers.get_one(key).map(|s| s.to_string()); + + match ( + get_h("x-nintendo-device-id"), + get_h("accept-language"), + get_h("x-nintendo-platform-id"), + get_h("x-nintendo-region"), + get_h("x-nintendo-serial-number"), + get_h("x-nintendo-system-version"), + ) { + ( + Some(device_id), + Some(accept_language), + Some(platform_id), + Some(region), + Some(serial_number), + Some(system_version), + ) => Outcome::Success(DeviceHeaders { + device_id, + accept_language, + platform_id, + region, + serial_number, + system_version, + }), + _ => Outcome::Error(( + rocket::http::Status::BadRequest, + Errors { + error: &[Error { + code: "1600", + message: "Unable to process request", + }], + }, + )), + } + } +} + +#[get("/v1/api/people/@me/devices")] +pub fn get_user_devices( + auth: Auth, + headers: DeviceHeaders, +) -> Xml { + let now = chrono::Utc::now().naive_utc(); + + Xml(DevicesWrapper { + devices: vec![DeviceInfo { + device_id: headers.device_id, + language: headers.accept_language, + updated: now, + pid: auth.pid, + platform_id: headers.platform_id, + region: headers.region, + serial_number: headers.serial_number, + status: "ACTIVE".to_string(), + system_version: headers.system_version, + r#type: "RETAIL".to_string(), + updated_by: "USER".to_string(), + }], + }) +} + +#[get("/v1/api/people/@me/devices/status")] +pub fn get_device_status(_auth: Auth) -> Xml { + Xml(EmptyDeviceResponse { + device: String::new(), + }) +} + +#[put("/v1/api/people/@me/devices/@current/inactivate")] +pub fn inactivate_current_device(_auth: Auth) -> () { + // just 200 +} + +#[post("/v1/api/people/@me/deletion")] +pub async fn delete_account( + database: &State, + auth: Auth, +) -> Result<(), Option>> { + let db = database.inner(); + + let result = sqlx::query!( + "DELETE FROM users WHERE pid = $1", + auth.pid + ) + .execute(db) + .await; + + if let Err(e) = result { + println!("failed to delete PID {}: {:?}", auth.pid, e); + return Err(Some(DATABASE_ERROR)); + } Ok(()) } + +#[put("/v1/api/people/@me", data = "")] +pub async fn update_account( + database: &State, + auth: Auth, + data: Xml, +) -> Result<(), Option>> { + let db = database.inner(); + let pid = auth.pid; + let data = data.0; + + let updated_password = if let Some(ref new_pass) = data.password { + generate_password(pid, new_pass) + } else { + None + }; + + let result = sqlx::query!( + " + UPDATE users SET + gender = COALESCE($1, gender), + region = COALESCE($2, region), + country = COALESCE($3, country), + language = COALESCE($4, language), + timezone = COALESCE($5, timezone), + marketing_allowed = COALESCE($6, marketing_allowed), + off_device_allowed = COALESCE($7, off_device_allowed), + password = COALESCE($8, password) + WHERE pid = $9 + ", + data.gender.as_deref(), + data.region, + data.country.as_deref(), + data.language.as_deref(), + data.tz_name.as_deref(), + data.marketing_flag.map(|v| v.0), + data.off_device_flag.map(|v| v.0), + updated_password, + pid + ) + .execute(db) + .await; + + if let Err(e) = result { + println!("failed to update account for PID {}: {:?}", pid, e); + return Err(Some(DATABASE_ERROR)); + } + + Ok(()) +} + +#[get("/v1/api/people/@me/emails")] +pub fn get_own_emails(user: Auth) -> Xml { + let profile = build_profile(user.into()); + Xml(EmailsWrapper { + emails: vec![profile.email], + }) +} + +#[put("/v1/api/people/@me/emails/@primary", data = "")] +pub async fn update_primary_email( + database: &State, + auth: Auth, + data: Xml, +) -> Result<(), Option>> { + let db = database.inner(); + let pid = auth.pid; + let new_address = data.0.email.address.to_lowercase(); + let verification_code: i32 = rand::rng().random_range(100_000..1_000_000); + + let result = sqlx::query!( + " + UPDATE users SET + email = $1, + email_verified_since = NULL, + verification_code = $2 + WHERE pid = $3 + ", + new_address, + verification_code, + pid + ) + .execute(db) + .await; + + if let Err(e) = result { + println!("failed to update email for PID {}: {:?}", pid, e); + return Err(Some(DATABASE_ERROR)); + } + + if let Err(e) = send_verification_email(&new_address, verification_code, &auth.username).await { + println!("failed to send verification email: {e}"); + } + + Ok(()) +} \ No newline at end of file diff --git a/src/nnid/support.rs b/src/nnid/support.rs index 715ca1c..aa63349 100644 --- a/src/nnid/support.rs +++ b/src/nnid/support.rs @@ -6,6 +6,8 @@ use rocket::form::Form; use rocket::{FromForm, State, post, put, get, Request}; use rocket::request::{self, FromRequest}; use rocket::http::Status; +use rand::RngExt; +use rand::distr::Alphanumeric; const BAD_CODE_ERROR: Errors = Errors { error: &[Error { @@ -14,6 +16,13 @@ const BAD_CODE_ERROR: Errors = Errors { }], }; +const UNAUTHORIZED_DEVICE_ERROR: Errors = Errors { + error: &[Error { + code: "0113", + message: "Unauthorized device", + }], +}; + #[derive(FromForm)] pub struct ValidateEmailInput { email: String, @@ -45,7 +54,6 @@ pub async fn validate( let email = data.email.trim(); - // 1. Validate presence + basic format if email.is_empty() || !email.contains('@') { return Err(Errors { error: &[Error { @@ -55,7 +63,6 @@ pub async fn validate( }); } - // 2. Extract domain safely let domain = match email.split('@').nth(1) { Some(d) if !d.is_empty() => d, _ => { @@ -68,7 +75,7 @@ pub async fn validate( } }; - // 3. DNS resolver + // This shouldn't ever fail unless there's something wrong with the server let resolver = TokioAsyncResolver::tokio_from_system_conf() .map_err(|_| Errors { error: &[Error { @@ -77,7 +84,6 @@ pub async fn validate( }], })?; - // 4. MX lookup match resolver.mx_lookup(domain).await { Ok(mx) if mx.iter().next().is_some() => Ok(()), @@ -159,5 +165,66 @@ pub async fn resend_email( BAD_CODE_ERROR })?; + Ok(()) +} + +#[get("/v1/api/support/forgotten_password/")] +pub async fn forgotten_password( + database: &State, + pid: i32, +) -> Result<(), Errors<'static>> { + let db = database.inner(); + + let user_data = sqlx::query!( + "SELECT username, email FROM users WHERE pid = $1", + pid + ) + .fetch_optional(db) + .await + .map_err(|e| { + eprintln!("database lookup error: {e}"); + UNAUTHORIZED_DEVICE_ERROR + })?; + + let user = match user_data { + Some(u) => u, + None => return Err(UNAUTHORIZED_DEVICE_ERROR), + }; + + let cleartext_password: String = rand::rng() + .sample_iter(&Alphanumeric) + .take(10) + .map(char::from) + .collect(); + + let nintendo_hash = crate::account::account::generate_password(pid, &cleartext_password) + .ok_or(UNAUTHORIZED_DEVICE_ERROR)?; + + let hashed_password = bcrypt::hash(nintendo_hash, 10) + .map_err(|e| { + eprintln!("bcrypt error: {e}"); + UNAUTHORIZED_DEVICE_ERROR + })?; + + let update_result = sqlx::query!( + "UPDATE users SET password = $1 WHERE pid = $2", + hashed_password, + pid + ) + .execute(db) + .await; + + if let Err(e) = update_result { + eprintln!("failed to update password for PID {pid}: {e}"); + return Err(UNAUTHORIZED_DEVICE_ERROR); + } + + crate::email::send_reset_email(&user.email, &cleartext_password, &user.username) + .await + .map_err(|e| { + eprintln!("failed to send reset email: {e}"); + UNAUTHORIZED_DEVICE_ERROR + })?; + Ok(()) } \ No newline at end of file