Merge branch 'dev' into feat/grpc-tokens

This commit is contained in:
Jonathan Barrow
2026-07-15 11:50:42 -04:00
committed by GitHub
31 changed files with 2067 additions and 1532 deletions

3190
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -28,12 +28,13 @@
"@aws-sdk/client-s3": "^3.657.0",
"@aws-sdk/client-ses": "^3.515.0",
"@inquirer/prompts": "^7.2.0",
"@pretendonetwork/grpc": "^2.4.3",
"@pretendonetwork/grpc": "^2.5.2",
"bcrypt": "^5.0.0",
"buffer-crc32": "^0.2.13",
"colors": "^1.4.0",
"cors": "^2.8.5",
"crc": "^4.3.2",
"cron": "^4.4.0",
"dicer": "^0.2.5",
"dotenv": "^16.0.3",
"ejs": "^3.1.10",
@@ -68,7 +69,7 @@
},
"devDependencies": {
"@hcaptcha/types": "^1.0.3",
"@pretendonetwork/eslint-config": "^0.0.8",
"@pretendonetwork/eslint-config": "^0.1.4",
"@types/bcrypt": "^5.0.0",
"@types/buffer-crc32": "^0.2.2",
"@types/cors": "^2.8.13",

View File

@@ -352,3 +352,21 @@ export async function removePNIDConnectionDiscord(pnid: HydratedPNIDDocument): P
status: 200
};
}
export async function checkMarkedDeletions(): Promise<void> {
const pnids = await PNID.find({
marked_for_deletion: true,
deleted: false,
hard_delete_time: {
$lte: new Date()
}
});
for (const pnid of pnids) {
try {
await pnid.scrub();
} catch (error) {
LOG_ERROR(`Failed to scrub PNID ${pnid.pid}: ${error}`);
}
}
}

View File

@@ -232,31 +232,27 @@ async function NASCMiddleware(request: express.Request, response: express.Respon
return next();
}
// * https://www.adminsub.net/mac-address-finder/nintendo
// * https://standards-oui.ieee.org
// * Saves us from doing an OUI lookup each time
const NINTENDO_VENDER_OUIS = [
'ECC40D', 'E84ECE', 'E0F6B5', 'E0E751', 'E00C7F', 'DC68EB',
'D86BF7', 'D4F057', 'CCFB65', 'CC9E00', 'B8AE6E', 'B88AEC',
'B87826', 'A4C0E1', 'A45C27', 'A438CC', '9CE635', '98E8FA',
'98B6E9', '98415C', '9458CB', '8CCDE8', '8C56C5', '7CBB8A',
'78A2A0', '7048F7', '64B5C6', '606BFF', '5C521E', '58BDA3',
'582F40', '48A5E7', '40F407', '40D28A', '34AF2C', '342FBD',
'2C10C1', '182A7B', '0403D6', '002709', '002659', '0025A0',
'0024F3', '002444', '00241E', '0023CC', '002331', '0022D7',
'0022AA', '00224C', '0021BD', '002147', '001FC5', '001F32',
'001EA9', '001E35', '001DBC', '001CBE', '001BEA', '001B7A',
'001AE9', '0019FD', '00191D', '0017AB', '001656', '0009BF',
'ECC40D', 'E84ECE', 'E0F6B5', 'E0E751', 'E00C7F', 'DC68EB',
'D86BF7', 'D4F057', 'CCFB65', 'CC9E00', 'B8AE6E', 'B88AEC',
'B87826', 'A4C0E1', 'A45C27', 'A438CC', '9CE635', '98E8FA',
'98B6E9', '98415C', '9458CB', '8CCDE8', '8C56C5', '7CBB8A',
'78A2A0', '7048F7', '64B5C6', '606BFF', '5C521E', '58BDA3',
'582F40', '48A5E7', '40F407', '40D28A', '34AF2C', '342FBD',
'2C10C1', '182A7B', '0403D6', '002709', '002659', '0025A0',
'0024F3', '002444', '00241E', '0023CC', '002331', '0022D7',
'0022AA', '00224C', '0021BD', '002147', '001FC5', '001F32',
'001EA9', '001E35', '001DBC', '001CBE', '001BEA', '001B7A',
'001AE9', '0019FD', '00191D', '0017AB', '001656', '0009BF'
'601AC7', 'BC9EBB', 'CC5B31', '1C4586', 'E8A0CD', '702C09',
'7048F7', '98E8FA', 'ECC40D', '606BFF', '64B5C6', '40D28A',
'A45C27', '8C56C5', '002659', '00241E', '002444', '98E255',
'E0EFBF', '948E6D', '38C6CE', 'C89143', 'DCCD18', '28CF51',
'58B03E', '200BCF', '748469', '70F088', '9458CB', '582F40',
'B88AEC', 'A438CC', '40F407', 'A4C0E1', '0022D7', '001CBE',
'001B7A', '001AE9', '0009BF', '904528', 'ACFAE4', 'BC89A6',
'201C3A', '7820A5', 'E0F6B5', '342FBD', '98415C', 'D4F057',
'5C521E', '98B6E9', 'CCFB65', 'B8AE6E', '182A7B', '2C10C1',
'002331', '001E35', '001BEA', '0017AB', '001656', 'BC744B',
'3CA9AB', 'C84805', 'C0A4CF', '3089EC', '483177', '50236D',
'D05509', 'E8DA20', '7CBB8A', '34AF2C', '78A2A0', 'E84ECE',
'002709', '0025A0', '0024F3', '0023CC', '001F32', '001EA9',
'001DBC', '0019FD', '00191D', 'A4C1E8', 'D86B83', '4044F7',
'B86870', 'BCCE25', '80D2E5', '5C0CE6', '74F9CA', '48A5E7',
'B87826', 'DC68EB', '0403D6', '9CE635', '8CCDE8', '58BDA3',
'E00C7F', 'CC9E00', 'D86BF7', 'E0E751', '0022AA', '00224C',
'0021BD', '002147', '001FC5', '48F1EB', '78818C', '4C306A'
];
// TODO - Make something better

