Merge pull request #194 from PretendoNetwork/feat/mississippi

Mississippi
This commit is contained in:
William Oldham 2025-10-01 22:08:32 +01:00 committed by GitHub
commit dc93a750a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 567 additions and 39 deletions

View File

@ -2,5 +2,7 @@
Replacement for several account-based services used by the WiiU and 3DS. It replaces the NNID api as well as NASC for the 3DS. It also contains a dedicated PNID api service for getting details of PNIDs outside of the consoles (used by the website)
Uses the [IP2Location LITE database](https://lite.ip2location.com) for IP geolocation.
## Setup
See [SETUP.md](SETUP.md) for how to self host

View File

@ -64,28 +64,29 @@ The Pretendo Network website uses this server as an API for querying user inform
Configurations are loaded through environment variables. `.env` files are supported. All configuration options will be gone over, both required and optional. There also exists an example `.env` file
| Name | Description | Optional |
|-----------------------------------------------|--------------------------------------------------------------------------------------------------|----------|
| `PN_ACT_CONFIG_HTTP_PORT` | The HTTP port the server listens on | No |
| `PN_ACT_CONFIG_MONGO_CONNECTION_STRING` | MongoDB connection string | No |
| `PN_ACT_CONFIG_MONGOOSE_CONNECT_OPTIONS_PATH` | Path to a `.json` file containing Mongoose connection options | Yes |
| `PN_ACT_CONFIG_REDIS_URL` | Redis URL | Yes |
| `PN_ACT_CONFIG_EMAIL_SES_REGION` | Amazon SES Region | Yes |
| `PN_ACT_CONFIG_EMAIL_SES_ACCESS_KEY` | Amazon SES Access Key | Yes |
| `PN_ACT_CONFIG_EMAIL_SES_SECRET_KEY` | Amazon SES Access Secret | Yes |
| `PN_ACT_CONFIG_EMAIL_FROM` | Email "from" address | Yes |
| `PN_ACT_CONFIG_S3_ENDPOINT` | s3 server endpoint | Yes |
| `PN_ACT_CONFIG_S3_ACCESS_KEY` | s3 secret key | Yes |
| `PN_ACT_CONFIG_S3_ACCESS_SECRET` | s3 secret | Yes |
| `PN_ACT_CONFIG_HCAPTCHA_SECRET` | hCaptcha secret (in the form `0x...`) | Yes |
| `PN_ACT_CONFIG_CDN_SUBDOMAIN` | Subdomain used to serve CDN contents if s3 is disabled | Yes |
| `PN_ACT_CONFIG_CDN_DISK_PATH` | File system path used to store CDN contents if s3 is disabled | Yes |
| `PN_ACT_CONFIG_CDN_BASE_URL` | URL for serving CDN contents (usually the same as s3 endpoint) | No |
| `PN_ACT_CONFIG_WEBSITE_BASE` | Website URL | Yes |
| `PN_ACT_CONFIG_AES_KEY` | AES-256 key used for encrypting tokens | No |
| `PN_ACT_CONFIG_DATASTORE_SIGNATURE_SECRET` | HMAC secret key (16 bytes in hex format) used to sign uploaded DataStore files | No |
| `PN_ACT_CONFIG_GRPC_MASTER_API_KEY_ACCOUNT` | Master API key to interact with the account gRPC service | No |
| `PN_ACT_CONFIG_GRPC_MASTER_API_KEY_API` | Master API key to interact with the API gRPC service | No |
| `PN_ACT_CONFIG_GRPC_PORT` | gRPC server port | No |
| `PN_ACT_CONFIG_STRIPE_SECRET_KEY` | Stripe API key. Used to cancel subscriptions when scrubbing PNIDs | Yes |
| `PN_ACT_CONFIG_SERVER_ENVIRONMENT` | Server environment. Currently only used by the Wii U Account Settings app. `prod`/`test`/`dev` | Yes |
| Name | Description | Optional |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------- | -------- |
| `PN_ACT_CONFIG_HTTP_PORT` | The HTTP port the server listens on | No |
| `PN_ACT_CONFIG_IP2LOCATION_TOKEN` | Download token for https://lite.ip2location.com. Used to download the local IP databases | Yes |
| `PN_ACT_CONFIG_MONGO_CONNECTION_STRING` | MongoDB connection string | No |
| `PN_ACT_CONFIG_MONGOOSE_CONNECT_OPTIONS_PATH` | Path to a `.json` file containing Mongoose connection options | Yes |
| `PN_ACT_CONFIG_REDIS_URL` | Redis URL | Yes |
| `PN_ACT_CONFIG_EMAIL_SES_REGION` | Amazon SES Region | Yes |
| `PN_ACT_CONFIG_EMAIL_SES_ACCESS_KEY` | Amazon SES Access Key | Yes |
| `PN_ACT_CONFIG_EMAIL_SES_SECRET_KEY` | Amazon SES Access Secret | Yes |
| `PN_ACT_CONFIG_EMAIL_FROM` | Email "from" address | Yes |
| `PN_ACT_CONFIG_S3_ENDPOINT` | s3 server endpoint | Yes |
| `PN_ACT_CONFIG_S3_ACCESS_KEY` | s3 secret key | Yes |
| `PN_ACT_CONFIG_S3_ACCESS_SECRET` | s3 secret | Yes |
| `PN_ACT_CONFIG_HCAPTCHA_SECRET` | hCaptcha secret (in the form `0x...`) | Yes |
| `PN_ACT_CONFIG_CDN_SUBDOMAIN` | Subdomain used to serve CDN contents if s3 is disabled | Yes |
| `PN_ACT_CONFIG_CDN_DISK_PATH` | File system path used to store CDN contents if s3 is disabled | Yes |
| `PN_ACT_CONFIG_CDN_BASE_URL` | URL for serving CDN contents (usually the same as s3 endpoint) | No |
| `PN_ACT_CONFIG_WEBSITE_BASE` | Website URL | Yes |
| `PN_ACT_CONFIG_AES_KEY` | AES-256 key used for encrypting tokens | No |
| `PN_ACT_CONFIG_DATASTORE_SIGNATURE_SECRET` | HMAC secret key (16 bytes in hex format) used to sign uploaded DataStore files | No |
| `PN_ACT_CONFIG_GRPC_MASTER_API_KEY_ACCOUNT` | Master API key to interact with the account gRPC service | No |
| `PN_ACT_CONFIG_GRPC_MASTER_API_KEY_API` | Master API key to interact with the API gRPC service | No |
| `PN_ACT_CONFIG_GRPC_PORT` | gRPC server port | No |
| `PN_ACT_CONFIG_STRIPE_SECRET_KEY` | Stripe API key. Used to cancel subscriptions when scrubbing PNIDs | Yes |
| `PN_ACT_CONFIG_SERVER_ENVIRONMENT` | Server environment. Currently only used by the Wii U Account Settings app. `prod`/`test`/`dev` | Yes |

321
package-lock.json generated
View File

@ -28,6 +28,7 @@
"got": "^11.8.2",
"hcaptcha": "^0.1.0",
"image-pixels": "^1.1.1",
"ip2location-nodejs": "^9.6.3",
"is-valid-hostname": "^1.0.2",
"joi": "^17.8.3",
"mii-js": "github:PretendoNetwork/mii-js#f1741e1f82771dd7c753fd408230373d33caa184",
@ -45,6 +46,7 @@
"validator": "^13.7.0",
"xmlbuilder": "^13.0.2",
"xmlbuilder2": "0.0.4",
"yauzl-promise": "^4.0.0",
"zod": "^3.21.4"
},
"devDependencies": {
@ -989,7 +991,6 @@
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
"integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@ -1001,7 +1002,6 @@
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz",
"integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@ -1012,7 +1012,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz",
"integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@ -1779,7 +1778,6 @@
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz",
"integrity": "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@ -1788,6 +1786,259 @@
"@tybys/wasm-util": "^0.9.0"
}
},
"node_modules/@node-rs/crc32": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/@node-rs/crc32/-/crc32-1.10.6.tgz",
"integrity": "sha512-+llXfqt+UzgoDzT9of5vPQPGqTAVCohU74I9zIBkNo5TH6s2P31DFJOGsJQKN207f0GHnYv5pV3wh3BCY/un/A==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@node-rs/crc32-android-arm-eabi": "1.10.6",
"@node-rs/crc32-android-arm64": "1.10.6",
"@node-rs/crc32-darwin-arm64": "1.10.6",
"@node-rs/crc32-darwin-x64": "1.10.6",
"@node-rs/crc32-freebsd-x64": "1.10.6",
"@node-rs/crc32-linux-arm-gnueabihf": "1.10.6",
"@node-rs/crc32-linux-arm64-gnu": "1.10.6",
"@node-rs/crc32-linux-arm64-musl": "1.10.6",
"@node-rs/crc32-linux-x64-gnu": "1.10.6",
"@node-rs/crc32-linux-x64-musl": "1.10.6",
"@node-rs/crc32-wasm32-wasi": "1.10.6",
"@node-rs/crc32-win32-arm64-msvc": "1.10.6",
"@node-rs/crc32-win32-ia32-msvc": "1.10.6",
"@node-rs/crc32-win32-x64-msvc": "1.10.6"
}
},
"node_modules/@node-rs/crc32-android-arm-eabi": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm-eabi/-/crc32-android-arm-eabi-1.10.6.tgz",
"integrity": "sha512-vZAMuJXm3TpWPOkkhxdrofWDv+Q+I2oO7ucLRbXyAPmXFNDhHtBxbO1rk9Qzz+M3eep8ieS4/+jCL1Q0zacNMQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/crc32-android-arm64": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm64/-/crc32-android-arm64-1.10.6.tgz",
"integrity": "sha512-Vl/JbjCinCw/H9gEpZveWCMjxjcEChDcDBM8S4hKay5yyoRCUHJPuKr4sjVDBeOm+1nwU3oOm6Ca8dyblwp4/w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/crc32-darwin-arm64": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-arm64/-/crc32-darwin-arm64-1.10.6.tgz",
"integrity": "sha512-kARYANp5GnmsQiViA5Qu74weYQ3phOHSYQf0G+U5wB3NB5JmBHnZcOc46Ig21tTypWtdv7u63TaltJQE41noyg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/crc32-darwin-x64": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-x64/-/crc32-darwin-x64-1.10.6.tgz",
"integrity": "sha512-Q99bevJVMfLTISpkpKBlXgtPUItrvTWKFyiqoKH5IvscZmLV++NH4V13Pa17GTBmv9n18OwzgQY4/SRq6PQNVA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/crc32-freebsd-x64": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/@node-rs/crc32-freebsd-x64/-/crc32-freebsd-x64-1.10.6.tgz",
"integrity": "sha512-66hpawbNjrgnS9EDMErta/lpaqOMrL6a6ee+nlI2viduVOmRZWm9Rg9XdGTK/+c4bQLdtC6jOd+Kp4EyGRYkAg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/crc32-linux-arm-gnueabihf": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm-gnueabihf/-/crc32-linux-arm-gnueabihf-1.10.6.tgz",
"integrity": "sha512-E8Z0WChH7X6ankbVm8J/Yym19Cq3otx6l4NFPS6JW/cWdjv7iw+Sps2huSug+TBprjbcEA+s4TvEwfDI1KScjg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/crc32-linux-arm64-gnu": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-gnu/-/crc32-linux-arm64-gnu-1.10.6.tgz",
"integrity": "sha512-LmWcfDbqAvypX0bQjQVPmQGazh4dLiVklkgHxpV4P0TcQ1DT86H/SWpMBMs/ncF8DGuCQ05cNyMv1iddUDugoQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/crc32-linux-arm64-musl": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-musl/-/crc32-linux-arm64-musl-1.10.6.tgz",
"integrity": "sha512-k8ra/bmg0hwRrIEE8JL1p32WfaN9gDlUUpQRWsbxd1WhjqvXea7kKO6K4DwVxyxlPhBS9Gkb5Urq7Y4mXANzaw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/crc32-linux-x64-gnu": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-gnu/-/crc32-linux-x64-gnu-1.10.6.tgz",
"integrity": "sha512-IfjtqcuFK7JrSZ9mlAFhb83xgium30PguvRjIMI45C3FJwu18bnLk1oR619IYb/zetQT82MObgmqfKOtgemEKw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/crc32-linux-x64-musl": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-musl/-/crc32-linux-x64-musl-1.10.6.tgz",
"integrity": "sha512-LbFYsA5M9pNunOweSt6uhxenYQF94v3bHDAQRPTQ3rnjn+mK6IC7YTAYoBjvoJP8lVzcvk9hRj8wp4Jyh6Y80g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/crc32-wasm32-wasi": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/@node-rs/crc32-wasm32-wasi/-/crc32-wasm32-wasi-1.10.6.tgz",
"integrity": "sha512-KaejdLgHMPsRaxnM+OG9L9XdWL2TabNx80HLdsCOoX9BVhEkfh39OeahBo8lBmidylKbLGMQoGfIKDjq0YMStw==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^0.2.5"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@node-rs/crc32-win32-arm64-msvc": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-arm64-msvc/-/crc32-win32-arm64-msvc-1.10.6.tgz",
"integrity": "sha512-x50AXiSxn5Ccn+dCjLf1T7ZpdBiV1Sp5aC+H2ijhJO4alwznvXgWbopPRVhbp2nj0i+Gb6kkDUEyU+508KAdGQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/crc32-win32-ia32-msvc": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-ia32-msvc/-/crc32-win32-ia32-msvc-1.10.6.tgz",
"integrity": "sha512-DpDxQLaErJF9l36aghe1Mx+cOnYLKYo6qVPqPL9ukJ5rAGLtCdU0C+Zoi3gs9ySm8zmbFgazq/LvmsZYU42aBw==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/crc32-win32-x64-msvc": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-x64-msvc/-/crc32-win32-x64-msvc-1.10.6.tgz",
"integrity": "sha512-5B1vXosIIBw1m2Rcnw62IIfH7W9s9f7H7Ma0rRuhT8HR4Xh8QCgw6NJSI2S2MCngsGktYnAhyUvs81b7efTyQw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -2924,7 +3175,6 @@
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
"integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@ -4795,6 +5045,18 @@
"node": ">= 8"
}
},
"node_modules/csv-parser": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.0.tgz",
"integrity": "sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA==",
"license": "MIT",
"bin": {
"csv-parser": "bin/csv-parser"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/d": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
@ -4943,7 +5205,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
@ -4961,7 +5222,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.0.1",
@ -6628,7 +6888,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-properties": "^1.2.1",
@ -6749,7 +7008,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
@ -7036,6 +7294,15 @@
"node": ">= 12"
}
},
"node_modules/ip2location-nodejs": {
"version": "9.6.3",
"resolved": "https://registry.npmjs.org/ip2location-nodejs/-/ip2location-nodejs-9.6.3.tgz",
"integrity": "sha512-npgq6Dwk0G53GKbxUCzZvosr0KPfreufohFKSzM7vAGtbmpuO2KwULQb5AxkCtlBIZ7xFShri7R1iVUbe7BKTw==",
"license": "MIT",
"dependencies": {
"csv-parser": "^3.0.0"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -7303,6 +7570,18 @@
"node": ">=0.10.0"
}
},
"node_modules/is-it-type": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/is-it-type/-/is-it-type-5.1.3.tgz",
"integrity": "sha512-AX2uU0HW+TxagTgQXOJY7+2fbFHemC7YFBwN1XqD8qQMKdtfbOC8OC3fUb4s5NU59a3662Dzwto8tWDdZYRXxg==",
"license": "MIT",
"dependencies": {
"globalthis": "^1.0.2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/is-map": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
@ -8461,7 +8740,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -9575,6 +9853,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-invariant": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/simple-invariant/-/simple-invariant-2.0.1.tgz",
"integrity": "sha512-1sbhsxqI+I2tqlmjbz99GXNmZtr6tKIyEgGGnJw/MKGblalqk/XoOYYFJlBzTKZCxx8kLaD3FD5s9BEEjx5Pyg==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
@ -10817,6 +11104,20 @@
"node": ">=10"
}
},
"node_modules/yauzl-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yauzl-promise/-/yauzl-promise-4.0.0.tgz",
"integrity": "sha512-/HCXpyHXJQQHvFq9noqrjfa/WpQC2XYs3vI7tBiAi4QiIU1knvYhZGaO1QPjwIVMdqflxbmwgMXtYeaRiAE0CA==",
"license": "MIT",
"dependencies": {
"@node-rs/crc32": "^1.7.0",
"is-it-type": "^5.1.2",
"simple-invariant": "^2.0.1"
},
"engines": {
"node": ">=16"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -6,7 +6,7 @@
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"build": "npm run lint && npm run clean && npx tsc && npx tsc-alias && npm run copy-static",
"build": "npm run lint && npm run clean && npx tsc && npx tsc-alias && npm run copy-static && node ./scripts/download-ip2location-databases.js",
"clean": "rimraf ./dist",
"copy-static": "copyfiles -e \"src/**/*.ts\" -u 1 \"src/**/*\" dist",
"start": "node .",
@ -43,6 +43,7 @@
"got": "^11.8.2",
"hcaptcha": "^0.1.0",
"image-pixels": "^1.1.1",
"ip2location-nodejs": "^9.6.3",
"is-valid-hostname": "^1.0.2",
"joi": "^17.8.3",
"mii-js": "github:PretendoNetwork/mii-js#f1741e1f82771dd7c753fd408230373d33caa184",
@ -60,6 +61,7 @@
"validator": "^13.7.0",
"xmlbuilder": "^13.0.2",
"xmlbuilder2": "0.0.4",
"yauzl-promise": "^4.0.0",
"zod": "^3.21.4"
},
"devDependencies": {
@ -85,4 +87,4 @@
"ndarray": "^1.0.19",
"typescript": "^4.9.5"
}
}
}

