A LOT of stuff
All checks were successful
Build and Test / account (push) Successful in 7m48s

Basically I removed all the warnings, removed some old APIs no longer in use, added cert verification to DB, im just cool like that.
This commit is contained in:
red binder 2026-04-27 16:37:54 +02:00
commit 5a8e61c255
18 changed files with 363 additions and 254 deletions

View file

@ -1,3 +1,4 @@
#![allow(unused)]
use std::io::{Cursor, Write};
use std::ops::{Deref, DerefMut};
// Don't import until required.
@ -60,6 +61,18 @@ pub struct User {
pub verification_code: Option<i32>,
}
#[derive(sqlx::FromRow)]
pub struct CertificateRecord {
pub _hash: Vec<u8>,
pub banned: bool,
}
#[derive(sqlx::FromRow)]
pub struct _CertificatePid {
pub cert_hash: Vec<u8>,
pub pid: i32,
}
fn generate_nintendo_hash(pid: i32, text_password: &str) -> String {
let mut sha = Sha256::new();
@ -239,6 +252,65 @@ impl<const FORCE_BEARER_AUTH: bool, const USE_CERT: bool> Into<User>
}
}
pub async fn handle_certificate(
pool: &sqlx::PgPool,
cert: &Certificate,
pid: i32,
) -> Result<(), Errors<'static>> {
let hash = cert.hash();
let existing = sqlx::query_as::<_, CertificateRecord>(
"SELECT hash, banned FROM certificates WHERE hash = $1"
)
.bind(&hash[..])
.fetch_optional(pool)
.await
.map_err(|_| INVALID_TOKEN_ERRORS)?;
if let Some(cert_row) = existing {
if cert_row.banned {
return Err(INVALID_TOKEN_ERRORS);
}
sqlx::query(
"INSERT INTO certificate_pids (cert_hash, pid)
VALUES ($1, $2)
ON CONFLICT DO NOTHING"
)
.bind(&hash[..])
.bind(pid)
.execute(pool)
.await
.map_err(|_| INVALID_TOKEN_ERRORS)?;
} else {
let mut tx = pool.begin().await.map_err(|_| INVALID_TOKEN_ERRORS)?;
sqlx::query(
"INSERT INTO certificates (hash, banned)
VALUES ($1, false)"
)
.bind(&hash[..])
.execute(&mut *tx)
.await
.map_err(|_| INVALID_TOKEN_ERRORS)?;
sqlx::query(
"INSERT INTO certificate_pids (cert_hash, pid)
VALUES ($1, $2)"
)
.bind(&hash[..])
.bind(pid)
.execute(&mut *tx)
.await
.map_err(|_| INVALID_TOKEN_ERRORS)?;
tx.commit().await.map_err(|_| INVALID_TOKEN_ERRORS)?;
}
Ok(())
}
#[async_trait]
impl<'r, const FORCE_BEARER_AUTH: bool, const USE_CERT: bool> FromRequest<'r>
for Auth<FORCE_BEARER_AUTH, USE_CERT>
@ -273,7 +345,7 @@ impl<'r, const FORCE_BEARER_AUTH: bool, const USE_CERT: bool> FromRequest<'r>
}
if USE_CERT {
let cert = request_try!(
let cert_header = request_try!(
request
.headers()
.get("X-Nintendo-Device-Cert")
@ -281,9 +353,13 @@ impl<'r, const FORCE_BEARER_AUTH: bool, const USE_CERT: bool> FromRequest<'r>
.ok_or(INVALID_TOKEN_ERRORS)
);
let Some(cert) = Certificate::new(&cert) else {
let Some(cert) = Certificate::new(&cert_header) else {
return Outcome::Error((Status::BadGateway, INVALID_TOKEN_ERRORS));
};
if let Err(_) = handle_certificate(pool, &cert, user.pid).await {
return Outcome::Error((Status::BadRequest, INVALID_TOKEN_ERRORS));
}
}
// let user = User{
@ -311,7 +387,7 @@ pub struct Certificate {
#[br(magic(0x10005u32))]
struct OuterCertificate {
signature: [u8; 0x3C],
padding: [u8; 0x40],
_padding: [u8; 0x40],
data: [u8; 0x100],
}

View file

@ -1,6 +1,5 @@
use chrono::NaiveDateTime;
use juniper::{graphql_object, EmptyMutation, EmptySubscription, GraphQLObject, RootNode};
use rocket::response::content::RawHtml;
use rocket::State;
use rocket::request::{FromRequest, Outcome, Request};
use std::env;

View file

@ -1,4 +1,4 @@
use chrono::{DateTime, Duration, NaiveDateTime, Utc};
use chrono::{Duration, NaiveDateTime, Utc};
use rocket::{get, State};
use rocket::serde::json::Json;
use serde::Serialize;
@ -8,7 +8,7 @@ use crate::nnid::oauth::generate_token::token_type::AUTH_TOKEN;
use crate::Pool;
#[derive(Serialize)]
struct TokenData{
pub struct TokenData{
token: String,
expiry: NaiveDateTime
}
@ -17,8 +17,6 @@ struct TokenData{
pub async fn generate_token(pool: &State<Pool>, auth: Auth<false>) -> Json<TokenData>{
let pool = pool.inner();
Json(
TokenData{
expiry: Utc::now().naive_utc() + Duration::hours(1),

View file

@ -1,9 +1,9 @@
use rocket::{get, State};
use rocket::serde::json::Json;
use serde::de::IntoDeserializer;
// use serde::de::IntoDeserializer;
use sqlx::query;
use crate::account::account::Auth;
use crate::nnid::people::{build_profile, GetOwnProfileData};
// use crate::account::account::Auth;
// use crate::nnid::people::{build_profile, GetOwnProfileData};
use crate::Pool;

View file

@ -5,6 +5,6 @@ use crate::nnid::people::{build_profile, GetOwnProfileData};
use crate::Pool;
#[get("/api/v2/users/@me/profile")]
pub async fn get_own_profile(pool: &State<Pool>, auth: Auth<true>) -> Json<GetOwnProfileData> {
pub async fn get_own_profile(_pool: &State<Pool>, auth: Auth<true>) -> Json<GetOwnProfileData> {
Json(build_profile(auth.into()))
}

View file

@ -1,10 +1,8 @@
use std::env;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use dotenvy::dotenv;
use juniper::{EmptyMutation, EmptySubscription};
use once_cell::sync::Lazy;
use rocket::fairing::AdHoc;
use rocket::http::{ContentType, Header, Method, Status};
use rocket::{catch, catchers, routes, Request};
@ -26,7 +24,6 @@ mod data_wrapper;
mod grpc;
mod graphql;
mod email;
mod papi;
mod mii_util;
mod json_api;
@ -116,7 +113,7 @@ async fn launch() -> _ {
EmptySubscription::new())
)
.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()
@ -155,8 +152,6 @@ async fn launch() -> _ {
json_api::users::profile::get_own_profile,
json_api::users::mii::get_mii_data_by_pid,
json_api::users::delete::delete_account,
papi::login::login,
papi::user::get_user,
nnid::people::thing,
// graphql::graphiql,
// graphql::playground,

View file

@ -1,6 +1,5 @@
use std::{env, io};
use std::collections::HashSet;
use gxhash::HashMap;
use once_cell::sync::Lazy;
use rocket::fs::NamedFile;
use rocket::{get, Request};

View file

@ -6,7 +6,7 @@ use std::io::Cursor;
use rocket::{Request, response::{Responder, Response}};
use rocket::http::Header;
use time::{OffsetDateTime, Time};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc2822;
#[derive(Serialize)]

View file

@ -1,3 +1,4 @@
#![allow(unused)]
use rocket::{post, FromForm, State};
use rocket::form::Form;
use serde::{Serialize};

View file

@ -1,3 +1,4 @@
#![allow(unused)]
use chrono::{NaiveDate, NaiveDateTime};
use gxhash::{gxhash32, gxhash64};
use rocket::{get, post, put, State};
@ -73,8 +74,8 @@ pub struct Email{
#[derive(Deserialize)]
pub struct UpdateMiiData {
name: Box<str>,
primary: crate::xml::YesNoVal,
_name: Box<str>,
_primary: crate::xml::YesNoVal,
data: Box<str>,
}
@ -109,7 +110,7 @@ pub struct AccountCreationResponseData{
}
#[post("/v1/api/people", data="<data>")]
pub async fn create_account(database: &State<Pool>, data: Xml<AccountCreationData>) -> Result<Xml<AccountCreationResponseData>, Option<Errors>>{
pub async fn create_account(database: &State<Pool>, data: Xml<AccountCreationData>) -> Result<Xml<AccountCreationResponseData>, Option<Errors<'_>>>{
let database = database.inner();
// its fine to crash here if we cant get the next pid as that is in my opinion a dead state

View file

@ -1,6 +1,7 @@
use crate::Pool;
use crate::error::{Error, Errors};
use chrono::Utc;
use hickory_resolver::TokioAsyncResolver;
use rocket::form::Form;
use rocket::{FromForm, State, post, put};
@ -15,8 +16,58 @@ const BAD_CODE_ERROR: Errors = Errors {
pub struct ValidateEmailInput {
email: String,
}
#[post("/v1/api/support/validate/email", data = "<data>")]
pub async fn validate(data: Form<ValidateEmailInput>) {}
pub async fn validate(
data: Form<ValidateEmailInput>,
) -> Result<(), Errors<'static>> {
let email = data.email.trim();
// 1. Validate presence + basic format
if email.is_empty() || !email.contains('@') {
return Err(Errors {
error: &[Error {
code: "0103",
message: "Email format is invalid",
}],
});
}
// 2. Extract domain safely
let domain = match email.split('@').nth(1) {
Some(d) if !d.is_empty() => d,
_ => {
return Err(Errors {
error: &[Error {
code: "0103",
message: "Email format is invalid",
}],
});
}
};
// 3. DNS resolver
let resolver = TokioAsyncResolver::tokio_from_system_conf()
.map_err(|_| Errors {
error: &[Error {
code: "1126",
message: "DNS resolver initialization failed",
}],
})?;
// 4. MX lookup
match resolver.mx_lookup(domain).await {
Ok(mx) if mx.iter().next().is_some() => Ok(()),
_ => Err(Errors {
error: &[Error {
code: "1126",
message: "The domain is not accessible",
}],
}),
}
}
#[put("/v1/api/support/email_confirmation/<pid>/<code>")]
pub async fn verify_email(

View file

@ -1,90 +0,0 @@
use rocket::{post, State};
use serde::Deserialize;
use serde::Serialize;
use crate::Pool;
use crate::account::account::{User, read_bearer_auth_token};
use crate::nnid::oauth::generate_token::{create_token, token_type::AUTH_TOKEN, token_type::AUTH_REFRESH_TOKEN};
use crate::error::{Error, Errors};
use rocket::serde::json::Json;
#[derive(Deserialize)]
pub struct LoginRequest {
grant_type: String,
username: Option<String>,
password: Option<String>,
refresh_token: Option<String>,
}
#[derive(Serialize)]
pub struct LoginResponse {
access_token: String,
token_type: String,
expires_in: i32,
refresh_token: String,
}
const INVALID_GRANT_TYPE_ERROR: Errors<'static> = Errors {
error: &[Error {
code: "0100",
message: "Invalid grant type",
}]
};
const ACCOUNT_ID_OR_PASSWORD_ERRORS: Errors<'static> = Errors {
error: &[Error {
code: "0106",
message: "Invalid account ID or password",
}]
};
const INVALID_REFRESH_TOKEN_ERRORS: Errors<'static> = Errors {
error: &[Error {
code: "0107",
message: "Invalid or missing refresh token",
}]
};
#[post("/v1/login", data = "<form_data>")]
pub async fn login(pool: &State<Pool>, form_data: Json<LoginRequest>) -> Result<Json<LoginResponse>, Option<Errors<'static>>> {
let pool = pool.inner();
let grant_type = form_data.grant_type.as_str();
if grant_type != "password" && grant_type != "refresh_token" {
return Err(Some(INVALID_GRANT_TYPE_ERROR));
}
let user: User;
if grant_type == "password" {
let username = form_data.username.as_ref().ok_or(Some(ACCOUNT_ID_OR_PASSWORD_ERRORS))?;
let password = form_data.password.as_ref().ok_or(Some(ACCOUNT_ID_OR_PASSWORD_ERRORS))?;
user = User::get_by_username(username, pool)
.await
.ok_or(Some(ACCOUNT_ID_OR_PASSWORD_ERRORS))?;
if !user.verify_cleartext_password(password).is_some_and(|v| v) {
return Err(Some(ACCOUNT_ID_OR_PASSWORD_ERRORS));
}
} else {
let refresh_token = form_data.refresh_token.as_ref().ok_or(Some(INVALID_REFRESH_TOKEN_ERRORS))?;
user = read_bearer_auth_token(pool, refresh_token)
.await
.ok_or(Some(INVALID_REFRESH_TOKEN_ERRORS))?;
}
if user.account_level < 0 {
return Err(Some(ACCOUNT_ID_OR_PASSWORD_ERRORS));
}
let access_token = create_token(pool, user.pid, AUTH_TOKEN, None).await;
let refresh_token = create_token(pool, user.pid, AUTH_REFRESH_TOKEN, None).await;
Ok(Json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: 3600,
refresh_token,
}))
}

View file

@ -1,4 +0,0 @@
#[deprecated(note="please use the upcoming api instead of this")]
pub mod user;
#[deprecated(note="please use the upcoming api instead of this")]
pub mod login;

View file

@ -1,113 +0,0 @@
use std::env;
use once_cell::sync::Lazy;
use rocket::{get};
use crate::account::account::{Auth};
use rocket::serde::json::Json;
pub static CDN_URL: Lazy<Box<str>> = Lazy::new(||
env::var("CDN_URL").expect("CDN_URL not specified").into_boxed_str()
);
#[derive(serde::Serialize)]
struct EmailInfo {
address: String,
}
#[derive(serde::Serialize)]
struct TimezoneInfo {
name: String,
}
#[derive(serde::Serialize)]
struct MiiInfo {
data: String,
name: String,
image_url: String,
}
#[derive(serde::Serialize)]
struct FlagsInfo {
marketing: bool,
}
#[derive(serde::Serialize)]
struct ConnectionsInfo {
discord: DiscordInfo,
stripe: StripeInfo,
}
#[derive(serde::Serialize)]
struct DiscordInfo {
id: Option<String>,
}
#[derive(serde::Serialize)]
struct StripeInfo {
tier_name: Option<String>,
tier_level: Option<i32>,
}
#[derive(serde::Serialize)]
struct UserInfoResponse {
deleted: bool,
access_level: i32,
server_access_level: String,
pid: i32,
creation_date: chrono::NaiveDateTime,
updated: chrono::NaiveDateTime,
username: String,
birthdate: chrono::NaiveDate,
gender: String,
country: String,
email: EmailInfo,
timezone: TimezoneInfo,
mii: MiiInfo,
flags: FlagsInfo,
connections: ConnectionsInfo,
}
#[get("/v1/user")]
pub async fn get_user(auth: Auth<false>) -> Json<UserInfoResponse> {
let user = auth.0;
Json(UserInfoResponse {
deleted: false,
access_level: user.account_level,
server_access_level: "test".to_string(),
pid: user.pid,
creation_date: user.creation_date,
updated: user.updated,
username: user.username.clone(),
birthdate: user.birthdate,
gender: user.gender.clone(),
country: user.country.clone(),
email: EmailInfo {
address: user.email.clone(),
},
timezone: TimezoneInfo {
name: user.timezone.clone(),
},
mii: MiiInfo {
data: user.mii_data.clone(),
name: {
let cleaned = user.mii_data.replace('\n', "").replace('\r', "");
mii::MiiData::read(&cleaned)
.map(|v| v.name)
.unwrap_or_else(|| "INVALID".to_string())
},
image_url: format!("https://{}/mii/{}/normal_face.png", &CDN_URL.to_string(), user.pid),
},
flags: FlagsInfo {
marketing: user.marketing_allowed,
},
connections: ConnectionsInfo {
discord: DiscordInfo {
id: None,
},
stripe: StripeInfo {
tier_name: None,
tier_level: None,
},
},
})
}