Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
82b5bb36c5 force bearer auth
no idea if this is right but i want to protect accounts
2026-08-11 04:35:12 +02:00
124916e62c parental controls 2026-08-11 03:34:00 +02:00
fa063d78fb fix bug and sqlx prepare 2026-08-10 22:08:17 +02:00
672674f7ad implement missing routes 2026-08-10 21:39:11 +02:00
acb04bd782 load oauth clients from db instead of env and hardcoding 2026-08-10 04:39:06 +02:00
b0221e55d5 run sqlx prepare 2026-08-09 14:55:51 +02:00
d7c226b5a1 add eula versioning 2026-08-09 14:52:45 +02:00
8c01e2d5fd log redirect uri 2026-08-08 21:11:30 +02:00
73ff739c81 Merge pull request 'Update Rust crate thiserror to v2.0.20' (#88) from renovate/thiserror-2.x-lockfile into main
Reviewed-on: spacebar/account#88
2026-08-08 15:26:42 +02:00
e00913e7a3 Update Rust crate thiserror to v2.0.20 2026-08-08 13:16:13 +00:00
fedb6f67d4 Merge pull request 'Update Rust crate ecdsa to 0.17.0' (#68) from renovate/ecdsa-0.x into main
Reviewed-on: spacebar/account#68
2026-08-05 14:36:17 +02:00
b934df8531 Update Cargo.toml 2026-08-05 14:31:15 +02:00
5546583b46 Merge pull request 'Update Rust crate base64 to v0.23.1' (#87) from renovate/base64-0.x-lockfile into main
Reviewed-on: spacebar/account#87
2026-08-05 01:54:43 +02:00
b0a2476cc0 Merge pull request 'Update Rust crate sentry to v0.49.1' (#86) from renovate/sentry-rust-monorepo into main
Reviewed-on: spacebar/account#86
2026-08-05 01:54:36 +02:00
b1c247961d Merge pull request 'Update Rust crate lettre to v0.11.23' (#85) from renovate/lettre-0.x-lockfile into main
Reviewed-on: spacebar/account#85
2026-08-05 01:54:13 +02:00
622f0e989a Merge pull request 'Update Rust crate time to v0.3.55' (#84) from renovate/time-0.x-lockfile into main
Reviewed-on: spacebar/account#84
2026-08-05 01:54:05 +02:00
282b1c34cf Update Rust crate base64 to v0.23.1 2026-08-04 13:00:36 +00:00
b814887461 Update Rust crate sentry to v0.49.1 2026-08-03 15:00:41 +00:00
e3489f4eaa Update Rust crate lettre to v0.11.23 2026-08-03 11:45:27 +00:00
66e8e689af Update Rust crate time to v0.3.55 2026-08-01 08:30:31 +00:00
7af0739a69 Merge pull request 'Update Rust crate nex-account to v0.2.5' (#83) from renovate/nex-account-0.x-lockfile into main
Reviewed-on: spacebar/account#83
2026-07-29 20:30:34 +02:00
d76175fbea Update Rust crate nex-account to v0.2.5 2026-07-29 15:00:37 +00:00
a055cbefd2 Merge pull request 'Update Rust crate bcrypt to v0.19.3' (#80) from renovate/bcrypt-0.x-lockfile into main
Reviewed-on: spacebar/account#80
2026-07-29 16:37:56 +02:00
9951c60327 Update Rust crate bcrypt to v0.19.3 2026-07-29 14:30:30 +00:00
8f1d0bf0e7 Merge pull request 'Update Rust crate k256 to 0.14.0' (#72) from renovate/k256-0.x into main
Reviewed-on: spacebar/account#72
2026-07-29 16:17:42 +02:00
db665374aa Merge pull request 'Update Rust crate thiserror to v2.0.19' (#76) from renovate/thiserror-2.x-lockfile into main
Reviewed-on: spacebar/account#76
2026-07-29 16:17:34 +02:00
7df36c1301 Merge pull request 'Update Rust crate binrw to v0.15.2' (#81) from renovate/binrw-0.x-lockfile into main
Reviewed-on: spacebar/account#81
2026-07-29 16:17:15 +02:00
7fa5d6a4d0 Merge pull request 'Update Rust crate base64 to 0.23.0' (#82) from renovate/base64-0.x into main
Reviewed-on: spacebar/account#82
2026-07-29 16:17:02 +02:00
68b5f2251d Update Rust crate k256 to 0.14.0 2026-07-29 14:01:24 +00:00
e7c94f30cd Update Rust crate base64 to 0.23.0 2026-07-29 14:01:10 +00:00
579ba418c1 Update Rust crate thiserror to v2.0.19 2026-07-29 14:01:04 +00:00
02a4b2b504 Update Rust crate binrw to v0.15.2 2026-07-29 14:00:54 +00:00
fdcdd1ef20 Merge pull request 'Update Rust crate sentry to 0.49.0' (#71) from renovate/sentry-rust-monorepo into main
Reviewed-on: spacebar/account#71
2026-07-29 15:48:10 +02:00
3a9c856e03 Merge pull request 'Update Rust crate bytemuck to v1.25.2' (#73) from renovate/bytemuck-1.x-lockfile into main
Reviewed-on: spacebar/account#73
2026-07-29 15:47:56 +02:00
426dbf3f1b Merge pull request 'Update Rust crate tokio to v1.53.1' (#75) from renovate/tokio-1.x-lockfile into main
Reviewed-on: spacebar/account#75
2026-07-29 15:47:51 +02:00
762214f81a Merge pull request 'Update Rust crate serde to v1.0.229' (#77) from renovate/serde-monorepo into main
Reviewed-on: spacebar/account#77
2026-07-29 15:47:40 +02:00
a0a48a3508 Merge pull request 'Update Rust crate serde_json to v1.0.151' (#78) from renovate/serde_json-1.x-lockfile into main
Reviewed-on: spacebar/account#78
2026-07-29 15:47:33 +02:00
487f06e311 Merge pull request 'Update Rust crate time to v0.3.54' (#79) from renovate/time-0.x-lockfile into main
Reviewed-on: spacebar/account#79
2026-07-29 15:47:28 +02:00
fae51441cd Merge pull request 'Update Rust crate dsa to 0.7.0' (#66) from renovate/dsa-0.x into main
Reviewed-on: spacebar/account#66
2026-07-29 15:47:09 +02:00
9b7d7928a3 Update Rust crate sentry to 0.49.0 2026-07-29 11:16:02 +00:00
08a789bd64 Update Rust crate dsa to 0.7.0 2026-07-28 13:55:06 +00:00
44b380e6c1 Update Rust crate tokio to v1.53.1 2026-07-20 17:45:57 +00:00
08146a3604 Update Rust crate time to v0.3.54 2026-07-20 08:45:40 +00:00
f2a5997dab Update Rust crate serde_json to v1.0.151 2026-07-20 06:15:34 +00:00
4621679bf5 Update Rust crate bytemuck to v1.25.2 2026-07-19 19:45:35 +00:00
9037439d9e Update Rust crate serde to v1.0.229 2026-07-18 23:45:29 +00:00
5160206147 actually actually asociate cert hash with token 2026-07-16 03:54:06 +02:00
19505b4348 actually asociate cert hash with token 2026-07-16 03:40:05 +02:00
9bf06fffd6 serial number and cert hash route 2026-07-16 02:06:51 +02:00
f32e69be99 serial 2026-07-15 17:37:53 +02:00
c47a3fb5fc fix accidentally forcing nex-accounts to be fully created before using nex provider 2026-07-07 08:13:42 +02:00
4970f09db5 Merge pull request 'Update Rust crate p256 to 0.14.0' (#69) from renovate/p256-0.x into main
Reviewed-on: spacebar/account#69
2026-07-06 18:32:54 +02:00
437ab17e81 Merge pull request 'Update actions/cache action to v6' (#63) from renovate/actions-cache-6.x into main
Reviewed-on: spacebar/account#63
2026-07-06 18:32:48 +02:00
a19d45a4ac Merge pull request 'Update Rust crate quick-xml to 0.41.0' (#65) from renovate/quick-xml-0.x into main
Reviewed-on: spacebar/account#65
2026-07-06 18:32:42 +02:00
35a3e27591 Update Rust crate quick-xml to 0.41.0 2026-07-05 20:31:27 +00:00
ace476b66b Update Rust crate p256 to 0.14.0 2026-07-05 20:31:12 +00:00
1576062160 Update Rust crate ecdsa to 0.17.0 2026-07-05 20:30:56 +00:00
2c7767124e switch to using nex-account for nex account management 2026-07-05 22:13:51 +02:00
3c3b8bf91c Merge pull request 'Fix default account level' (#70) from fix-tester-bug into main
Reviewed-on: spacebar/account#70
2026-07-05 11:14:07 +02:00
18da6e247b Fix default account level 2026-07-05 01:47:37 +02:00
f7d6b3ebf0 sqlx prepare 2026-07-04 20:52:24 +02:00
ea5f9d1c13 simple ban system for admins (to be replaced later) 2026-07-04 19:26:28 +02:00
cfd4bc21e3 fix signups because i am a professional dumbass 2026-06-27 09:26:51 +02:00
2dd404a957 Update actions/cache action to v6 2026-06-23 15:30:19 +00:00
2f1cb63e1e merge 2026-06-21 23:20:13 +02:00
381610c2ec allow website to not use a client secret 2026-06-21 23:19:41 +02:00
cec6414bc1 Update actions/checkout action to v7 2026-06-18 15:15:18 +00:00
52f044a51f Update Rust crate bcrypt to v0.19.2 2026-06-16 09:45:37 +00:00
250cb9a509 Merge pull request 'fix: Change act ban from 102-2814 to 102-2802' (#57) from kitten/account:main into main
Reviewed-on: spacebar/account#57
2026-06-14 09:38:25 +02:00
d310f81376 Update Rust crate time to v0.3.49 2026-06-14 06:38:48 +02:00
ccd10c62a9 fix: update act ban code 2026-06-13 04:39:52 +02:00
44 changed files with 3464 additions and 1606 deletions

View file

@ -1,2 +1,5 @@
[target.'cfg(target_arch = "x86_64")']
rustflags = ["-C", "target-feature=+aes,+sse2"]
rustflags = ["-C", "target-feature=+aes,+sse2"]
[registries]
spbr = { index = "sparse+https://crates.spbr.net/api/v1/crates/" }

View file

@ -15,12 +15,12 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
- name: Cache container storage
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: |
/var/lib/containers/storage

View file

@ -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"
}

View file

@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "select serial from certificates where hash = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "serial",
"type_info": "Varchar",
"origin": {
"Table": {
"table": "certificates",
"name": "serial"
}
}
}
],
"parameters": {
"Left": [
"Bytea"
]
},
"nullable": [
true
]
},
"hash": "0ef5f08a84d6864291cb6125610d9d3e1961530ae963f9ab5442482d81228590"
}

View file

@ -1,21 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT nextval('pid_counter') as pid",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "pid",
"type_info": "Int8",
"origin": "Expression"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "3c9b1695f8ae49e4308c048de98c1c262351465b6f102e98912f67442a1f54d9"
}

View file

@ -68,6 +68,17 @@
"name": "expires"
}
}
},
{
"ordinal": 6,
"name": "cert_hash",
"type_info": "Bytea",
"origin": {
"Table": {
"table": "tokens",
"name": "cert_hash"
}
}
}
],
"parameters": {
@ -83,7 +94,8 @@
false,
false,
true,
false
false,
true
]
},
"hash": "48710e0b87742cc3fef816b3c95604095f71324011e7093ec37af15da8c158f4"

View file

@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "SELECT username, email FROM users WHERE pid = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar",
"origin": {
"Table": {
"table": "users",
"name": "username"
}
}
},
{
"ordinal": 1,
"name": "email",
"type_info": "Varchar",
"origin": {
"Table": {
"table": "users",
"name": "email"
}
}
}
],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": [
false,
false
]
},
"hash": "48fd98db66a68865babe4ac6a453de747bba2ce1da198dda4b6f3b78eacfebf3"
}

View file

@ -211,6 +211,17 @@
"name": "verification_code"
}
}
},
{
"ordinal": 19,
"name": "eula_version",
"type_info": "Int4",
"origin": {
"Table": {
"table": "users",
"name": "eula_version"
}
}
}
],
"parameters": {
@ -237,6 +248,7 @@
false,
false,
false,
false,
false
]
},

View file

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET password = $1 WHERE pid = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int4"
]
},
"nullable": []
},
"hash": "6c276038b92bef06b30da32dab42256be431b361cfd36754aa41b6da220618cc"
}