View File

@ -0,0 +1,47 @@
const { pipeline } = require('node:stream/promises');
const fs = require('node:fs');
const path = require('node:path');
const yauzl = require('yauzl-promise');
require('dotenv').config();
const databases = {
DB3LITEBIN: {
file_name: 'IP2LOCATION-LITE-DB3.BIN',
save_path: path.join(__dirname, '..', 'dist', 'IP2LOCATION-LITE-DB3.IPV4.BIN')
},
DB3LITEBINIPV6: {
file_name: 'IP2LOCATION-LITE-DB3.IPV6.BIN',
save_path: path.join(__dirname, '..', 'dist', 'IP2LOCATION-LITE-DB3.IPV6.BIN')
}
};
async function main() {
if (!process.env.PN_ACT_CONFIG_IP2LOCATION_TOKEN) {
// * Optional
return;
}
for (const name in databases) {
const database = databases[name];
const response = await fetch(`https://www.ip2location.com/download/?token=${process.env.PN_ACT_CONFIG_IP2LOCATION_TOKEN}&file=${name}`);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const zip = await yauzl.fromBuffer(buffer);
try {
for await (const entry of zip) {
if (entry.filename === database.file_name) {
const readStream = await entry.openReadStream();
const writeStream = fs.createWriteStream(database.save_path);
await pipeline(readStream, writeStream);
}
}
} catch (error) {
console.error('Error downloading IP2Location databases:', error);
}
}
}
main();

