Merge branch 'dev'
Some checks failed
Build and Publish Docker Image / Build and Publish Docker Image (amd64) (push) Has been cancelled
Build and Publish Docker Image / Build and Publish Docker Image (arm64) (push) Has been cancelled

This commit is contained in:
limes
2026-09-08 22:24:38 +02:00
34 changed files with 694 additions and 136 deletions

View File

@@ -96,4 +96,6 @@ Configurations are loaded through environment variables. `.env` files are suppor
| `PN_ACT_CONFIG_GRPC_MIIVERSE_HOST` | Used to remove Miiverse user data during account deletion | No |
| `PN_ACT_CONFIG_GRPC_MIIVERSE_PORT` | Used to remove Miiverse user data during account deletion | No |
| `PN_ACT_CONFIG_GRPC_MIIVERSE_KEY_API` | Used to remove Miiverse user data during account deletion | No |
| `PN_ACT_PROVISIONING_SERVER_CONFIG` | Specify a path to a JSON file containing a list of servers to provision automatically to the DB | Yes |
| `PN_ACT_PROVISIONING_SERVER_CONFIG` | Specify a path to a JSON file containing a list of servers to provision automatically to the DB | Yes |
| `PN_ACT_CONFIG_METRICS_ENABLED` | Set to `true` to enable the metrics server, uses the metrics port | Yes |
| `PN_ACT_CONFIG_METRICS_PORT` | The HTTP port the metrics server listens on | Yes |

245
package-lock.json generated
View File

