Merge branch 'dev' into chore/nnas-people-updates
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:
Jonathan Barrow
2026-09-11 16:25:11 -04:00
committed by GitHub
28 changed files with 471 additions and 45 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 |

89
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.7",
"@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",
@@ -1636,6 +1637,16 @@
"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.2.0",
"resolved": "https://registry.npmjs.org/@pretendonetwork/eslint-config/-/eslint-config-0.2.0.tgz",
@@ -1669,9 +1680,9 @@
}
},
"node_modules/@pretendonetwork/grpc": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-2.5.7.tgz",
"integrity": "sha512-HmWyBxm/Om6S5k+PYLxVU/MaRcSUEV0NAfKccy4i02ZJTZccCJRRKiSBl3WfXSYE+TJNJhSoURGK56E1i8CXqA==",
"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",
@@ -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",
@@ -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",
@@ -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.7",
"@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",

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

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

@@ -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
@@ -28,6 +28,7 @@ export async function getUserData(_request: Empty, context: CallContext & Authen
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

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

@@ -10,7 +10,7 @@ import type {
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,
@@ -160,6 +160,7 @@ export async function updateUserData(
timezone: pnid.timezone.name,
language: pnid.language,
emailAddress: pnid.email.address,
emailValidated: pnid.email.validated,
connections: {
discord: {
id: pnid.connections.discord.id

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

@@ -7,7 +7,7 @@ import Mii from 'mii-js';
import deviceCertificateMiddleware from '@/middleware/device-certificate';
import { deviceRatelimit } from '@/middleware/ratelimit';
import { connection as databaseConnection, doesPNIDExist, getPNIDProfileJSONByPID } from '@/database';
import { isValidBirthday, getAgeFromDate, checkNNIDUsernameValid, checkNNIDPasswordValid, isObject, getValueFromHeaders, nintendoPasswordHash, sendConfirmationEmail, sendPNIDDeletedEmail } from '@/util';
import { isValidBirthday, getAgeFromDate, checkNNIDUsernameValid, checkNNIDPasswordValid, isObject, getValueFromHeaders, nintendoPasswordHash, sendConfirmationEmail, sendPNIDDeletedEmail, sendPasswordResetNoticeEmail } from '@/util';
import IP2LocationManager from '@/ip2location';
import { PNID } from '@/models/pnid';
import { NEXAccount } from '@/models/nex-account';
@@ -1113,6 +1113,7 @@ router.put('/@me', async (request: express.Request, response: express.Response):
pnid.password = passwordHash;
await pnid.removeAllTokens();
await sendPasswordResetNoticeEmail(pnid);
}
pnid.gender = gender;
@@ -1196,6 +1197,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,6 +28,7 @@ export interface IPNID {
validated: boolean;
validated_date: string;
id: number;
history: Types.DocumentArray<IEmailUpdateEvent>;
};
region: number;
timezone: {

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';
@@ -160,7 +160,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}`;
@@ -211,8 +211,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!');
@@ -227,18 +227,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> {
@@ -273,7 +306,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 = {
@@ -381,6 +414,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;
@@ -433,5 +480,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}`);
}