55
src/ip2location.ts Normal file
View File

@ -0,0 +1,55 @@
import path from 'node:path';
import net from 'node:net';
import fs from 'fs-extra';
import * as IP2Location from 'ip2location-nodejs';
import { LOG_WARN } from '@/logger';
class IP2LocationManager {
private ipv4?: IP2Location.IP2Location;
private ipv6?: IP2Location.IP2Location;
constructor() {
const ipv4Path = path.join(__dirname, 'IP2LOCATION-LITE-DB3.IPV4.BIN');
const ipv6Path = path.join(__dirname, 'IP2LOCATION-LITE-DB3.IPV6.BIN');
if (!fs.existsSync(ipv4Path)) {
LOG_WARN('Could not find IP2LOCATION-LITE-DB3.IPV4.BIN. IP location checking disabled. To enable, run `node scripts/download-ip2location-databases.js` and restart the server.');
} else {
this.ipv4 = new IP2Location.IP2Location();
this.ipv4.open(ipv4Path);
}
if (!fs.existsSync(ipv6Path)) {
LOG_WARN('Could not find IP2LOCATION-LITE-DB3.IPV6.BIN. IP location checking disabled. To enable, run `node scripts/download-ip2location-databases.js` and restart the server.');
} else {
this.ipv6 = new IP2Location.IP2Location();
this.ipv6.open(ipv6Path);
}
}
public lookup(ip: string): { country: string; region: string } | null {
if (!this.ipv4 || !this.ipv6) {
return null;
}
const ipVersion = net.isIP(ip);
let result;
if (ipVersion === 4) {
result = this.ipv4.getAll(ip);
} else if (ipVersion === 6) {
result = this.ipv6.getAll(ip);
} else {
return null;
}
return {
country: result.countryShort,
region: result.region
};
}
}
const manager = new IP2LocationManager();
export default manager;