@@ -12,7 +12,7 @@
"@aws-sdk/client-s3": "^3.657.0",
"@aws-sdk/client-ses": "^3.515.0",
"@inquirer/prompts": "^7.2.0",
"@pretendonetwork/grpc": "^2.5.4",
"@pretendonetwork/grpc": "^2.6.1",
"bcrypt": "^5.0.0",
"buffer-crc32": "^0.2.13",
"colors": "^1.4.0",
@@ -24,6 +24,7 @@
"ejs": "^3.1.10",
"email-validator": "^2.0.4",
"express": "^4.17.1",
"express-prom-bundle": "^7.0.2",
"express-rate-limit": "^6.7.0",
"fs-extra": "^8.1.0",
"got": "^11.8.2",
@@ -52,7 +53,7 @@
},
"devDependencies": {
"@hcaptcha/types": "^1.0.3",
"@pretendonetwork/eslint-config": "^0.1.4",
"@pretendonetwork/eslint-config": "^0.2.0",
"@types/bcrypt": "^5.0.0",
"@types/buffer-crc32": "^0.2.2",
"@types/cors": "^2.8.13",
@@ -1636,29 +1637,39 @@
"node": ">=6"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@pretendonetwork/eslint-config": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@pretendonetwork/eslint-config/-/eslint-config-0.1.4.tgz",
"integrity": "sha512-3Y6PvfAOLBLMstWUwbuAP/Qfiq0KitAvKpYFWCR6aSel60wxHEJpPAB92rGSXep6LRaZw2qlLbGcLrHn12onsA==",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@pretendonetwork/eslint-config/-/eslint-config-0.2.0.tgz",
"integrity": "sha512-1E3eDYxWVjICsfcVYB4St2pVBDcZTi7/NRYdM+smEWwZ+2SY2r0CuwlgenTejSBLyF5xvtZpR66RSUp/YUyJFw==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^4.6.0",
"@stylistic/eslint-plugin": "^5.9.0",
"eslint": "^9.39.3",
"eslint-import-resolver-typescript": "^4.4.4",
"@eslint-community/eslint-plugin-eslint-comments": "^4.7.2",
"@stylistic/eslint-plugin": "^5.10.0",
"eslint": "^9.39.5",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.4.0",
"typescript-eslint": "^8.56.1"
"globals": "^17.11.0",
"typescript-eslint": "^8.67.0"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.20.0"
}
},
"node_modules/@pretendonetwork/eslint-config/node_modules/globals": {
"version": "17.7.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
"integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==",
"version": "17.11.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz",
"integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1669,9 +1680,9 @@
}
},
"node_modules/@pretendonetwork/grpc": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-2.5.4.tgz",
"integrity": "sha512-spjg6sOSP8z+9T9vqR9RM5guJb7UHYQlFBfb6p6WxpaYHsXlmVIRPeQlR7NTkgpNzARV/4QVpqerBbtLDvN4oA==",
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-2.6.1.tgz",
"integrity": "sha512-+HKJ8cTV4AV8PJgUqg2Tocz4EJTajqErY9r/Oul8WIZM2lO+0gRPSf3Mlxi9Ghvjq88ZS9pWWccAKtn77ePrew==",
"license": "AGPL-3.0-only",
"dependencies": {
"@bufbuild/protobuf": "^2.2.2",
@@ -1965,7 +1976,6 @@
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
@@ -1998,7 +2008,6 @@
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -2035,7 +2044,6 @@
"version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
@@ -2048,7 +2056,6 @@
"version": "4.19.9",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz",
"integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@@ -2085,7 +2092,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
@@ -2131,7 +2137,6 @@
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/morgan": {
@@ -2184,14 +2189,12 @@
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/responselike": {
@@ -2207,7 +2210,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -2217,7 +2219,6 @@
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
@@ -2229,7 +2230,6 @@
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/mime": "^1",
@@ -2260,17 +2260,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz",
"integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==",
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz",
"integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/type-utils": "8.64.0",
"@typescript-eslint/utils": "8.64.0",
"@typescript-eslint/visitor-keys": "8.64.0",
"@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/type-utils": "8.67.0",
"@typescript-eslint/utils": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -2283,22 +2283,22 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.64.0",
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz",
"integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==",
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz",
"integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/typescript-estree": "8.64.0",
"@typescript-eslint/visitor-keys": "8.64.0",
"@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0",
"debug": "^4.4.3"
},
"engines": {
@@ -2314,14 +2314,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz",
"integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==",
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz",
"integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.64.0",
"@typescript-eslint/types": "^8.64.0",
"@typescript-eslint/tsconfig-utils": "^8.67.0",
"@typescript-eslint/types": "^8.67.0",
"debug": "^4.4.3"
},
"engines": {
@@ -2336,14 +2336,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz",
"integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==",
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz",
"integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/visitor-keys": "8.64.0"
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2354,9 +2354,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz",
"integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==",
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz",
"integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2371,15 +2371,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz",
"integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==",
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz",
"integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/typescript-estree": "8.64.0",
"@typescript-eslint/utils": "8.64.0",
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0",
"@typescript-eslint/utils": "8.67.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -2396,9 +2396,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz",
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz",
"integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2410,16 +2410,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz",
"integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==",
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz",
"integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.64.0",
"@typescript-eslint/tsconfig-utils": "8.64.0",
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/visitor-keys": "8.64.0",
"@typescript-eslint/project-service": "8.67.0",
"@typescript-eslint/tsconfig-utils": "8.67.0",
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -2438,16 +2438,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz",
"integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==",
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz",
"integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/typescript-estree": "8.64.0"
"@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2462,13 +2462,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz",
"integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==",
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz",
"integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/types": "8.67.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -3302,6 +3302,13 @@
"tweetnacl": "^0.14.3"
}
},
"node_modules/bintrees": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz",
"integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==",
"license": "MIT",
"peer": true
},
"node_modules/bit-buffer": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/bit-buffer/-/bit-buffer-0.2.5.tgz",
@@ -5308,6 +5315,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-prom-bundle": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/express-prom-bundle/-/express-prom-bundle-7.0.2.tgz",
"integrity": "sha512-ffFV4HGHvCKnkNJFqm42sYztRJE5mLgOj8MpGey1HOatuFhtcwXoBD2m5gca7ZbcyjkIf7lOH5ZdrhlrBf0sGw==",
"license": "MIT",
"dependencies": {
"@types/express": "^4.17.21",
"express": "^4.18.2",
"on-finished": "^2.3.0",
"url-value-parser": "^2.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"prom-client": ">=15.0.0"
}
},
"node_modules/express-rate-limit": {
"version": "6.11.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.11.2.tgz",
@@ -7290,13 +7315,13 @@
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
@@ -8154,6 +8179,21 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prom-client": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz",
"integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==",
"deprecated": "prom-client has been replaced by @prometheus-io/client",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/api": "^1.4.0",
"tdigest": "^0.1.1"
},
"engines": {
"node": "^16 || ^18 || >=20"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -9328,6 +9368,16 @@
"node": ">=10"
}
},
"node_modules/tdigest": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.3.tgz",
"integrity": "sha512-zbRt+lT+/H4fRItHshczHErVCQnitJk8MfMT24MqFJf3YL7SJJPqGIGeuOdvxXxM/AHFzKBl7WoyaYwqO9s3Kw==",
"license": "MIT",
"peer": true,
"dependencies": {
"bintrees": "1.0.2"
}
},
"node_modules/tga": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/tga/-/tga-1.0.7.tgz",
@@ -9671,16 +9721,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.64.0.tgz",
"integrity": "sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==",
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz",
"integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.64.0",
"@typescript-eslint/parser": "8.64.0",
"@typescript-eslint/typescript-estree": "8.64.0",
"@typescript-eslint/utils": "8.64.0"
"@typescript-eslint/eslint-plugin": "8.67.0",
"@typescript-eslint/parser": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0",
"@typescript-eslint/utils": "8.67.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -9813,6 +9863,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/url-value-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/url-value-parser/-/url-value-parser-2.2.0.tgz",
"integrity": "sha512-yIQdxJpgkPamPPAPuGdS7Q548rLhny42tg8d4vyTNzFqvOnwqrgHXvgehT09U7fwrzxi3RxCiXjoNUNnNOlQ8A==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@@ -28,7 +28,7 @@
"@aws-sdk/client-s3": "^3.657.0",
"@aws-sdk/client-ses": "^3.515.0",
"@inquirer/prompts": "^7.2.0",
"@pretendonetwork/grpc": "^2.5.4",
"@pretendonetwork/grpc": "^2.6.1",
"bcrypt": "^5.0.0",
"buffer-crc32": "^0.2.13",
"colors": "^1.4.0",
@@ -40,6 +40,7 @@
"ejs": "^3.1.10",
"email-validator": "^2.0.4",
"express": "^4.17.1",
"express-prom-bundle": "^7.0.2",
"express-rate-limit": "^6.7.0",
"fs-extra": "^8.1.0",
"got": "^11.8.2",
@@ -68,7 +69,7 @@
},
"devDependencies": {
"@hcaptcha/types": "^1.0.3",
"@pretendonetwork/eslint-config": "^0.1.4",
"@pretendonetwork/eslint-config": "^0.2.0",
"@types/bcrypt": "^5.0.0",
"@types/buffer-crc32": "^0.2.2",
"@types/cors": "^2.8.13",

View File

@@ -38,6 +38,10 @@ export const config: Config = {
http: {
port: Number(process.env.PN_ACT_CONFIG_HTTP_PORT || '')
},
metrics: {
enabled: process.env.PN_ACT_CONFIG_METRICS_ENABLED === 'true',
port: Number(process.env.PN_ACT_CONFIG_METRICS_PORT || '')
},
mongoose: {
connection_string: process.env.PN_ACT_CONFIG_MONGO_CONNECTION_STRING || '',
options: mongooseConnectOptions

75
src/metrics.ts Normal file
View File

@@ -0,0 +1,75 @@
import { format } from 'node:util';
import { Gauge } from 'prom-client';
import expressMetrics from 'express-prom-bundle';
import express from 'express';
import { LOG_ERROR, LOG_INFO, LOG_SUCCESS } from '@/logger';
import { config } from '@/config-manager';
import { PNID } from '@/models/pnid';
import { NEXToken } from '@/models/nex-token';
import type { Express, NextFunction, Request, Response } from 'express';
export const pnidTotalGauge = new Gauge({
name: 'pn_account_pnid_total',
help: 'Total number of registered PNIDs',
async collect(): Promise<void> {
// * Aggregations are faster on large collections
const [result] = await PNID.aggregate<{ n: number } | undefined>([
{ $match: { deleted: false } },
{ $count: 'n' }
]);
this.set(result?.n ?? 0);
}
});
export const nexTokenTotalGauge = new Gauge({
name: 'pn_account_nex_token_total',
help: 'Total number of NEX tokens',
async collect(): Promise<void> {
// * Aggregations are faster on large collections
const [result] = await NEXToken.aggregate<{ n: number } | undefined>([
{ $count: 'n' }
]);
this.set(result?.n ?? 0);
}
});
export function registerMetrics(app: Express): Express {
const metrics = express();
if (config.metrics.enabled) {
LOG_INFO('Setting up metrics');
app.use(expressMetrics({
// * Include full express and nodejs metrics
includeMethod: true,
includePath: true,
urlValueParser: {
minBase64Length: 15
},
promClient: {
collectDefaultMetrics: {}
},
// * Keep metrics on a different app (so they aren't exposed)
autoregister: false,
metricsApp: metrics
}));
}
metrics.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
LOG_ERROR(`Request failed (metrics): ${format(error)}`);
res.sendStatus(500);
});
return metrics;
}
export function listenMetrics(metricsApp: Express): void {
if (!config.metrics.enabled) {
return;
}
const port = config.metrics.port;
metricsApp.listen(port, () => {
LOG_SUCCESS(`Metrics HTTP server started on port ${port}`);
});
}

View File

@@ -44,4 +44,6 @@ export const DeviceSchema = new Schema<IDevice, DeviceModel, IDeviceMethods>({
certificate_hash: String
});
export const Device = model<IDevice, DeviceModel>('Device', DeviceSchema);
DeviceSchema.index({ linked_pids: 1 });
export const Device = model<IDevice, DeviceModel>('Device', DeviceSchema);

View File

@@ -0,0 +1,10 @@
import { Schema, model } from 'mongoose';
import type { IEmailUpdateEvent, EmailUpdateEventModel, IEmailUpdateEventMethods } from '@/types/mongoose/email-update-event';
export const EmailUpdateEventSchema = new Schema<IEmailUpdateEvent, EmailUpdateEventModel, IEmailUpdateEventMethods>({
old: String,
new: String,
on: Date
});
export const EmailUpdateEvent = model<IEmailUpdateEvent, EmailUpdateEventModel>('EmailUpdateEvent', EmailUpdateEventSchema);

View File

@@ -1,5 +1,5 @@
import crypto from 'node:crypto';
import { Schema, model } from 'mongoose';
import { Schema, Types, model } from 'mongoose';
import uniqueValidator from 'mongoose-unique-validator';
import imagePixels from 'image-pixels';
import TGA from 'tga';
@@ -16,8 +16,10 @@ import { IndependentServiceToken } from '@/models/independent-service-token';
import { NEXToken } from '@/models/nex-token';
import { OAuthToken } from '@/models/oauth-token';
import { PasswordResetToken } from '@/models/password-reset-token';
import { EmailUpdateEventSchema } from '@/models/email-update-event';
import type { IPNID, IPNIDMethods, PNIDModel } from '@/types/mongoose/pnid';
import type { PNIDPermissionFlag } from '@/types/common/permission-flags';
import type { IEmailUpdateEvent } from '@/types/mongoose/email-update-event';
let stripe: Stripe;
@@ -82,7 +84,8 @@ const PNIDSchema = new Schema<IPNID, PNIDModel, IPNIDMethods>({
reachable: Boolean,
validated: Boolean,
validated_date: String,
id: Number
id: Number,
history: [EmailUpdateEventSchema]
},
region: Number,
timezone: {
@@ -141,6 +144,9 @@ PNIDSchema.index({ 'pid': 1, 'username': 1, 'connections.discord.id': 1 });
PNIDSchema.plugin(uniqueValidator, { message: '{PATH} already in use.' });
// * Used by metrics
PNIDSchema.index({ deleted: 1 });
/*
According to http://pf2m.com/tools/rank.php Nintendo PID's start at 1,800,000,000 and count down with each account
This means the max PID is 1799999999 and hard-limits the number of potential accounts to 1,800,000,000
@@ -350,6 +356,7 @@ PNIDSchema.method('scrub', async function scrub() {
this.email.reachable = false;
this.email.validated = false;
this.email.validated_date = '';
this.email.history = new Types.DocumentArray<IEmailUpdateEvent>([]);
this.email.id = 0;
this.region = 0;
this.timezone.name = '';

View File

@@ -1,9 +1,10 @@
import { format } from 'node:util';
import express from 'express';
import morgan from 'morgan';
import xmlbuilder from 'xmlbuilder';
import xmlparser from '@/middleware/xml-parser';
import { connect as connectCache } from '@/cache';
import { checkMarkedDeletions, connect as connectDatabase } from '@/database';
import { connect as connectDatabase } from '@/database';
import { startGRPCServer } from '@/services/grpc/server';
import { fullUrl, getValueFromHeaders, setupScheduledTasks } from '@/util';
import { LOG_INFO, LOG_SUCCESS, LOG_WARN } from '@/logger';
@@ -15,8 +16,10 @@ import datastore from '@/services/datastore';
import api from '@/services/api';
import localcdn from '@/services/local-cdn';
import assets from '@/services/assets';
import healthz from '@/services/healthz';
import { config, disabledFeatures } from '@/config-manager';
import { startProvisioner } from '@/provisioning';
import { listenMetrics, registerMetrics } from '@/metrics';
process.title = 'Pretendo - Account';
process.on('uncaughtException', (err, origin) => {
@@ -29,6 +32,9 @@ process.on('SIGTERM', () => {
const app = express();
// * Metrics has to happen first so we can measure the other middleware
const metricsApp = registerMetrics(app);
// * START APPLICATION
app.set('view engine', 'ejs');
app.set('views', __dirname + '/views');
@@ -51,6 +57,7 @@ app.use(nasc);
app.use(api);
app.use(localcdn);
app.use(assets);
app.use(healthz);
if (!disabledFeatures.datastore) {
app.use(datastore);
@@ -94,7 +101,7 @@ app.use((error: any, request: express.Request, response: express.Response, _next
deviceID = 'Unknown';
}
LOG_WARN(`HTTP ${status} at ${url} from ${deviceID}: ${error.message}`);
LOG_WARN(`HTTP ${status} at ${url} from ${deviceID}: ${format(error)}`);
response.status(status).json({
app: 'api',
@@ -116,13 +123,12 @@ async function main(): Promise<void> {
startProvisioner();
await checkMarkedDeletions();
setupScheduledTasks();
app.listen(config.http.port, () => {
LOG_SUCCESS(`HTTP server started on port ${config.http.port}`);
});
listenMetrics(metricsApp);
}
main().catch(console.error);

View File

@@ -142,7 +142,7 @@ router.post('/', loginRatelimit, async (request: express.Request, response: expr
token_type: TokenType.OAuthRefresh,
title_id: BigInt(0),
issued: new Date(),
expires: new Date(Date.now() + 12 * 3600 * 1000)
expires: new Date(Date.now() + 7 * 24 * 3600 * 1000)
}
});

View File

@@ -465,7 +465,7 @@ router.post('/', webRegisterRatelimit, async (request: express.Request, response
token_type: TokenType.OAuthRefresh,
title_id: BigInt(0),
issued: new Date(),
expires: new Date(Date.now() + 12 * 3600 * 1000)
expires: new Date(Date.now() + 7 * 24 * 3600 * 1000)
}
});

View File

@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
import express from 'express';
import bcrypt from 'bcrypt';
import { PasswordResetToken } from '@/models/password-reset-token';
import { nintendoPasswordHash } from '@/util';
import { nintendoPasswordHash, sendPasswordResetNoticeEmail } from '@/util';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
import { getPNIDByPID } from '@/database';
@@ -177,6 +177,8 @@ router.post('/', passwordResetRatelimit, async (request: express.Request, respon
await pnid.removeAllTokens();
await pnid.save();
await sendPasswordResetNoticeEmail(pnid);
response.json({
app: 'api',
status: 200

View File

@@ -84,7 +84,7 @@ export async function login(request: LoginRequest): Promise<DeepPartial<LoginRes
token_type: TokenType.OAuthRefresh,
title_id: BigInt(0),
issued: new Date(),
expires: new Date(Date.now() + 12 * 3600 * 1000)
expires: new Date(Date.now() + 7 * 24 * 3600 * 1000)
}
});

View File

@@ -259,7 +259,7 @@ export async function register(request: RegisterRequest): Promise<DeepPartial<Lo
token_type: TokenType.OAuthRefresh,
title_id: BigInt(0),
issued: new Date(),
expires: new Date(Date.now() + 12 * 3600 * 1000)
expires: new Date(Date.now() + 7 * 24 * 3600 * 1000)
}
});

View File

@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
import bcrypt from 'bcrypt';
import { Status, ServerError } from 'nice-grpc';
import { PasswordResetToken } from '@/models/password-reset-token';
import { nintendoPasswordHash } from '@/util';
import { nintendoPasswordHash, sendPasswordResetNoticeEmail } from '@/util';
import { getPNIDByPID } from '@/database';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
@@ -92,5 +92,7 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Empt
await pnid.removeAllTokens();
await pnid.save();
await sendPasswordResetNoticeEmail(pnid);
return {};
}

View File

@@ -8,9 +8,11 @@ const TOKEN_REQUIRED_PATHS = [
'/api.v2.ApiService/GetUserData',
'/api.v2.ApiService/UpdateUserData',
'/api.v2.ApiService/ResetPassword', // * This paths token is not an authentication token, it is a password reset token
'/api.v2.ApiService/UpdatePassword',
'/api.v2.ApiService/SetDiscordConnectionData',
'/api.v2.ApiService/SetStripeConnectionData',
'/api.v2.ApiService/RemoveConnection'
'/api.v2.ApiService/RemoveConnection',
'/api.v2.ApiService/UpdateEmail'
];
export type AuthenticationCallContextExt = {

View File

@@ -2,7 +2,7 @@ import { config } from '@/config-manager';
import type { CallContext } from 'nice-grpc';
import type { GetUserDataResponse, DeepPartial } from '@pretendonetwork/grpc/api/v2/get_user_data_rpc';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/v1/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/v2/authentication-middleware';
export async function getUserData(_request: Empty, context: CallContext & AuthenticationCallContextExt): Promise<DeepPartial<GetUserDataResponse>> {
// * This is asserted in authentication-middleware, we know this is never null
@@ -24,9 +24,11 @@ export async function getUserData(_request: Empty, context: CallContext & Authen
birthday: pnid.birthdate,
gender: pnid.gender,
country: pnid.country,
region: pnid.region,
timezone: pnid.timezone.name,
language: pnid.language,
emailAddress: pnid.email.address,
emailValidated: pnid.email.validated,
connections: {
discord: {
id: pnid.connections.discord.id

View File

@@ -2,7 +2,10 @@ import { register } from '@/services/grpc/api/v2/register';
import { login } from '@/services/grpc/api/v2/login';
import { getUserData } from '@/services/grpc/api/v2/get-user-data';
import { updateUserData } from '@/services/grpc/api/v2/update-user-data';
import { updateEmail } from '@/services/grpc/api/v2/update-email';
import { verifyEmail } from '@/services/grpc/api/v2/verify-email';
import { forgotPassword } from '@/services/grpc/api/v2/forgot-password';
import { updatePassword } from '@/services/grpc/api/v2/update-password';
import { resetPassword } from '@/services/grpc/api/v2/reset-password';
import { setDiscordConnectionData } from '@/services/grpc/api/v2/set-discord-connection-data';
import { setStripeConnectionData } from '@/services/grpc/api/v2/set-stripe-connection-data';
@@ -13,7 +16,10 @@ export const apiServiceImplementationV2 = {
login,
getUserData,
updateUserData,
updateEmail,
verifyEmail,
forgotPassword,
updatePassword,
resetPassword,
setDiscordConnectionData,
setStripeConnectionData,

View File

@@ -84,7 +84,7 @@ export async function login(request: LoginRequest): Promise<DeepPartial<LoginRes
token_type: TokenType.OAuthRefresh,
title_id: BigInt(0),
issued: new Date(),
expires: new Date(Date.now() + 12 * 3600 * 1000)
expires: new Date(Date.now() + 7 * 24 * 3600 * 1000)
}
});

View File

@@ -259,7 +259,7 @@ export async function register(request: RegisterRequest): Promise<DeepPartial<Lo
token_type: TokenType.OAuthRefresh,
title_id: BigInt(0),
issued: new Date(),
expires: new Date(Date.now() + 12 * 3600 * 1000)
expires: new Date(Date.now() + 7 * 24 * 3600 * 1000)
}
});

View File

@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
import bcrypt from 'bcrypt';
import { Status, ServerError } from 'nice-grpc';
import { PasswordResetToken } from '@/models/password-reset-token';
import { nintendoPasswordHash } from '@/util';
import { nintendoPasswordHash, sendPasswordResetNoticeEmail } from '@/util';
import { getPNIDByPID } from '@/database';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
@@ -91,5 +91,7 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese
await pnid.removeAllTokens();
await pnid.save();
await sendPasswordResetNoticeEmail(pnid);
return {};
}

View File

@@ -1,7 +1,7 @@
import { Status, ServerError } from 'nice-grpc';
import type { CallContext } from 'nice-grpc';
import type { SetDiscordConnectionDataRequest, SetDiscordConnectionDataResponse } from '@pretendonetwork/grpc/api/v2/set_discord_connection_data_rpc';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/v1/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/v2/authentication-middleware';
export async function setDiscordConnectionData(request: SetDiscordConnectionDataRequest, context: CallContext & AuthenticationCallContextExt): Promise<SetDiscordConnectionDataResponse> {
// * This is asserted in authentication-middleware, we know this is never null

View File

@@ -2,7 +2,7 @@ import { Status, ServerError } from 'nice-grpc';
import { PNID } from '@/models/pnid';
import type { CallContext } from 'nice-grpc';
import type { SetStripeConnectionDataRequest, SetStripeConnectionDataResponse } from '@pretendonetwork/grpc/api/v2/set_stripe_connection_data_rpc';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/v1/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/v2/authentication-middleware';
type StripeMongoUpdateScheme = {
'access_level'?: number;

View File

@@ -0,0 +1,52 @@
import crypto from 'node:crypto';
import validator from 'validator';
import { ServerError, Status } from 'nice-grpc';
import { sendConfirmationEmail } from '@/util';
import type { CallContext } from 'nice-grpc';
import type {
UpdateEmailRequest,
UpdateEmailResponse
} from '@pretendonetwork/grpc/api/v2/update_email_rpc';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/v2/authentication-middleware';
export async function updateEmail(
request: UpdateEmailRequest,
context: CallContext & AuthenticationCallContextExt
): Promise<UpdateEmailResponse> {
// * This is asserted in authentication-middleware, we know this is never null
const pnid = context.pnid!;
const newEmail = request.email?.trim().toLowerCase();
if (!newEmail) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Must provide new email address');
}
if (!validator.isEmail(newEmail)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid email address');
}
/* We allow the new email to equal the old email, and treat this as a verification email resend request
if (newEmail === pnid.email.address) {
throw new ServerError(Status.INVALID_ARGUMENT, 'New email address must differ from current');
}
*/
const emailUpdateEvent = { old: pnid.email.address, new: newEmail, on: new Date() };
pnid.email.history.unshift(emailUpdateEvent);
pnid.email.address = newEmail;
pnid.email.reachable = false;
pnid.email.validated = false;
pnid.email.validated_date = '';
pnid.email.id = crypto.randomBytes(4).readUInt32LE();
await pnid.generateEmailValidationCode();
await pnid.generateEmailValidationToken();
await sendConfirmationEmail(pnid);
await pnid.save();
return {};
}

View File

@@ -0,0 +1,69 @@
import bcrypt from 'bcrypt';
import { Status, ServerError } from 'nice-grpc';
import { nintendoPasswordHash, sendPasswordResetNoticeEmail } from '@/util';
import type { CallContext } from 'nice-grpc';
import type { UpdatePasswordRequest, UpdatePasswordResponse } from '@pretendonetwork/grpc/api/v2/update_password_rpc';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/v2/authentication-middleware';
// * This sucks
const PASSWORD_WORD_OR_NUMBER_REGEX = /(?=.*[a-zA-Z])(?=.*\d).*/;
const PASSWORD_WORD_OR_PUNCTUATION_REGEX = /(?=.*[a-zA-Z])(?=.*[_\-.]).*/;
const PASSWORD_NUMBER_OR_PUNCTUATION_REGEX = /(?=.*\d)(?=.*[_\-.]).*/;
const PASSWORD_REPEATED_CHARACTER_REGEX = /(.)\1\1/;
export async function updatePassword(request: UpdatePasswordRequest,
context: CallContext & AuthenticationCallContextExt
): Promise<UpdatePasswordResponse> {
// * This is asserted in authentication-middleware, we know this is never null
const pnid = context.pnid!;
const oldPassword = request.oldPassword.trim();
const newPassword = request.newPassword.trim();
const newPasswordConfirm = request.newPasswordConfirm.trim();
const hashedOldPassword = nintendoPasswordHash(oldPassword, pnid.pid);
if (!bcrypt.compareSync(hashedOldPassword, pnid.password)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Password is incorrect');
}
if (!newPassword) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Must enter a new password');
}
if (newPassword !== newPasswordConfirm) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Passwords do not match');
}
if (newPassword === oldPassword) {
throw new ServerError(Status.INVALID_ARGUMENT, 'New password must not equal current password');
}
if (newPassword.length < 6 || newPassword.length > 16) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Password must be between 6 and 16 characters long');
}
if (newPassword.toLowerCase() === pnid.username.toLowerCase()) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Password cannot be the same as username');
}
if (!PASSWORD_WORD_OR_NUMBER_REGEX.test(newPassword) && !PASSWORD_WORD_OR_PUNCTUATION_REGEX.test(newPassword) && !PASSWORD_NUMBER_OR_PUNCTUATION_REGEX.test(newPassword)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Password must have combination of letters, numbers, and/or punctuation characters');
}
if (PASSWORD_REPEATED_CHARACTER_REGEX.test(newPassword)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Password may not have 3 repeating characters');
}
const primaryPasswordHash = nintendoPasswordHash(newPassword, pnid.pid);
const passwordHash = await bcrypt.hash(primaryPasswordHash, 10);
pnid.password = passwordHash;
await pnid.removeAllTokens();
await pnid.save();
await sendPasswordResetNoticeEmail(pnid);
return {};
}

