diff --git a/SETUP.md b/SETUP.md index 1672729..e0a4570 100644 --- a/SETUP.md +++ b/SETUP.md @@ -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 | \ No newline at end of file +| `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 | diff --git a/package-lock.json b/package-lock.json index 88b594d..ce68520 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7a7a7e2..83c140c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/config-manager.ts b/src/config-manager.ts index 3499f05..7c0ef58 100644 --- a/src/config-manager.ts +++ b/src/config-manager.ts @@ -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 diff --git a/src/metrics.ts b/src/metrics.ts new file mode 100644 index 0000000..d2b2f9f --- /dev/null +++ b/src/metrics.ts @@ -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 { + // * 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 { + // * 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}`); + }); +} diff --git a/src/models/device.ts b/src/models/device.ts index 6fd130b..cb8db98 100644 --- a/src/models/device.ts +++ b/src/models/device.ts @@ -44,4 +44,6 @@ export const DeviceSchema = new Schema({ certificate_hash: String }); -export const Device = model('Device', DeviceSchema); +DeviceSchema.index({ linked_pids: 1 }); + +export const Device = model('Device', DeviceSchema); diff --git a/src/models/email-update-event.ts b/src/models/email-update-event.ts new file mode 100644 index 0000000..6ee332f --- /dev/null +++ b/src/models/email-update-event.ts @@ -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({ + old: String, + new: String, + on: Date +}); + +export const EmailUpdateEvent = model('EmailUpdateEvent', EmailUpdateEventSchema); diff --git a/src/models/pnid.ts b/src/models/pnid.ts index e15dcf8..0b2d8ca 100644 --- a/src/models/pnid.ts +++ b/src/models/pnid.ts @@ -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({ 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([]); this.email.id = 0; this.region = 0; this.timezone.name = ''; diff --git a/src/server.ts b/src/server.ts index 0914cd8..289004f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 { 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); diff --git a/src/services/api/routes/v1/login.ts b/src/services/api/routes/v1/login.ts index 5ea2b21..606182e 100644 --- a/src/services/api/routes/v1/login.ts +++ b/src/services/api/routes/v1/login.ts @@ -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) } }); diff --git a/src/services/api/routes/v1/register.ts b/src/services/api/routes/v1/register.ts index 0707d1f..87d05e2 100644 --- a/src/services/api/routes/v1/register.ts +++ b/src/services/api/routes/v1/register.ts @@ -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) } }); diff --git a/src/services/api/routes/v1/resetPassword.ts b/src/services/api/routes/v1/resetPassword.ts index 1b1f1ce..5132ddd 100644 --- a/src/services/api/routes/v1/resetPassword.ts +++ b/src/services/api/routes/v1/resetPassword.ts @@ -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 diff --git a/src/services/grpc/api/v1/login.ts b/src/services/grpc/api/v1/login.ts index c9101f3..a54c6a3 100644 --- a/src/services/grpc/api/v1/login.ts +++ b/src/services/grpc/api/v1/login.ts @@ -84,7 +84,7 @@ export async function login(request: LoginRequest): Promise> { // * 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 diff --git a/src/services/grpc/api/v2/implementation.ts b/src/services/grpc/api/v2/implementation.ts index e06994e..08281b3 100644 --- a/src/services/grpc/api/v2/implementation.ts +++ b/src/services/grpc/api/v2/implementation.ts @@ -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, diff --git a/src/services/grpc/api/v2/login.ts b/src/services/grpc/api/v2/login.ts index 947ff8e..e00087d 100644 --- a/src/services/grpc/api/v2/login.ts +++ b/src/services/grpc/api/v2/login.ts @@ -84,7 +84,7 @@ export async function login(request: LoginRequest): Promise { // * This is asserted in authentication-middleware, we know this is never null diff --git a/src/services/grpc/api/v2/set-stripe-connection-data.ts b/src/services/grpc/api/v2/set-stripe-connection-data.ts index 04e0163..4000019 100644 --- a/src/services/grpc/api/v2/set-stripe-connection-data.ts +++ b/src/services/grpc/api/v2/set-stripe-connection-data.ts @@ -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; diff --git a/src/services/grpc/api/v2/update-email.ts b/src/services/grpc/api/v2/update-email.ts new file mode 100644 index 0000000..ce45ee6 --- /dev/null +++ b/src/services/grpc/api/v2/update-email.ts @@ -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 { + // * 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 {}; +} diff --git a/src/services/grpc/api/v2/update-password.ts b/src/services/grpc/api/v2/update-password.ts new file mode 100644 index 0000000..fdd36fe --- /dev/null +++ b/src/services/grpc/api/v2/update-password.ts @@ -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 { + // * 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 {}; +} diff --git a/src/services/grpc/api/v2/update-user-data.ts b/src/services/grpc/api/v2/update-user-data.ts index fc5d909..26188b9 100644 --- a/src/services/grpc/api/v2/update-user-data.ts +++ b/src/services/grpc/api/v2/update-user-data.ts @@ -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> { +export async function updateUserData( + request: UpdateUserDataRequest, + context: CallContext & AuthenticationCallContextExt +): Promise> { // * 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 diff --git a/src/services/grpc/api/v2/verify-email.ts b/src/services/grpc/api/v2/verify-email.ts new file mode 100644 index 0000000..2407553 --- /dev/null +++ b/src/services/grpc/api/v2/verify-email.ts @@ -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 { + 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 {}; +} diff --git a/src/services/healthz.ts b/src/services/healthz.ts new file mode 100644 index 0000000..a875ac8 --- /dev/null +++ b/src/services/healthz.ts @@ -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; diff --git a/src/services/nnas/routes/account-settings.ts b/src/services/nnas/routes/account-settings.ts index 8162ac4..51b2b9d 100644 --- a/src/services/nnas/routes/account-settings.ts +++ b/src/services/nnas/routes/account-settings.ts @@ -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 = ''; diff --git a/src/services/nnas/routes/people.ts b/src/services/nnas/routes/people.ts index 476ac9e..31626a0 100644 --- a/src/services/nnas/routes/people.ts +++ b/src/services/nnas/routes/people.ts @@ -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; diff --git a/src/types/common/config.ts b/src/types/common/config.ts index 0eab22f..ec100e1 100644 --- a/src/types/common/config.ts +++ b/src/types/common/config.ts @@ -9,6 +9,10 @@ export interface Config { http: { port: number; }; + metrics: { + enabled: boolean; + port: number; + }; mongoose: { connection_string: string; options: mongoose.ConnectOptions; diff --git a/src/types/mongoose/email-update-event.ts b/src/types/mongoose/email-update-event.ts new file mode 100644 index 0000000..f2cd1ee --- /dev/null +++ b/src/types/mongoose/email-update-event.ts @@ -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 {} + +export type HydratedEmailUpdateDocument = HydratedDocument; diff --git a/src/types/mongoose/pnid.ts b/src/types/mongoose/pnid.ts index 17a9194..1e6756c 100644 --- a/src/types/mongoose/pnid.ts +++ b/src/types/mongoose/pnid.ts @@ -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; }; region: number; timezone: { name: string; offset: number; - marketing: boolean; - off_device: boolean; }; mii: { name: string; diff --git a/src/util.ts b/src/util.ts index fa6da85..f80ed68 100644 --- a/src/util.ts +++ b/src/util.ts @@ -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): Promise { const email = new CreateEmail() .addHeader('Hello {{pnid}}!', { pnid: pnid.username }) - .addParagraph('Your Pretendo Network ID 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): Promise { - 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): Promise { + 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): Promise { @@ -191,7 +224,7 @@ export async function sendForgotPasswordEmail(pnid: mongoose.HydratedDocument void | Promise< start: true }); - LOG_ERROR(`Added schedule ${name} for ${schedule}`); + LOG_SUCCESS(`Added schedule ${name} for ${schedule}`); }