View File

@@ -53,7 +53,7 @@ async function PNIDMiddleware(request: express.Request, response: express.Respon
return;
}
if (pnid.deleted) {
if (pnid.deleted || pnid.marked_for_deletion) {
response.status(400).send(xmlbuilder.create({
errors: {
error: {

View File

@@ -33,6 +33,11 @@ const PNIDSchema = new Schema<IPNID, PNIDModel, IPNIDMethods>({
type: Boolean,
default: false
},
marked_for_deletion: {
type: Boolean,
default: false
},
hard_delete_time: Date,
permissions: {
type: BigInt,
default: 0n
@@ -125,6 +130,11 @@ const PNIDSchema = new Schema<IPNID, PNIDModel, IPNIDMethods>({
}
}, { id: false });
PNIDSchema.index({ pid: 1 });
PNIDSchema.index({ usernameLower: 1 });
// Used for the admin panel querying
PNIDSchema.index({ 'pid': 1, 'username': 1, 'connections.discord.id': 1 });
PNIDSchema.plugin(uniqueValidator, { message: '{PATH} already in use.' });
/*
@@ -225,6 +235,39 @@ PNIDSchema.method('generateMiiImages', async function generateMiiImages(): Promi
await uploadCDNAsset(config.s3.bucket, `${userMiiKey}/body.png`, miiStudioBodyImageData, 'public-read');
});
PNIDSchema.method('markForDeletion', async function markForDeletion() {
this.marked_for_deletion = true;
this.hard_delete_time = new Date(Date.now() + (7 * 24 * 3600 * 1000)); // * 7 day grace period
if (this.connections?.stripe?.subscription_id) {
const subscriptionID = this.connections.stripe.subscription_id;
try {
if (stripe) {
// * If a user has an active subscription when they mark themselves for deletion, then they
// * may get charged again in those 7 days while not having access to their account. To prevent
// * that, just update the subscription to cancel at the end of the period. That way the charge
// * doesn't happen, and the user likely won't be restoring their account anyway. If they do
// * restore the account and do want their subscription back then they can create a new one
// * after this one expires which is effectively the same as the old subscription renewing.
// * Pausing collection is another option however it requires much more effort on our end to
// * properly resume the paused charges and recitify any missed ones to prevent invoices
// * from being skipped. This method is just way easier for us to deal with right now, at the
// * expense of making the user do a tad more work on their end
await stripe.subscriptions.update(subscriptionID, {
cancel_at_period_end: true
});
} else {
LOG_WARN(`UPDATING SUBSCRIPTION FOR USER ${this.username}. HAS STRIPE DATA UDER ID ${this.connections.stripe.customer_id}, BUT STRIPE CLIENT NOT ENABLED.`);
}
} catch (error) {
LOG_ERROR(`ERROR UPDATING SUBSCRIPTION FOR USER ${this.username} (${subscriptionID}). ${error}`);
}
}
await this.save();
});
PNIDSchema.method('scrub', async function scrub() {
// * Remove all personal info from a PNID
// * Username and PID remain so thye do not get assigned again
@@ -287,7 +330,10 @@ PNIDSchema.method('scrub', async function scrub() {
});
this.deleted = true;
this.access_level = 0;
this.marked_for_deletion = false;
if (this.access_level > 0) {
this.access_level = 0;
}
this.server_access_level = 'prod';
this.creation_date = '';
this.birthdate = '';
@@ -318,6 +364,8 @@ PNIDSchema.method('scrub', async function scrub() {
this.connections.stripe.tier_level = 0;
this.connections.stripe.tier_name = '';
this.connections.stripe.latest_webhook_timestamp = 0;
await this.save();
});
PNIDSchema.method('hasPermission', function hasPermission(flag: PNIDPermissionFlag): boolean {

View File

@@ -98,19 +98,24 @@ ServerSchema.method('getServerConnectInfo', async function (): Promise<IServerCo
// * and just Hope For The Best:tm:
let target = randomIP;
// * Check the random IP and start the race at the same time, preferring
// * the result of the random IP should it succeed. Worst case scenario
// * this takes 2 seconds to complete
const [randomResult, raceResult] = await Promise.allSettled([
healthCheck({ host: randomIP, port: this.health_check_port! }),
Promise.race(healthCheckTargets.map(target => healthCheck(target)))
]);
// * If the server only has 1 IP, healthCheckTargets will be empty and Promise.race
// * will never resolve. In those cases, just skip this step entirely since we'd
// * have to use that singular IP regardless
if (healthCheckTargets.length !== 0) {
// * Check the random IP and start the race at the same time, preferring
// * the result of the random IP should it succeed. Worst case scenario
// * this takes 2 seconds to complete
const [randomResult, raceResult] = await Promise.allSettled([
healthCheck({ host: randomIP, port: this.health_check_port! }),
Promise.race(healthCheckTargets.map(target => healthCheck(target)))
]);
if (randomResult.status === 'rejected') {
if (raceResult.status === 'fulfilled') {
target = raceResult.value;
} else {
LOG_WARN(`Server ${this.service_name} failed to find healthy NEX server. Using the randomly selected IP ${target}`);
if (randomResult.status === 'rejected') {
if (raceResult.status === 'fulfilled') {
target = raceResult.value;
} else {
LOG_WARN(`Server ${this.service_name} failed to find healthy NEX server. Using the randomly selected IP ${target}`);
}
}
}

View File

@@ -1,9 +1,9 @@
import fs from 'node:fs/promises';
import { z } from 'zod';
import mongoose from 'mongoose';
import { config, disabledFeatures } from './config-manager';
import { LOG_INFO, LOG_WARN } from './logger';
import { Server } from './models/server';
import { config, disabledFeatures } from '@/config-manager';
import { LOG_INFO, LOG_WARN } from '@/logger';
import { Server } from '@/models/server';
// Provisioning has a couple edgecases:
// - It will only update existing entries, will not add new one

View File

@@ -3,9 +3,9 @@ import morgan from 'morgan';
import xmlbuilder from 'xmlbuilder';
import xmlparser from '@/middleware/xml-parser';
import { connect as connectCache } from '@/cache';
import { connect as connectDatabase } from '@/database';
import { checkMarkedDeletions, connect as connectDatabase } from '@/database';
import { startGRPCServer } from '@/services/grpc/server';
import { fullUrl, getValueFromHeaders } from '@/util';
import { fullUrl, getValueFromHeaders, setupScheduledTasks } from '@/util';
import { LOG_INFO, LOG_SUCCESS, LOG_WARN } from '@/logger';
import conntest from '@/services/conntest';
import cbvc from '@/services/cbvc';
@@ -16,7 +16,7 @@ import api from '@/services/api';
import localcdn from '@/services/local-cdn';
import assets from '@/services/assets';
import { config, disabledFeatures } from '@/config-manager';
import { startProvisioner } from './provisioning';
import { startProvisioner } from '@/provisioning';
process.title = 'Pretendo - Account';
process.on('uncaughtException', (err, origin) => {
@@ -116,6 +116,10 @@ async function main(): Promise<void> {
startProvisioner();
await checkMarkedDeletions();
setupScheduledTasks();
app.listen(config.http.port, () => {
LOG_SUCCESS(`HTTP server started on port ${config.http.port}`);
});

View File

@@ -103,7 +103,7 @@ router.post('/', async (request: express.Request, response: express.Response): P
}
}
if (pnid.deleted) {
if (pnid.deleted || pnid.marked_for_deletion) {
response.status(400).json({
app: 'api',
status: 400,

View File

@@ -119,6 +119,22 @@ router.post('/', async (request: express.Request, response: express.Response): P
}
}
if (age < 13) {
// * Wii U firmware 5.5.6 changed NNID setup to block setup of new accounts if the users age is
// * under 13, telling parents that they MUST call Nintendo to create the account, and we trusted
// * that users would be on these firmwares. Lower firmwares won't have this though, and will use
// * the old "COPPA approval" system
// *
// * Just block it all the time though, we don't want to deal with this headache
response.status(400).json({
app: 'api',
status: 400,
error: 'Must be 13 or older to use these services.'
});
return;
}
if (!email || email === '') {
response.status(400).json({
app: 'api',

View File

@@ -17,7 +17,7 @@ export async function exchangeTokenForUserData(request: ExchangeTokenForUserData
}
return {
deleted: pnid.deleted,
deleted: pnid.deleted || pnid.marked_for_deletion,
pid: pnid.pid,
username: pnid.username,
accessLevel: pnid.access_level,

View File

@@ -15,7 +15,7 @@ export async function getUserData(request: GetUserDataRequest): Promise<GetUserD
}
return {
deleted: pnid.deleted,
deleted: pnid.deleted || pnid.marked_for_deletion,
pid: pnid.pid,
username: pnid.username,
accessLevel: pnid.access_level,

View File

@@ -1,7 +1,7 @@
import { Status, ServerError } from 'nice-grpc';
import { getPNIDByPID } from '@/database';
import { sendPNIDDeletedEmail } from '@/util';
import { LOG_ERROR } from '@/logger';
import { LOG_ERROR, LOG_INFO } from '@/logger';
import type { DeleteAccountRequest, DeleteAccountResponse } from '@pretendonetwork/grpc/account/v2/delete_account_rpc';
export async function deleteAccount(request: DeleteAccountRequest): Promise<DeleteAccountResponse> {
@@ -15,17 +15,27 @@ export async function deleteAccount(request: DeleteAccountRequest): Promise<Dele
}
try {
LOG_INFO(`Deleting PNID ${pnid.pid} (bypass grace period=${request.bypassGracePeriod})`);
const email = pnid.email.address;
await pnid.scrub();
await pnid.save();
if (request.bypassGracePeriod) {
await pnid.scrub();
} else {
await pnid.markForDeletion();
}
await sendPNIDDeletedEmail(email, pnid.username);
if (request.bypassGracePeriod) {
LOG_INFO(`PNID ${pnid.pid} deleted immediately (bypassed grace period)`);
} else {
LOG_INFO(`PNID ${pnid.pid} marked for deletion (will be deleted after grace period)`);
}
} catch (error) {
LOG_ERROR(`Deleting PNID ${error}`);
LOG_ERROR(`Error deleting PNID ${pnid.pid}: ${error}`);
}
return {
hasDeleted: pnid.deleted
hasDeleted: pnid.deleted || pnid.marked_for_deletion
};
}

View File

@@ -5,8 +5,9 @@ import { config } from '@/config-manager';
import { Device } from '@/models/device';
import type { GetUserDataResponse } from '@pretendonetwork/grpc/account/v2/get_user_data_rpc';
import type { ExchangeTokenForUserDataRequest } from '@pretendonetwork/grpc/account/v2/exchange_token_for_user_data_rpc';
import type { ExchangeTokenForUserDataRequest, ExchangeTokenForUserDataResponse } from '@pretendonetwork/grpc/account/v2/exchange_token_for_user_data_rpc';
export async function exchangeTokenForUserData(request: ExchangeTokenForUserDataRequest): Promise<GetUserDataResponse> {
export async function exchangeTokenForUserData(request: ExchangeTokenForUserDataRequest): Promise<ExchangeTokenForUserDataResponse> {
if (!request.token.trim()) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
@@ -31,7 +32,7 @@ export async function exchangeTokenForUserData(request: ExchangeTokenForUserData
});
return {
deleted: pnid.deleted,
deleted: pnid.deleted || pnid.marked_for_deletion,
pid: pnid.pid,
username: pnid.username,
accessLevel: pnid.access_level,
@@ -71,7 +72,6 @@ export async function exchangeTokenForUserData(request: ExchangeTokenForUserData
updateBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_FILES),
deleteBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_FILES),
updatePnidPermissions: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_PNID_PERMISSIONS)
},
linkedDevices: devices
}
};
}

View File

@@ -0,0 +1,75 @@
import { getPNIDByPID } from '@/database';
import { PNID_PERMISSION_FLAGS } from '@/types/common/permission-flags';
import { config } from '@/config-manager';
import { Device } from '@/models/device';
import type { GetPNIDsRequest, GetPNIDsResponse } from '@pretendonetwork/grpc/account/v2/get_pnids_rpc';
export async function getPNIDs(request: GetPNIDsRequest): Promise<GetPNIDsResponse> {
const response: GetPNIDsResponse = { userData: [] };
for (const pid of request.pid) {
const pnid = await getPNIDByPID(pid);
if (!pnid) {
continue;
}
const devices = (await Device.find({
linked_pids: pnid.pid
})).map((device) => {
return {
model: device.get('model'), // ".model" gives the Mongoose model...
serial: device.serial,
linkedPids: device.linked_pids,
accessLevel: device.access_level,
serverAccessLevel: device.server_access_level,
deviceId: device.device_id
};
});
response.userData[pid] = {
deleted: pnid.deleted || pnid.marked_for_deletion,
pid: pnid.pid,
username: pnid.username,
accessLevel: pnid.access_level,
serverAccessLevel: pnid.server_access_level,
mii: {
name: pnid.mii.name,
data: pnid.mii.data,
url: `${config.cdn.base_url}/mii/${pnid.pid}/standard.tga`
},
creationDate: pnid.creation_date,
birthdate: pnid.birthdate,
gender: pnid.gender,
country: pnid.country,
language: pnid.language,
emailAddress: pnid.email.address,
tierName: pnid.connections.stripe.tier_name,
permissions: {
bannedAllPermanently: pnid.hasPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_PERMANENTLY),
bannedAllTemporarily: pnid.hasPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_TEMPORARILY),
betaAccess: pnid.hasPermission(PNID_PERMISSION_FLAGS.BETA_ACCESS),
accessAdminPanel: pnid.hasPermission(PNID_PERMISSION_FLAGS.ACCESS_ADMIN_PANEL),
createServerConfigs: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_SERVER_CONFIGS),
modifyServerConfigs: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_SERVER_CONFIGS),
deployServer: pnid.hasPermission(PNID_PERMISSION_FLAGS.DEPLOY_SERVER),
modifyPnids: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_PNIDS),
modifyNexAccounts: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_NEX_ACCOUNTS),
modifyConsoles: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_CONSOLES),
banPnids: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_PNIDS),
banNexAccounts: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_NEX_ACCOUNTS),
banConsoles: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_CONSOLES),
moderateMiiverse: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODERATE_MIIVERSE),
createApiKeys: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_API_KEYS),
createBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_BOSS_TASKS),
updateBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_TASKS),
deleteBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_TASKS),
uploadBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPLOAD_BOSS_FILES),
updateBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_FILES),
deleteBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_FILES),
updatePnidPermissions: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_PNID_PERMISSIONS)
},
linkedDevices: devices
};
}
return response;
}

View File

@@ -19,7 +19,8 @@ export async function getUserData(request: GetUserDataRequest): Promise<GetUserD
linked_pids: pnid.pid
})).map((device) => {
return {
model: device.get('model'), // * ".model" gives the Mongoose model
deviceId: device.device_id,
model: device.get('model'), // ".model" gives the Mongoose model...
serial: device.serial,
linkedPids: device.linked_pids,
accessLevel: device.access_level,
@@ -29,7 +30,7 @@ export async function getUserData(request: GetUserDataRequest): Promise<GetUserD
});
return {
deleted: pnid.deleted,
deleted: pnid.deleted || pnid.marked_for_deletion,
pid: pnid.pid,
username: pnid.username,
accessLevel: pnid.access_level,

View File

@@ -1,3 +1,4 @@
import { ServerError, Status } from 'nice-grpc';
import { getUserData } from '@/services/grpc/account/v2/get-user-data';
import { getNEXPassword } from '@/services/grpc/account/v2/get-nex-password';
import { getNEXData } from '@/services/grpc/account/v2/get-nex-data';

View File

@@ -3,8 +3,9 @@ import { getPNIDByPID } from '@/database';
import { PNID_PERMISSION_FLAGS } from '@/types/common/permission-flags';
import type { UpdatePNIDPermissionsRequest } from '@pretendonetwork/grpc/account/v2/update_pnid_permissions_rpc';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
import type { UpdatePNIDPermissionsRequest, UpdatePNIDPermissionsResponse } from '@pretendonetwork/grpc/account/v2/update_pnid_permissions_rpc';
export async function updatePNIDPermissions(request: UpdatePNIDPermissionsRequest): Promise<Empty> {
export async function updatePNIDPermissions(request: UpdatePNIDPermissionsRequest): Promise<UpdatePNIDPermissionsResponse> {
const pnid = await getPNIDByPID(request.pid);
if (!pnid) {

View File

@@ -9,7 +9,7 @@ export async function getUserData(_request: Empty, context: CallContext & Authen
const pnid = context.pnid!;
return {
deleted: pnid.deleted,
deleted: pnid.deleted || pnid.marked_for_deletion,
creationDate: pnid.creation_date,
updatedDate: pnid.updated,
pid: pnid.pid,

View File

@@ -53,7 +53,7 @@ export async function login(request: LoginRequest): Promise<DeepPartial<LoginRes
}
}
if (pnid.deleted) {
if (pnid.deleted || pnid.marked_for_deletion) {
throw new ServerError(Status.UNAUTHENTICATED, 'Account has been deleted');
}

View File

@@ -11,7 +11,7 @@ export async function updateUserData(_request: UpdateUserDataRequest, context: C
// TODO - STUBBED, DO SOMETHING HERE
return {
deleted: pnid.deleted,
deleted: pnid.deleted || pnid.marked_for_deletion,
creationDate: pnid.creation_date,
updatedDate: pnid.updated,
pid: pnid.pid,

View File

@@ -17,8 +17,7 @@ export async function deleteAccount(request: DeleteAccountRequest): Promise<Dele
try {
const email = pnid.email.address;
await pnid.scrub();
await pnid.save();
await pnid.markForDeletion();
await sendPNIDDeletedEmail(email, pnid.username);
} catch (error) {
@@ -26,6 +25,6 @@ export async function deleteAccount(request: DeleteAccountRequest): Promise<Dele
}
return {
hasDeleted: pnid.deleted
hasDeleted: pnid.deleted || pnid.marked_for_deletion
};
}

View File

@@ -9,7 +9,7 @@ export async function getUserData(_request: Empty, context: CallContext & Authen
const pnid = context.pnid!;
return {
deleted: pnid.deleted,
deleted: pnid.deleted || pnid.marked_for_deletion,
creationDate: pnid.creation_date,
updatedDate: pnid.updated,
pid: pnid.pid,

View File

@@ -53,7 +53,7 @@ export async function login(request: LoginRequest): Promise<DeepPartial<LoginRes
}
}
if (pnid.deleted) {
if (pnid.deleted || pnid.marked_for_deletion) {
throw new ServerError(Status.UNAUTHENTICATED, 'Account has been deleted');
}

View File

@@ -11,7 +11,7 @@ export async function updateUserData(_request: UpdateUserDataRequest, context: C
// TODO - STUBBED, DO SOMETHING HERE
return {
deleted: pnid.deleted,
deleted: pnid.deleted || pnid.marked_for_deletion,
creationDate: pnid.creation_date,
updatedDate: pnid.updated,
pid: pnid.pid,

View File

@@ -128,7 +128,7 @@ router.post('/access_token/generate', deviceCertificateMiddleware, consoleStatus
}
}
if (pnid.deleted) {
if (pnid.deleted || pnid.marked_for_deletion) {
// * 0112 is the "account deleted" error, but unsure if this unlinks the PNID from the user?
// * 0143 is the "The link to this Nintendo Network ID has been temporarliy removed" error,
// * maybe that is a better error to use here?

View File

@@ -89,6 +89,25 @@ router.post('/', ratelimit, deviceCertificateMiddleware, async (request: express
}
}
if (age < 13) {
// * Wii U firmware 5.5.6 changed NNID setup to block setup of new accounts if the users age is
// * under 13, telling parents that they MUST call Nintendo to create the account, and we trusted
// * that users would be on these firmwares. Lower firmwares won't have this though, and will use
// * the old "COPPA approval" system
// *
// * Just block it all the time though, we don't want to deal with this headache
response.status(400).send(xmlbuilder.create({
errors: {
error: {
code: '0114',
message: 'COPPA approval is not complete'
}
}
}).end());
return;
}
const userExists = await doesPNIDExist(person.user_id);
if (userExists) {
@@ -561,8 +580,7 @@ router.post('/@me/deletion', async (request: express.Request, response: express.
const email = pnid.email.address;
await pnid.scrub();
await pnid.save();
await pnid.markForDeletion();
try {
await sendPNIDDeletedEmail(email, pnid.username);

View File

@@ -1983,7 +1983,7 @@
{
"area": "America/Nassau",
"language": "en",
"name": "Eastern Time (US &amp; Canada)",
"name": "Eastern Time (US & Canada)",
"utc_offset": "-18000",
"order": "0"
}
@@ -2171,14 +2171,14 @@
{
"area": "America/Dawson",
"language": "en",
"name": "Pacific Time (US &amp; Canada)",
"name": "Pacific Time (US & Canada)",
"utc_offset": "-28800",
"order": "1"
},
{
"area": "America/Cambridge_Bay",
"language": "en",
"name": "Mountain Time (US &amp; Canada)",
"name": "Mountain Time (US & Canada)",
"utc_offset": "-25200",
"order": "2"
},
@@ -2192,7 +2192,7 @@
{
"area": "America/Rainy_River",
"language": "en",
"name": "Central Time (US &amp; Canada)",
"name": "Central Time (US & Canada)",
"utc_offset": "-21600",
"order": "4"
},
@@ -2213,7 +2213,7 @@
{
"area": "America/Iqaluit",
"language": "en",
"name": "Eastern Time (US &amp; Canada)",
"name": "Eastern Time (US & Canada)",
"utc_offset": "-18000",
"order": "7"
},
@@ -5038,7 +5038,7 @@
{
"area": "America/Grand_Turk",
"language": "en",
"name": "Eastern Time (US &amp; Canada)",
"name": "Eastern Time (US & Canada)",
"utc_offset": "-14400",
"order": "3"
},
@@ -12519,7 +12519,7 @@
{
"area": "America/Grand_Turk",
"language": "en",
"name": "Eastern Time (US &amp; Canada)",
"name": "Eastern Time (US & Canada)",
"utc_offset": "-14400",
"order": "0"
}
@@ -13049,14 +13049,14 @@
{
"area": "America/Los_Angeles",
"language": "en",
"name": "Pacific Time (US &amp; Canada)",
"name": "Pacific Time (US & Canada)",
"utc_offset": "-28800",
"order": "5"
},
{
"area": "America/Boise",
"language": "en",
"name": "Mountain Time (US &amp; Canada)",
"name": "Mountain Time (US & Canada)",
"utc_offset": "-25200",
"order": "6"
},
@@ -13070,7 +13070,7 @@
{
"area": "America/Chicago",
"language": "en",
"name": "Central Time (US &amp; Canada)",
"name": "Central Time (US & Canada)",
"utc_offset": "-21600",
"order": "8"
},
@@ -13091,7 +13091,7 @@
{
"area": "America/New_York",
"language": "en",
"name": "Eastern Time (US &amp; Canada)",
"name": "Eastern Time (US & Canada)",
"utc_offset": "-18000",
"order": "11"
},

View File

@@ -4,6 +4,8 @@ import type { PNIDPermissionFlag } from '@/types/common/permission-flags';
export interface IPNID {
deleted: boolean;
marked_for_deletion: boolean;
hard_delete_time: Date;
permissions: bigint;
access_level: number;
server_access_level: string;
@@ -81,6 +83,7 @@ export interface IPNIDMethods {
generateEmailValidationToken(): Promise<void>;
updateMii(mii: { name: string; primary: string; data: string }): Promise<void>;
generateMiiImages(): Promise<void>;
markForDeletion(): void;
scrub(): Promise<void>;
hasPermission(flag: PNIDPermissionFlag): boolean;
addPermission(flag: PNIDPermissionFlag): void;

View File

@@ -2,16 +2,24 @@ import crypto from 'node:crypto';
import path from 'node:path';
import { S3 } from '@aws-sdk/client-s3';
import fs from 'fs-extra';
import bufferCrc32 from 'buffer-crc32';
import { crc32 } from 'crc';
import { CronJob } from 'cron';
import { checkMarkedDeletions } from '@/database';
import { sendMail, CreateEmail } from '@/mailer';
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 type { IncomingHttpHeaders } from 'node:http';
import type { ParsedQs } from 'qs';
import type mongoose from 'mongoose';
import type express from 'express';
import type { ObjectCannedACL } from '@aws-sdk/client-s3';
import type { IncomingHttpHeaders } from 'node:http';
import type { TokenOptions } from '@/types/common/token-options';
import type { Token } from '@/types/common/token';
import type { IPNID, IPNIDMethods } from '@/types/mongoose/pnid';
import type { SafeQs } from '@/types/common/safe-qs';
import type { HydratedServerDocument } from '@/types/mongoose/server';
@@ -201,14 +209,24 @@ export async function sendForgotPasswordEmail(pnid: mongoose.HydratedDocument<IP
}
export async function sendPNIDDeletedEmail(emailAddress: string, username: string): Promise<void> {
const deletionDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toLocaleString('en-US', {
timeZone: 'UTC',
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
const email = new CreateEmail()
.addHeader('Dear {{pnid}},', { pnid: username })
.addParagraph('your PNID has successfully been deleted.')
.addParagraph('If you had a tier subscription, a separate cancellation email will be sent. If you do not receive this cancellation email, or you are still being charged for your subscription, please contact <b>@jonbarrow</b> on our [Discord server](https://discord.pretendo.network/).');
.addHeader('Dear {{pnid}}.', { pnid: username })
.addParagraph('Your PNID has been scheduled for deletion.')
.addParagraph(`Your account and related data will be permanently deleted in 7 days (${deletionDate}). Note that this will not free the username associated with the account for use in future accounts.`)
.addParagraph('You may restore your account at any time before the deletion date. To do so, email [restore-account@pretendo.network](mailto:restore-account@pretendo.network?subject=Requesting%20account%20restore&body=Please%20restore%20my%20account) from the email address used to register, with the subject "Requesting account restore" and your PNID username in the body. Requests must be submitted more than 24 hours before the deletion date, as after this point your data may be unrecoverable. For additional help, visit our [Forum](https://forum.pretendo.network/) or [Discord server](https://discord.pretendo.network/).')
.addParagraph('If you have or have had an active tier subscription, your associated Stripe data (including payment info and invoices) will be permanently deleted on the deletion date and cannot be restored.')
.addParagraph('No new charges will be made during the grace period, even if a renewal would normally occur. If your account is restored with an active subscription, you may need to resubscribe. If you notice unexpected charges during the grace period, please contact us via our [Forum](https://forum.pretendo.network/) or [Discord server](https://discord.pretendo.network/) before the deletion date.');
const options = {
to: emailAddress,
subject: '[Pretendo Network] PNID Deleted',
subject: '[Pretendo Network] PNID Deletion',
email
};
@@ -315,3 +333,24 @@ export function getAgeFromDate(dateString: string): number {
return age;
}
export async function setupScheduledTasks(): Promise<void> {
scheduledTask('0 2 * * *', 'check-account-deletions', checkMarkedDeletions);
}
function scheduledTask(schedule: string, name: string, fn: () => void | Promise<void>): void {
CronJob.from({
cronTime: schedule,
onTick: async () => {
try {
const result = fn();
await result;
} catch (err) {
LOG_ERROR(`Error in schedule ${name}: ${err}`);
}
},
start: true
});
LOG_ERROR(`Added schedule ${name} for ${schedule}`);
}