View File

@@ -1,14 +1,144 @@
import { ServerError, Status } from 'nice-grpc';
import Mii from 'mii-js';
import { isValidBirthday } from '@/util';
import { config } from '@/config-manager';
import timezones from '@/services/nnas/timezones.json';
import regions from '@/services/nnas/regions.json';
import type { CallContext } from 'nice-grpc';
import type { UpdateUserDataRequest, DeepPartial } from '@pretendonetwork/grpc/api/v2/update_user_data_rpc';
import type {
UpdateUserDataRequest,
DeepPartial
} from '@pretendonetwork/grpc/api/v2/update_user_data_rpc';
import type { GetUserDataResponse } from '@pretendonetwork/grpc/api/v2/get_user_data_rpc';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/v1/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/v2/authentication-middleware';
export async function updateUserData(_request: UpdateUserDataRequest, context: CallContext & AuthenticationCallContextExt): Promise<DeepPartial<GetUserDataResponse>> {
export async function updateUserData(
request: UpdateUserDataRequest,
context: CallContext & AuthenticationCallContextExt
): Promise<DeepPartial<GetUserDataResponse>> {
// * This is asserted in authentication-middleware, we know this is never null
const pnid = context.pnid!;
// TODO - STUBBED, DO SOMETHING HERE
const serverAccessLevel = request.serverAccessLevel?.trim();
const mii = request?.mii?.trim();
const birthday = request.birthday?.trim();
const gender = request.gender?.trim();
const region = request.region;
const timezone = request.timezone?.trim();
/* TODO: implement these if/when needed */
// const language = request.language?.trim();
// const marketingFlag = request.marketingFlag;
if (serverAccessLevel) {
if (!['prod', 'test', 'dev'].includes(serverAccessLevel)) {
throw new ServerError(
Status.INVALID_ARGUMENT,
'Must be one of: prod, test, dev'
);
}
if (serverAccessLevel === 'prod') {
if (pnid.access_level < 0) {
throw new ServerError(Status.PERMISSION_DENIED, 'Banned');
}
pnid.server_access_level = serverAccessLevel;
}
if (serverAccessLevel === 'test') {
if (pnid.access_level < 1) {
throw new ServerError(
Status.INVALID_ARGUMENT,
'Do not have permission to enter this environment'
);
}
pnid.server_access_level = serverAccessLevel;
}
if (serverAccessLevel === 'dev') {
if (pnid.access_level < 3) {
throw new ServerError(
Status.INVALID_ARGUMENT,
'Do not have permission to enter this environment'
);
}
pnid.server_access_level = serverAccessLevel;
}
}
if (birthday) {
if (!isValidBirthday(birthday)) {
throw new ServerError(
Status.INVALID_ARGUMENT,
'Must be a valid date formatted as: YYYY-MM-DD'
);
}
pnid.birthdate = birthday;
}
if (gender) {
if (!['M', 'F'].includes(gender)) {
throw new ServerError(
Status.INVALID_ARGUMENT,
'Must be one of: F, M'
);
}
pnid.gender = gender;
}
if (region) {
const countryObj = regions.find(c => c.id === ((region >>> 24) & 0xFF));
const regionObj = countryObj?.regions.find(r => r.id === region);
if (!countryObj || !regionObj) {
throw new ServerError(
Status.INVALID_ARGUMENT,
'Invalid region'
);
}
pnid.country = countryObj.iso_code;
pnid.region = regionObj.id;
}
if (timezone) {
const pnidCountryTimezones =
timezones[pnid.country as keyof typeof timezones];
// using japanese because some timezones are only available in that locale
const newTimezone = pnidCountryTimezones.ja.find(
t => t.area === timezone
);
if (!newTimezone) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid timezone');
}
pnid.timezone.name = newTimezone.area;
pnid.timezone.offset = Number(newTimezone.utc_offset);
}
if (mii) {
try {
const parsedMii = new Mii(Buffer.from(mii, 'base64'));
parsedMii.validate();
await pnid.updateMii({
name: parsedMii.miiName,
primary: 'Y',
data: parsedMii.encode().toString('base64')
});
} catch {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid mii data');
}
}
await pnid.save();
return {
deleted: pnid.deleted || pnid.marked_for_deletion,
@@ -26,9 +156,11 @@ export async function updateUserData(_request: UpdateUserDataRequest, context: C
birthday: pnid.birthdate,
gender: pnid.gender,
country: pnid.country,
region: pnid.region,
timezone: pnid.timezone.name,
language: pnid.language,
emailAddress: pnid.email.address,
emailValidated: pnid.email.validated,
connections: {
discord: {
id: pnid.connections.discord.id
@@ -39,7 +171,9 @@ export async function updateUserData(_request: UpdateUserDataRequest, context: C
priceId: pnid.connections.stripe.price_id,
tierLevel: pnid.connections.stripe.tier_level,
tierName: pnid.connections.stripe.tier_name,
latestWebhookTimestamp: BigInt(pnid.connections.stripe.latest_webhook_timestamp ?? 0)
latestWebhookTimestamp: BigInt(
pnid.connections.stripe.latest_webhook_timestamp ?? 0
)
}
},
marketingFlag: pnid.flags.marketing

View File

@@ -0,0 +1,39 @@
import moment from 'moment';
import { ServerError, Status } from 'nice-grpc';
import { sendEmailConfirmedEmail } from '@/util';
import { PNID } from '@/models/pnid';
import type {
VerifyEmailRequest,
VerifyEmailResponse
} from '@pretendonetwork/grpc/api/v2/verify_email_rpc';
export async function verifyEmail(
request: VerifyEmailRequest
): Promise<VerifyEmailResponse> {
const token = request?.token?.trim();
if (!token) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing email token');
}
const pnid = await PNID.findOne({
'identification.email_token': token
});
if (!pnid) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid email token');
}
if (!pnid.email.validated) {
const validatedDate = moment().format('YYYY-MM-DDTHH:MM:SS');
pnid.email.reachable = true;
pnid.email.validated = true;
pnid.email.validated_date = validatedDate;
await pnid.save();
await sendEmailConfirmedEmail(pnid);
}
return {};
}

9
src/services/healthz.ts Normal file
View File

@@ -0,0 +1,9 @@
import express from 'express';
const router = express.Router();
router.get('/healthz', (req, res) => {
res.status(200).send('OK');
});
export default router;

View File

@@ -220,9 +220,13 @@ router.post('/update', async function (request: express.Request, response: expre
pnid.server_access_level = environment;
}
if (person.data.email.trim().toLowerCase() !== pnid.email.address) {
const newEmail = person.data.email.trim().toLowerCase();
if (newEmail !== pnid.email.address) {
pnid.email.history.unshift({ old: pnid.email.address, new: newEmail, on: new Date() });
// TODO - Better email check
pnid.email.address = person.data.email.trim().toLowerCase();
pnid.email.address = newEmail;
pnid.email.reachable = false;
pnid.email.validated = false;
pnid.email.validated_date = '';

View File

@@ -6,7 +6,7 @@ import moment from 'moment';
import deviceCertificateMiddleware from '@/middleware/device-certificate';
import { deviceRatelimit } from '@/middleware/ratelimit';
import { connection as databaseConnection, doesPNIDExist, getPNIDProfileJSONByPID } from '@/database';
import { getAgeFromDate, getValueFromHeaders, nintendoPasswordHash, sendConfirmationEmail, sendPNIDDeletedEmail } from '@/util';
import { getAgeFromDate, getValueFromHeaders, nintendoPasswordHash, sendConfirmationEmail, sendPNIDDeletedEmail, sendPasswordResetNoticeEmail } from '@/util';
import IP2LocationManager from '@/ip2location';
import { PNID } from '@/models/pnid';
import { NEXAccount } from '@/models/nex-account';
@@ -652,6 +652,7 @@ router.put('/@me', async (request: express.Request, response: express.Response):
pnid.password = passwordHash;
await pnid.removeAllTokens();
await sendPasswordResetNoticeEmail(pnid);
}
pnid.gender = gender;
@@ -735,6 +736,8 @@ router.put('/@me/emails/@primary', async (request: express.Request, response: ex
return;
}
pnid.email.history.unshift({ old: pnid.email.address, new: email.address.toLowerCase(), on: new Date() });
// TODO - Better email check
pnid.email.address = email.address.toLowerCase();
pnid.email.reachable = false;

View File

@@ -9,6 +9,10 @@ export interface Config {
http: {
port: number;
};
metrics: {
enabled: boolean;
port: number;
};
mongoose: {
connection_string: string;
options: mongoose.ConnectOptions;

View File

@@ -0,0 +1,15 @@
import type { Model, HydratedDocument } from 'mongoose';
export interface IEmailUpdateEvent {
new: string;
old: string;
on: Date;
}
export interface IEmailUpdateEventMethods {}
interface IEmailUpdateEventQueryHelpers {}
export interface EmailUpdateEventModel extends Model<IEmailUpdateEvent, IEmailUpdateEventQueryHelpers, IEmailUpdateEventMethods> {}
export type HydratedEmailUpdateDocument = HydratedDocument<IEmailUpdateEvent, IEmailUpdateEventMethods>;

View File

@@ -1,5 +1,6 @@
import type { Model, Types, HydratedDocument } from 'mongoose';
import type { IDevice } from '@/types/mongoose/device';
import type { IEmailUpdateEvent } from '@/types/mongoose/email-update-event';
import type { PNIDPermissionFlag } from '@/types/common/permission-flags';
export interface IPNID {
@@ -27,13 +28,12 @@ export interface IPNID {
validated: boolean;
validated_date: string;
id: number;
history: Types.DocumentArray<IEmailUpdateEvent>;
};
region: number;
timezone: {
name: string;
offset: number;
marketing: boolean;
off_device: boolean;
};
mii: {
name: string;

View File

@@ -9,7 +9,7 @@ import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
import { config, disabledFeatures } from '@/config-manager';
import { PasswordResetToken } from '@/models/password-reset-token';
import { LOG_ERROR } from '@/logger';
import { LOG_ERROR, LOG_SUCCESS } from '@/logger';
import type { IncomingHttpHeaders } from 'node:http';
import type { ParsedQs } from 'qs';
import type mongoose from 'mongoose';
@@ -78,7 +78,7 @@ export function createServiceToken(server: HydratedServerDocument, options: Serv
export function fullUrl(request: express.Request): string {
const protocol = request.protocol;
const host = request.host;
const host = request.hostname;
const opath = request.originalUrl;
return `${protocol}://${host}${opath}`;
@@ -129,8 +129,8 @@ export function nascError(errorCode: string): URLSearchParams {
export async function sendConfirmationEmail(pnid: mongoose.HydratedDocument<IPNID, IPNIDMethods>): Promise<void> {
const email = new CreateEmail()
.addHeader('Hello {{pnid}}!', { pnid: pnid.username })
.addParagraph('Your <b>Pretendo Network ID</b> activation is almost complete. Please click the link below to confirm your e-mail address and complete the activation process.')
.addButton('Confirm email address', `https://api.pretendo.cc/v1/email/verify?token=${pnid.identification.email_token}`)
.addParagraph('Please click the link below to confirm your e-mail address.')
.addButton('Confirm email address', `${config.website_base}/account/verify-email?token=${pnid.identification.email_token}`)
.addParagraph('You may also enter the following 6-digit code on your console:')
.addButton(pnid.identification.email_code, '', false)
.addParagraph('We hope you have fun using our services!');
@@ -145,18 +145,51 @@ export async function sendConfirmationEmail(pnid: mongoose.HydratedDocument<IPNI
}
export async function sendEmailConfirmedEmail(pnid: mongoose.HydratedDocument<IPNID, IPNIDMethods>): Promise<void> {
const email = new CreateEmail()
const noticeEmail = new CreateEmail()
.addHeader('Dear {{pnid}}!', { pnid: pnid.username })
.addParagraph('Your email address has been confirmed.')
.addParagraph('We hope you have fun on Pretendo Network!');
const options = {
const noticeOptions = {
to: pnid.email.address,
subject: '[Pretendo Network] Email address confirmed',
email
email: noticeEmail
};
await sendMail(options);
await sendMail(noticeOptions);
if (pnid.email.history.length > 0) {
// we can just grab the latest email update event, since it's guaranteed to be the relevant one (or the tokens wouldn't be valid)
const emailUpdateEvent = pnid.email.history[0];
const warningEmail = new CreateEmail()
.addHeader('Dear {{pnid}},', { pnid: pnid.username })
.addParagraph('your email address has been changed.')
.addParagraph('If this wasn\'t you, contact [support@pretendo.network](mailto:support@pretendo.network).');
const warningOptions = {
to: emailUpdateEvent.old,
subject: '[Pretendo Network] Email address changed',
email: warningEmail
};
await sendMail(warningOptions);
}
}
export async function sendPasswordResetNoticeEmail(pnid: mongoose.HydratedDocument<IPNID, IPNIDMethods>): Promise<void> {
const noticeEmail = new CreateEmail()
.addHeader('Dear {{pnid}},', { pnid: pnid.username })
.addParagraph('your password has been changed.')
.addParagraph('If this wasn\'t you, contact [support@pretendo.network](mailto:support@pretendo.network).');
const noticeOptions = {
to: pnid.email.address,
subject: '[Pretendo Network] Password changed',
email: noticeEmail
};
await sendMail(noticeOptions);
}
export async function sendEmailConfirmedParentalControlsEmail(pnid: mongoose.HydratedDocument<IPNID, IPNIDMethods>): Promise<void> {
@@ -191,7 +224,7 @@ export async function sendForgotPasswordEmail(pnid: mongoose.HydratedDocument<IP
const email = new CreateEmail()
.addHeader('Dear {{pnid}},', { pnid: pnid.username })
.addParagraph('a password reset has been requested from this account.')
.addParagraph('If you did not request the password reset, please ignore this email. If you did request this password reset, please click the link below to reset your password.')
.addParagraph('If you did not request the password reset, please ignore this email. Otherwise, please click the link below to reset your password.')
.addButton('Reset password', `${config.website_base}/account/reset-password?token=${encodeURIComponent(token)}`);
const mailerOptions = {
@@ -299,6 +332,20 @@ export function isValidBirthday(dateString: string): boolean {
const month = parseInt(parts[1], 10);
const day = parseInt(parts[2], 10);
const today = new Date();
const currentYear = today.getFullYear();
const currentMonth = today.getMonth() + 1;
const currentDay = today.getDate();
// Check that date isn't in the future
if (currentYear < year && currentMonth < month && currentDay < day) {
return false;
}
if (year < 1900) {
return false;
}
const date = new Date(year, month - 1, day);
return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
@@ -347,5 +394,5 @@ function scheduledTask(schedule: string, name: string, fn: () => void | Promise<
start: true
});
LOG_ERROR(`Added schedule ${name} for ${schedule}`);
LOG_SUCCESS(`Added schedule ${name} for ${schedule}`);
}