View file

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO device_parental_controls (serial_number, email, updated_at)\n VALUES ($1, $2, NOW())\n ON CONFLICT (serial_number)\n DO UPDATE SET email = EXCLUDED.email, updated_at = NOW()\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "7d5d57a9c0065301382767c85de4f25f0636ec0e7c0beed4cdb627c419e407c4"
}

View file

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET eula_version = $1 WHERE pid = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Int4"
]
},
"nullable": []
},
"hash": "7e74c28ce6daa8155e268756411f73f1d281a736aa5a838ad3e72fce0e9a109e"
}

View file

@ -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"
}

View file

@ -211,6 +211,17 @@
"name": "verification_code"
}
}
},
{
"ordinal": 19,
"name": "eula_version",
"type_info": "Int4",
"origin": {
"Table": {
"table": "users",
"name": "eula_version"
}
}
}
],
"parameters": {
@ -237,6 +248,7 @@
false,
false,
false,
false,
false
]
},

View file

@ -68,6 +68,17 @@
"name": "expires"
}
}
},
{
"ordinal": 6,
"name": "cert_hash",
"type_info": "Bytea",
"origin": {
"Table": {
"table": "tokens",
"name": "cert_hash"
}
}
}
],
"parameters": {
@ -83,7 +94,8 @@
false,
false,
true,
false
false,
true
]
},
"hash": "9d3cee43a86cead9a6d078abc1266fc2a97ac6e25a9733d1d20faf555c67abe1"

View file

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET account_level = $1 WHERE username = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Text"
]
},
"nullable": []
},
"hash": "9d41e04076235e9a8c857c58c6f5fe7b26cba4da50ce8527ba32128152a31ce6"
}

View file

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "update certificates set serial = $1 where hash = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Bytea"
]
},
"nullable": []
},
"hash": "beeeea07993ba8daac1e43c985e8c840f66eec4e5458d3709c0e0f09afe51377"
}

View file

@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(select 1 from users where pid = $1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool",
"origin": "Expression"
}
],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": [
null
]
},
"hash": "c8d50662530cac49c4261fb321cd15f9e4bafdfca12d2130a873d44a88dd435b"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "insert into tokens (token_type, pid, title_id)\n values ($1, $2, $3) returning token_id, random",
"query": "insert into tokens (token_type, pid, title_id, cert_hash)\n values ($1, $2, $3, $4) returning token_id, random",
"describe": {
"columns": [
{
@ -30,7 +30,8 @@
"Left": [
"Int4",
"Int4",
"Varchar"
"Varchar",
"Bytea"
]
},
"nullable": [
@ -38,5 +39,5 @@
false
]
},
"hash": "e5a2f7f28c3d7b9524d3dce48a9e47d6180ff634ebf59f3a1efd92b797170ac2"
"hash": "d8298703381f0e220bdbe122dbdc4dc5cd7ae99fac34ae16023f9a6a701857c9"
}

View file

@ -1,64 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT game_server_id, maintenance_mode, address, port FROM nex_servers WHERE title_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "game_server_id",
"type_info": "Varchar",
"origin": {
"Table": {
"table": "nex_servers",
"name": "game_server_id"
}
}
},
{
"ordinal": 1,
"name": "maintenance_mode",
"type_info": "Bool",
"origin": {
"Table": {
"table": "nex_servers",
"name": "maintenance_mode"
}
}
},
{
"ordinal": 2,
"name": "address",
"type_info": "Inet",
"origin": {
"Table": {
"table": "nex_servers",
"name": "address"
}
}
},
{
"ordinal": 3,
"name": "port",
"type_info": "Int4",
"origin": {
"Table": {
"table": "nex_servers",
"name": "port"
}
}
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "e3349c0e5ab82bbef359cf573caf454f23588f3cfa27299e58863dc9397d55de"
}

View file

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM users WHERE pid = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": []
},
"hash": "ffef185a4db4b101477f7a78444192a7dfa8af98c00394978a9627b5ecdae9b5"
}

2423
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -16,7 +16,7 @@ incremental = false
rocket = { version = "0.5.1", features = ["json"] }
serde = { version = "1.0.218", features = ["derive"] }
log = "0.4.26"
quick-xml = { version = "0.40.0", features = ["serialize"] }
quick-xml = { version = "0.41.0", features = ["serialize"] }
tokio = "1.43.0"
dotenvy = "0.15.7"
once_cell = "1.20.3"
@ -25,7 +25,7 @@ chrono = { version = "0.4.39", features = ["serde"] }
argon2 = "0.5.3"
sha2 = "0.11.0"
bytemuck = { version = "1.21.0", features = ["derive"] }
base64 = "0.22.1"
base64 = "0.23.0"
hex = "0.4.3"
thiserror = "2.0.11"
bcrypt = "0.19.0"
@ -36,7 +36,7 @@ md-5 = "0.11.0"
cbc = "0.1.2"
crc32fast = "1.4.2"
gxhash = "3.4.1"
sentry = "0.48.0"
sentry = "0.49.0"
rocket_cors = "0.6.0"
juniper = { version = "0.17.0", features = ["chrono"] }
@ -47,11 +47,12 @@ lettre = "0.11.15"
rand = { version = "0.10.0", features = ["std"] }
reqwest = "0.13.0"
binrw = "0.15.1"
ecdsa = { version = "0.16.9", features = ["pem", "std", "verifying"] }
ecdsa = { version = "0.17.0", features = ["pem", "std", "algorithm", "digest"] }
sha256 = "1.6.0"
p256 = "0.13.2"
k256 = "0.13.4"
dsa = "0.6.3"
p256 = "0.14.0"
k256 = "0.14.0"
dsa = "0.7.0"
openssl = {version = "0.10.78", features = ["vendored"]}
time = "0.3.47"
hickory-resolver = { version = "0.24", features = ["tokio-runtime"] }
nex-account = { version = "0.2.4", registry = "spbr" }

View file

