|
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
|