View File

@ -31,6 +31,7 @@ const app = express();
// * START APPLICATION
app.set('view engine', 'ejs');
app.set('views', __dirname + '/views');
app.set('trust proxy', true); // TODO - Make this configurable
// * Create router
LOG_INFO('Setting up Middleware');

View File

@ -6,7 +6,8 @@ import moment from 'moment';
import hcaptcha from 'hcaptcha';
import Mii from 'mii-js';
import { doesPNIDExist, connection as databaseConnection } from '@/database';
import { nintendoPasswordHash, sendConfirmationEmail, generateToken } from '@/util';
import { isValidBirthday, getAgeFromDate, nintendoPasswordHash, sendConfirmationEmail, generateToken } from '@/util';
import IP2LocationManager from '@/ip2location';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
import { LOG_ERROR } from '@/logger';
@ -37,6 +38,8 @@ const DEFAULT_MII_DATA = Buffer.from('AwAAQOlVognnx0GC2/uogAOzuI0n2QAAAEBEAGUAZg
* Description: Creates a new user PNID
*/
router.post('/', async (request: express.Request, response: express.Response): Promise<void> => {
const clientIP = request.body.ip?.trim(); // * This has to be forwarded since this request comes from the websites server
const birthday = request.body.birthday?.trim();
const email = request.body.email?.trim();
const username = request.body.username?.trim();
const miiName = request.body.mii_name?.trim();
@ -68,6 +71,53 @@ router.post('/', async (request: express.Request, response: express.Response): P
}
}
if (!clientIP || clientIP === '') {
response.status(400).json({
app: 'api',
status: 400,
error: 'IP must be forwarded to check local laws'
});
return;
}
if (!birthday || birthday === '') {
response.status(400).json({
app: 'api',
status: 400,
error: 'Birthday must be set'
});
return;
}
if (!isValidBirthday(birthday)) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Birthday must be a valid date'
});
return;
}
const age = getAgeFromDate(birthday);
if (age < 18) {
// TODO - Enable `CF-IPCountry` in Cloudflare and only use IP2Location as a fallback
const location = IP2LocationManager.lookup(clientIP);
if (location?.country === 'US' && location?.region === 'Mississippi') {
// * See https://bsky.social/about/blog/08-22-2025-mississippi-hb1126 for details
response.status(403).json({
app: 'api',
status: 403,
error: 'Mississippi law prevents us from collecting any data from any users under the age of 18 without extreme parental verification methods.' // TODO - Expand on this and translate it? this will be shown on the website
});
return;
}
}
if (!email || email === '') {
response.status(400).json({
app: 'api',

View File

@ -6,7 +6,8 @@ import moment from 'moment';
import deviceCertificateMiddleware from '@/middleware/device-certificate';
import ratelimit from '@/middleware/ratelimit';
import { connection as databaseConnection, doesPNIDExist, getPNIDProfileJSONByPID } from '@/database';
import { getValueFromHeaders, nintendoPasswordHash, sendConfirmationEmail, sendPNIDDeletedEmail } from '@/util';
import { getAgeFromDate, getValueFromHeaders, nintendoPasswordHash, sendConfirmationEmail, sendPNIDDeletedEmail } from '@/util';
import IP2LocationManager from '@/ip2location';
import { PNID } from '@/models/pnid';
import { NEXAccount } from '@/models/nex-account';
import { LOG_ERROR } from '@/logger';
@ -63,6 +64,28 @@ router.post('/', ratelimit, deviceCertificateMiddleware, async (request: express
}
const person: Person = request.body.person;
const age = getAgeFromDate(person.birth_date);
if (age < 18) {
// TODO - Enable `CF-IPCountry` in Cloudflare and only use IP2Location as a fallback
const ip = request.ip;
if (ip) {
const location = IP2LocationManager.lookup(ip);
if (location?.country === 'US' && location?.region === 'Mississippi') {
// * See https://bsky.social/about/blog/08-22-2025-mississippi-hb1126 for details
response.status(403).send(xmlbuilder.create({
errors: {
error: {
code: '1228', // TODO - This is made up because 228 is a Mississippi area code /shrug
message: 'Mississippi law prevents us from collecting any data from any users under the age of 18 without extreme parental verification methods.' // TODO - Translate this? It wont show to end users so maybe not though
}
}
}).end());
return;
}
}
}
const userExists = await doesPNIDExist(person.user_id);

View File

@ -338,3 +338,49 @@ export function getValueFromHeaders(headers: IncomingHttpHeaders, key: string):
export function mapToObject(map: Map<any, any>): object {
return Object.fromEntries(Array.from(map.entries(), ([k, v]) => v instanceof Map ? [k, mapToObject(v)] : [k, v]));
}
export function isValidBirthday(dateString: string): boolean {
// * Birthdays MUST be in the format YYYY-MM-DD. This is how the
// * console sends them, regardless of region
// * Make sure general format is right
const regex = /^\d{4}-\d{2}-\d{2}$/;
if (!regex.test(dateString)) {
return false;
}
// * Actually check that it's a valid date
const parts = dateString.split('-');
const year = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10);
const day = parseInt(parts[2], 10);
const date = new Date(year, month - 1, day);
return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
}
export function getAgeFromDate(dateString: string): number {
if (!isValidBirthday(dateString)) {
return -1;
}
const parts = dateString.split('-');
const birthYear = parseInt(parts[0], 10);
const birthMonth = parseInt(parts[1], 10);
const birthDay = parseInt(parts[2], 10);
const today = new Date();
const currentYear = today.getFullYear();
const currentMonth = today.getMonth() + 1;
const currentDay = today.getDate();
let age = currentYear - birthYear;
// * Check if birthday has actually happened this year yet
if (currentMonth < birthMonth || (currentMonth === birthMonth && currentDay < birthDay)) {
age--;
}
return age;
}