@ -1 +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 TV/Maple(username: djt.v.) on discord.]]></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 djt.v. en discord.]]></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>
<?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 TV/Maple(username: djt.v.) on discord.]]></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 djt.v. en discord.]]></main_text><sub_title><![CDATA[Política de Privacidad]]></sub_title><sub_text index="1"><![CDATA[Tenga en cuenta que almacenaremos lo siguiente: direcció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>

View file

View file

@ -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 @@
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td>
<a href="https://spfn.net">
<img class="logo" width="auto" height="48px" src="https://spfn.net/res/img/spfn.png" alt="SPFN">
<a href="https://spfn.spbr.net">
<img class="logo" width="auto" height="48px" src="https://spfn.spbr.net/res/img/spfn.png" alt="SPFN">
</a>
</td>
</tr>
@ -122,19 +122,6 @@
Your Splatfestival Network ID activation is almost complete.
</td>
</tr>
<tr>
<td width="100%" height="16px" style="line-height: 16px;">&nbsp;</td>
</tr>
<!-- <tr>-->
<!-- <td class="confirm-link" bgcolor="#673db6" style="font-size: 14px; font-weight: 700; border-radius: 10px; padding: 12px" align="center">-->
<!-- <a href="{{confirmation-href}}" style="text-decoration: none; color: #ffffff; " width="100%">-->
<!-- Confirm email address-->
<!-- </a>-->
<!-- </td>-->
<!-- </tr>-->
<tr>
<td width="100%" height="48px" style="line-height: 48px;">&nbsp;</td>
</tr>
<tr>
<td>
Enter the following 6-digit code on your console:

View file

@ -0,0 +1,190 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd" lang="en">
<html lang="en">
<head>
<meta name="color-scheme" content="light dark">
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
<style>
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;700&display=swap');
:root {
color-scheme: light dark;
supported-color-schemes:light dark;
}
@media (prefers-color-scheme: light) {
body.email-body,
table.centerer,
table.wrapper {
background-color: #FFFFFF !important;
color: #000000 !important;
}
table.card {
background-color: #B60000 !important;
}
span.shoutout {
color: #FFB3B3 !important;
}
td.confirm-link {
background-color: #FF4D4D !important;
}
td.confirm-code {
background-color: #FFB3B3 !important;
color: #660000 !important;
}
td.notice {
color: #FF4D4D !important;
}
td.notice a {
color: #B60000 !important;
}
img.logo {
content: url("https://spfn.spbr.net/res/img/spfn.png") !important;
}
}
@media (prefers-color-scheme: dark) {
body.email-body,
table.centerer,
table.wrapper {
background-color: #3B1B1B !important;
color: #FFFFFF !important;
}
table.card {
background-color: #4A2323 !important;
}
span.shoutout {
color: #FF9999 !important;
}
td.confirm-link {
background-color: #B60000 !important;
}
td.confirm-code {
background-color: #652323 !important;
color: #ffffff !important;
}
td.notice {
color: #C18989 !important;
}
td.notice a {
color: #F5C1C1 !important;
}
}
</style>
</head>
<body class="email-body" bgcolor="#1B1F3B" style="margin-left: 0; margin-right: 0; margin-top: 0; margin-bottom: 0; padding-left: 0; padding-right: 0; padding-top: 0; padding-bottom: 0; font-family: Poppins, Arial, Helvetica, sans-serif;">
<div style="display:none;">Hello. You have requested a reset of parental controls on your console.</div>
<table class="centerer" bgcolor="#1B1F3B" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td align="center">
<table class="wrapper" bgcolor="#1B1F3B" style="font-family: Poppins, Arial, Helvetica, sans-serif;" border="0" cellpadding="0" cellspacing="0" height="100%" width="420px">
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td width="32px">&nbsp;</td>
<td>
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td height="36px" style="line-height: 36px;" width="100%">&nbsp;</td>
</tr>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td>
<a href="https://spfn.spbr.net">
<img class="logo" width="auto" height="48px" src="https://spfn.spbr.net/res/img/spfn.png" alt="SPFN">
</a>
</td>
</tr>
<tr>
<td width="100%" height="36px" style="line-height: 36px;">&nbsp;</td>
</tr>
<tr>
<td>
<table class="card" bgcolor="#23274a" style="color: #ffffff; border-radius: 10px;" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td width="24px" height="100%">&nbsp;</td>
<td>
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr width="100%" height="48px" style="line-height: 48px;">
<td>&nbsp;</td>
</tr>
<tr style="font-size: 24px; font-weight: 700;">
<td>
Hello.
</td>
</tr>
<tr>
<td width="100%" height="24px" style="line-height: 24px;">&nbsp;</td>
</tr>
<tr>
<td style="color: #ffffff; ">
Your email was linked to a Wii U console with serial number {{serial}} for parental controls.
</td>
</tr>
<tr>
<td width="100%" height="24px" style="line-height: 24px;">&nbsp;</td>
</tr>
<tr>
<td>
You have requested a master key to reset your parental controls PIN. The master key you must enter on your Wii U is:
</td>
</tr>
<tr>
<td width="100%" height="24px" style="line-height: 24px;">&nbsp;</td>
</tr>
<tr>
<td class="confirm-code" bgcolor="#373c65" style="color: #ffffff; font-size: 14px; font-weight: 700; border-radius: 10px; padding: 12px" align="center">
{{mkey}}
</td>
</tr>
<tr>
<td width="100%" height="36px" style="line-height: 36px;">&nbsp;</td>
</tr>
<tr>
<td>
Please make sure to change your PIN as soon as possible.
</td>
</tr>
<tr>
<td align="right">
The SPFN team
</td>
</tr>
<tr>
<td width="100%" height="24px" style="line-height: 24px;">&nbsp;</td>
</tr>
</table>
</td>
<td width="24px" height="100%">&nbsp;</td>
</tr>
</table>
</td>
</tr>
<tr>
<td width="100%" height="18px" style="line-height: 18px;">&nbsp;</td>
</tr>
<tr>
<td class="notice" style="color: #8990c1; font-size: 12px;">
Note: this email message was auto-generated, please do not respond. For further assistance, please join our <a href="https://discord.gg/grMSxZf" style="text-decoration: none; color: #ffffff; ">Discord server</a>.
</td>
</tr>
<tr>
<td width="100%" height="48px" style="line-height: 48px;">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
<td width="32px">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,177 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd" lang="en">
<html lang="en">
<head>
<meta name="color-scheme" content="light dark">
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
<style>
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;700&display=swap');
:root {
color-scheme: light dark;
supported-color-schemes:light dark;
}
@media (prefers-color-scheme: light) {
body.email-body,
table.centerer,
table.wrapper {
background-color: #FFFFFF !important;
color: #000000 !important;
}
table.card {
background-color: #B60000 !important;
}
span.shoutout {
color: #FFB3B3 !important;
}
td.confirm-link {
background-color: #FF4D4D !important;
}
td.confirm-code {
background-color: #FFB3B3 !important;
color: #660000 !important;
}
td.notice {
color: #FF4D4D !important;
}
td.notice a {
color: #B60000 !important;
}
img.logo {
content: url("https://spfn.spbr.net/res/img/spfn.png") !important;
}
}
@media (prefers-color-scheme: dark) {
body.email-body,
table.centerer,
table.wrapper {
background-color: #3B1B1B !important;
color: #FFFFFF !important;
}
table.card {
background-color: #4A2323 !important;
}
span.shoutout {
color: #FF9999 !important;
}
td.confirm-link {
background-color: #B60000 !important;
}
td.confirm-code {
background-color: #652323 !important;
color: #ffffff !important;
}
td.notice {
color: #C18989 !important;
}
td.notice a {
color: #F5C1C1 !important;
}
}
</style>
</head>
<body class="email-body" bgcolor="#1B1F3B" style="margin-left: 0; margin-right: 0; margin-top: 0; margin-bottom: 0; padding-left: 0; padding-right: 0; padding-top: 0; padding-bottom: 0; font-family: Poppins, Arial, Helvetica, sans-serif;">
<div style="display:none;">Hello. The console with the serial number {{serial}} has been linked to your email for parental controls.</div>
<table class="centerer" bgcolor="#1B1F3B" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td align="center">
<table class="wrapper" bgcolor="#1B1F3B" style="font-family: Poppins, Arial, Helvetica, sans-serif;" border="0" cellpadding="0" cellspacing="0" height="100%" width="420px">
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td width="32px">&nbsp;</td>
<td>
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td height="36px" style="line-height: 36px;" width="100%">&nbsp;</td>
</tr>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td>
<a href="https://spfn.spbr.net">
<img class="logo" width="auto" height="48px" src="https://spfn.spbr.net/res/img/spfn.png" alt="SPFN">
</a>
</td>
</tr>
<tr>
<td width="100%" height="36px" style="line-height: 36px;">&nbsp;</td>
</tr>
<tr>
<td>
<table class="card" bgcolor="#23274a" style="color: #ffffff; border-radius: 10px;" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td width="24px" height="100%">&nbsp;</td>
<td>
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr width="100%" height="48px" style="line-height: 48px;">
<td>&nbsp;</td>
</tr>
<tr style="font-size: 24px; font-weight: 700;">
<td>
Hello.
</td>
</tr>
<tr>
<td width="100%" height="24px" style="line-height: 24px;">&nbsp;</td>
</tr>
<tr>
<td style="color: #ffffff; ">
Your email was linked to a Wii U console with serial number {{serial}} for parental controls.
</td>
</tr>
<tr>
<td width="100%" height="24px" style="line-height: 24px;">&nbsp;</td>
</tr>
<tr>
<td>
If you did not authorize this, please contact our team at support@spbr.net.
</td>
</tr>
<tr>
<td width="100%" height="36px" style="line-height: 36px;">&nbsp;</td>
</tr>
<tr>
<td align="right">
The SPFN team
</td>
</tr>
<tr>
<td width="100%" height="24px" style="line-height: 24px;">&nbsp;</td>
</tr>
</table>
</td>
<td width="24px" height="100%">&nbsp;</td>
</tr>
</table>
</td>
</tr>
<tr>
<td width="100%" height="18px" style="line-height: 18px;">&nbsp;</td>
</tr>
<tr>
<td class="notice" style="color: #8990c1; font-size: 12px;">
Note: this email message was auto-generated, please do not respond. For further assistance, please join our <a href="https://discord.gg/grMSxZf" style="text-decoration: none; color: #ffffff; ">Discord server</a>.
</td>
</tr>
<tr>
<td width="100%" height="48px" style="line-height: 48px;">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
<td width="32px">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,190 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd" lang="en">
<html lang="en">
<head>
<meta name="color-scheme" content="light dark">
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
<style>
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;700&display=swap');
:root {
color-scheme: light dark;
supported-color-schemes:light dark;
}
@media (prefers-color-scheme: light) {
body.email-body,
table.centerer,
table.wrapper {
background-color: #FFFFFF !important;
color: #000000 !important;
}
table.card {
background-color: #B60000 !important;
}
span.shoutout {
color: #FFB3B3 !important;
}
td.confirm-link {
background-color: #FF4D4D !important;
}
td.confirm-code {
background-color: #FFB3B3 !important;
color: #660000 !important;
}
td.notice {
color: #FF4D4D !important;
}
td.notice a {
color: #B60000 !important;
}
img.logo {
content: url("https://spfn.spbr.net/res/img/spfn.png") !important;
}
}
@media (prefers-color-scheme: dark) {
body.email-body,
table.centerer,
table.wrapper {
background-color: #3B1B1B !important;
color: #FFFFFF !important;
}
table.card {
background-color: #4A2323 !important;
}
span.shoutout {
color: #FF9999 !important;
}
td.confirm-link {
background-color: #B60000 !important;
}
td.confirm-code {
background-color: #652323 !important;
color: #ffffff !important;
}
td.notice {
color: #C18989 !important;
}
td.notice a {
color: #F5C1C1 !important;
}
}
</style>
</head>
<body class="email-body" bgcolor="#1B1F3B" style="margin-left: 0; margin-right: 0; margin-top: 0; margin-bottom: 0; padding-left: 0; padding-right: 0; padding-top: 0; padding-bottom: 0; font-family: Poppins, Arial, Helvetica, sans-serif;">
<div style="display:none;">Hello {{username}}. Your Splatfestival Network ID password reset has been processed. You may use the temporary password below to log into your account.</div>
<table class="centerer" bgcolor="#1B1F3B" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td align="center">
<table class="wrapper" bgcolor="#1B1F3B" style="font-family: Poppins, Arial, Helvetica, sans-serif;" border="0" cellpadding="0" cellspacing="0" height="100%" width="420px">
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td width="32px">&nbsp;</td>
<td>
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td height="36px" style="line-height: 36px;" width="100%">&nbsp;</td>
</tr>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td>
<a href="https://spfn.spbr.net">
<img class="logo" width="auto" height="48px" src="https://spfn.spbr.net/res/img/spfn.png" alt="SPFN">
</a>
</td>
</tr>
<tr>
<td width="100%" height="36px" style="line-height: 36px;">&nbsp;</td>
</tr>
<tr>
<td>
<table class="card" bgcolor="#23274a" style="color: #ffffff; border-radius: 10px;" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr>
<td width="24px" height="100%">&nbsp;</td>
<td>
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%">
<tr width="100%" height="48px" style="line-height: 48px;">
<td>&nbsp;</td>
</tr>
<tr style="font-size: 24px; font-weight: 700;">
<td>
Hello <span class="shoutout" style="color: #cab1fb;">{{username}}</span>.
</td>
</tr>
<tr>
<td width="100%" height="24px" style="line-height: 24px;">&nbsp;</td>
</tr>
<tr>
<td style="color: #ffffff; ">
Your Splatfestival Network ID password reset has been processed.
</td>
</tr>
<tr>
<td>
You may use the following temporary password to login:
</td>
</tr>
<tr>
<td width="100%" height="16px" style="line-height: 16px;">&nbsp;</td>
</tr>
<tr>
<td class="confirm-code" bgcolor="#373c65" style="color: #ffffff; font-size: 14px; font-weight: 700; border-radius: 10px; padding: 12px" align="center">
{{password}}
</td>
</tr>
<tr>
<td width="100%" height="48px" style="line-height: 48px;">&nbsp;</td>
</tr>
<tr>
<td>
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")
</td>
</tr>
<tr>
<td width="100%" height="36px" style="line-height: 36px;">&nbsp;</td>
</tr>
<tr>
<td align="right">
The SPFN team
</td>
</tr>
<tr>
<td width="100%" height="24px" style="line-height: 24px;">&nbsp;</td>
</tr>
</table>
</td>
<td width="24px" height="100%">&nbsp;</td>
</tr>
</table>
</td>
</tr>
<tr>
<td width="100%" height="18px" style="line-height: 18px;">&nbsp;</td>
</tr>
<tr>
<td class="notice" style="color: #8990c1; font-size: 12px;">
Note: this email message was auto-generated, please do not respond. For further assistance, please join our <a href="https://discord.gg/grMSxZf" style="text-decoration: none; color: #ffffff; ">Discord server</a>.
</td>
</tr>
<tr>
<td width="100%" height="48px" style="line-height: 48px;">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
<td width="32px">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View file

@ -64,6 +64,7 @@ pub struct User {
pub updated: NaiveDateTime,
pub nex_password: String,
pub verification_code: Option<i32>,
pub eula_version: i32,
}
#[derive(sqlx::FromRow)]
@ -117,7 +118,10 @@ pub fn generate_password(pid: i32, cleartext_password: &str) -> Option<String> {
bcrypt::hash(password, 10).ok()
}
pub async fn read_basic_auth_token(connection: &Pool, token: &str) -> Option<User> {
pub async fn read_basic_auth_token(
connection: &Pool,
token: &str,
) -> Option<(User, Option<[u8; 32]>)> {
let data = match BASE64_STANDARD.decode(&token) {
Ok(d) => d,
Err(e) => {
@ -161,13 +165,69 @@ pub async fn read_basic_auth_token(connection: &Pool, token: &str) -> Option<Use
let password_valid = user.verify_cleartext_password(&login_password);
if password_valid == Some(true) {
Some(user)
Some((user, None))
} else {
None
}
}
pub async fn read_bearer_auth_token(connection: &Pool, token: &str) -> Option<User> {
pub async fn read_hashed_basic_auth_token(
connection: &Pool,
token: &str,
) -> Option<(User, Option<[u8; 32]>)> {
let data = match BASE64_STANDARD.decode(token) {
Ok(d) => d,
Err(e) => {
println!("Failed to decode base64: {:?}", e);
return None;
}
};
let decoded_token = match String::from_utf8(data) {
Ok(s) => s,
Err(e) => {
println!("Failed to convert decoded bytes to UTF-8 string: {:?}", e);
return None;
}
};
let (login_username, login_hash) = match decoded_token.split_once(' ') {
Some(parts) => parts,
None => {
println!("Failed to split hashed basic token into username and hash");
return None;
}
};
let user_result = sqlx::query_as!(
User,
"SELECT * FROM users WHERE username = $1",
login_username
)
.fetch_one(connection)
.await;
let user = match user_result {
Ok(u) => u,
Err(e) => {
println!("Failed to fetch user from database: {:?}", e);
return None;
}
};
let password_valid = user.verify_hashed_password(login_hash);
if password_valid == Some(true) {
Some((user, None))
} else {
None
}
}
pub async fn read_bearer_auth_token(
connection: &Pool,
token: &str,
) -> Option<(User, Option<[u8; 32]>)> {
let data = TokenData::decode(token)?;
let token_info = sqlx::query!(
@ -189,11 +249,11 @@ pub async fn read_bearer_auth_token(connection: &Pool, token: &str) -> Option<Us
.await
.ok()?;
Some(user)
Some((user, token_info.cert_hash.and_then(|v| v.try_into().ok())))
}
pub fn generate_nex_password() -> String {
let mut rng = rand::rng();
let mut rng = rand::rng();
let mut output = String::with_capacity(16);
while output.len() < 16 {
@ -214,6 +274,7 @@ pub fn generate_nex_password() -> String {
pub struct Auth<const FORCE_BEARER_AUTH: bool = true, const USE_CERT: bool = FORCE_BEARER_AUTH>(
pub User,
pub Option<[u8; 32]>,
);
impl<const FORCE_BEARER_AUTH: bool, const USE_CERT: bool> AsRef<User>
@ -278,6 +339,7 @@ pub async fn link_certificate_to_pid(
Ok(())
}
// todo: make this more consistent by requiring a cert to be asociated to the token if USE_CERT is set
#[async_trait]
impl<'r, const FORCE_BEARER_AUTH: bool, const USE_CERT: bool> FromRequest<'r>
for Auth<FORCE_BEARER_AUTH, USE_CERT>
@ -297,13 +359,14 @@ impl<'r, const FORCE_BEARER_AUTH: bool, const USE_CERT: bool> FromRequest<'r>
let (auth_type, token) = request_try!(auth.split_once(' ').ok_or(INVALID_TOKEN_ERRORS));
let user = match auth_type {
let data = match auth_type {
"Basic" if !FORCE_BEARER_AUTH => read_basic_auth_token(pool, token).await,
"HashedBasic" if !FORCE_BEARER_AUTH => read_hashed_basic_auth_token(pool, token).await,
"Bearer" => read_bearer_auth_token(pool, token).await,
_ => return Outcome::Error((Status::BadRequest, INVALID_TOKEN_ERRORS)),
};
let Some(user) = user else {
let Some((user, cert)) = data else {
return Outcome::Error((Status::BadRequest, INVALID_TOKEN_ERRORS));
};
@ -316,7 +379,7 @@ impl<'r, const FORCE_BEARER_AUTH: bool, const USE_CERT: bool> FromRequest<'r>
// ..user
// };
Outcome::Success(Self(user))
Outcome::Success(Self(user, cert))
}
}

View file

@ -36,3 +36,104 @@ 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(())
}
pub async fn send_pc_email(to: &str, serial: &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/parentalControlsTemplate.html")
.map_err(|e| format!("Failed to read email template: {}", e))?;
// Replace placeholders
let body = template
.replace("{{serial}}", serial);
let email = Message::builder()
.from(smtp_user.parse().unwrap())
.to(to.parse().unwrap())
.subject("Parental controls confirmation 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(())
}
pub async fn send_reset_pin_email(to: &str, serial: &str, master_key: &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/masterKeyTemplate.html")
.map_err(|e| format!("Failed to read email template: {}", e))?;
// Replace placeholders
let body = template
.replace("{{serial}}", serial)
.replace("{{mkey}}", master_key);
let email = Message::builder()
.from(smtp_user.parse().unwrap())
.to(to.parse().unwrap())
.subject("Parental controls PIN 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(())
}

View file

@ -0,0 +1,35 @@
use rocket::{post, FromForm, State};
use rocket::form::Form;
use rocket::http::Status;
use crate::account::account::Auth;
use crate::Pool;
#[derive(FromForm)]
pub struct AdminRequest<'r> {
pub username: &'r str,
}
#[post("/api/v2/admin/ban", data = "<request>")]
pub async fn ban_user(pool: &State<Pool>, auth: Auth<true>, request: Form<AdminRequest<'_>>) -> Result<(), Status> {
if auth.account_level < 2 {
return Err(Status::Forbidden);
};
log::info!("banning user {:?} from moderator {:?}", request.username, auth.username);
let _row = sqlx::query!(
"UPDATE users SET account_level = $1 WHERE username = $2",
-1,
request.username
)
.execute(pool.inner())
.await
.map_err(|e| {
log::error!("failed to execute query: {:?}", e);
return Err::<(), rocket::http::Status>(Status::InternalServerError);
});
log::info!("banned user {:?}", request.username);
Ok(())
}

View file

@ -0,0 +1 @@
pub mod bans;

View file

@ -1,2 +1,3 @@
pub mod oauth;
pub mod users;
pub mod users;
pub mod admin;

View file

@ -65,12 +65,13 @@ pub async fn authorize_submit(
let expires_at = Utc::now().naive_utc() + Duration::minutes(5);
if let Err(db_err) = sqlx::query(
"INSERT INTO oauth_auth_codes (code, pid, redirect_uri, expires_at) VALUES ($1, $2, $3, $4)"
"INSERT INTO oauth_auth_codes (code, pid, redirect_uri, expires_at, client_id) VALUES ($1, $2, $3, $4, $5)"
)
.bind(&secure_auth_code)
.bind(pid)
.bind(form_data.redirect_uri)
.bind(expires_at)
.bind(form_data.client_id)
.execute(pool.inner())
.await {
eprintln!("failed to save auth code: {:?}", db_err);

View file

@ -1,19 +1,13 @@
use crate::Pool;
use serde::Serialize;
use rocket::FromForm;
use sha2::{Sha256, Digest};
use bytemuck::bytes_of;
use rocket::{post, State, form::Form, http::Status, serde::json::Json};
use sqlx::Row;
use std::env;
use once_cell::sync::Lazy;
use crate::nnid::oauth::generate_token::create_token;
use crate::nnid::oauth::generate_token::token_type::AUTH_TOKEN;
use bytemuck::bytes_of;
use chrono::Utc;
pub static CLIENT_SECRET: Lazy<String> = Lazy::new(|| {
env::var("OAUTH_CLIENT_SECRET").expect("OAUTH_CLIENT_SECRET not set")
});
use rocket::FromForm;
use rocket::{State, form::Form, http::Status, post, serde::json::Json};
use serde::Serialize;
use sha2::{Digest, Sha256};
use sqlx::Row;
#[derive(Serialize)]
pub struct OAuthTokenResponse {
@ -34,20 +28,14 @@ pub struct TokenRequest<'r> {
}
pub fn verify_nintendo_password(pid: i32, text_password: &str, db_bcrypt_hash: &str) -> bool {
// maple: binder, there's already a function for this, why duplicate code?
// binder: dear maple, with this function i can just hand it a pid, the password from the user and the db_bcrypt_hash and it gives me a true/false value.
let mut sha = Sha256::new();
sha.update(bytes_of(&pid));
sha.update(&[0x02, 0x65, 0x43, 0x46]);
sha.update(text_password.as_bytes());
let hashed_password_hex = hex::encode(sha.finalize());
match bcrypt::verify(hashed_password_hex, db_bcrypt_hash) {
Ok(valid) => valid,
Err(_) => false,
}
bcrypt::verify(hashed_password_hex, db_bcrypt_hash).unwrap_or(false)
}
// dummy error responses
#[derive(Serialize)]
pub struct OAuthErrorResponse {
pub error: String,
@ -55,47 +43,92 @@ pub struct OAuthErrorResponse {
#[post("/api/v2/oauth2/generate_token", data = "<form_data>")]
pub async fn generate_token(
pool: &State<Pool>,
form_data: Form<TokenRequest<'_>>
pool: &State<Pool>,
form_data: Form<TokenRequest<'_>>,
) -> Result<Json<OAuthTokenResponse>, (Status, Json<OAuthErrorResponse>)> {
if form_data.client_id != Some("account") {
return Err((
Status::BadRequest,
Json(OAuthErrorResponse { error: "invalid_client".to_string() })
));
}
println!("redirect URI is: {:?}", form_data.redirect_uri);
let cl_secret: String = CLIENT_SECRET.clone();
if form_data.client_secret != Some(&cl_secret) {
return Err((
let client_id = form_data.client_id.ok_or((
Status::BadRequest,
Json(OAuthErrorResponse {
error: "invalid_client".to_string(),
}),
))?;
let client_row = match sqlx::query(
"SELECT client_secret, is_confidential FROM oauth_clients WHERE client_id = $1",
)
.bind(client_id)
.fetch_optional(pool.inner())
.await
{
Ok(Some(row)) => row,
_ => {
return Err((
Status::Unauthorized,
Json(OAuthErrorResponse {
error: "invalid_client".to_string(),
}),
));
}
};
let db_client_secret: Option<String> = client_row.get("client_secret");
let is_confidential: bool = client_row.get("is_confidential");
if is_confidential {
let provided_secret = form_data.client_secret.ok_or((
Status::Unauthorized,
Json(OAuthErrorResponse { error: "invalid_client".to_string() })
));
Json(OAuthErrorResponse {
error: "invalid_client".to_string(),
}),
))?;
let matches = match db_client_secret {
Some(secret) => secret == provided_secret,
None => false,
};
if !matches {
return Err((
Status::Unauthorized,
Json(OAuthErrorResponse {
error: "invalid_client".to_string(),
}),
));
}
}
// i'm only supporting the password grant incase someone feels lazy.
// i'm only supporting the password grant for the id server.
let pid: i32 = match form_data.grant_type {
"password" => {
let username = form_data.username.ok_or((
Status::BadRequest,
Json(OAuthErrorResponse { error: "invalid_request".to_string() })
Json(OAuthErrorResponse {
error: "invalid_request".to_string(),
}),
))?;
let password = form_data.password.ok_or((
Status::BadRequest,
Json(OAuthErrorResponse { error: "invalid_request".to_string() })
Json(OAuthErrorResponse {
error: "invalid_request".to_string(),
}),
))?;
let user_row = match sqlx::query("SELECT pid, password FROM users WHERE username = $1")
.bind(username)
.fetch_optional(pool.inner())
.await
.await
{
Ok(Some(row)) => row,
_ => return Err((
Status::BadRequest,
Json(OAuthErrorResponse { error: "invalid_grant".to_string() })
)),
_ => {
return Err((
Status::BadRequest,
Json(OAuthErrorResponse {
error: "invalid_grant".to_string(),
}),
));
}
};
let db_pid: i32 = user_row.get("pid");
@ -104,31 +137,39 @@ pub async fn generate_token(
if !verify_nintendo_password(db_pid, password, &db_bcrypt_hash) {
return Err((
Status::Unauthorized,
Json(OAuthErrorResponse { error: "invalid_grant".to_string() })
Json(OAuthErrorResponse {
error: "invalid_grant".to_string(),
}),
));
}
db_pid
},
}
"authorization_code" => {
let incoming_code = form_data.code.ok_or((
Status::BadRequest,
Json(OAuthErrorResponse { error: "invalid_request".to_string() })
Status::BadRequest,
Json(OAuthErrorResponse {
error: "invalid_request".to_string(),
}),
))?;
let code_row = match sqlx::query(
"SELECT pid, expires_at, used FROM oauth_auth_codes WHERE code = $1"
"SELECT pid, expires_at, used FROM oauth_auth_codes WHERE code = $1",
)
.bind(incoming_code)
.fetch_optional(pool.inner())
.await
{
Ok(Some(row)) => row,
_ => return Err((
Status::BadRequest,
Json(OAuthErrorResponse { error: "invalid_grant".to_string() })
)),
_ => {
return Err((
Status::BadRequest,
Json(OAuthErrorResponse {
error: "invalid_grant".to_string(),
}),
));
}
};
let is_used: bool = code_row.get("used");
@ -137,8 +178,10 @@ pub async fn generate_token(
if is_used || expires_at < Utc::now().naive_utc() {
return Err((
Status::BadRequest,
Json(OAuthErrorResponse { error: "invalid_grant".to_string() })
Status::BadRequest,
Json(OAuthErrorResponse {
error: "invalid_grant".to_string(),
}),
));
}
@ -148,15 +191,19 @@ pub async fn generate_token(
.await;
target_pid
},
}
_ => return Err((
Status::BadRequest,
Json(OAuthErrorResponse { error: "unsupported_grant_type".to_string() })
))
_ => {
return Err((
Status::BadRequest,
Json(OAuthErrorResponse {
error: "unsupported_grant_type".to_string(),
}),
));
}
};
let token = create_token(pool.inner(), pid, AUTH_TOKEN, None).await;
let token = create_token(pool.inner(), pid, AUTH_TOKEN, None, None).await;
Ok(Json(OAuthTokenResponse {
access_token: token,

View file

@ -0,0 +1,40 @@
use rocket::serde::json::Json;
use rocket::{get, State};
use rocket::http::Status;
use crate::account::account::Auth;
use serde::Serialize;
use crate::Pool;
#[derive(Serialize)]
pub struct ConsoleData {
pub serial: String,
pub cert_hash: String,
}
#[get("/api/v2/users/@me/console")]
pub async fn get_own_console(pool: &State<Pool>, auth: Auth<true>) -> Result<Json<ConsoleData>, (Status, &'static str)> {
let hash = auth.1.ok_or((Status::BadRequest, "token needs cert hash"))?;
let row = sqlx::query!(
"select serial from certificates where hash = $1",
&hash[..]
)
.fetch_one(pool.inner())
.await
.map_err(|e| {
log::error!("failed to execute query: {:?}", e);
(Status::InternalServerError, "error in database query")
})?;
let hex_hash = hex::encode(hash);
let serial = row.serial.ok_or((Status::BadRequest, "we had no serial"))?;
Ok(
Json (
ConsoleData {
serial,
cert_hash: hex_hash
}
)
)
}

View file

@ -1,3 +1,4 @@
pub mod profile;
pub mod mii;
pub mod delete;
pub mod delete;
pub mod console;

View file

@ -101,11 +101,21 @@ async fn launch() -> _ {
nnid::support::validate,
nnid::support::verify_email,
nnid::support::resend_email,
nnid::support::forgotten_password,
nnid::support::send_parental_controls_pin,
nnid::support::send_forgotten_pin,
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,
@ -118,6 +128,8 @@ async fn launch() -> _ {
json_api::users::delete::delete_account,
json_api::oauth::authorize::authorize_page,
json_api::oauth::authorize::authorize_submit,
json_api::admin::bans::ban_user,
json_api::users::console::get_own_console,
nnid::people::thing,
// graphql::graphiql,
// graphql::playground,

View file

@ -1,37 +1,64 @@
use std::{env, io};
use std::collections::HashSet;
use std::{env, io, fs};
use serde::Deserialize;
use once_cell::sync::Lazy;
use rocket::fs::NamedFile;
use rocket::{get, Request};
use rocket::http::Status;
use rocket::request::{FromRequest, Outcome};
use rocket::get;
use rocket::response::content::RawXml;
use tokio::fs::try_exists;
use tokio::sync::RwLock;
use rocket::async_trait;
use crate::dsresponse::Ds;
pub static EVIL_AGREEMENT_THING: Lazy<RwLock<HashSet<String>>> = Lazy::new(|| Default::default());
// pub static EVIL_AGREEMENT_THING: Lazy<RwLock<HashSet<String>>> = Lazy::new(|| Default::default());
pub struct CFIP(pub String);
// pub struct CFIP(pub String);
#[async_trait]
impl<'r> FromRequest<'r> for CFIP{
type Error = ();
// #[async_trait]
// impl<'r> FromRequest<'r> for CFIP{
// type Error = ();
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
match request.headers().get("CF-Connecting-IP").next(){
Some(v) => Outcome::Success(Self(v.to_owned())),
None => Outcome::Error((Status::ImATeapot, ()))
}
}
// async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
// match request.headers().get("CF-Connecting-IP").next(){
// Some(v) => Outcome::Success(Self(v.to_owned())),
// None => Outcome::Error((Status::ImATeapot, ()))
// }
// }
// }
#[derive(Debug, Deserialize)]
struct Agreement {
version: String,
}
#[derive(Debug, Deserialize)]
struct Agreements {
#[serde(rename = "agreement")]
agreements: Vec<Agreement>,
}
pub fn get_latest_eula_version() -> Result<String, Box<dyn std::error::Error>> {
let mut path = std::env::current_dir()?;
path.push("res");
path.push("agreement");
path.push("DEFAULT.xml");
let content = fs::read_to_string(path)?;
let parsed: Agreements = quick_xml::de::from_str(&content)?;
parsed
.agreements
.first()
.map(|a| a.version.clone())
.ok_or_else(|| "No agreement found in XML".into())
}
pub static LATEST_EULA_VERSION: Lazy<i32> = Lazy::new(|| {
get_latest_eula_version()
.ok()
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(300)
});
#[get("/v1/api/content/agreements/Nintendo-Network-EULA/<lang>/@latest")]
pub async fn get_agreement(lang: &str, ip: CFIP) -> io::Result<Ds<RawXml<NamedFile>>>{
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();
@ -42,38 +69,26 @@ pub async fn get_agreement(lang: &str, ip: CFIP) -> io::Result<Ds<RawXml<NamedFi
path
};
if EVIL_AGREEMENT_THING.read().await.contains(&ip.0) {
let 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("EVIL.xml");
path.push("DEFAULT.xml");
path
};
Ok(Ds(RawXml(NamedFile::open(&path).await?)))
} else {
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?)))
}
Ok(Ds(RawXml(NamedFile::open(&fallback_path).await?)))
}
}

View file

@ -1,50 +1,50 @@
#![allow(unused)]
use rocket::{post, FromForm, State};
use rocket::form::Form;
use serde::{Serialize};
use crate::Pool;
use crate::account::account::{Auth, DeviceCert, User, link_certificate_to_pid};
use crate::error::{Error, Errors};
use crate::nnid::agreements::{CFIP, EVIL_AGREEMENT_THING};
use crate::nnid::oauth::generate_token::token_type::{AUTH_REFRESH_TOKEN, AUTH_TOKEN};
use crate::nnid::oauth::TokenData;
use crate::Pool;
use crate::nnid::oauth::generate_token::token_type::{AUTH_REFRESH_TOKEN, AUTH_TOKEN};
use crate::nnid::agreements::LATEST_EULA_VERSION;
use crate::xml::Xml;
use rocket::form::Form;
use rocket::{FromForm, State, post};
use serde::Serialize;
pub mod token_type{
pub mod token_type {
pub const AUTH_REFRESH_TOKEN: i32 = 1;
pub const AUTH_TOKEN: i32 = 0;
pub const NEX_TOKEN: i32 = 2;
}
const ACCOUNT_ID_OR_PASSWORD_ERRORS: Errors = Errors{
error: &[
Error{
code: "0106",
message: "Invalid account ID or password"
}
]
const ACCOUNT_ID_OR_PASSWORD_ERRORS: Errors = Errors {
error: &[Error {
code: "0106",
message: "Invalid account ID or password",
}],
};
const ACCOUNT_BANNED_ERRORS: Errors = Errors{
error: &[
Error{
code: "0122",
message: "Device has been banned by game server"
}
]
const ACCOUNT_BANNED_ERRORS: Errors = Errors {
error: &[Error {
code: "0108",
message: "Account banned from server",
}],
};
const ACCOUNT_TEMPBANNED_ERRORS: Errors = Errors {
error: &[Error {
code: "0132",
message: "Account temporarily banned from server",
}],
};
const REREAD_EULA_EXTRABANNED_ERRORS: Errors = Errors{
error: &[
Error{
code: "0109",
message: "REREAD THE EULA LOL"
}
]
const REREAD_EULA_ERRORS: Errors = Errors {
error: &[Error {
code: "0109",
message: "The EULA has been updated",
}],
};
#[derive(FromForm)]
pub struct TokenRequestData<'a>{
pub struct TokenRequestData<'a> {
grant_type: &'a str,
user_id: &'a str,
password: &'a str,
@ -55,17 +55,27 @@ pub struct TokenRequestData<'a>{
pub struct TokenReturnData {
token: String,
refresh_token: String,
expires_in: i32
expires_in: i32,
}
pub async fn create_token(pool: &Pool, pid: i32, token_type: i32, title_id: Option<&str>) -> String{
pub async fn create_token(
pool: &Pool,
pid: i32,
token_type: i32,
title_id: Option<&str>,
cert_hash: Option<&[u8]>,
) -> String {
let data = sqlx::query!(
"insert into tokens (token_type, pid, title_id)
values ($1, $2, $3) returning token_id, random",
token_type, pid, title_id
)
.fetch_one(pool)
.await.unwrap();
"insert into tokens (token_type, pid, title_id, cert_hash)
values ($1, $2, $3, $4) returning token_id, random",
token_type,
pid,
title_id,
cert_hash
)
.fetch_one(pool)
.await
.unwrap();
let token_id = data.token_id;
let random = data.random;
@ -73,61 +83,77 @@ pub async fn create_token(pool: &Pool, pid: i32, token_type: i32, title_id: Opti
let token = TokenData {
token_id,
random,
pid
pid,
};
token.encode().to_string()
}
impl TokenReturnData {
async fn new(pid: i32, pool: &Pool) -> Self{
let token = create_token(pool, pid, AUTH_TOKEN, None).await;
async fn new(pid: i32, pool: &Pool) -> Self {
let token = create_token(pool, pid, AUTH_TOKEN, None, None).await;
let refresh_token = create_token(pool, pid, AUTH_REFRESH_TOKEN, None).await;
let refresh_token = create_token(pool, pid, AUTH_REFRESH_TOKEN, None, None).await;
Self{
Self {
token,
refresh_token,
expires_in: 3600
expires_in: 3600,
}
}
async fn new_with_cert(pid: i32, cert: Option<&[u8]>, pool: &Pool) -> Self {
let token = create_token(pool, pid, AUTH_TOKEN, None, cert).await;
let refresh_token = create_token(pool, pid, AUTH_REFRESH_TOKEN, None, cert).await;
Self {
token,
refresh_token,
expires_in: 3600,
}
}
}
#[derive(Serialize)]
#[serde(rename="OAuth20")]
pub struct TokenRequestReturnData{
access_token: TokenReturnData
#[serde(rename = "OAuth20")]
pub struct TokenRequestReturnData {
access_token: TokenReturnData,
}
#[post("/v1/api/oauth20/access_token/generate", data="<data>")]
pub async fn generate_token(pool: &State<Pool>, data: Form<TokenRequestData<'_>>, ip: CFIP, cert: DeviceCert) -> Result<Xml<TokenRequestReturnData>, Option<Errors<'static>>>{
#[post("/v1/api/oauth20/access_token/generate", data = "<data>")]
pub async fn generate_token(
pool: &State<Pool>,
data: Form<TokenRequestData<'_>>,
cert: DeviceCert,
) -> Result<Xml<TokenRequestReturnData>, Option<Errors<'static>>> {
let pool = pool.inner();
let user = User::get_by_username(data.user_id, pool).await
let user = User::get_by_username(data.user_id, pool)
.await
.ok_or(Some(ACCOUNT_ID_OR_PASSWORD_ERRORS))?;
if !user.verify_hashed_password(&data.password).is_some_and(|v| v){
if !user
.verify_hashed_password(&data.password)
.is_some_and(|v| v)
{
return Err(Some(ACCOUNT_ID_OR_PASSWORD_ERRORS));
}
if user.account_level < 0{
if user.account_level == -2 {
return Err(Some(REREAD_EULA_EXTRABANNED_ERRORS));
}
if user.account_level == -3{
EVIL_AGREEMENT_THING.write().await.insert(ip.0);
if user.eula_version != *LATEST_EULA_VERSION {
return Err(Some(REREAD_EULA_ERRORS));
}
return Err(Some(REREAD_EULA_EXTRABANNED_ERRORS));
if user.account_level < 0 {
if user.account_level == -2 {
return Err(Some(ACCOUNT_TEMPBANNED_ERRORS));
}
return Err(Some(ACCOUNT_BANNED_ERRORS));
}
link_certificate_to_pid(&pool, &cert.0, user.pid).await?;
let access_token = TokenReturnData::new(user.pid, pool).await;
let access_token =
TokenReturnData::new_with_cert(user.pid, Some(&cert.0.hash()[..]), pool).await;
Ok(Xml(TokenRequestReturnData{
access_token
}))
}
Ok(Xml(TokenRequestReturnData { access_token }))
}

View file

@ -1,42 +1,42 @@
#![allow(unused)]
use chrono::{NaiveDate, NaiveDateTime};
use gxhash::{gxhash32, gxhash64};
use rocket::{get, post, put, State};
use rocket::serde::{Deserialize, Serialize};
use crate::Pool;
use crate::account::account::{Auth, User, generate_nex_password, generate_password};
use crate::dsresponse::Ds;
use crate::error::{Error, Errors};
use crate::nnid::pid_distribution::next_pid;
use crate::nnid::timezones::{OFFSET_FROM_TIMEZONE};
use crate::Pool;
use crate::xml::{Xml, YesNoVal};
use crate::email::send_verification_email;
use rand::prelude::*;
use crate::error::{Error, Errors};
use crate::mii_util::get_mii_img_url;
use crate::nnid::timezones::OFFSET_FROM_TIMEZONE;
use crate::xml::{Xml, YesNoVal};
use chrono::{NaiveDate, NaiveDateTime};
use gxhash::{gxhash32, gxhash64};
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{
error: &[
Error{
code: "9999",
message: "Internal server error"
}
]
const DATABASE_ERROR: Errors = Errors {
error: &[Error {
code: "2001",
message: "Internal server error",
}],
};
#[derive(Deserialize)]
pub struct Email{
address: Box<str>
pub struct Email {
address: Box<str>,
}
#[derive(Deserialize)]
pub struct UpdateMiiData {
_name: Box<str>,
_primary: crate::xml::YesNoVal,
name: Box<str>,
primary: crate::xml::YesNoVal,
data: Box<str>,
}
#[derive(Deserialize, Serialize)]
pub struct Mii{
pub struct Mii {
name: Box<str>,
primary: YesNoVal,
data: Box<str>,
@ -44,7 +44,7 @@ pub struct Mii{
#[derive(Deserialize)]
#[serde(rename(serialize = "person"))]
pub struct AccountCreationData{
pub struct AccountCreationData {
birth_date: NaiveDate,
user_id: Box<str>,
password: Box<str>,
@ -56,24 +56,99 @@ pub struct AccountCreationData{
gender: Box<str>,
marketing_flag: YesNoVal,
off_device_flag: YesNoVal,
region: i32
region: i32,
}
#[derive(Serialize)]
#[serde(rename(serialize = "person"))]
pub struct AccountCreationResponseData{
pid: i32
pub struct AccountCreationResponseData {
pid: i32,
}
#[post("/v1/api/people", data="<data>")]
pub async fn create_account(database: &State<Pool>, data: Xml<AccountCreationData>) -> Result<Xml<AccountCreationResponseData>, Option<Errors<'_>>>{
#[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<DeviceInfo>,
}
#[derive(Serialize)]
pub struct EmptyDeviceResponse {
pub device: String,
}
#[derive(Deserialize)]
#[serde(rename = "person")]
pub struct UpdateAccountData {
pub gender: Option<Box<str>>,
pub region: Option<i32>,
pub country: Option<Box<str>>,
pub language: Option<Box<str>>,
pub tz_name: Option<Box<str>>,
pub marketing_flag: Option<YesNoVal>,
pub off_device_flag: Option<YesNoVal>,
pub password: Option<Box<str>>,
}
#[derive(Serialize)]
pub struct EmailWrapper {
pub email: EmailInfoOwnProfileData,
}
#[derive(Serialize)]
pub struct EmailsWrapper {
#[serde(rename = "email")]
pub emails: Vec<EmailInfoOwnProfileData>,
}
#[derive(Deserialize)]
pub struct UpdateEmailData {
pub address: Box<str>,
}
#[derive(Deserialize)]
pub struct UpdateEmailRequest {
pub email: UpdateEmailData,
}
#[post("/v1/api/people", data = "<data>")]
pub async fn create_account(
database: &State<Pool>,
data: Xml<AccountCreationData>,
) -> Result<Xml<AccountCreationResponseData>, Option<Errors<'_>>> {
let database = database.inner();
let nex_password = generate_nex_password();
let mut client = grpc_client().await.expect("unable to connect to grpc");
let Ok(ret) = client
.stage_new_account(ActStageInfo {
password: nex_password.clone().into_bytes(),
})
.await
else {
return Err(Some(DATABASE_ERROR));
};
let ActStageReturn { pid, .. } = ret.into_inner();
// its fine to crash here if we cant get the next pid as that is in my opinion a dead state
// anyways as noone can register anymore, EVER
let pid = next_pid(database).await;
let verification_code: i32 = rand::rng().random_range(100_000..1_000_000);
let AccountCreationData {
@ -82,14 +157,8 @@ pub async fn create_account(database: &State<Pool>, data: Xml<AccountCreationDat
birth_date,
tz_name,
language,
email: Email{
address
},
mii: Mii{
name,
data,
..
},
email: Email { address },
mii: Mii { name, data, .. },
marketing_flag,
gender,
region,
@ -98,17 +167,16 @@ pub async fn create_account(database: &State<Pool>, data: Xml<AccountCreationDat
..
} = data.0;
let account_level = if user_id.to_lowercase().contains("omey"){
let account_level = if user_id.to_lowercase().contains("omey") {
-1
} else {
1
0
};
let password = generate_password(pid, &password).ok_or(None)?;
let nex_password = generate_nex_password();
sqlx::query!("
sqlx::query!(
"
INSERT INTO users (
pid,
username,
@ -146,19 +214,20 @@ pub async fn create_account(database: &State<Pool>, data: Xml<AccountCreationDat
verification_code,
account_level,
nex_password
).execute(database).await.unwrap();
)
.execute(database)
.await
.unwrap();
//generate_s3_images(pid, &data).await;
if let Err(e) = send_verification_email(address.as_ref(), verification_code, user_id.as_ref()).await {
if let Err(e) =
send_verification_email(address.as_ref(), verification_code, user_id.as_ref()).await
{
println!("Failed to send verification email: {e}");
}
Ok(
Xml(AccountCreationResponseData{
pid
})
)
Ok(Xml(AccountCreationResponseData { pid }))
}
// #[derive(Serialize)]
@ -167,21 +236,21 @@ pub async fn create_account(database: &State<Pool>, data: Xml<AccountCreationDat
// }
#[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<NaiveDateTime>
pub email_type: String,
pub updated_by: String,
pub validated: YesNoVal,
pub validated_date: Option<NaiveDateTime>,
}
#[derive(Serialize)]
struct EmailInfoOwnOAuthProfileData{
struct EmailInfoOwnOAuthProfileData {
address: String,
id: u32,
parent: bool,
@ -191,40 +260,37 @@ struct EmailInfoOwnOAuthProfileData{
email_type: String,
updated_by: String,
validated: bool,
validated_date: Option<NaiveDateTime>
validated_date: Option<NaiveDateTime>,
}
#[derive(Serialize)]
struct MiiImage{
struct MiiImage {
cached_url: String,
id: u32,
url: String,
#[serde(rename = "type")]
image_type: String
}
#[derive(Serialize)]
struct MiiImages{
mii_image: MiiImage
image_type: String,
}
#[derive(Serialize)]
struct MiiDataOwnProfileData{
struct MiiImages {
mii_image: MiiImage,
}
#[derive(Serialize)]
struct MiiDataOwnProfileData {
status: String,
data: String,
id: u32,
mii_hash: String,
mii_images: MiiImages,
name: String,
primary: YesNoVal
primary: YesNoVal,
}
#[derive(Serialize)]
#[serde(rename(serialize = "person"))]
pub struct GetOwnProfileData{
pub struct GetOwnProfileData {
active_flag: YesNoVal,
birth_date: NaiveDate,
country: String,
@ -246,7 +312,7 @@ pub struct GetOwnProfileData{
#[derive(Serialize)]
#[serde(rename(serialize = "person"))]
pub struct GetOwnOAuthProfileData{
pub struct GetOwnOAuthProfileData {
active_flag: bool,
birth_date: NaiveDate,
country: String,
@ -270,17 +336,17 @@ pub struct GetOwnOAuthProfileData{
}
#[get("/v1/api/people/@me/profile")]
pub fn get_own_profile(user: Auth<false>) -> Ds<Xml<GetOwnProfileData>>{
pub fn get_own_profile(user: Auth<false>) -> Ds<Xml<GetOwnProfileData>> {
Ds(Xml(build_profile(user.into())))
}
#[get("/v1/api/people/@me/devices/owner")]
pub fn get_device_owner(user: Auth<false>) -> Ds<Xml<GetOwnProfileData>>{
pub fn get_device_owner(user: Auth<false>) -> Ds<Xml<GetOwnProfileData>> {
Ds(Xml(build_profile(user.into())))
}
#[post("/v1/api/people/@me/devices")]
pub fn get_own_device(user: Auth<false>) -> Ds<Xml<GetOwnProfileData>>{
pub fn get_own_device(user: Auth<false>) -> Ds<Xml<GetOwnProfileData>> {
Ds(Xml(build_profile(user.into())))
}
@ -314,59 +380,58 @@ pub fn build_profile(user: User) -> GetOwnProfileData {
.replace("\r", "")
.replace(" ", "");
GetOwnProfileData {
active_flag: YesNoVal(true),
pid,
user_id: username,
gender,
birth_date: birthdate,
country,
create_date: creation_date,
tz_name: timezone,
language,
updated,
marketing_flag: YesNoVal(marketing_allowed),
email: EmailInfoOwnProfileData {
id: gxhash32(email.as_bytes(), 0),
address: email,
validated: YesNoVal(email_verified_since.is_some()),
validated_date: email_verified_since,
email_type: "DEFAULT".to_string(),
updated_by: "USER".to_string(),
reachable: YesNoVal(true),
primary: YesNoVal(true),
parent: YesNoVal(false),
},
mii: MiiDataOwnProfileData {
id: gxhash32(mii_data.as_bytes(), 0),
mii_hash: hex::encode(bytemuck::bytes_of(
&(gxhash64(mii_data.as_bytes(), 1) & !(0x1000000000000000))
)),
name: crate::mii_util::MiiData::read(&mii_data)
GetOwnProfileData {
active_flag: YesNoVal(true),
pid,
user_id: username,
gender,
birth_date: birthdate,
country,
create_date: creation_date,
tz_name: timezone,
language,
updated,
marketing_flag: YesNoVal(marketing_allowed),
email: EmailInfoOwnProfileData {
id: gxhash32(email.as_bytes(), 0),
address: email,
validated: YesNoVal(email_verified_since.is_some()),
validated_date: email_verified_since,
email_type: "DEFAULT".to_string(),
updated_by: "USER".to_string(),
reachable: YesNoVal(true),
primary: YesNoVal(true),
parent: YesNoVal(false),
},
mii: MiiDataOwnProfileData {
id: gxhash32(mii_data.as_bytes(), 0),
mii_hash: hex::encode(bytemuck::bytes_of(
&(gxhash64(mii_data.as_bytes(), 1) & !(0x1000000000000000)),
)),
name: crate::mii_util::MiiData::read(&mii_data)
.map(|v| v.name)
.unwrap_or_else(|| "INVALID".to_string()),
primary: YesNoVal(true),
data: mii_data,
status: "COMPLETED".to_string(),
mii_images: MiiImages {
mii_image: {
let image_url = get_mii_img_url(pid, "tga");
let url_hash = gxhash32(image_url.as_bytes(), 0);
MiiImage {
image_type: "standard".to_string(),
id: url_hash,
url: image_url.clone(),
cached_url: image_url,
}
primary: YesNoVal(true),
data: mii_data,
status: "COMPLETED".to_string(),
mii_images: MiiImages {
mii_image: {
let image_url = get_mii_img_url(pid, "tga");
let url_hash = gxhash32(image_url.as_bytes(), 0);
MiiImage {
image_type: "standard".to_string(),
id: url_hash,
url: image_url.clone(),
cached_url: image_url,
}
}
},
},
off_device_flag: YesNoVal(off_device_allowed),
region,
utc_offset: timezone_offset,
account_level,
}
},
off_device_flag: YesNoVal(off_device_allowed),
region,
utc_offset: timezone_offset,
account_level,
}
}
pub fn build_oauth_profile(user: User) -> GetOwnOAuthProfileData {
@ -404,62 +469,61 @@ pub fn build_oauth_profile(user: User) -> GetOwnOAuthProfileData {
.replace("\r", "")
.replace(" ", "");
GetOwnOAuthProfileData {
id,
sub,
uid,
active_flag: true,
pid,
user_id: username,
gender,
birth_date: birthdate,
country,
create_date: creation_date,
tz_name: timezone,
language,
updated,
marketing_flag: marketing_allowed,
email: EmailInfoOwnOAuthProfileData {
id: gxhash32(email.as_bytes(), 0),
address: email,
validated: email_verified_since.is_some(),
validated_date: email_verified_since,
email_type: "DEFAULT".to_string(),
updated_by: "USER".to_string(),
reachable: true,
primary: true,
parent: false,
},
mii: MiiDataOwnProfileData {
id: gxhash32(mii_data.as_bytes(), 0),
mii_hash: hex::encode(bytemuck::bytes_of(
&(gxhash64(mii_data.as_bytes(), 1) & !(0x1000000000000000))
)),
name: crate::mii_util::MiiData::read(&mii_data)
GetOwnOAuthProfileData {
id,
sub,
uid,
active_flag: true,
pid,
user_id: username,
gender,
birth_date: birthdate,
country,
create_date: creation_date,
tz_name: timezone,
language,
updated,
marketing_flag: marketing_allowed,
email: EmailInfoOwnOAuthProfileData {
id: gxhash32(email.as_bytes(), 0),
address: email,
validated: email_verified_since.is_some(),
validated_date: email_verified_since,
email_type: "DEFAULT".to_string(),
updated_by: "USER".to_string(),
reachable: true,
primary: true,
parent: false,
},
mii: MiiDataOwnProfileData {
id: gxhash32(mii_data.as_bytes(), 0),
mii_hash: hex::encode(bytemuck::bytes_of(
&(gxhash64(mii_data.as_bytes(), 1) & !(0x1000000000000000)),
)),
name: crate::mii_util::MiiData::read(&mii_data)
.map(|v| v.name)
.unwrap_or_else(|| "INVALID".to_string()),
primary: YesNoVal(true),
data: mii_data,
status: "COMPLETED".to_string(),
mii_images: MiiImages {
mii_image: {
let image_url = get_mii_img_url(pid, "png");
let url_hash = gxhash32(image_url.as_bytes(), 0);
MiiImage {
image_type: "standard".to_string(),
id: url_hash,
url: image_url.clone(),
cached_url: image_url,
}
primary: YesNoVal(true),
data: mii_data,
status: "COMPLETED".to_string(),
mii_images: MiiImages {
mii_image: {
let image_url = get_mii_img_url(pid, "png");
let url_hash = gxhash32(image_url.as_bytes(), 0);
MiiImage {
image_type: "standard".to_string(),
id: url_hash,
url: image_url.clone(),
cached_url: image_url,
}
}
},
},
off_device_flag: off_device_allowed,
region,
utc_offset: timezone_offset,
account_level,
}
},
off_device_flag: off_device_allowed,
region,
utc_offset: timezone_offset,
account_level,
}
}
#[put("/v1/api/people/@me/miis/@primary", data = "<data>")]
@ -479,8 +543,8 @@ pub async fn change_mii(
mii_data,
pid
)
.execute(db)
.await;
.execute(db)
.await;
if let Err(e) = result {
println!("Failed to update Mii data for PID {}: {:?}", pid, e);
@ -492,8 +556,245 @@ pub async fn change_mii(
Ok(())
}
#[derive(Deserialize, Debug)]
pub struct AgreedEulaData {
pub agreement_date: NaiveDateTime,
pub country: Box<str>,
pub location: Box<str>,
pub version: i32
}
#[post("/v1/api/people/@me/agreements")]
pub async fn thing(){
#[post("/v1/api/people/@me/agreements", data = "<data>")]
pub async fn thing(
database: &State<Pool>,
auth: Auth<false>,
data: Xml<AgreedEulaData>,
) -> Result<(), Option<Errors<'static>>> {
let db = database.inner();
let version = data.version;
let pid = auth.pid;
println!("eula data: {:?}", data.version);
let result = sqlx::query!(
"UPDATE users SET eula_version = $1 WHERE pid = $2",
version,
pid
)
.execute(db)
.await;
if let Err(e) = result {
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<Self, Self::Error> {
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<true>,
headers: DeviceHeaders,
) -> Xml<DevicesWrapper> {
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<true>) -> Xml<EmptyDeviceResponse> {
Xml(EmptyDeviceResponse {
device: String::new(),
})
}
#[put("/v1/api/people/@me/devices/@current/inactivate")]
pub fn inactivate_current_device(_auth: Auth<true>) -> () {
// just 200
}
#[post("/v1/api/people/@me/deletion")]
pub async fn delete_account(
database: &State<Pool>,
auth: Auth<true>,
) -> Result<(), Option<Errors<'static>>> {
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 = "<data>")]
pub async fn update_account(
database: &State<Pool>,
auth: Auth<true>,
data: Xml<UpdateAccountData>,
) -> Result<(), Option<Errors<'static>>> {
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<false>) -> Xml<EmailsWrapper> {
let profile = build_profile(user.into());
Xml(EmailsWrapper {
emails: vec![profile.email],
})
}
#[put("/v1/api/people/@me/emails/@primary", data = "<data>")]
pub async fn update_primary_email(
database: &State<Pool>,
auth: Auth<true>,
data: Xml<UpdateEmailRequest>,
) -> Result<(), Option<Errors<'static>>> {
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(())
}

View file

@ -1,5 +1,6 @@
#![deprecated = "handled by nex-account now"]
/*
use crate::Pool;
pub async fn next_pid(pool: &Pool) -> i32{
loop {
let next_pid = sqlx::query!("SELECT nextval('pid_counter') as pid")
@ -26,4 +27,5 @@ pub async fn next_pid(pool: &Pool) -> i32{
}
}
*/

View file

@ -1,69 +1,112 @@
use std::net::Ipv4Addr;
use rocket::{get, State};
use serde::Serialize;
use sqlx::types::ipnetwork::IpNetwork::V4;
use crate::Pool;
use crate::account::account::Auth;
use crate::error::{Error, Errors};
use crate::nnid::oauth::generate_token::{create_token};
use crate::nnid::oauth::generate_token::create_token;
use crate::nnid::oauth::generate_token::token_type::NEX_TOKEN;
use crate::Pool;
use crate::xml::Xml;
use log::{info, warn};
use nex_account::grpc::Pid;
use rocket::http::Status;
use rocket::request::{FromRequest, Outcome, Request};
use rocket::{State, async_trait, get};
use serde::Serialize;
use sqlx::query;
use sqlx::types::ipnetwork::IpNetwork::V4;
use std::net::Ipv4Addr;
const NO_IPV4_ERROR: Errors = Errors{
error: &[
Error{
code: "1022",
message: "Server is not a valid IPv4 address"
}
]
const NO_IPV4_ERROR: Errors = Errors {
error: &[Error {
code: "1022",
message: "Server is not a valid IPv4 address",
}],
};
const SERVER_ERROR: Errors = Errors{
error: &[
Error{
code: "9999",
message: "Internal Server Error"
}
]
const SERVER_ERROR: Errors = Errors {
error: &[Error {
code: "9999",
message: "Internal Server Error",
}],
};
const NO_SERVER_ERROR: Errors = Errors{
error: &[
Error{
code: "1021",
message: "The requested game server was not found"
}
]
const NO_SERVER_ERROR: Errors = Errors {
error: &[Error {
code: "1021",
message: "The requested game server was not found",
}],
};
const MAINTENANCE_ERROR: Errors = Errors{
error: &[
Error{
code: "2002",
message: "The requested game server is under maintenance"
}
]
const MAINTENANCE_ERROR: Errors = Errors {
error: &[Error {
code: "2002",
message: "The requested game server is under maintenance",
}],
};
#[derive(Serialize)]
#[serde(rename = "nex_token")]
pub struct NexToken{
pub struct NexToken {
host: Ipv4Addr,
nex_password: String,
pid: i32,
port: u16,
token: String
token: Box<str>,
}
#[derive(Serialize)]
#[serde(rename = "service_token")]
pub struct ServiceToken{
token: String
pub struct ServiceToken {
token: String,
}
pub async fn store_or_check_serial(pool: &Pool, serial: &str, cert_hash: [u8; 32]) -> bool {
let Ok(res) = query!(
"select serial from certificates where hash = $1",
&cert_hash[..]
)
.fetch_one(pool)
.await
else {
warn!(
"user tried to access a route which is locked behind a console asociated token without a console token"
);
return false;
};
let Some(stored_serial) = res.serial else {
let _row = query!(
"update certificates set serial = $1 where hash = $2",
serial,
&cert_hash[..]
).execute(pool)
.await
.ok();
return true;
};
serial == stored_serial
}
pub struct Serial(pub String);
#[async_trait]
impl<'r> FromRequest<'r> for Serial {
type Error = Errors<'static>;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let Some(header) = request.headers().get("X-Nintendo-Serial-Number").next() else {
warn!("serial number wasnt sent on request which expected a serial number");
return Outcome::Error((Status::BadRequest, SERVER_ERROR));
};
Outcome::Success(Self(header.to_owned()))
}
}
#[get("/v1/api/provider/service_token/@me")]
pub async fn get_service_token(pool: &State<Pool>, auth: Auth<true, false>) -> Result<Xml<ServiceToken>, Option<Errors<'static>>>{
pub async fn get_service_token(
pool: &State<Pool>,
auth: Auth<true, false>,
serial: Serial,
) -> Result<Xml<ServiceToken>, Option<Errors<'static>>> {
// just gonna put this here as a side note for the future:
// we could also be using key derivation to derive the nex token as if it were a key
// that way we could reduce the data the database needs to store and also reduce the transfer
@ -73,19 +116,27 @@ pub async fn get_service_token(pool: &State<Pool>, auth: Auth<true, false>) -> R
let pool = pool.inner();
let token = create_token(pool, auth.pid, NEX_TOKEN, None).await;
let Some(cert_hash) = auth.1 else {
info!("attempt to generate service token using non wii u/certificate token");
return Err(Some(SERVER_ERROR));
};
Ok(
Xml(
ServiceToken{
token
}
)
)
if !store_or_check_serial(pool, &serial.0, cert_hash).await {
info!("serial mismatched with certificate");
return Err(Some(SERVER_ERROR));
}
let token = create_token(pool, auth.pid, NEX_TOKEN, None, Some(&cert_hash[..])).await;
Ok(Xml(ServiceToken { token }))
}
#[get("/v1/api/provider/nex_token/@me?<game_server_id>")]
pub async fn get_nex_token(pool: &State<Pool>, auth: Auth<true, false>, game_server_id: &str) -> Result<Xml<NexToken>, Option<Errors<'static>>>{
pub async fn get_nex_token(
pool: &State<Pool>,
auth: Auth<true, false>,
game_server_id: &str,
) -> Result<Xml<NexToken>, Option<Errors<'static>>> {
// just gonna put this here as a side note for the future:
// we could also be using key derivation to derive the nex token as if it were a key
// that way we could reduce the data the database needs to store and also reduce the transfer
@ -108,12 +159,10 @@ pub async fn get_nex_token(pool: &State<Pool>, auth: Auth<true, false>, game_ser
Some(row) => row,
None => return Err(Some(NO_SERVER_ERROR)),
}; // only crash on db failure (not missing row)
if server.maintenance_mode {
return Err(Some(MAINTENANCE_ERROR))
}
let token = create_token(pool, auth.pid, NEX_TOKEN, None).await;
if server.maintenance_mode {
return Err(Some(MAINTENANCE_ERROR));
}
let V4(host) = server.address else {
return Err(Some(NO_IPV4_ERROR));
@ -121,15 +170,29 @@ pub async fn get_nex_token(pool: &State<Pool>, auth: Auth<true, false>, game_ser
let host = host.ip();
Ok(
Xml(
NexToken{
host,
port: server.port as u16,
nex_password: auth.nex_password.clone(),
pid: auth.pid,
token
}
)
let mut client = nex_account::grpc_client().await.unwrap();
let Ok(key) = client
.get_nex_key_by_pid_maybe_staged(Pid { pid: auth.pid })
.await
else {
println!("account does not exist on nex-account server");
return Err(Some(SERVER_ERROR));
};
let token = nex_account::gen_nexact_token(
auth.pid,
key.into_inner()
.key
.try_into()
.map_err(|_| Some(SERVER_ERROR))?,
)
.expect("NEX_ACCOUNT_KEYPAIR not set");
Ok(Xml(NexToken {
host,
port: server.port as u16,
nex_password: auth.nex_password.clone(),
pid: auth.pid,
token,
}))
}

View file

@ -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,165 @@ pub async fn resend_email(
BAD_CODE_ERROR
})?;
Ok(())
}
#[get("/v1/api/support/forgotten_password/<pid>")]
pub async fn forgotten_password(
database: &State<Pool>,
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 update_result = sqlx::query!(
"UPDATE users SET password = $1 WHERE pid = $2",
nintendo_hash,
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(())
}
pub struct DeviceSerial(pub String);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for DeviceSerial {
type Error = Errors<'static>;
async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
match req.headers().get_one("x-nintendo-serial-number") {
Some(val) if !val.trim().is_empty() => {
request::Outcome::Success(DeviceSerial(val.to_string()))
}
_ => request::Outcome::Error((
Status::BadRequest,
Errors {
error: &[Error {
code: "0130",
message: "Serial not found",
}],
},
)),
}
}
}
#[get("/v1/api/support/send_confirmation/pin/<email>")]
pub async fn send_parental_controls_pin(
database: &State<Pool>,
serial: DeviceSerial,
email: &str,
) -> Result<(), Errors<'static>> {
let db = database.inner();
let serial_number = serial.0;
let email = email.trim().to_lowercase();
if email.is_empty() || !email.contains('@') {
return Err(Errors {
error: &[Error {
code: "0103",
message: "Email format is invalid",
}],
});
}
let db_result = sqlx::query!(
"
INSERT INTO device_parental_controls (serial_number, email, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (serial_number)
DO UPDATE SET email = EXCLUDED.email, updated_at = NOW()
",
serial_number,
email
)
.execute(db)
.await;
if let Err(e) = db_result {
eprintln!("Failed to associate serial {serial_number} with email {email}: {e}");
return Err(Errors {
error: &[Error {
code: "0130",
message: "PID has not been registered yet",
}],
});
}
crate::email::send_pc_email(&email, &serial_number)
.await
.map_err(|e| {
eprintln!("Failed to send parental controls email to {email}: {e}");
Errors {
error: &[Error {
code: "0130",
message: "PID has not been registered yet",
}],
}
})?;
Ok(())
}
#[get("/v1/api/support/send_forgotten/pin/<email>/<master_key>")]
pub async fn send_forgotten_pin(
serial: DeviceSerial,
email: &str,
master_key: &str,
) -> Result<(), Errors<'static>> {
let serial_number = serial.0;
let email = email.trim().to_lowercase();
crate::email::send_reset_pin_email(&email, &serial_number, &master_key)
.await
.map_err(|e| {
eprintln!("Failed to send parental controls email to {email}: {e}");
Errors {
error: &[Error {
code: "0130",
message: "PID has not been registered yet",
}],
}
})?;
Ok(())
}