Merge pull request #65 from PretendoNetwork/ts-migration

TypeScript migration
This commit is contained in:
Jonathan Barrow
2023-04-27 15:35:35 -04:00
committed by GitHub
119 changed files with 10790 additions and 4999 deletions

View File

@@ -0,0 +1,2 @@
dist
*.js

View File

@@ -4,13 +4,17 @@
"commonjs": true,
"es6": true
},
"parserOptions": {
"ecmaVersion": 2020
},
"parser": "@typescript-eslint/parser",
"globals": {
"BigInt": true
},
"extends": "eslint:recommended",
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"plugins": [
"@typescript-eslint"
],
"rules": {
"require-atomic-updates": "warn",
"no-case-declarations": "off",
@@ -20,6 +24,13 @@
"no-global-assign": "off",
"prefer-const": "error",
"no-var": "error",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"no-extra-semi": "off",
"@typescript-eslint/no-extra-semi": "error",
"@typescript-eslint/no-empty-interface": "warn",
"@typescript-eslint/no-inferrable-types": "off",
"@typescript-eslint/explicit-function-return-type": "error",
"one-var": [
"error",
"never"

7
.gitignore vendored
View File

@@ -58,10 +58,7 @@ typings/
.env
# custom
sign.js
t.js
p.js
config.json
certs
/cdn
dump.rdb
cdn
dist

7220
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,15 @@
{
"name": "account",
"version": "1.0.0",
"version": "2.0.0",
"description": "",
"main": "./src/server.js",
"main": "./dist/server.js",
"scripts": {
"lint": "./node_modules/.bin/eslint .",
"lint": "npx eslint .",
"build": "npm run lint && npm run clean && npx tsc && npx tsc-alias && npm run copy-static",
"clean": "rimraf ./dist",
"copy-static": "npm run copy-assets && npm run copy-timezones",
"copy-assets": "cp -r ./src/assets ./dist/assets",
"copy-timezones": "cp ./src/services/nnid/timezones.json ./dist/services/nnid/timezones.json",
"start": "node .",
"start:dev": "NODE_ENV=development node ."
},
@@ -28,35 +33,49 @@
"dotenv": "^16.0.3",
"email-validator": "^2.0.4",
"express": "^4.17.1",
"express-form-data": "^2.0.17",
"express-rate-limit": "^5.3.0",
"express-rate-limit": "^6.7.0",
"express-subdomain": "^1.0.5",
"formidable": "^1.2.2",
"fs-extra": "^8.1.0",
"got": "^11.8.2",
"hcaptcha": "^0.1.0",
"image-pixels": "^1.1.1",
"joi": "^17.6.1",
"kaitai-struct": "^0.9.0",
"lodash.get": "^4.4.2",
"lodash.set": "^4.3.2",
"joi": "^17.8.3",
"mii-js": "github:PretendoNetwork/mii-js",
"moment": "^2.24.0",
"moment-timezone": "^0.5.27",
"mongoose": "^5.8.3",
"mongoose-unique-validator": "^2.0.3",
"moment": "^2.29.4",
"mongoose": "^7.0.0",
"mongoose-unique-validator": "github:stenneepro/mongoose-unique-validator#bump/mongoose7",
"morgan": "^1.9.1",
"multer": "^1.4.3",
"node-rsa": "^1.0.7",
"nodemailer": "^6.4.2",
"redis": "^4.3.1",
"tga": "^1.0.4",
"typescript-is": "^0.19.0",
"validator": "^13.7.0",
"xmlbuilder": "^13.0.2",
"xmlbuilder2": "0.0.4"
},
"devDependencies": {
"node-rsa": "^1.0.7",
"@hcaptcha/types": "^1.0.3",
"@types/bcrypt": "^5.0.0",
"@types/cors": "^2.8.13",
"@types/dicer": "^0.2.2",
"@types/express": "^4.17.17",
"@types/fs-extra": "^11.0.1",
"@types/mongoose-unique-validator": "^1.0.7",
"@types/morgan": "^1.9.4",
"@types/ndarray": "^1.0.11",
"@types/node": "^18.14.4",
"@types/node-rsa": "^1.1.1",
"@types/nodemailer": "^6.4.7",
"@types/qs": "^6.9.7",
"@types/validator": "^13.7.14",
"@types/w3c-css-typed-object-model-level-1": "^20180410.0.5",
"@typescript-eslint/eslint-plugin": "^5.54.1",
"@typescript-eslint/parser": "^5.54.1",
"eslint": "^8.35.0",
"ndarray": "^1.0.19",
"prompt": "^1.0.0",
"typescript": "^4.9.5",
"yesno": "^0.4.0"
}
}

View File

@@ -1,212 +0,0 @@
const fs = require('fs-extra');
const redis = require('redis');
const { config, disabledFeatures } = require('./config-manager');
let client;
const memoryCache = {};
const SERVICE_CERTS_BASE = `${__dirname}/../certs/service`;
const NEX_CERTS_BASE = `${__dirname}/../certs/nex`;
const LOCAL_CDN_BASE = `${__dirname}/../cdn`;
async function connect() {
if (!disabledFeatures.redis) {
client = redis.createClient(config.redis.client);
client.on('error', (err) => console.log('Redis Client Error', err));
await client.connect();
}
}
async function setCachedFile(fileName, value) {
if (disabledFeatures.redis) {
memoryCache[fileName] = value;
} else {
await client.set(fileName, value);
}
}
async function getCachedFile(fileName, encoding) {
let cachedFile;
if (disabledFeatures.redis) {
cachedFile = memoryCache[fileName] || null;
} else {
cachedFile = await client.get(fileName);
}
if (cachedFile !== null) {
cachedFile = Buffer.from(cachedFile, encoding);
}
return cachedFile;
}
// NEX server cache functions
async function getNEXPublicKey(name, encoding) {
let publicKey = await getCachedFile(`nex:${name}:public_key`, encoding);
if (publicKey === null) {
publicKey = await fs.readFile(`${NEX_CERTS_BASE}/${name}/public.pem`, { encoding });
await setNEXPublicKey(name, publicKey);
}
return publicKey;
}
async function getNEXPrivateKey(name, encoding) {
let privateKey = await getCachedFile(`nex:${name}:private_key`, encoding);
if (privateKey === null) {
privateKey = await fs.readFile(`${NEX_CERTS_BASE}/${name}/private.pem`, { encoding });
await setNEXPrivateKey(name, privateKey);
}
return privateKey;
}
async function getNEXSecretKey(name, encoding) {
let secretKey = await getCachedFile(`nex:${name}:secret_key`, encoding);
if (secretKey === null) {
const fileBuffer = await fs.readFile(`${NEX_CERTS_BASE}/${name}/secret.key`, { encoding: 'utf8' });
secretKey = Buffer.from(fileBuffer, encoding);
await setNEXSecretKey(name, secretKey);
}
return secretKey;
}
async function getNEXAESKey(name, encoding) {
let aesKey = await getCachedFile(`nex:${name}:aes_key`, encoding);
if (aesKey === null) {
const fileBuffer = await fs.readFile(`${NEX_CERTS_BASE}/${name}/aes.key`, { encoding: 'utf8' });
aesKey = Buffer.from(fileBuffer, encoding);
await setNEXAESKey(name, aesKey);
}
return aesKey;
}
async function setNEXPublicKey(name, value) {
await setCachedFile(`nex:${name}:public_key`, value);
}
async function setNEXPrivateKey(name, value) {
await setCachedFile(`nex:${name}:private_key`, value);
}
async function setNEXSecretKey(name, value) {
await setCachedFile(`nex:${name}:secret_key`, value);
}
async function setNEXAESKey(name, value) {
await setCachedFile(`nex:${name}:aes_key`, value);
}
// 3rd party service cache functions
async function getServicePublicKey(name, encoding) {
let publicKey = await getCachedFile(`service:${name}:public_key`, encoding);
if (publicKey === null) {
publicKey = await fs.readFile(`${SERVICE_CERTS_BASE}/${name}/public.pem`, { encoding });
await setServicePublicKey(name, publicKey);
}
return publicKey;
}
async function getServicePrivateKey(name, encoding) {
let privateKey = await getCachedFile(`service:${name}:private_key`, encoding);
if (privateKey === null) {
privateKey = await fs.readFile(`${SERVICE_CERTS_BASE}/${name}/private.pem`, { encoding });
await setServicePrivateKey(name, privateKey);
}
return privateKey;
}
async function getServiceSecretKey(name, encoding) {
let secretKey = await getCachedFile(`service:${name}:secret_key`, encoding);
if (secretKey === null) {
const fileBuffer = await fs.readFile(`${SERVICE_CERTS_BASE}/${name}/secret.key`, { encoding: 'utf8' });
secretKey = Buffer.from(fileBuffer, encoding);
await setServiceSecretKey(name, secretKey);
}
return secretKey;
}
async function getServiceAESKey(name, encoding) {
let aesKey = await getCachedFile(`service:${name}:aes_key`, encoding);
if (aesKey === null) {
const fileBuffer = await fs.readFile(`${SERVICE_CERTS_BASE}/${name}/aes.key`, { encoding: 'utf8' });
aesKey = Buffer.from(fileBuffer, encoding);
await setServiceAESKey(name, aesKey);
}
return aesKey;
}
async function setServicePublicKey(name, value) {
await setCachedFile(`service:${name}:public_key`, value);
}
async function setServicePrivateKey(name, value) {
await setCachedFile(`service:${name}:private_key`, value);
}
async function setServiceSecretKey(name, value) {
await setCachedFile(`service:${name}:secret_key`, value);
}
async function setServiceAESKey(name, value) {
await setCachedFile(`service:${name}:aes_key`, value);
}
// Local CDN cache functions
async function getLocalCDNFile(name, encoding) {
let file = await getCachedFile(`local_cdn:${name}`, encoding);
if (file === null) {
if (await fs.pathExists(`${LOCAL_CDN_BASE}/${name}`)) {
file = await fs.readFile(`${LOCAL_CDN_BASE}/${name}`, { encoding });
await setLocalCDNFile(name, file);
}
}
return file;
}
async function setLocalCDNFile(name, value) {
await setCachedFile(`local_cdn:${name}`, value);
}
module.exports = {
connect,
getNEXPublicKey,
getNEXPrivateKey,
getNEXSecretKey,
getNEXAESKey,
setNEXPublicKey,
setNEXPrivateKey,
setNEXSecretKey,
setNEXAESKey,
getServicePublicKey,
getServicePrivateKey,
getServiceSecretKey,
getServiceAESKey,
setServicePublicKey,
setServicePrivateKey,
setServiceSecretKey,
setServiceAESKey,
getLocalCDNFile,
setLocalCDNFile
};

61
src/cache.ts Normal file
View File

@@ -0,0 +1,61 @@
import fs from 'fs-extra';
import redis from 'redis';
import { config, disabledFeatures } from '@/config-manager';
let client: redis.RedisClientType;
const memoryCache: { [key: string]: Buffer } = {};
const LOCAL_CDN_BASE: string = `${__dirname}/../cdn`;
export async function connect(): Promise<void> {
if (!disabledFeatures.redis) {
client = redis.createClient(config.redis.client);
client.on('error', (err) => console.log('Redis Client Error', err));
await client.connect();
}
}
export async function setCachedFile(fileName: string, value: Buffer): Promise<void> {
if (disabledFeatures.redis) {
memoryCache[fileName] = value;
} else {
await client.set(fileName, value);
}
}
export async function getCachedFile(fileName: string, encoding?: BufferEncoding): Promise<Buffer> {
let cachedFile: Buffer = Buffer.alloc(0);
if (disabledFeatures.redis) {
cachedFile = memoryCache[fileName] || null;
} else {
const redisValue: string | null = await client.get(fileName);
if (redisValue) {
cachedFile = Buffer.from(redisValue, encoding);
}
}
return cachedFile;
}
// * Local CDN cache functions
export async function getLocalCDNFile(name: string, encoding?: BufferEncoding): Promise<Buffer> {
let file: Buffer = await getCachedFile(`local_cdn:${name}`, encoding);
if (file === null) {
if (await fs.pathExists(`${LOCAL_CDN_BASE}/${name}`)) {
const fileBuffer: string | Buffer = await fs.readFile(`${LOCAL_CDN_BASE}/${name}`, { encoding });
file = Buffer.from(fileBuffer);
await setLocalCDNFile(name, file);
}
}
return file;
}
export async function setLocalCDNFile(name: string, value: Buffer): Promise<void> {
await setCachedFile(`local_cdn:${name}`, value);
}

View File

@@ -1,335 +0,0 @@
const fs = require('fs-extra');
const get = require('lodash.get');
const set = require('lodash.set');
const logger = require('../logger');
require('dotenv').config();
/**
* @typedef {Object} Config
* @property {object} http HTTP server settings
* @property {number} http.port HTTP port the server will listen on
* @property {object} mongoose Mongose connection settings
* @property {string} mongoose.connection_string MongoDB connection string
* @property {object} mongoose.options MongoDB connection options
* @property {object} [redis] redis settings
* @property {string} [redis.client] redis client settings
* @property {string} [redis.client.url] redis server URL
* @property {object} [email] node-mailer client settings
* @property {string} [email.host] SMTP server address
* @property {number} [email.port] SMTP server port
* @property {boolean} [email.secure] Secure SMTP
* @property {string} [email.from] Email 'from' name/address
* @property {object} [email.auth] Email authentication settings
* @property {string} [email.auth.user] Email username
* @property {string} [email.auth.pass] Email password
* @property {object} [s3] s3 client settings
* @property {object} [s3.endpoint] s3 endpoint URL
* @property {string} [s3.key] s3 access key
* @property {string} [s3.secret] s3 access secret
* @property {object} [hcaptcha] hCaptcha settings
* @property {string} [hcaptcha.secret] hCaptcha secret
* @property {object} cdn CDN config settings
* @property {object} [cdn.subdomain] Subdomain used for serving CDN contents when s3 is disabled
* @property {string} [cdn.disk_path] Fully qualified file system path for storing and reading local CDN contents
* @property {string} cdn.base_url Base URL for CDN server
* @property {string} website_base Base URL for service website (used with emails)
*/
/**
* @type {Config}
*/
let config = {};
/**
* @typedef {Object} DisabledFeatures
* @property {boolean} redis true if redis is disabled
* @property {boolean} email true if email sending is disabled
* @property {boolean} captcha true if captcha verification is disabled
* @property {boolean} s3 true if s3 services is disabled
*/
/**
* @type {DisabledFeatures}
*/
const disabledFeatures = {
redis: false,
email: false,
captcha: false,
s3: false
};
const requiredFields = [
['http.port', 'PN_ACT_CONFIG_HTTP_PORT', Number],
['mongoose.connection_string', 'PN_ACT_CONFIG_MONGO_CONNECTION_STRING'],
['cdn.base_url', 'PN_ACT_CONFIG_CDN_BASE_URL']
];
function configure() {
const usingEnv = process.env.PN_ACT_PREFER_ENV_CONFIG === 'true';
if (usingEnv) {
logger.info('Loading config from environment variable');
config = {
http: {
port: Number(process.env.PN_ACT_CONFIG_HTTP_PORT)
},
mongoose: {
connection_string: process.env.PN_ACT_CONFIG_MONGO_CONNECTION_STRING,
options: Object.keys(process.env)
.filter(key => key.startsWith('PN_ACT_CONFIG_MONGOOSE_OPTION_'))
.reduce((obj, key) => {
obj[key.split('_').pop()] = process.env[key];
return obj;
}, {})
},
redis: {
client: {
url: process.env.PN_ACT_CONFIG_REDIS_URL
}
},
email: {
host: process.env.PN_ACT_CONFIG_EMAIL_HOST,
port: Number(process.env.PN_ACT_CONFIG_EMAIL_PORT),
secure: Boolean(process.env.PN_ACT_CONFIG_EMAIL_SECURE),
auth: {
user: process.env.PN_ACT_CONFIG_EMAIL_USERNAME,
pass: process.env.PN_ACT_CONFIG_EMAIL_PASSWORD
},
from: process.env.PN_ACT_CONFIG_EMAIL_FROM
},
s3: {
endpoint: process.env.PN_ACT_CONFIG_S3_ENDPOINT,
key: process.env.PN_ACT_CONFIG_S3_ACCESS_KEY,
secret: process.env.PN_ACT_CONFIG_S3_ACCESS_SECRET
},
hcaptcha: {
secret: process.env.PN_ACT_CONFIG_HCAPTCHA_SECRET
},
cdn: {
subdomain: process.env.PN_ACT_CONFIG_CDN_SUBDOMAIN,
disk_path: process.env.PN_ACT_CONFIG_CDN_DISK_PATH,
base_url: process.env.PN_ACT_CONFIG_CDN_BASE_URL
},
website_base: process.env.PN_ACT_CONFIG_WEBSITE_BASE
};
} else {
logger.info('Loading config from config.json');
if (!fs.pathExistsSync(`${__dirname}/../config.json`)) {
logger.error('Failed to locate config.json file');
process.exit(0);
}
config = require(`${__dirname}/../config.json`);
}
logger.info('Config loaded, checking integrity');
// * Check for required settings
for (const requiredField of requiredFields) {
const [keyPath, env, convertType] = requiredField;
const configValue = get(config, keyPath);
const envValue = get(process.env, keyPath);
if (!configValue || (typeof configValue === 'string' && configValue.trim() === '')) {
if (!envValue || envValue.trim() === '') {
logger.error(`Failed to locate required field ${keyPath}. Set ${keyPath} in config.json or the ${env} environment variable`);
process.exit(0);
} else {
logger.info(`${keyPath} not found in config, using environment variable ${env}`);
const newValue = envValue;
set(config, keyPath, convertType ? convertType(newValue) : newValue);
}
}
}
// * Check for optional settings
const redisCheck = get(config, 'redis.client.url');
if (!redisCheck || redisCheck.trim() === '') {
if (usingEnv) {
logger.warn('Failed to find Redis connection url. Disabling feature and using in-memory cache. To enable feature set the PN_ACT_CONFIG_REDIS_URL environment variable');
} else {
logger.warn('Failed to find Redis connection url. Disabling feature and using in-memory cache. To enable feature set redis.client.url in your config.json');
}
disabledFeatures.redis = true;
}
const emailHostCheck = get(config, 'email.host');
if (!emailHostCheck || emailHostCheck.trim() === '') {
if (usingEnv) {
logger.warn('Failed to find email SMTP host. Disabling feature. To enable feature set the PN_ACT_CONFIG_EMAIL_HOST environment variable');
} else {
logger.warn('Failed to find email SMTP host. Disabling feature. To enable feature set email.host in your config.json');
}
disabledFeatures.email = true;
}
const emailPortCheck = get(config, 'email.port');
if (!emailPortCheck) {
if (usingEnv) {
logger.warn('Failed to find email SMTP port. Disabling feature. To enable feature set the PN_ACT_CONFIG_EMAIL_PORT environment variable');
} else {
logger.warn('Failed to find email SMTP port. Disabling feature. To enable feature set email.port in your config.json');
}
disabledFeatures.email = true;
}
const emailSecureCheck = get(config, 'email.secure');
if (emailSecureCheck === undefined) {
if (usingEnv) {
logger.warn('Failed to find email SMTP secure flag. Disabling feature. To enable feature set the PN_ACT_CONFIG_EMAIL_SECURE environment variable');
} else {
logger.warn('Failed to find email SMTP secure flag. Disabling feature. To enable feature set email.secure in your config.json');
}
disabledFeatures.email = true;
}
const emailUsernameCheck = get(config, 'email.auth.user');
if (!emailUsernameCheck || emailUsernameCheck.trim() === '') {
if (usingEnv) {
logger.warn('Failed to find email account username. Disabling feature. To enable feature set the auth.user environment variable');
} else {
logger.warn('Failed to find email account username. Disabling feature. To enable feature set email.auth.user in your config.json');
}
disabledFeatures.email = true;
}
const emailPasswordCheck = get(config, 'email.auth.pass');
if (!emailPasswordCheck || emailPasswordCheck.trim() === '') {
if (usingEnv) {
logger.warn('Failed to find email account password. Disabling feature. To enable feature set the PN_ACT_CONFIG_EMAIL_PASSWORD environment variable');
} else {
logger.warn('Failed to find email account password. Disabling feature. To enable feature set email.auth.pass in your config.json');
}
disabledFeatures.email = true;
}
const emailFromCheck = get(config, 'email.from');
if (!emailFromCheck || emailFromCheck.trim() === '') {
if (usingEnv) {
logger.warn('Failed to find email from config. Disabling feature. To enable feature set the PN_ACT_CONFIG_EMAIL_FROM environment variable');
} else {
logger.warn('Failed to find email from config. Disabling feature. To enable feature set email.from in your config.json');
}
disabledFeatures.email = true;
}
if (!disabledFeatures.email) {
const websiteBaseCheck = get(config, 'website_base');
if (!websiteBaseCheck || websiteBaseCheck.trim() === '') {
if (usingEnv) {
logger.error('Email sending is enabled and no website base was configured. Set the PN_ACT_CONFIG_WEBSITE_BASE environment variable');
} else {
logger.error('Email sending is enabled and no website base was configured. Set website_base in your config.json');
}
process.exit(0);
}
}
const captchaSecretCheck = get(config, 'hcaptcha.secret');
if (!captchaSecretCheck || captchaSecretCheck.trim() === '') {
if (usingEnv) {
logger.warn('Failed to find captcha secret config. Disabling feature. To enable feature set the PN_ACT_CONFIG_HCAPTCHA_SECRET environment variable');
} else {
logger.warn('Failed to find captcha secret config. Disabling feature. To enable feature set hcaptcha.secret in your config.json');
}
disabledFeatures.captcha = true;
}
const s3EndpointCheck = get(config, 's3.endpoint');
if (!s3EndpointCheck || s3EndpointCheck.trim() === '') {
if (usingEnv) {
logger.warn('Failed to find s3 endpoint config. Disabling feature. To enable feature set the PN_ACT_CONFIG_S3_ENDPOINT environment variable');
} else {
logger.warn('Failed to find s3 endpoint config. Disabling feature. To enable feature set s3.endpoint in your config.json');
}
disabledFeatures.s3 = true;
} else {
}
const s3AccessKeyCheck = get(config, 's3.key');
if (!s3AccessKeyCheck || s3AccessKeyCheck.trim() === '') {
if (usingEnv) {
logger.warn('Failed to find s3 access key config. Disabling feature. To enable feature set the PN_ACT_CONFIG_S3_ACCESS_KEY environment variable');
} else {
logger.warn('Failed to find s3 access key config. Disabling feature. To enable feature set s3.key in your config.json');
}
disabledFeatures.s3 = true;
}
const s3SecretKeyCheck = get(config, 's3.secret');
if (!s3SecretKeyCheck || s3SecretKeyCheck.trim() === '') {
if (usingEnv) {
logger.warn('Failed to find s3 secret key config. Disabling feature. To enable feature set the PN_ACT_CONFIG_S3_ACCESS_SECRET environment variable');
} else {
logger.warn('Failed to find s3 secret key config. Disabling feature. To enable feature set s3.secret in your config.json');
}
disabledFeatures.s3 = true;
}
if (disabledFeatures.s3) {
const cdnSubdomainCheck = get(config, 'cdn.subdomain');
if (!cdnSubdomainCheck || cdnSubdomainCheck.trim() === '') {
if (usingEnv) {
logger.error('s3 file storage is disabled and no CDN subdomain was set. Set the PN_ACT_CONFIG_CDN_SUBDOMAIN environment variable');
} else {
logger.error('s3 file storage is disabled and no CDN subdomain was set. Set cdn.subdomain in your config.json');
}
process.exit(0);
}
if (disabledFeatures.redis) {
logger.warn('Both s3 and Redis are disabled. Large CDN files will use the in-memory cache, which may result in high memory use. Please enable s3 if you\'re running a production server.');
}
logger.warn(`s3 file storage disabled. Using disk-based file storage. Please ensure cdn.base_url config or PN_ACT_CONFIG_CDN_BASE env variable is set to point to this server with the subdomain being ${config.cdn.subdomain}`);
}
module.exports.config = config;
}
module.exports = {
configure,
config,
disabledFeatures
};

171
src/config-manager.ts Normal file
View File

@@ -0,0 +1,171 @@
import fs from 'fs-extra';
import mongoose from 'mongoose';
import dotenv from 'dotenv';
import { LOG_INFO, LOG_WARN, LOG_ERROR } from '@/logger';
import { Config, DisabledFeatures } from '@/types/common/config';
dotenv.config();
export const disabledFeatures: DisabledFeatures = {
redis: false,
email: false,
captcha: false,
s3: false
};
LOG_INFO('Loading config');
let mongooseConnectOptions: mongoose.ConnectOptions = {};
if (process.env.PN_ACT_CONFIG_MONGOOSE_CONNECT_OPTIONS_PATH) {
mongooseConnectOptions = fs.readJSONSync(process.env.PN_ACT_CONFIG_MONGOOSE_CONNECT_OPTIONS_PATH);
}
if (process.env.PN_ACT_CONFIG_EMAIL_SECURE) {
if (process.env.PN_ACT_CONFIG_EMAIL_SECURE !== 'true' && process.env.PN_ACT_CONFIG_EMAIL_SECURE !== 'false') {
LOG_ERROR(`PN_ACT_CONFIG_EMAIL_SECURE must be either true or false, got ${process.env.PN_ACT_CONFIG_EMAIL_SECURE}`);
process.exit(0);
}
}
export const config: Config = {
http: {
port: Number(process.env.PN_ACT_CONFIG_HTTP_PORT || '')
},
mongoose: {
connection_string: process.env.PN_ACT_CONFIG_MONGO_CONNECTION_STRING || '',
options: mongooseConnectOptions
},
redis: {
client: {
url: process.env.PN_ACT_CONFIG_REDIS_URL || ''
}
},
email: {
host: process.env.PN_ACT_CONFIG_EMAIL_HOST || '',
port: Number(process.env.PN_ACT_CONFIG_EMAIL_PORT || ''),
secure: (process.env.PN_ACT_CONFIG_EMAIL_SECURE || '') === 'true',
auth: {
user: process.env.PN_ACT_CONFIG_EMAIL_USERNAME || '',
pass: process.env.PN_ACT_CONFIG_EMAIL_PASSWORD || ''
},
from: process.env.PN_ACT_CONFIG_EMAIL_FROM || ''
},
s3: {
endpoint: process.env.PN_ACT_CONFIG_S3_ENDPOINT || '',
key: process.env.PN_ACT_CONFIG_S3_ACCESS_KEY || '',
secret: process.env.PN_ACT_CONFIG_S3_ACCESS_SECRET || ''
},
hcaptcha: {
secret: process.env.PN_ACT_CONFIG_HCAPTCHA_SECRET || ''
},
cdn: {
subdomain: process.env.PN_ACT_CONFIG_CDN_SUBDOMAIN || '',
disk_path: process.env.PN_ACT_CONFIG_CDN_DISK_PATH || '',
base_url: process.env.PN_ACT_CONFIG_CDN_BASE_URL || ''
},
website_base: process.env.PN_ACT_CONFIG_WEBSITE_BASE || '',
aes_key: process.env.PN_ACT_CONFIG_AES_KEY || ''
};
LOG_INFO('Config loaded, checking integrity');
if (!config.http.port) {
LOG_ERROR('Failed to find HTTP port. Set the PN_ACT_CONFIG_HTTP_PORT environment variable');
process.exit(0);
}
if (!config.mongoose.connection_string) {
LOG_ERROR('Failed to find MongoDB connection string. Set the PN_ACT_CONFIG_MONGO_CONNECTION_STRING environment variable');
process.exit(0);
}
if (!config.cdn.base_url) {
LOG_ERROR('Failed to find asset CDN base URL. Set the PN_ACT_CONFIG_CDN_BASE_URL environment variable');
process.exit(0);
}
if (!config.redis.client.url) {
LOG_WARN('Failed to find Redis connection url. Disabling feature and using in-memory cache. To enable feature set the PN_ACT_CONFIG_REDIS_URL environment variable');
disabledFeatures.redis = true;
}
if (!config.email.host) {
LOG_WARN('Failed to find email SMTP host. Disabling feature. To enable feature set the PN_ACT_CONFIG_EMAIL_HOST environment variable');
disabledFeatures.email = true;
}
if (!config.email.port) {
LOG_WARN('Failed to find email SMTP port. Disabling feature. To enable feature set the PN_ACT_CONFIG_EMAIL_PORT environment variable');
disabledFeatures.email = true;
}
if (config.email.secure === undefined) {
LOG_WARN('Failed to find email SMTP secure flag. Disabling feature. To enable feature set the PN_ACT_CONFIG_EMAIL_SECURE environment variable');
disabledFeatures.email = true;
}
if (!config.email.auth.user) {
LOG_WARN('Failed to find email account username. Disabling feature. To enable feature set the auth.user environment variable');
disabledFeatures.email = true;
}
if (!config.email.auth.pass) {
LOG_WARN('Failed to find email account password. Disabling feature. To enable feature set the PN_ACT_CONFIG_EMAIL_PASSWORD environment variable');
disabledFeatures.email = true;
}
if (!config.email.from) {
LOG_WARN('Failed to find email from config. Disabling feature. To enable feature set the PN_ACT_CONFIG_EMAIL_FROM environment variable');
disabledFeatures.email = true;
}
if (!disabledFeatures.email) {
if (!config.website_base) {
LOG_ERROR('Email sending is enabled and no website base was configured. Set the PN_ACT_CONFIG_WEBSITE_BASE environment variable');
process.exit(0);
}
}
if (!config.hcaptcha.secret) {
LOG_WARN('Failed to find captcha secret config. Disabling feature. To enable feature set the PN_ACT_CONFIG_HCAPTCHA_SECRET environment variable');
disabledFeatures.captcha = true;
}
if (!config.s3.endpoint) {
LOG_WARN('Failed to find s3 endpoint config. Disabling feature. To enable feature set the PN_ACT_CONFIG_S3_ENDPOINT environment variable');
disabledFeatures.s3 = true;
}
if (!config.s3.key) {
LOG_WARN('Failed to find s3 access key config. Disabling feature. To enable feature set the PN_ACT_CONFIG_S3_ACCESS_KEY environment variable');
disabledFeatures.s3 = true;
}
if (!config.s3.secret) {
LOG_WARN('Failed to find s3 secret key config. Disabling feature. To enable feature set the PN_ACT_CONFIG_S3_ACCESS_SECRET environment variable');
disabledFeatures.s3 = true;
}
if (disabledFeatures.s3) {
if (!config.cdn.subdomain) {
LOG_ERROR('s3 file storage is disabled and no CDN subdomain was set. Set the PN_ACT_CONFIG_CDN_SUBDOMAIN environment variable');
process.exit(0);
}
if (!config.cdn.disk_path) {
LOG_ERROR('s3 file storage is disabled and no CDN disk path was set. Set the PN_ACT_CONFIG_CDN_DISK_PATH environment variable');
process.exit(0);
}
LOG_WARN(`s3 file storage disabled. Using disk-based file storage. Please ensure cdn.base_url config or PN_ACT_CONFIG_CDN_BASE env variable is set to point to this server with the subdomain being ${config.cdn.subdomain}`);
if (disabledFeatures.redis) {
LOG_WARN('Both s3 and Redis are disabled. Large CDN files will use the in-memory cache, which may result in high memory use. Please enable s3 if you\'re running a production server.');
}
}
if (!config.aes_key) {
LOG_ERROR('Token AES key is not set. Set the PN_ACT_CONFIG_AES_KEY environment variable to your AES-256-CBC key');
process.exit(0);
}

View File

@@ -1,274 +0,0 @@
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const joi = require('joi');
const util = require('./util');
const { PNID } = require('./models/pnid');
const { Server } = require('./models/server');
const logger = require('../logger');
const { config } = require('./config-manager');
const { connection_string, options } = config.mongoose;
// TODO: Extend this later with more settings
const discordConnectionSchema = joi.object({
id: joi.string()
});
let connection;
async function connect() {
await mongoose.connect(connection_string, options);
connection = mongoose.connection;
connection.on('error', console.error.bind(console, 'connection error:'));
module.exports.connection = connection;
}
function verifyConnected() {
if (!connection) {
throw new Error('Cannot make database requets without being connected');
}
}
async function getUserByUsername(username) {
verifyConnected();
if (typeof username !== 'string') {
return null;
}
const user = await PNID.findOne({
usernameLower: username.toLowerCase()
});
return user;
}
async function getUserByPID(pid) {
verifyConnected();
const user = await PNID.findOne({
pid
});
return user;
}
async function getUserByEmailAddress(email) {
verifyConnected();
const user = await PNID.findOne({
'email.address': new RegExp(email, 'i') // * Ignore case
});
return user;
}
async function doesUserExist(username) {
verifyConnected();
return !!await getUserByUsername(username);
}
async function getUserBasic(token) {
verifyConnected();
// Wii U sends Basic auth as `username password`, where the password may not have spaces
// This is not to spec, but that is the consoles fault not ours
const [username, password] = Buffer.from(token, 'base64').toString().split(' ');
const user = await getUserByUsername(username);
if (!user) {
return null;
}
const hashedPassword = util.nintendoPasswordHash(password, user.pid);
if (!bcrypt.compareSync(hashedPassword, user.password)) {
return null;
}
return user;
}
async function getUserBearer(token) {
verifyConnected();
try {
const decryptedToken = await util.decryptToken(Buffer.from(token, 'base64'));
const unpackedToken = util.unpackToken(decryptedToken);
const user = await getUserByPID(unpackedToken.pid);
if (user) {
const expireTime = Math.floor((Number(unpackedToken.expire_time) / 1000));
if (Math.floor(Date.now() / 1000) > expireTime) {
return null;
}
}
return user;
} catch (error) {
// TODO: Handle error
logger.error(error);
return null;
}
}
async function getUserProfileJSONByPID(pid) {
verifyConnected();
const user = await getUserByPID(pid);
const device = user.get('devices')[0]; // Just grab the first device
let device_attributes;
if (device) {
device_attributes = device.get('device_attributes').map(({name, value, created_date}) => {
const deviceAttributeDocument = {
name,
value
};
if (created_date) {
deviceAttributeDocument.created_date = created_date;
}
return {
device_attribute: deviceAttributeDocument
};
});
}
const userObject = {
//accounts: {}, We need to figure this out, no idea what these values mean or what they do
active_flag: user.get('flags.active') ? 'Y' : 'N',
birth_date: user.get('birthdate'),
country: user.get('country'),
create_date: user.get('creation_date'),
device_attributes: device_attributes,
gender: user.get('gender'),
language: user.get('language'),
updated: user.get('updated'),
marketing_flag: user.get('flags.marketing') ? 'Y' : 'N',
off_device_flag: user.get('flags.off_device') ? 'Y' : 'N',
pid: user.get('pid'),
email: {
address: user.get('email.address'),
id: user.get('email.id'),
parent: user.get('email.parent') ? 'Y' : 'N',
primary: user.get('email.primary') ? 'Y' : 'N',
reachable: user.get('email.reachable') ? 'Y' : 'N',
type: 'DEFAULT',
updated_by: 'USER', // Can also be INTERNAL WS, don't know the difference
validated: user.get('email.validated') ? 'Y' : 'N'
},
mii: {
status: 'COMPLETED',
data: user.get('mii.data').replace(/(\r\n|\n|\r)/gm, ''),
id: user.get('mii.id'),
mii_hash: user.get('mii.hash'),
mii_images: {
mii_image: {
// Images MUST be loaded over HTTPS or console ignores them
// Bunny CDN is the only CDN which seems to support TLS 1.0/1.1 (required)
cached_url: `${config.cdn.base_url}/mii/${user.pid}/standard.tga`,
id: user.get('mii.image_id'),
url: `${config.cdn.base_url}/mii/${user.pid}/standard.tga`,
type: 'standard'
}
},
name: user.get('mii.name'),
primary: user.get('mii.primary') ? 'Y' : 'N',
},
region: user.get('region'),
tz_name: user.get('timezone.name'),
user_id: user.get('username'),
utc_offset: user.get('timezone.offset')
};
if (user.get('email.validated')) {
userObject.email.validated_date = user.get('email.validated_date');
}
return userObject;
}
function getServer(gameServerId, accessMode) {
return Server.findOne({
game_server_id: gameServerId,
access_mode: accessMode,
});
}
function getServerByTitleId(titleId, accessMode) {
return Server.findOne({
title_ids: titleId,
access_mode: accessMode,
});
}
async function addUserConnection(pnid, data, type) {
if (type === 'discord') {
return await addUserConnectionDiscord(pnid, data);
}
}
async function addUserConnectionDiscord(pnid, data) {
const valid = discordConnectionSchema.validate(data);
if (valid.error) {
return {
app: 'api',
status: 400,
error: 'Invalid or missing connection data'
};
}
await PNID.updateOne({ pid: pnid.get('pid') }, {
$set: {
'connections.discord.id': data.id
}
});
return {
app: 'api',
status: 200
};
}
async function removeUserConnection(pnid, type) {
// Add more connections later?
if (type === 'discord') {
return await removeUserConnectionDiscord(pnid);
}
}
async function removeUserConnectionDiscord(pnid) {
await PNID.updateOne({ pid: pnid.get('pid') }, {
$set: {
'connections.discord.id': ''
}
});
return {
app: 'api',
status: 200
};
}
module.exports = {
connect,
connection,
getUserByUsername,
getUserByPID,
getUserByEmailAddress,
doesUserExist,
getUserBasic,
getUserBearer,
getUserProfileJSONByPID,
getServer,
getServerByTitleId,
addUserConnection,
removeUserConnection,
};

272
src/database.ts Normal file
View File

@@ -0,0 +1,272 @@
import mongoose from 'mongoose';
import bcrypt from 'bcrypt';
import joi from 'joi';
import { nintendoPasswordHash, decryptToken, unpackToken } from '@/util';
import { PNID } from '@/models/pnid';
import { Server } from '@/models/server';
import { LOG_ERROR } from '@/logger';
import { config } from '@/config-manager';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
import { IDevice } from '@/types/mongoose/device';
import { IDeviceAttribute } from '@/types/mongoose/device-attribute';
import { HydratedServerDocument } from '@/types/mongoose/server';
import { Token } from '@/types/common/token';
import { PNIDProfile } from '@/types/services/nnid/pnid-profile';
import { ConnectionData } from '@/types/services/api/connection-data';
import { ConnectionResponse } from '@/types/services/api/connection-response';
import { DiscordConnectionData } from '@/types/services/api/discord-connection-data';
const connection_string: string = config.mongoose.connection_string;
const options: mongoose.ConnectOptions = config.mongoose.options;
// TODO: Extend this later with more settings
const discordConnectionSchema: joi.ObjectSchema = joi.object({
id: joi.string()
});
let _connection: mongoose.Connection;
export async function connect(): Promise<void> {
await mongoose.connect(connection_string, options);
_connection = mongoose.connection;
_connection.on('error', console.error.bind(console, 'connection error:'));
}
export function connection(): mongoose.Connection {
return _connection;
}
export function verifyConnected(): void {
if (!connection()) {
throw new Error('Cannot make database requets without being connected');
}
}
export async function getPNIDByUsername(username: string): Promise<HydratedPNIDDocument | null> {
verifyConnected();
return await PNID.findOne<HydratedPNIDDocument>({
usernameLower: username.toLowerCase()
});
}
export async function getPNIDByPID(pid: number): Promise<HydratedPNIDDocument | null> {
verifyConnected();
return await PNID.findOne<HydratedPNIDDocument>({
pid
});
}
export async function getPNIDByEmailAddress(email: string): Promise<HydratedPNIDDocument | null> {
verifyConnected();
// TODO - Update documents to store email normalized
return await PNID.findOne<HydratedPNIDDocument>({
'email.address': email
});
}
export async function doesPNIDExist(username: string): Promise<boolean> {
verifyConnected();
return !!await getPNIDByUsername(username);
}
export async function getPNIDByBasicAuth(token: string): Promise<HydratedPNIDDocument | null> {
verifyConnected();
// * Wii U sends Basic auth as `username password`, where the password may not have spaces
// * This is not to spec, but that is the consoles fault not ours
const decoded: string = Buffer.from(token, 'base64').toString();
const parts: string[] = decoded.split(' ');
const username: string = parts[0];
const password: string = parts[1];
const pnid: HydratedPNIDDocument | null = await getPNIDByUsername(username);
if (!pnid) {
return null;
}
const hashedPassword: string = nintendoPasswordHash(password, pnid.pid);
if (!bcrypt.compareSync(hashedPassword, pnid.password)) {
return null;
}
return pnid;
}
export async function getPNIDByBearerAuth(token: string): Promise<HydratedPNIDDocument | null> {
verifyConnected();
try {
const decryptedToken: Buffer = await decryptToken(config.aes_key, Buffer.from(token, 'hex'));
const unpackedToken: Token = unpackToken(decryptedToken);
const pnid: HydratedPNIDDocument | null = await getPNIDByPID(unpackedToken.pid);
if (pnid) {
const expireTime: number = Math.floor((Number(unpackedToken.expire_time) / 1000));
if (Math.floor(Date.now() / 1000) > expireTime) {
return null;
}
}
return pnid;
} catch (error: any) {
// TODO: Handle error
LOG_ERROR(error);
return null;
}
}
export async function getPNIDProfileJSONByPID(pid: number): Promise<PNIDProfile | null> {
verifyConnected();
const pnid: HydratedPNIDDocument | null = await getPNIDByPID(pid);
if (!pnid) {
return null;
}
const device: IDevice = pnid.devices[0]; // * Just grab the first device
let device_attributes: {
device_attribute: {
name: string;
value: string;
created_date: string;
};
}[] = [];
if (device) {
device_attributes = device.device_attributes.map((attribute: IDeviceAttribute) => {
const name: string = attribute.name;
const value: string = attribute.value;
const created_date: string | undefined = attribute.created_date;
return {
device_attribute: {
name,
value,
created_date: created_date ? created_date : ''
}
};
});
}
return {
//accounts: {}, // * We need to figure this out, no idea what these values mean or what they do
active_flag: pnid.flags.active ? 'Y' : 'N',
birth_date: pnid.birthdate,
country: pnid.country,
create_date: pnid.creation_date,
device_attributes: device_attributes,
gender: pnid.gender,
language: pnid.language,
updated: pnid.updated,
marketing_flag: pnid.flags.marketing ? 'Y' : 'N',
off_device_flag: pnid.flags.off_device ? 'Y' : 'N',
pid: pnid.pid,
email: {
address: pnid.email.address,
id: pnid.email.id,
parent: pnid.email.parent ? 'Y' : 'N',
primary: pnid.email.primary ? 'Y' : 'N',
reachable: pnid.email.reachable ? 'Y' : 'N',
type: 'DEFAULT',
updated_by: 'USER', // * Can also be INTERNAL WS, don't know the difference
validated: pnid.email.validated ? 'Y' : 'N',
validated_date: pnid.email.validated ? pnid.email.validated_date : ''
},
mii: {
status: 'COMPLETED',
data: pnid.mii.data.replace(/(\r\n|\n|\r)/gm, ''),
id: pnid.mii.id,
mii_hash: pnid.mii.hash,
mii_images: {
mii_image: {
// * Images MUST be loaded over HTTPS or console ignores them
// * Bunny CDN is the only CDN which seems to support TLS 1.0/1.1 (required)
cached_url: `${config.cdn.base_url}/mii/${pnid.pid}/standard.tga`,
id: pnid.mii.image_id,
url: `${config.cdn.base_url}/mii/${pnid.pid}/standard.tga`,
type: 'standard'
}
},
name: pnid.mii.name,
primary: pnid.mii.primary ? 'Y' : 'N',
},
region: pnid.region,
tz_name: pnid.timezone.name,
user_id: pnid.username,
utc_offset: pnid.timezone.offset
};
}
export async function getServerByGameServerId(gameServerId: string, accessMode: string): Promise<HydratedServerDocument | null> {
return await Server.findOne({
game_server_id: gameServerId,
access_mode: accessMode
});
}
export async function getServerByTitleId(titleId: string, accessMode: string): Promise<HydratedServerDocument | null> {
return await Server.findOne({
title_ids: titleId,
access_mode: accessMode
});
}
export async function addPNIDConnection(pnid: HydratedPNIDDocument, data: ConnectionData, type: string): Promise<ConnectionResponse | undefined> {
if (type === 'discord') {
return await addPNIDConnectionDiscord(pnid, data);
}
}
export async function addPNIDConnectionDiscord(pnid: HydratedPNIDDocument, data: DiscordConnectionData): Promise<ConnectionResponse> {
const valid: joi.ValidationResult = discordConnectionSchema.validate(data);
if (valid.error) {
return {
app: 'api',
status: 400,
error: 'Invalid or missing connection data'
};
}
await PNID.updateOne({ pid: pnid.pid }, {
$set: {
'connections.discord.id': data.id
}
});
return {
app: 'api',
status: 200
};
}
export async function removePNIDConnection(pnid: HydratedPNIDDocument, type: string): Promise<ConnectionResponse | undefined> {
// * Add more connections later?
if (type === 'discord') {
return await removePNIDConnectionDiscord(pnid);
}
}
export async function removePNIDConnectionDiscord(pnid: HydratedPNIDDocument): Promise<ConnectionResponse> {
await PNID.updateOne({ pid: pnid.pid }, {
$set: {
'connections.discord.id': ''
}
});
return {
app: 'api',
status: 200
};
}

View File

@@ -1,7 +1,9 @@
const fs = require('fs-extra');
require('colors');
import fs from 'fs-extra';
import colors from 'colors';
const root = __dirname;
colors.enable();
const root: string = process.env.PN_ACT_LOGGER_PATH ? process.env.PN_ACT_LOGGER_PATH : `${__dirname}/..`;
fs.ensureDirSync(`${root}/logs`);
const streams = {
@@ -10,43 +12,36 @@ const streams = {
error: fs.createWriteStream(`${root}/logs/error.log`),
warn: fs.createWriteStream(`${root}/logs/warn.log`),
info: fs.createWriteStream(`${root}/logs/info.log`)
};
} as const;
function success(input) {
const time = new Date();
export function LOG_SUCCESS(input: string): void {
const time: Date = new Date();
input = `[${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}] [SUCCESS]: ${input}`;
streams.success.write(`${input}\n`);
console.log(`${input}`.green.bold);
}
function error(input) {
const time = new Date();
export function LOG_ERROR(input: string): void {
const time: Date = new Date();
input = `[${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}] [ERROR]: ${input}`;
streams.error.write(`${input}\n`);
console.log(`${input}`.red.bold);
}
function warn(input) {
const time = new Date();
export function LOG_WARN(input: string): void {
const time: Date = new Date();
input = `[${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}] [WARN]: ${input}`;
streams.warn.write(`${input}\n`);
console.log(`${input}`.yellow.bold);
}
function info(input) {
const time = new Date();
export function LOG_INFO(input: string): void {
const time: Date = new Date();
input = `[${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}] [INFO]: ${input}`;
streams.info.write(`${input}\n`);
console.log(`${input}`.cyan.bold);
}
module.exports = {
success,
error,
warn,
info
};
}

View File

@@ -1,65 +0,0 @@
const nodemailer = require('nodemailer');
const { config, disabledFeatures } = require('./config-manager');
const path = require('path');
const fs = require("fs");
const genericEmailTemplate = fs.readFileSync(path.join(__dirname, './assets/emails/genericTemplate.html'), 'utf8');
const confirmationEmailTemplate = fs.readFileSync(path.join(__dirname, './assets/emails/confirmationTemplate.html'), 'utf8');
let transporter;
if (!disabledFeatures.email) {
transporter = nodemailer.createTransport(config.email);
}
/**
@param {Object} options
@param {String} options.to The address of the recipient
@param {String} options.subject The subject of the email
@param {String} options.username The username of the user (shown in the greeting)
@param {String} options.preview The preview text of the email (shown in the inbox by the email client)
@param {String} options.text The text version of the email
@param {String} options.paragraph The main content of the email
@param {Object} options.confirmation Whether or not the email is a confirmation email
@param {String} options.confirmation.href The link to the confirmation page
@param {String} options.confirmation.code The confirmation code
@param {Object} options.link An object containing the link to be shown in the email
@param {String} options.link.href The URL of the link
@param {String} options.link.text The text of the link
*/
async function sendMail(options) {
if (!disabledFeatures.email) {
const { to, subject, username, paragraph, preview, text, link, confirmation } = options;
let html = confirmation ? confirmationEmailTemplate : genericEmailTemplate;
html = html.replace(/{{username}}/g, username);
html = html.replace(/{{paragraph}}/g, paragraph);
html = html.replace(/{{preview}}/g, (preview || ""));
html = html.replace(/{{confirmation-href}}/g, (confirmation?.href || ""));
html = html.replace(/{{confirmation-code}}/g, (confirmation?.code || ""));
if (link) {
const { href, text } = link;
const button = `<tr><td width="100%" height="16px" style="line-height: 16px;">&nbsp;</td></tr><tr><td class="confirm-link" bgcolor="#673db6" style="font-size: 14px; font-weight: 700; border-radius: 10px; padding: 12px" align="center"><a href="${href}" style="text-decoration: none; color: #ffffff; " width="100%">${text}</a></td></tr>`
html = html.replace(/<!--{{buttonPlaceholder}}-->/g, button);
}
await transporter.sendMail({
from: config.email.from,
to,
subject,
text,
html
});
}
}
module.exports = {
sendMail
};

43
src/mailer.ts Normal file
View File

@@ -0,0 +1,43 @@
import path from 'node:path';
import fs from 'node:fs';
import nodemailer from 'nodemailer';
import { config, disabledFeatures } from '@/config-manager';
import { MailerOptions } from '@/types/common/mailer-options';
const genericEmailTemplate: string = fs.readFileSync(path.join(__dirname, './assets/emails/genericTemplate.html'), 'utf8');
const confirmationEmailTemplate: string = fs.readFileSync(path.join(__dirname, './assets/emails/confirmationTemplate.html'), 'utf8');
let transporter: nodemailer.Transporter;
if (!disabledFeatures.email) {
transporter = nodemailer.createTransport(config.email);
}
export async function sendMail(options: MailerOptions): Promise<void> {
if (!disabledFeatures.email) {
const { to, subject, username, paragraph, preview, text, link, confirmation } = options;
let html: string = confirmation ? confirmationEmailTemplate : genericEmailTemplate;
html = html.replace(/{{username}}/g, username);
html = html.replace(/{{paragraph}}/g, paragraph || '');
html = html.replace(/{{preview}}/g, preview || '');
html = html.replace(/{{confirmation-href}}/g, confirmation?.href || '');
html = html.replace(/{{confirmation-code}}/g, confirmation?.code || '');
if (link) {
const { href, text } = link;
const button: string = `<tr><td width="100%" height="16px" style="line-height: 16px;">&nbsp;</td></tr><tr><td class="confirm-link" bgcolor="#673db6" style="font-size: 14px; font-weight: 700; border-radius: 10px; padding: 12px" align="center"><a href="${href}" style="text-decoration: none; color: #ffffff; " width="100%">${text}</a></td></tr>`;
html = html.replace(/<!--{{buttonPlaceholder}}-->/g, button);
}
await transporter.sendMail({
from: config.email.from,
to,
subject,
text,
html
});
}
}

View File

@@ -1,19 +0,0 @@
const xmlbuilder = require('xmlbuilder');
const database = require('../database');
async function APIMiddleware(request, response, next) {
const { headers } = request;
if (!headers.authorization || !(headers.authorization.startsWith('Bearer'))) {
return next();
}
const token = headers.authorization.split(' ')[1];
const user = await database.getUserBearer(token);
request.pnid = user;
return next();
}
module.exports = APIMiddleware;

21
src/middleware/api.ts Normal file
View File

@@ -0,0 +1,21 @@
import express from 'express';
import { getValueFromHeaders } from '@/util';
import { getPNIDByBearerAuth } from '@/database';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
async function APIMiddleware(request: express.Request, _response: express.Response, next: express.NextFunction): Promise<void> {
const authHeader: string | undefined = getValueFromHeaders(request.headers, 'authorization');
if (!authHeader || !(authHeader.startsWith('Bearer'))) {
return next();
}
const token: string = authHeader.split(' ')[1];
const pnid: HydratedPNIDDocument | null = await getPNIDByBearerAuth(token);
request.pnid = pnid;
return next();
}
export default APIMiddleware;

View File

@@ -1,9 +0,0 @@
async function CemuMiddleware(request, response, next) {
const subdomain = request.subdomains.reverse().join('.');
request.isCemu = subdomain === 'c.account';
return next();
}
module.exports = CemuMiddleware;

11
src/middleware/cemu.ts Normal file
View File

@@ -0,0 +1,11 @@
import express from 'express';
function CemuMiddleware(request: express.Request, _response: express.Response, next: express.NextFunction): void {
const subdomain: string = request.subdomains.reverse().join('.');
request.isCemu = subdomain === 'c.account';
return next();
}
export default CemuMiddleware;

View File

@@ -1,38 +0,0 @@
const xmlbuilder = require('xmlbuilder');
const VALID_CLIENT_ID_SECRET_PAIRS = {
// 'Key' is the client ID, 'Value' is the client secret
'a2efa818a34fa16b8afbc8a74eba3eda': 'c91cdb5658bd4954ade78533a339cf9a', // Possibly WiiU exclusive?
'daf6227853bcbdce3d75baee8332b': '3eff548eac636e2bf45bb7b375e7b6b0', // Possibly 3DS exclusive?
'ea25c66c26b403376b4c5ed94ab9cdea': 'd137be62cb6a2b831cad8c013b92fb55', // Possibly 3DS exclusive?
};
function nintendoClientHeaderCheck(request, response, next) {
response.set('Content-Type', 'text/xml');
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', new Date().getTime());
const {headers} = request;
if (
!headers['x-nintendo-client-id'] ||
!headers['x-nintendo-client-secret'] ||
!VALID_CLIENT_ID_SECRET_PAIRS[headers['x-nintendo-client-id']] ||
headers['x-nintendo-client-secret'] !== VALID_CLIENT_ID_SECRET_PAIRS[headers['x-nintendo-client-id']]
) {
return response.send(xmlbuilder.create({
errors: {
error: {
cause: 'client_id',
code: '0004',
message: 'API application invalid or incorrect application credentials'
}
}
}).end());
}
return next();
}
module.exports = nintendoClientHeaderCheck;

View File

@@ -0,0 +1,43 @@
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import { getValueFromHeaders } from '@/util';
const VALID_CLIENT_ID_SECRET_PAIRS: { [key: string]: string } = {
// * 'Key' is the client ID, 'Value' is the client secret
'a2efa818a34fa16b8afbc8a74eba3eda': 'c91cdb5658bd4954ade78533a339cf9a', // * Possibly WiiU exclusive?
'daf6227853bcbdce3d75baee8332b': '3eff548eac636e2bf45bb7b375e7b6b0', // * Possibly 3DS exclusive?
'ea25c66c26b403376b4c5ed94ab9cdea': 'd137be62cb6a2b831cad8c013b92fb55', // * Possibly 3DS exclusive?
};
function nintendoClientHeaderCheck(request: express.Request, response: express.Response, next: express.NextFunction): void {
response.set('Content-Type', 'text/xml');
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', new Date().getTime().toString());
const clientId: string | undefined = getValueFromHeaders(request.headers, 'x-nintendo-client-id');
const clientSecret: string | undefined = getValueFromHeaders(request.headers, 'x-nintendo-client-secret');
if (
!clientId ||
!clientSecret ||
!VALID_CLIENT_ID_SECRET_PAIRS[clientId] ||
clientSecret !== VALID_CLIENT_ID_SECRET_PAIRS[clientId]
) {
response.send(xmlbuilder.create({
errors: {
error: {
cause: 'client_id',
code: '0004',
message: 'API application invalid or incorrect application credentials'
}
}
}).end());
return;
}
return next();
}
export default nintendoClientHeaderCheck;

View File

@@ -1,16 +0,0 @@
const NintendoCertificate = require('../nintendo-certificate');
async function deviceCertificateMiddleware(request, response, next) {
const { headers } = request;
if (!headers['x-nintendo-device-cert']) {
return next();
}
const certificate = headers['x-nintendo-device-cert'];
request.certificate = new NintendoCertificate(certificate);
return next();
}
module.exports = deviceCertificateMiddleware;

View File

@@ -0,0 +1,17 @@
import express from 'express';
import NintendoCertificate from '@/nintendo-certificate';
import { getValueFromHeaders } from '@/util';
function deviceCertificateMiddleware(request: express.Request, _response: express.Response, next: express.NextFunction): void {
const certificate: string | undefined = getValueFromHeaders(request.headers, 'x-nintendo-device-cert');
if (!certificate) {
return next();
}
request.certificate = new NintendoCertificate(certificate);
return next();
}
export default deviceCertificateMiddleware;

View File

@@ -1,12 +1,18 @@
const crypto = require('crypto');
const { Device } = require('../models/device');
const { NEXAccount } = require('../models/nex-account');
const util = require('../util');
const database = require('../database');
const NintendoCertificate = require('../nintendo-certificate');
import crypto from 'node:crypto';
import express from 'express';
import mongoose from 'mongoose';
import { Device } from '@/models/device';
import { NEXAccount } from '@/models/nex-account';
import { nascError, nintendoBase64Decode } from '@/util';
import { connection as databaseConnection } from '@/database';
import NintendoCertificate from '@/nintendo-certificate';
import { LOG_ERROR } from '@/logger';
import { NASCRequestParams } from '@/types/services/nasc/request-params';
import { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account';
import { HydratedDeviceDocument } from '@/types/mongoose/device';
async function NASCMiddleware(request, response, next) {
const requestParams = request.body;
async function NASCMiddleware(request: express.Request, response: express.Response, next: express.NextFunction): Promise<void> {
const requestParams: NASCRequestParams = request.body;
if (!requestParams.action ||
!requestParams.fcdcert ||
@@ -15,50 +21,54 @@ async function NASCMiddleware(request, response, next) {
!requestParams.titleid ||
!requestParams.servertype
) {
return response.status(200).send(util.nascError('null')); // This is what Nintendo sends
response.status(200).send(nascError('null').toString()); // This is what Nintendo sends
return;
}
const action = util.nintendoBase64Decode(requestParams.action).toString();
const fcdcert = util.nintendoBase64Decode(requestParams.fcdcert);
const serialNumber = util.nintendoBase64Decode(requestParams.csnum).toString();
const macAddress = util.nintendoBase64Decode(requestParams.macadr).toString();
const titleID = util.nintendoBase64Decode(requestParams.titleid).toString();
const environment = util.nintendoBase64Decode(requestParams.servertype).toString();
const action: string = nintendoBase64Decode(requestParams.action).toString();
const fcdcert: Buffer = nintendoBase64Decode(requestParams.fcdcert);
const serialNumber: string = nintendoBase64Decode(requestParams.csnum).toString();
const macAddress: string = nintendoBase64Decode(requestParams.macadr).toString();
const titleID: string = nintendoBase64Decode(requestParams.titleid).toString();
const environment: string = nintendoBase64Decode(requestParams.servertype).toString();
const macAddressHash = crypto.createHash('sha256').update(macAddress).digest('base64');
const fcdcertHash = crypto.createHash('sha256').update(fcdcert).digest('base64');
const macAddressHash: string = crypto.createHash('sha256').update(macAddress).digest('base64');
const fcdcertHash: string = crypto.createHash('sha256').update(fcdcert).digest('base64');
let pid;
let pidHmac;
let password;
let pid: number = 0; // * Real PIDs are always positive and non-zero
let pidHmac: string = '';
let password: string = '';
if (requestParams.userid) {
pid = util.nintendoBase64Decode(requestParams.userid).toString();
pid = Number(nintendoBase64Decode(requestParams.userid).toString());
}
if (requestParams.uidhmac) {
pidHmac = util.nintendoBase64Decode(requestParams.uidhmac).toString();
pidHmac = nintendoBase64Decode(requestParams.uidhmac).toString();
}
if (requestParams.passwd) {
password = util.nintendoBase64Decode(requestParams.passwd).toString();
password = nintendoBase64Decode(requestParams.passwd).toString();
}
if (action !== 'LOGIN' && action !== 'SVCLOC') {
return response.status(200).send(util.nascError('null')); // This is what Nintendo sends
response.status(200).send(nascError('null').toString()); // This is what Nintendo sends
return;
}
const cert = new NintendoCertificate(fcdcert);
const cert: NintendoCertificate = new NintendoCertificate(fcdcert);
if (!cert.valid) {
return response.status(200).send(util.nascError('121'));
response.status(200).send(nascError('121').toString());
return;
}
if (!validNintendoMACAddress(macAddress)) {
return response.status(200).send(util.nascError('null'));
response.status(200).send(nascError('null').toString());
return;
}
let model;
let model: string = '';
switch (serialNumber[0]) {
case 'C':
model = 'ctr';
@@ -81,10 +91,11 @@ async function NASCMiddleware(request, response, next) {
}
if (!model) {
return response.status(200).send(util.nascError('null'));
response.status(200).send(nascError('null').toString());
return;
}
let device = await Device.findOne({
let device: HydratedDeviceDocument | null = await Device.findOne({
model,
serial: serialNumber,
environment,
@@ -94,14 +105,16 @@ async function NASCMiddleware(request, response, next) {
if (device) {
if (device.get('access_level') < 0) {
return response.status(200).send(util.nascError('102'));
response.status(200).send(nascError('102').toString());
return;
}
if (pid) {
const linkedPIDs = device.get('linked_pids');
if (!linkedPIDs.includes(pid)) {
return response.status(200).send(util.nascError('102'));
response.status(200).send(nascError('102').toString());
return;
}
}
}
@@ -110,12 +123,12 @@ async function NASCMiddleware(request, response, next) {
if (password && !pid && !pidHmac) {
// Register new user
const session = await database.connection.startSession();
const session: mongoose.ClientSession = await databaseConnection().startSession();
await session.startTransaction();
try {
// Create new NEX account
const nexAccount = await new NEXAccount({
const nexAccount: HydratedNEXAccountDocument = new NEXAccount({
device_type: '3ds',
password
});
@@ -124,7 +137,7 @@ async function NASCMiddleware(request, response, next) {
await nexAccount.save({ session });
pid = nexAccount.get('pid');
pid = nexAccount.pid;
// Set password
@@ -146,12 +159,13 @@ async function NASCMiddleware(request, response, next) {
await session.commitTransaction();
} catch (error) {
logger.error('[NASC] REGISTER ACCOUNT: ' + error);
LOG_ERROR('[NASC] REGISTER ACCOUNT: ' + error);
await session.abortTransaction();
// 3DS expects 200 even on error
return response.status(200).send(util.nascError('102'));
response.status(200).send(nascError('102').toString());
return;
} finally {
// * This runs regardless of failure
// * Returning on catch will not prevent this from running
@@ -160,20 +174,21 @@ async function NASCMiddleware(request, response, next) {
}
}
const nexUser = await NEXAccount.findOne({ pid });
const nexAccount: HydratedNEXAccountDocument | null = await NEXAccount.findOne({ pid });
if (!nexUser || nexUser.get('access_level') < 0) {
return response.status(200).send(util.nascError('102'));
if (!nexAccount || nexAccount.access_level < 0) {
response.status(200).send(nascError('102').toString());
return;
}
request.nexUser = nexUser;
request.nexAccount = nexAccount;
return next();
}
// https://www.adminsub.net/mac-address-finder/nintendo
// Saves us from doing an OUI lookup each time
const NINTENDO_VENDER_OUIS = [
const NINTENDO_VENDER_OUIS: string[] = [
'ECC40D', 'E84ECE', 'E0F6B5', 'E0E751', 'E00C7F', 'DC68EB',
'D86BF7', 'D4F057', 'CCFB65', 'CC9E00', 'B8AE6E', 'B88AEC',
'B87826', 'A4C0E1', 'A45C27', 'A438CC', '9CE635', '98E8FA',
@@ -199,10 +214,10 @@ const NINTENDO_VENDER_OUIS = [
];
// TODO: Make something better
const MAC_REGEX = /^[0-9a-fA-F]{12}$/;
const MAC_REGEX: RegExp = /^[0-9a-fA-F]{12}$/;
// Maybe should later parse more data out
function validNintendoMACAddress(macAddress) {
function validNintendoMACAddress(macAddress: string): boolean {
if (!NINTENDO_VENDER_OUIS.includes(macAddress.substring(0, 6).toUpperCase())) {
return false;
}
@@ -210,4 +225,4 @@ function validNintendoMACAddress(macAddress) {
return MAC_REGEX.test(macAddress);
}
module.exports = NASCMiddleware;
export default NASCMiddleware;

View File

@@ -1,65 +0,0 @@
const xmlbuilder = require('xmlbuilder');
const database = require('../database');
async function PNIDMiddleware(request, response, next) {
const { headers } = request;
if (!headers.authorization || !(headers.authorization.startsWith('Bearer') || headers.authorization.startsWith('Basic'))) {
return next();
}
let [type, token] = headers.authorization.split(' ');
let user;
if (request.isCemu) {
token = Buffer.from(token, 'hex').toString('base64');
}
if (type === 'Basic') {
user = await database.getUserBasic(token);
} else {
user = await database.getUserBearer(token);
}
if (!user) {
response.status(401);
if (type === 'Bearer') {
return response.send(xmlbuilder.create({
errors: {
error: {
cause: 'access_token',
code: '0005',
message: 'Invalid access token'
}
}
}).end());
}
return response.send(xmlbuilder.create({
errors: {
error: {
code: '1105',
message: 'Email address, username, or password, is not valid'
}
}
}).end());
}
if (user.get('access_level') < 0) {
return response.status(400).send(xmlbuilder.create({
errors: {
error: {
code: '0122',
message: 'Device has been banned by game server'
}
}
}).end());
}
request.pnid = user;
return next();
}
module.exports = PNIDMiddleware;

76
src/middleware/pnid.ts Normal file
View File

@@ -0,0 +1,76 @@
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import { getValueFromHeaders } from '@/util';
import { getPNIDByBasicAuth, getPNIDByBearerAuth } from '@/database';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
async function PNIDMiddleware(request: express.Request, response: express.Response, next: express.NextFunction): Promise<void> {
const authHeader: string | undefined = getValueFromHeaders(request.headers, 'authorization');
if (!authHeader || !(authHeader.startsWith('Bearer') || authHeader.startsWith('Basic'))) {
return next();
}
const parts: string[] = authHeader.split(' ');
const type: string = parts[0];
let token: string = parts[1];
let pnid: HydratedPNIDDocument | null;
if (request.isCemu) {
token = Buffer.from(token, 'hex').toString('base64');
}
if (type === 'Basic') {
pnid = await getPNIDByBasicAuth(token);
} else {
pnid = await getPNIDByBearerAuth(token);
}
if (!pnid) {
response.status(401);
if (type === 'Bearer') {
response.send(xmlbuilder.create({
errors: {
error: {
cause: 'access_token',
code: '0005',
message: 'Invalid access token'
}
}
}).end());
return;
}
response.send(xmlbuilder.create({
errors: {
error: {
code: '1105',
message: 'Email address, username, or password, is not valid'
}
}
}).end());
return;
}
if (pnid.access_level < 0) {
response.status(400).send(xmlbuilder.create({
errors: {
error: {
code: '0122',
message: 'Device has been banned by game server'
}
}
}).end());
return;
}
request.pnid = pnid;
return next();
}
export default PNIDMiddleware;

View File

@@ -1,11 +0,0 @@
const crypto = require('crypto');
const ratelimit = require('express-rate-limit');
module.exports = ratelimit({
windowMs: 60 * 1000,
max: 1,
keyGenerator: request => {
const data = request.headers['x-nintendo-device-cert'];
return crypto.createHash('md5').update(data).digest('hex');
}
});

View File

@@ -0,0 +1,18 @@
import crypto from 'node:crypto';
import express from 'express';
import ratelimit from 'express-rate-limit';
import { getValueFromHeaders } from '@/util';
export default ratelimit({
windowMs: 60 * 1000,
max: 1,
keyGenerator: (request: express.Request) => {
let data: string | undefined = getValueFromHeaders(request.headers, 'x-nintendo-device-cert');
if (!data) {
data = request.ip;
}
return crypto.createHash('md5').update(data).digest('hex');
}
});

View File

@@ -1,53 +0,0 @@
const xmlbuilder = require('xmlbuilder');
const { document: xmlParser } = require('xmlbuilder2');
function XMLMiddleware(request, response, next) {
if (request.method == 'POST' || request.method == 'PUT') {
const headers = request.headers;
let body = '';
if (
!headers['content-type'] ||
!headers['content-type'].toLowerCase().includes('xml')
) {
return next();
}
if (
!headers['content-length'] ||
parseInt(headers['content-length']) === 0
) {
return next();
}
request.setEncoding('utf-8');
request.on('data', (chunk) => {
body += chunk;
});
request.on('end', () => {
try {
request.body = xmlParser(body);
request.body = request.body.toObject();
} catch (error) {
response.status(401);
// TODO: This is not a real error code, check to see if better one exists
return response.send(xmlbuilder.create({
errors: {
error: {
code: '0004',
message: 'XML parse error'
}
}
}).end());
}
next();
});
} else {
next();
}
}
module.exports = XMLMiddleware;

View File

@@ -0,0 +1,57 @@
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import { document as xmlParser } from 'xmlbuilder2';
import { getValueFromHeaders, mapToObject } from '@/util';
function XMLMiddleware(request: express.Request, response: express.Response, next: express.NextFunction): void {
if (request.method == 'POST' || request.method == 'PUT') {
const contentType: string | undefined = getValueFromHeaders(request.headers, 'content-type');
const contentLength: string | undefined = getValueFromHeaders(request.headers, 'content-length');
let body: string = '';
if (
!contentType ||
!contentType.toLowerCase().includes('xml')
) {
return next();
}
if (
!contentLength ||
parseInt(contentLength) === 0
) {
return next();
}
request.setEncoding('utf-8');
request.on('data', (chunk: string) => {
body += chunk;
});
request.on('end', () => {
try {
request.body = xmlParser(body);
request.body = request.body.toObject();
request.body = mapToObject(request.body);
} catch (error) {
response.status(401);
// TODO: This is not a real error code, check to see if better one exists
return response.send(xmlbuilder.create({
errors: {
error: {
code: '0004',
message: 'XML parse error'
}
}
}).end());
}
next();
});
} else {
next();
}
}
export default XMLMiddleware;

View File

@@ -1,214 +0,0 @@
const KaitaiStream = require('kaitai-struct/KaitaiStream');
class Mii extends KaitaiStream {
constructor(arrayBuffer, byteOffset) {
super(arrayBuffer, byteOffset);
this.decode();
}
decode() {
// Decode raw data
// A lot of this goes unused
this.unknown1 = this.readU1();
this.characterSet = this.readBitsIntBe(2);
this.regionLock = this.readBitsIntBe(2);
this.profanityFlag = this.readBitsIntBe(1) !== 0;
this.copying = this.readBitsIntBe(1) !== 0;
this.unknown2 = this.readBitsIntBe(2);
this.slotIndex = this.readBitsIntBe(4);
this.pageIndex = this.readBitsIntBe(4);
this.version = this.readBitsIntBe(4);
this.unknown3 = this.readBitsIntBe(4);
this.systemId = Array(8).fill().map(() => this.readU1());
this.avatarId = Array(4).fill().map(() => this.readU1());
this.clientId = Array(6).fill().map(() => this.readU1());
this.padding = this.readU2le();
this.miiMetaData = this.readU2le();
this.miiName = Buffer.from(this.readBytes(20)).toString('utf16le');
this.height = this.readU1();
this.build = this.readU1();
this.faceColor = this.readBitsIntBe(3);
this.faceType = this.readBitsIntBe(4);
this.mingle = this.readBitsIntBe(1) !== 0;
this.faceMakeup = this.readBitsIntBe(4);
this.faceWrinkles = this.readBitsIntBe(4);
this.alignToByte();
this.hairType = this.readU1();
this.unknown5 = this.readBitsIntBe(4);
this.hairFlip = this.readBitsIntBe(1) !== 0;
this.hairColor = this.readBitsIntBe(3);
this.alignToByte();
this.eyeData = this.readU4le();
this.eyebrowData = this.readU4le();
this.noseData = this.readU2le();
this.mouthData = this.readU2le();
this.mouthData2 = this.readU2le();
this.facialHairData = this.readU2le();
this.glassesData = this.readU2le();
this.moleData = this.readU2le();
this.creatorName = Buffer.from(this.readBytes(20)).toString('utf16le');
this.padding2 = this.readU2le();
this.checksum = this.readU2le();
// Carve out more specific data from the above values
// TODO: read these bits directly instead of getting them later
this.gender = (this.miiMetaData & 1);
this.birthMonth = ((this.miiMetaData >> 1) & 15);
this.birthDay = ((this.miiMetaData >> 5) & 31);
this.favoriteColor = ((this.miiMetaData >> 10) & 15);
this.favorite = ((this.miiMetaData >> 14) & 1);
this.eyeType = (this.eyeData & 63);
this.eyeColor = ((this.eyeData >> 6) & 7);
this.eyeSize = ((this.eyeData >> 9) & 7);
this.eyeStretch = ((this.eyeData >> 13) & 7);
this.eyeRotation = ((this.eyeData >> 16) & 31);
this.eyeHorizontal = ((this.eyeData >> 21) & 15);
this.eyeVertical = ((this.eyeData >> 25) & 31);
this.eyebrowType = (this.eyebrowData & 31);
this.eyebrowColor = ((this.eyebrowData >> 5) & 7);
this.eyebrowSize = ((this.eyebrowData >> 8) & 15);
this.eyebrowStretch = ((this.eyebrowData >> 12) & 7);
this.eyebrowRotation = ((this.eyebrowData >> 16) & 15);
this.eyebrowHorizontal = ((this.eyebrowData >> 21) & 15);
this.eyebrowVertical = ((this.eyebrowData >> 25) & 31);
this.noseType = (this.noseData & 31);
this.noseSize = ((this.noseData >> 5) & 15);
this.noseVertical = ((this.noseData >> 9) & 31);
this.mouthType = (this.mouthData & 63);
this.mouthColor = ((this.mouthData >> 6) & 7);
this.mouthSize = ((this.mouthData >> 9) & 15);
this.mouthStretch = ((this.mouthData >> 13) & 7);
this.mouthVertical = (this.mouthData2 & 31);
this.facialHairMustache = ((this.mouthData2 >> 5) & 7);
this.facialHairType = (this.facialHairData & 7);
this.facialHairColor = ((this.facialHairData >> 3) & 7);
this.facialHairSize = ((this.facialHairData >> 6) & 15);
this.facialHairVertical = ((this.facialHairData >> 10) & 31);
this.glassesType = (this.glassesData & 15);
this.glassesColor = (this.glassesData >> 4) & 7;
this.glassesSize = (this.glassesData >> 7) & 15;
this.glassesVertical = (this.glassesData >> 11) & 15;
this.moleEnable = (this.moleData >> 15);
this.moleSize = ((this.moleData >> 1) & 15);
this.moleHorizontal = ((this.moleData >> 5) & 31);
this.moleVertical = ((this.moleData >> 10) & 31);
}
toStudioMii() {
/*
Can also disable randomization with:
let miiStudioData = Buffer.alloc(0x2F);
let next = 256;
and removing "randomizer" and the "miiStudioData.writeUInt8(randomizer);" call
*/
const miiStudioData = Buffer.alloc(0x2F);
const randomizer = Math.floor(256 * Math.random());
let next = randomizer;
let pos = 1;
function encodeMiiPart(partValue) {
const encoded = (7 + (partValue ^ next)) % 256;
next = encoded;
miiStudioData.writeUInt8(encoded, pos);
pos++;
}
miiStudioData.writeUInt8(randomizer);
if (this.facialHairColor === 0) {
encodeMiiPart(8);
} else {
encodeMiiPart(this.facialHairColor);
}
encodeMiiPart(this.facialHairType);
encodeMiiPart(this.build);
encodeMiiPart(this.eyeStretch);
encodeMiiPart(this.eyeColor + 8);
encodeMiiPart(this.eyeRotation);
encodeMiiPart(this.eyeSize);
encodeMiiPart(this.eyeType);
encodeMiiPart(this.eyeHorizontal);
encodeMiiPart(this.eyeVertical);
encodeMiiPart(this.eyebrowStretch);
if (this.eyebrowColor === 0) {
encodeMiiPart(8);
} else {
encodeMiiPart(this.eyebrowColor);
}
encodeMiiPart(this.eyebrowRotation);
encodeMiiPart(this.eyebrowSize);
encodeMiiPart(this.eyebrowType);
encodeMiiPart(this.eyebrowHorizontal);
encodeMiiPart(this.eyebrowVertical);
encodeMiiPart(this.faceColor);
encodeMiiPart(this.faceMakeup);
encodeMiiPart(this.faceType);
encodeMiiPart(this.faceWrinkles);
encodeMiiPart(this.favoriteColor);
encodeMiiPart(this.gender);
if (this.glassesColor == 0) {
encodeMiiPart(8);
} else if (this.glassesColor < 6) {
encodeMiiPart(this.glassesColor + 13);
} else {
encodeMiiPart(0);
}
encodeMiiPart(this.glassesSize);
encodeMiiPart(this.glassesType);
encodeMiiPart(this.glassesVertical);
if (this.hairColor == 0) {
encodeMiiPart(8);
} else {
encodeMiiPart(this.hairColor);
}
encodeMiiPart(this.hairFlip ? 1 : 0);
encodeMiiPart(this.hairType);
encodeMiiPart(this.height);
encodeMiiPart(this.moleSize);
encodeMiiPart(this.moleEnable);
encodeMiiPart(this.moleHorizontal);
encodeMiiPart(this.moleVertical);
encodeMiiPart(this.mouthStretch);
if (this.mouthColor < 4) {
encodeMiiPart(this.mouthColor + 19);
} else {
encodeMiiPart(0);
}
encodeMiiPart(this.mouthSize);
encodeMiiPart(this.mouthType);
encodeMiiPart(this.mouthVertical);
encodeMiiPart(this.facialHairSize);
encodeMiiPart(this.facialHairMustache);
encodeMiiPart(this.facialHairVertical);
encodeMiiPart(this.noseSize);
encodeMiiPart(this.noseType);
encodeMiiPart(this.noseVertical);
return miiStudioData;
}
}
module.exports = Mii;

View File

@@ -1,14 +1,14 @@
const { Schema, model } = require('mongoose');
import { Schema, model } from 'mongoose';
import { IDeviceAttribute, IDeviceAttributeMethods, DeviceAttributeModel } from '@/types/mongoose/device-attribute';
import { IDevice, IDeviceMethods, DeviceModel } from '@/types/mongoose/device';
const DeviceAttributeSchema = new Schema({
const DeviceAttributeSchema = new Schema<IDeviceAttribute, DeviceAttributeModel, IDeviceAttributeMethods>({
created_date: String,
name: String,
value: String,
value: String
});
const DeviceAttribute = model('DeviceAttribute', DeviceAttributeSchema);
const DeviceSchema = new Schema({
export const DeviceSchema = new Schema<IDevice, DeviceModel, IDeviceMethods>({
is_emulator: {
type: Boolean,
default: false
@@ -22,7 +22,7 @@ const DeviceSchema = new Schema({
'ftr', // Nintendo 2DS
'ktr', // New Nintendo 3DS
'red', // New Nintendo 3DS XL
'jan' // New Nintendo 2DS XL
'jan' // New Nintendo 2DS XL
]
},
device_id: Number,
@@ -48,11 +48,4 @@ const DeviceSchema = new Schema({
}
});
const Device = model('Device', DeviceSchema);
module.exports = {
DeviceSchema,
Device,
DeviceAttributeSchema,
DeviceAttribute
};
export const Device: DeviceModel = model<IDevice, DeviceModel>('Device', DeviceSchema);

View File

@@ -1,7 +1,8 @@
const { Schema, model } = require('mongoose');
const uniqueValidator = require('mongoose-unique-validator');
import { Schema, model } from 'mongoose';
import uniqueValidator from 'mongoose-unique-validator';
import { HydratedNEXAccountDocument, INEXAccount, INEXAccountMethods, NEXAccountModel } from '@/types/mongoose/nex-account';
const NEXAccountSchema = new Schema({
const NEXAccountSchema = new Schema<INEXAccount, NEXAccountModel, INEXAccountMethods>({
device_type: {
type: String,
enum: [
@@ -23,7 +24,7 @@ const NEXAccountSchema = new Schema({
server_access_level: {
type: String,
default: 'prod' // everyone is in production by default
},
}
});
NEXAccountSchema.plugin(uniqueValidator, { message: '{PATH} already in use.' });
@@ -36,39 +37,36 @@ NEXAccountSchema.plugin(uniqueValidator, { message: '{PATH} already in use.' });
https://account.nintendo.net/v1/api/admin/mapped_ids?input_type=pid&output_type=user_id&input=1799999999 returns `prodtest1`
and the next few accounts counting down seem to be admin, service and internal test accounts
*/
NEXAccountSchema.methods.generatePID = async function () {
const min = 1000000000; // The console (WiiU) seems to not accept PIDs smaller than this
const max = 1799999999;
NEXAccountSchema.method('generatePID', async function generatePID(): Promise<void> {
const min: number = 1000000000; // The console (WiiU) seems to not accept PIDs smaller than this
const max: number = 1799999999;
let pid = Math.floor(Math.random() * (max - min + 1) + min);
const pid: number = Math.floor(Math.random() * (max - min + 1) + min);
const inuse = await NEXAccount.findOne({ pid });
const inuse: HydratedNEXAccountDocument | null = await NEXAccount.findOne({ pid });
pid = (inuse ? await NEXAccount.generatePID() : pid);
if (inuse) {
await this.generatePID();
} else {
this.pid = pid;
}
});
this.set('pid', pid);
};
NEXAccountSchema.methods.generatePassword = function () {
function character() {
const offset = Math.floor(Math.random() * 62);
NEXAccountSchema.method('generatePassword', function generatePassword(): void {
function character(): string | number {
const offset: number = Math.floor(Math.random() * 62);
if (offset < 10) return offset;
if (offset < 36) return String.fromCharCode(offset + 55);
return String.fromCharCode(offset + 61);
}
const output = [];
const output: string[] = [];
while (output.length < 16) {
output.push(character());
output.push(String(character()));
}
this.set('password', output.join(''));
};
this.password = output.join('');
});
const NEXAccount = model('NEXAccount', NEXAccountSchema);
module.exports = {
NEXAccountSchema,
NEXAccount,
};
export const NEXAccount: NEXAccountModel = model<INEXAccount, NEXAccountModel>('NEXAccount', NEXAccountSchema);

View File

@@ -1,239 +0,0 @@
const { Schema, model } = require('mongoose');
const uniqueValidator = require('mongoose-unique-validator');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const imagePixels = require('image-pixels');
const TGA = require('tga');
const got = require('got');
const util = require('../util');
const { DeviceSchema } = require('./device');
const Mii = require('mii-js');
const PNIDSchema = new Schema({
access_level: {
type: Number,
default: 0 // 0: standard, 1: tester, 2: mod?, 3: dev
},
server_access_level: {
type: String,
default: 'prod' // everyone is in production by default
},
pid: {
type: Number,
unique: true
},
creation_date: String,
updated: String,
username: {
type: String,
unique: true,
minlength: 6,
maxlength: 16
},
usernameLower: {
type: String,
unique: true
},
password: String,
birthdate: String,
gender: String,
country: String,
language: String,
email: {
address: String,
primary: Boolean,
parent: Boolean,
reachable: Boolean,
validated: Boolean,
validated_date: String,
id: {
type: Number,
unique: true
}
},
region: Number,
timezone: {
name: String,
offset: Number
},
mii: {
name: String,
primary: Boolean,
data: String,
id: {
type: Number,
unique: true
},
hash: {
type: String,
unique: true
},
image_url: String,
image_id: {
type: Number,
unique: true
},
},
flags: {
active: Boolean,
marketing: Boolean,
off_device: Boolean
},
devices: [DeviceSchema],
identification: { // user identification tokens
email_code: {
type: String,
unique: true
},
email_token: {
type: String,
unique: true
},
access_token: {
value: String,
ttl: Number
},
refresh_token: {
value: String,
ttl: Number
}
},
connections: {
discord: {
id: String
}
}
});
PNIDSchema.plugin(uniqueValidator, {message: '{PATH} already in use.'});
/*
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
The author of that site does not give any information on how they found this out, but it does seem to hold true
https://account.nintendo.net/v1/api/admin/mapped_ids?input_type=pid&output_type=user_id&input=1800000000 returns nothing
https://account.nintendo.net/v1/api/admin/mapped_ids?input_type=pid&output_type=user_id&input=1799999999 returns `prodtest1`
and the next few accounts counting down seem to be admin, service and internal test accounts
*/
PNIDSchema.methods.generatePID = async function() {
const min = 1000000000; // The console (WiiU) seems to not accept PIDs smaller than this
const max = 1799999999;
let pid = Math.floor(Math.random() * (max - min + 1) + min);
const inuse = await PNID.findOne({
pid
});
pid = (inuse ? await PNID.generatePID() : pid);
this.set('pid', pid);
};
PNIDSchema.methods.generateEmailValidationCode = async function() {
// WiiU passes the PID along with the email code
// Does not actually need to be unique to all users
const code = Math.random().toFixed(6).split('.')[1]; // Dirty one-liner to generate numbers of 6 length and padded 0
this.set('identification.email_code', code);
};
PNIDSchema.methods.generateEmailValidationToken = async function() {
let token = crypto.randomBytes(32).toString('hex');
const inuse = await PNID.findOne({
'identification.email_token': token
});
token = (inuse ? await PNID.generateEmailValidationToken() : token);
this.set('identification.email_token', token);
};
PNIDSchema.methods.getDevice = async function(document) {
const devices = this.get('devices');
return devices.find(device => {
return (
(device.device_id === document.device_id) &&
(device.device_type === document.device_type) &&
(device.serial === document.serial)
);
});
};
PNIDSchema.methods.addDevice = async function(device) {
this.devices.push(device);
await this.save();
};
PNIDSchema.methods.removeDevice = async function(device) {
this.devices = this.devices.filter(({ _id }) => _id !== device._id);
await this.save();
};
PNIDSchema.methods.updateMii = async function({name, primary, data}) {
this.set('mii.name', name);
this.set('mii.primary', primary === 'Y');
this.set('mii.data', data);
this.set('mii.hash', crypto.randomBytes(7).toString('hex'));
this.set('mii.id', crypto.randomBytes(4).readUInt32LE());
this.set('mii.image_id', crypto.randomBytes(4).readUInt32LE());
await this.generateMiiImages();
await this.save();
};
PNIDSchema.methods.generateMiiImages = async function() {
const miiData = this.get('mii.data');
const mii = new Mii(Buffer.from(miiData, 'base64'));
const miiStudioUrl = mii.studioUrl({
type: 'face',
width: '128',
instanceCount: '1',
});
const miiStudioNormalFaceImageData = await got(miiStudioUrl).buffer();
const pngData = await imagePixels(miiStudioNormalFaceImageData);
const tga = TGA.createTgaBuffer(pngData.width, pngData.height, pngData.data);
const userMiiKey = `mii/${this.get('pid')}`;
await util.uploadCDNAsset('pn-cdn', `${userMiiKey}/standard.tga`, tga, 'public-read');
await util.uploadCDNAsset('pn-cdn', `${userMiiKey}/normal_face.png`, miiStudioNormalFaceImageData, 'public-read');
const expressions = ['frustrated', 'smile_open_mouth', 'wink_left', 'sorrow', 'surprise_open_mouth'];
for (const expression of expressions) {
const miiStudioExpressionUrl = mii.studioUrl({
type: 'face',
expression: expression,
width: '128',
instanceCount: '1',
});
const miiStudioExpressionImageData = await got(miiStudioExpressionUrl).buffer();
await util.uploadCDNAsset('pn-cdn', `${userMiiKey}/${expression}.png`, miiStudioExpressionImageData, 'public-read');
}
const miiStudioBodyUrl = mii.studioUrl({
type: 'all_body',
width: '270',
instanceCount: '1',
});
const miiStudioBodyImageData = await got(miiStudioBodyUrl).buffer();
await util.uploadCDNAsset('pn-cdn', `${userMiiKey}/body.png`, miiStudioBodyImageData, 'public-read');
};
PNIDSchema.methods.getServerMode = function () {
const serverMode = this.get('server_mode') || 'prod';
return serverMode;
};
const PNID = model('PNID', PNIDSchema);
module.exports = {
PNIDSchema,
PNID
};

212
src/models/pnid.ts Normal file
View File

@@ -0,0 +1,212 @@
import crypto from 'node:crypto';
import { Schema, model } from 'mongoose';
import uniqueValidator from 'mongoose-unique-validator';
import imagePixels from 'image-pixels';
import TGA from 'tga';
import got from 'got';
import Mii from 'mii-js';
import { DeviceSchema } from '@/models/device';
import { uploadCDNAsset } from '@/util';
import { HydratedPNIDDocument, IPNID, IPNIDMethods, PNIDModel } from '@/types/mongoose/pnid';
const PNIDSchema = new Schema<IPNID, PNIDModel, IPNIDMethods>({
access_level: {
type: Number,
default: 0 // 0: standard, 1: tester, 2: mod?, 3: dev
},
server_access_level: {
type: String,
default: 'prod' // everyone is in production by default
},
pid: {
type: Number,
unique: true
},
creation_date: String,
updated: String,
username: {
type: String,
unique: true,
minlength: 6,
maxlength: 16
},
usernameLower: {
type: String,
unique: true
},
password: String,
birthdate: String,
gender: String,
country: String,
language: String,
email: {
address: String,
primary: Boolean,
parent: Boolean,
reachable: Boolean,
validated: Boolean,
validated_date: String,
id: {
type: Number,
unique: true
}
},
region: Number,
timezone: {
name: String,
offset: Number
},
mii: {
name: String,
primary: Boolean,
data: String,
id: {
type: Number,
unique: true
},
hash: {
type: String,
unique: true
},
image_url: String,
image_id: {
type: Number,
unique: true
},
},
flags: {
active: Boolean,
marketing: Boolean,
off_device: Boolean
},
devices: [DeviceSchema],
identification: { // user identification tokens
email_code: {
type: String,
unique: true
},
email_token: {
type: String,
unique: true
},
access_token: {
value: String,
ttl: Number
},
refresh_token: {
value: String,
ttl: Number
}
},
connections: {
discord: {
id: String
}
}
}, { id: false });
PNIDSchema.plugin(uniqueValidator, {message: '{PATH} already in use.'});
/*
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
The author of that site does not give any information on how they found this out, but it does seem to hold true
https://account.nintendo.net/v1/api/admin/mapped_ids?input_type=pid&output_type=user_id&input=1800000000 returns nothing
https://account.nintendo.net/v1/api/admin/mapped_ids?input_type=pid&output_type=user_id&input=1799999999 returns `prodtest1`
and the next few accounts counting down seem to be admin, service and internal test accounts
*/
PNIDSchema.method('generatePID', async function generatePID(): Promise<void> {
const min: number = 1000000000; // The console (WiiU) seems to not accept PIDs smaller than this
const max: number = 1799999999;
const pid: number = Math.floor(Math.random() * (max - min + 1) + min);
const inuse: HydratedPNIDDocument | null = await PNID.findOne({
pid
});
if (inuse) {
await this.generatePID();
} else {
this.pid = pid;
}
});
PNIDSchema.method('generateEmailValidationCode', async function generateEmailValidationCode(): Promise<void> {
// WiiU passes the PID along with the email code
// Does not actually need to be unique to all users
const code: string = Math.random().toFixed(6).split('.')[1]; // Dirty one-liner to generate numbers of 6 length and padded 0
this.identification.email_code = code;
});
PNIDSchema.method('generateEmailValidationToken', async function generateEmailValidationToken(): Promise<void> {
const token: string = crypto.randomBytes(32).toString('hex');
const inuse: HydratedPNIDDocument | null = await PNID.findOne({
'identification.email_token': token
});
if (inuse) {
await this.generateEmailValidationToken();
} else {
this.identification.email_token = token;
}
});
PNIDSchema.method('updateMii', async function updateMii({name, primary, data}): Promise<void> {
this.mii.name = name;
this.mii.primary = primary === 'Y';
this.mii.data = data;
this.mii.hash = crypto.randomBytes(7).toString('hex');
this.mii.id = crypto.randomBytes(4).readUInt32LE();
this.mii.image_id = crypto.randomBytes(4).readUInt32LE();
await this.generateMiiImages();
await this.save();
});
PNIDSchema.method('generateMiiImages', async function generateMiiImages(): Promise<void> {
const miiData: string = this.get('mii.data');
const mii: Mii = new Mii(Buffer.from(miiData, 'base64'));
const miiStudioUrl: string = mii.studioUrl({
type: 'face',
width: 128,
instanceCount: 1,
});
const miiStudioNormalFaceImageData: Buffer = await got(miiStudioUrl).buffer();
const pngData: ImageData = await imagePixels(miiStudioNormalFaceImageData);
const tga: Buffer = TGA.createTgaBuffer(pngData.width, pngData.height, Uint8Array.from(pngData.data), false);
const userMiiKey: string = `mii/${this.get('pid')}`;
await uploadCDNAsset('pn-cdn', `${userMiiKey}/standard.tga`, tga, 'public-read');
await uploadCDNAsset('pn-cdn', `${userMiiKey}/normal_face.png`, miiStudioNormalFaceImageData, 'public-read');
const expressions: string[] = ['frustrated', 'smile_open_mouth', 'wink_left', 'sorrow', 'surprise_open_mouth'];
for (const expression of expressions) {
const miiStudioExpressionUrl: string = mii.studioUrl({
type: 'face',
expression: expression,
width: 128,
instanceCount: 1,
});
const miiStudioExpressionImageData: Buffer = await got(miiStudioExpressionUrl).buffer();
await uploadCDNAsset('pn-cdn', `${userMiiKey}/${expression}.png`, miiStudioExpressionImageData, 'public-read');
}
const miiStudioBodyUrl: string = mii.studioUrl({
type: 'all_body',
width: 270,
instanceCount: 1,
});
const miiStudioBodyImageData: Buffer = await got(miiStudioBodyUrl).buffer();
await uploadCDNAsset('pn-cdn', `${userMiiKey}/body.png`, miiStudioBodyImageData, 'public-read');
});
PNIDSchema.method('getServerMode', function getServerMode(): string {
return this.get('server_mode') || 'prod';
});
export const PNID: PNIDModel = model<IPNID, PNIDModel>('PNID', PNIDSchema);

View File

@@ -1,23 +0,0 @@
const { Schema, model } = require('mongoose');
const uniqueValidator = require('mongoose-unique-validator');
const ServerSchema = new Schema({
ip: String, // Example: 1.1.1.1
port: Number, // Example: 60000
service_name: String, // Example: friends
service_type: String, // Example: nex
game_server_id: String, // Example: 00003200
title_ids: [String], // Example: ["000500001018DB00", "000500001018DC00", "000500001018DD00"]
access_mode: String, // Example: prod
maintenance_mode: Boolean, // Example: false
device: Number, // Example: 1 (WiiU)
});
ServerSchema.plugin(uniqueValidator, { message: '{PATH} already in use.' });
const Server = model('Server', ServerSchema);
module.exports = {
ServerSchema,
Server,
};

20
src/models/server.ts Normal file
View File

@@ -0,0 +1,20 @@
import { Schema, model } from 'mongoose';
import uniqueValidator from 'mongoose-unique-validator';
import { IServer, IServerMethods, ServerModel } from '@/types/mongoose/server';
const ServerSchema = new Schema<IServer, ServerModel, IServerMethods>({
ip: String,
port: Number,
service_name: String,
service_type: String,
game_server_id: String,
title_ids: [String],
access_mode: String,
maintenance_mode: Boolean,
device: Number,
aes_key: String
});
ServerSchema.plugin(uniqueValidator, { message: '{PATH} already in use.' });
export const Server: ServerModel = model<IServer, ServerModel>('Server', ServerSchema);

View File

@@ -1,7 +1,6 @@
// Parse Nintendo certificates
const crypto = require('crypto');
const NodeRSA = require('node-rsa');
import crypto from 'node:crypto';
import NodeRSA from 'node-rsa';
import { SignatureSize } from '@/types/common/signature-size';
const WIIU_DEVICE_PUB_PEM = `-----BEGIN PUBLIC KEY-----
MFIwEAYHKoZIzj0CAQYFK4EEABsDPgAEAP1WBBgs8XUJIQDDCK5IOZEbb5+h1TqV
@@ -44,44 +43,57 @@ const CTR_LFCS_B_PUB = Buffer.from([
// Signature options
const SIGNATURE_SIZES = {
RSA_4096_SHA1: {
RSA_4096_SHA1: <SignatureSize>{
SIZE: 0x200,
PADDING_SIZE: 0x3C
},
RSA_2048_SHA1: {
RSA_2048_SHA1: <SignatureSize>{
SIZE: 0x100,
PADDING_SIZE: 0x3C
},
ELLIPTIC_CURVE_SHA1: {
ELLIPTIC_CURVE_SHA1: <SignatureSize>{
SIZE: 0x3C,
PADDING_SIZE: 0x40
},
RSA_4096_SHA256: {
RSA_4096_SHA256: <SignatureSize>{
SIZE: 0x200,
PADDING_SIZE: 0x3C
},
RSA_2048_SHA256: {
RSA_2048_SHA256: <SignatureSize>{
SIZE: 0x100,
PADDING_SIZE: 0x3C
},
ECDSA_SHA256: {
ECDSA_SHA256: <SignatureSize>{
SIZE: 0x3C,
PADDING_SIZE: 0x40
}
};
} as const;
class NintendoCertificate {
constructor(certificate) {
this._certificate = null;
this._certificateBody = null;
this.signatureType = null;
this.signature = null;
this.issuer = null;
this.keyType = null;
this.certificateName = null;
this.ngKeyId = null;
this.publicKey = null;
this.valid = null;
_certificate: Buffer;
_certificateBody: Buffer;
signatureType: number;
signature: Buffer;
issuer: string;
keyType: number;
certificateName: string;
ngKeyId: number;
publicKey: Buffer;
valid: boolean;
publicKeyData: Buffer;
constructor(certificate: string | Buffer) {
this._certificate = Buffer.alloc(0);
this._certificateBody = Buffer.alloc(0);
this.signatureType = 0;
this.signature = Buffer.alloc(0);
this.issuer = '';
this.keyType = 0;
this.certificateName = '';
this.ngKeyId = 0;
this.publicKey = Buffer.alloc(0);
this.valid = false;
this.publicKeyData = Buffer.alloc(0);
if (certificate) {
if (certificate instanceof Buffer) {
@@ -91,11 +103,10 @@ class NintendoCertificate {
}
this._parseCertificateData();
this._verifySignature();
}
}
_parseCertificateData() {
_parseCertificateData(): void {
if (this._certificate.length === 0x110) {
// Assume fcdcert (3DS LFCS)
this.signature = this._certificate.subarray(0x0, 0x100);
@@ -106,7 +117,7 @@ class NintendoCertificate {
// Assume regular certificate
this.signatureType = this._certificate.readUInt32BE(0x00);
const signatureTypeSizes = this._signatureTypeSizes(this.signatureType);
const signatureTypeSizes: SignatureSize = this._signatureTypeSizes(this.signatureType);
this._certificateBody = this._certificate.subarray(0x4 + signatureTypeSizes.SIZE + signatureTypeSizes.PADDING_SIZE);
@@ -116,10 +127,12 @@ class NintendoCertificate {
this.certificateName = this._certificate.subarray(0xC4, 0x104).toString().split('\0')[0];
this.ngKeyId = this._certificate.readUInt32BE(0x104);
this.publicKeyData = this._certificate.subarray(0x108);
this._verifySignature();
}
}
_signatureTypeSizes(signatureType) {
_signatureTypeSizes(signatureType: number): SignatureSize {
switch (signatureType) {
case 0x10000:
return SIGNATURE_SIZES.RSA_4096_SHA1;
@@ -138,7 +151,7 @@ class NintendoCertificate {
}
}
_verifySignature() {
_verifySignature(): void {
switch (this.keyType) {
case 0x0:
this._verifySignatureRSA4096();
@@ -157,8 +170,8 @@ class NintendoCertificate {
}
}
_verifySignatureRSA4096() {
const publicKey = new NodeRSA();
_verifySignatureRSA4096(): void {
const publicKey: NodeRSA = new NodeRSA();
publicKey.importKey({
n: this.publicKeyData.subarray(0x0, 0x200),
@@ -168,8 +181,8 @@ class NintendoCertificate {
this.valid = publicKey.verify(this._certificateBody, this.signature);
}
_verifySignatureRSA2048() {
const publicKey = new NodeRSA();
_verifySignatureRSA2048(): void {
const publicKey: NodeRSA = new NodeRSA();
publicKey.importKey({
n: this.publicKeyData.subarray(0x0, 0x100),
@@ -183,18 +196,18 @@ class NintendoCertificate {
// with Nodes native crypto module and getting the keys
// from bytes to PEM!
// https://github.com/Myriachan
_verifySignatureECDSA() {
const pem = this.issuer == 'Root-CA00000003-MS00000012' ? WIIU_DEVICE_PUB_PEM : CTR_DEVICE_PUB_PEM;
const key = crypto.createPublicKey({
key: pem
});
key.dsaEncoding = 'ieee-p1363';
_verifySignatureECDSA(): void {
const pem: string = this.issuer == 'Root-CA00000003-MS00000012' ? WIIU_DEVICE_PUB_PEM : CTR_DEVICE_PUB_PEM;
const key: crypto.VerifyPublicKeyInput = {
key: pem,
dsaEncoding: 'ieee-p1363' as crypto.DSAEncoding
};
this.valid = crypto.verify('sha256', this._certificateBody, key, this.signature);
}
_verifySignatureLFCS() {
const publicKey = new NodeRSA();
_verifySignatureLFCS(): void {
const publicKey: NodeRSA = new NodeRSA();
publicKey.importKey({
n: CTR_LFCS_B_PUB,
@@ -205,4 +218,4 @@ class NintendoCertificate {
}
}
module.exports = NintendoCertificate;
export default NintendoCertificate;

View File

@@ -1,93 +0,0 @@
process.title = 'Pretendo - Account';
const configManager = require('./config-manager');
configManager.configure();
const express = require('express');
const morgan = require('morgan');
const xmlparser = require('./middleware/xml-parser');
const cache = require('./cache');
const database = require('./database');
const util = require('./util');
const logger = require('../logger');
const { config } = configManager;
const { http: { port } } = config;
const app = express();
const conntest = require('./services/conntest');
const nnid = require('./services/nnid');
const nasc = require('./services/nasc');
const datastore = require('./services/datastore');
const api = require('./services/api');
const localcdn = require('./services/local-cdn');
const assets = require('./services/assets');
// START APPLICATION
// Create router
logger.info('Setting up Middleware');
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({
extended: true
}));
app.use(xmlparser);
// import the servers into one
app.use(conntest);
app.use(nnid);
app.use(nasc);
app.use(datastore);
app.use(api);
app.use(localcdn);
app.use(assets);
// 404 handler
logger.info('Creating 404 status handler');
app.use((request, response) => {
const fullUrl = util.fullUrl(request);
const deviceId = request.headers['X-Nintendo-Device-ID'] || 'Unknown';
logger.warn(`HTTP 404 at ${fullUrl} from ${deviceId}`);
response.set('Content-Type', 'text/xml');
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', new Date().getTime());
response.status(404);
response.send('<errors><error><cause/><code>0008</code><message>Not Found</message></error></errors>');
});
// non-404 error handler
logger.info('Creating non-404 status handler');
app.use((error, request, response) => {
const status = error.status || 500;
const fullUrl = util.fullUrl(request);
const deviceId = request.headers['X-Nintendo-Device-ID'] || 'Unknown';
logger.warn(`HTTP ${status} at ${fullUrl} from ${deviceId}: ${error.message}`);
response.status(status);
response.json({
app: 'api',
status,
error: error.message
});
});
async function main() {
// Starts the server
logger.info('Starting server');
await database.connect();
await cache.connect();
app.listen(port, () => {
logger.success(`Server started on port ${port}`);
});
}
main().catch(console.error);

109
src/server.ts Normal file
View File

@@ -0,0 +1,109 @@
process.title = 'Pretendo - Account';
process.on('uncaughtException', (err, origin) => {
console.log(err);
console.log(origin);
});
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 { connect as connectDatabase } from '@/database';
import { fullUrl, getValueFromHeaders } from '@/util';
import { LOG_INFO, LOG_SUCCESS, LOG_WARN } from '@/logger';
import conntest from '@/services/conntest';
import nnid from '@/services/nnid';
import nasc from '@/services/nasc';
import datastore from '@/services/datastore';
import api from '@/services/api';
import localcdn from '@/services/local-cdn';
import assets from '@/services/assets';
import { config } from '@/config-manager';
const { http: { port } } = config;
const app = express();
// START APPLICATION
// Create router
LOG_INFO('Setting up Middleware');
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({
extended: true
}));
app.use(xmlparser);
// import the servers into one
app.use(conntest);
app.use(nnid);
app.use(nasc);
app.use(datastore);
app.use(api);
app.use(localcdn);
app.use(assets);
// 404 handler
LOG_INFO('Creating 404 status handler');
app.use((request: express.Request, response: express.Response) => {
const url: string = fullUrl(request);
let deviceId: string | undefined = getValueFromHeaders(request.headers, 'X-Nintendo-Device-ID');
if (!deviceId) {
deviceId = 'Unknown';
}
LOG_WARN(`HTTP 404 at ${url} from ${deviceId}`);
response.set('Content-Type', 'text/xml');
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', new Date().getTime().toString());
response.status(404).send(xmlbuilder.create({
errors: {
error: {
cause: '',
code: '0008',
message: 'Not Found'
}
}
}).end());
});
// non-404 error handler
LOG_INFO('Creating non-404 status handler');
app.use((error: any, request: express.Request, response: express.Response, _next: express.NextFunction) => {
const status: number = error.status || 500;
const url: string = fullUrl(request);
let deviceId: string | undefined = getValueFromHeaders(request.headers, 'X-Nintendo-Device-ID');
if (!deviceId) {
deviceId = 'Unknown';
}
LOG_WARN(`HTTP ${status} at ${url} from ${deviceId}: ${error.message}`);
response.status(status);
response.json({
app: 'api',
status,
error: error.message
});
});
async function main(): Promise<void> {
// Starts the server
LOG_INFO('Starting server');
await connectDatabase();
await connectCache();
app.listen(port, () => {
LOG_SUCCESS(`Server started on port ${port}`);
});
}
main().catch(console.error);

View File

@@ -1,36 +0,0 @@
// handles "api.nintendo.cc" endpoints
const express = require('express');
const subdomain = require('express-subdomain');
const cors = require('cors');
const APIMiddleware = require('../../middleware/api');
const logger = require('../../../logger');
const routes = require('./routes');
// Router to handle the subdomain restriction
const api = express.Router();
logger.info('[USER API] Importing middleware');
api.use(APIMiddleware);
api.use(cors());
api.options('*', cors());
// Setup routes
logger.info('[USER API] Applying imported routes');
api.use('/v1/connections', routes.V1.CONNECTIONS);
api.use('/v1/email', routes.V1.EMAIL);
api.use('/v1/forgot-password', routes.V1.FORGOT_PASSWORD);
api.use('/v1/login', routes.V1.LOGIN);
api.use('/v1/register', routes.V1.REGISTER);
api.use('/v1/reset-password', routes.V1.RESET_PASSWORD);
api.use('/v1/user', routes.V1.USER);
// Main router for endpoints
const router = express.Router();
// Create subdomains
logger.info('[USER API] Creating \'api\' subdomain');
router.use(subdomain('api', api));
module.exports = router;

37
src/services/api/index.ts Normal file
View File

@@ -0,0 +1,37 @@
// handles "api.nintendo.cc" endpoints
import express from 'express';
import subdomain from 'express-subdomain';
import cors from 'cors';
import APIMiddleware from '@/middleware/api';
import { LOG_INFO } from '@/logger';
import { V1 } from '@/services/api/routes';
// Router to handle the subdomain restriction
const api: express.Router = express.Router();
LOG_INFO('[USER API] Importing middleware');
api.use(APIMiddleware);
api.use(cors());
api.options('*', cors());
// Setup routes
LOG_INFO('[USER API] Applying imported routes');
api.use('/v1/connections', V1.CONNECTIONS);
api.use('/v1/email', V1.EMAIL);
api.use('/v1/forgot-password', V1.FORGOT_PASSWORD);
api.use('/v1/login', V1.LOGIN);
api.use('/v1/register', V1.REGISTER);
api.use('/v1/reset-password', V1.RESET_PASSWORD);
api.use('/v1/user', V1.USER);
// Main router for endpoints
const router: express.Router = express.Router();
// Create subdomains
LOG_INFO('[USER API] Creating \'api\' subdomain');
router.use(subdomain('api', api));
export default router;

View File

@@ -1,11 +0,0 @@
module.exports = {
V1: {
CONNECTIONS: require('./v1/connections'),
EMAIL: require('./v1/email'),
FORGOT_PASSWORD: require('./v1/forgotPassword'),
LOGIN: require('./v1/login'),
REGISTER: require('./v1/register'),
RESET_PASSWORD: require('./v1/resetPassword'),
USER: require('./v1/user'),
}
};

View File

@@ -0,0 +1,17 @@
import connections_v1 from '@/services/api/routes/v1/connections';
import email_v1 from '@/services/api/routes/v1/email';
import forgotPassword_v1 from '@/services/api/routes/v1/forgotPassword';
import login_v1 from '@/services/api/routes/v1/login';
import register_v1 from '@/services/api/routes/v1/register';
import resetPassword_v1 from '@/services/api/routes/v1/resetPassword';
import user_v1 from '@/services/api/routes/v1/user';
export const V1 = {
CONNECTIONS: connections_v1,
EMAIL: email_v1,
FORGOT_PASSWORD: forgotPassword_v1,
LOGIN: login_v1,
REGISTER: register_v1,
RESET_PASSWORD: resetPassword_v1,
USER: user_v1
};

View File

@@ -1,77 +0,0 @@
const router = require('express').Router();
const database = require('../../../../database');
const VALID_CONNECTION_TYPES = [
'discord'
];
/**
* [POST]
* Implementation of for: https://api.pretendo.cc/v1/connections/add/TYPE
* Description: Adds an account connection to the users PNID
*/
router.post('/add/:type', async (request, response) => {
const { body, pnid } = request;
const { type } = request.params;
const { data } = body;
if (!pnid) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing access token'
});
}
if (!data) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing connection data'
});
}
if (!VALID_CONNECTION_TYPES.includes(type)) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing connection type'
});
}
const result = await database.addUserConnection(pnid, data, type);
response.status(result.status).json(result);
});
/**
* [DELETE]
* Implementation of for: https://api.pretendo.cc/v1/connections/remove/TYPE
* Description: Removes an account connection from the users PNID
*/
router.delete('/remove/:type', async (request, response) => {
const { pnid } = request;
const { type } = request.params;
if (!pnid) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing access token'
});
}
if (!VALID_CONNECTION_TYPES.includes(type)) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing connection type'
});
}
const result = await database.removeUserConnection(pnid, type);
response.status(result.status).json(result);
});
module.exports = router;

View File

@@ -0,0 +1,98 @@
import express from 'express';
import { addPNIDConnection, removePNIDConnection } from '@/database';
import { ConnectionData } from '@/types/services/api/connection-data';
import { ConnectionResponse } from '@/types/services/api/connection-response';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
const router: express.Router = express.Router();
const VALID_CONNECTION_TYPES: string[] = [
'discord'
];
/**
* [POST]
* Implementation of for: https://api.pretendo.cc/v1/connections/add/TYPE
* Description: Adds an account connection to the users PNID
*/
router.post('/add/:type', async (request: express.Request, response: express.Response) => {
const data: ConnectionData = request.body?.data;
const pnid: HydratedPNIDDocument | null = request.pnid;
const type: string = request.params.type;
if (!pnid) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing access token'
});
}
if (!data) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing connection data'
});
}
if (!VALID_CONNECTION_TYPES.includes(type)) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing connection type'
});
}
let result: ConnectionResponse | undefined = await addPNIDConnection(pnid, data, type);
if (!result) {
result = {
app: 'api',
status: 500,
error: 'Unknown server error'
};
}
response.status(result.status || 500).json(result);
});
/**
* [DELETE]
* Implementation of for: https://api.pretendo.cc/v1/connections/remove/TYPE
* Description: Removes an account connection from the users PNID
*/
router.delete('/remove/:type', async (request: express.Request, response: express.Response) => {
const pnid: HydratedPNIDDocument | null = request.pnid;
const type: string = request.params.type;
if (!pnid) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing access token'
});
}
if (!VALID_CONNECTION_TYPES.includes(type)) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing connection type'
});
}
let result: ConnectionResponse | undefined = await removePNIDConnection(pnid, type);
if (!result) {
result = {
app: 'api',
status: 500,
error: 'Unknown server error'
};
}
response.status(result.status).json(result);
});
export default router;

View File

@@ -1,42 +0,0 @@
const router = require('express').Router();
const moment = require('moment');
const { PNID } = require('../../../../models/pnid');
const util = require('../../../../util');
router.get('/verify', async (request, response) => {
const { token } = request.query;
if (!token || token.trim() == '') {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Missing email token'
});
}
const pnid = await PNID.findOne({
'identification.email_token': token
});
if (!pnid) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid email token'
});
}
const validatedDate = moment().format('YYYY-MM-DDTHH:MM:SS');
pnid.set('email.reachable', true);
pnid.set('email.validated', true);
pnid.set('email.validated_date', validatedDate);
await pnid.save();
await util.sendEmailConfirmedEmail(pnid);
response.status(200).send('Email validated. You may close this window');
});
module.exports = router;

View File

@@ -0,0 +1,45 @@
import express from 'express';
import moment from 'moment';
import { PNID } from '@/models/pnid';
import { getValueFromQueryString, sendEmailConfirmedEmail } from '@/util';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
const router: express.Router = express.Router();
router.get('/verify', async (request: express.Request, response: express.Response) => {
const token: string | undefined = getValueFromQueryString(request.query, 'token');
if (!token || token.trim() == '') {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Missing email token'
});
}
const pnid: HydratedPNIDDocument | null = await PNID.findOne({
'identification.email_token': token
});
if (!pnid) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid email token'
});
}
const validatedDate: string = 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);
response.status(200).send('Email validated. You may close this window');
});
export default router;

View File

@@ -1,36 +0,0 @@
const router = require('express').Router();
const validator = require('validator');
const database = require('../../../../database');
const util = require('../../../../util');
router.post('/', async (request, response) => {
const { body } = request;
const { input } = body;
if (!input || input.trim() === '') {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing input'
});
}
let pnid;
if (validator.isEmail(input)) {
pnid = await database.getUserByEmailAddress(input);
} else {
pnid = await database.getUserByUsername(input);
}
if (pnid) {
await util.sendForgotPasswordEmail(pnid);
}
response.json({
app: 'api',
status: 200
});
});
module.exports = router;

View File

@@ -0,0 +1,38 @@
import express from 'express';
import validator from 'validator';
import { getPNIDByEmailAddress, getPNIDByUsername } from '@/database';
import { sendForgotPasswordEmail } from '@/util';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
const router: express.Router = express.Router();
router.post('/', async (request: express.Request, response: express.Response) => {
const input: string = request.body?.input;
if (!input || input.trim() === '') {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing input'
});
}
let pnid: HydratedPNIDDocument | null;
if (validator.isEmail(input)) {
pnid = await getPNIDByEmailAddress(input);
} else {
pnid = await getPNIDByUsername(input);
}
if (pnid) {
await sendForgotPasswordEmail(pnid);
}
response.json({
app: 'api',
status: 200
});
});
export default router;

View File

@@ -1,129 +0,0 @@
const router = require('express').Router();
const bcrypt = require('bcrypt');
const fs = require('fs-extra');
const database = require('../../../../database');
const cache = require('../../../../cache');
const util = require('../../../../util');
/**
* [POST]
* Implementation of for: https://api.pretendo.cc/v1/login
* Description: Generates an access token for an API user
* TODO: Replace this with a more robust OAuth2 implementation
*/
router.post('/', async (request, response) => {
const { body } = request;
const { grant_type, username, password, refresh_token } = body;
if (!['password', 'refresh_token'].includes(grant_type)) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid grant type'
});
}
if (grant_type === 'password' && (!username || username.trim() === '')) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing username'
});
}
if (grant_type === 'password' && (!password || password.trim() === '')) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing password'
});
}
if (grant_type === 'refresh_token' && (!refresh_token || refresh_token.trim() === '')) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing refresh token'
});
}
let pnid;
if (grant_type === 'password') {
pnid = await database.getUserByUsername(username);
if (!pnid) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'User not found'
});
}
const hashedPassword = util.nintendoPasswordHash(password, pnid.get('pid'));
if (!pnid || !bcrypt.compareSync(hashedPassword, pnid.password)) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing password'
});
}
} else {
pnid = await database.getUserBearer(refresh_token);
if (!pnid) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing refresh token'
});
}
}
const cryptoPath = `${__dirname}/../../../../../certs/service/account`;
if (!await fs.pathExists(cryptoPath)) {
// Need to generate keys
return response.status(500).json({
app: 'api',
status: 500,
error: 'Failed to locate crypto keys. Please contact an administrator'
});
}
const publicKey = await cache.getServicePublicKey('account');
const secretKey = await cache.getServiceSecretKey('account');
const cryptoOptions = {
public_key: publicKey,
hmac_secret: secretKey
};
const accessTokenOptions = {
system_type: 0xF, // API
token_type: 0x1, // OAuth Access,
pid: pnid.get('pid'),
access_level: pnid.get('access_level'),
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const refreshTokenOptions = {
system_type: 0xF, // API
token_type: 0x2, // OAuth Refresh,
pid: pnid.get('pid'),
access_level: pnid.get('access_level'),
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const accessToken = await util.generateToken(cryptoOptions, accessTokenOptions);
const refreshToken = await util.generateToken(cryptoOptions, refreshTokenOptions);
response.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: refreshToken
});
});
module.exports = router;

View File

@@ -0,0 +1,119 @@
import express from 'express';
import bcrypt from 'bcrypt';
import { getPNIDByUsername, getPNIDByBearerAuth } from '@/database';
import { nintendoPasswordHash, generateToken} from '@/util';
import { config } from '@/config-manager';
import { TokenOptions } from '@/types/common/token-options';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
const router: express.Router = express.Router();
/**
* [POST]
* Implementation of for: https://api.pretendo.cc/v1/login
* Description: Generates an access token for an API user
* TODO: Replace this with a more robust OAuth2 implementation
*/
router.post('/', async (request: express.Request, response: express.Response) => {
const grantType: string = request.body?.grantType;
const username: string = request.body?.username;
const password: string = request.body?.password;
const refreshToken: string = request.body?.refresh_token;
if (!['password', 'refresh_token'].includes(grantType)) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid grant type'
});
}
if (grantType === 'password' && (!username || username.trim() === '')) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing username'
});
}
if (grantType === 'password' && (!password || password.trim() === '')) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing password'
});
}
if (grantType === 'refresh_token' && (!refreshToken || refreshToken.trim() === '')) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing refresh token'
});
}
let pnid: HydratedPNIDDocument | null;
if (grantType === 'password') {
pnid = await getPNIDByUsername(username);
if (!pnid) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'User not found'
});
}
const hashedPassword: string = nintendoPasswordHash(password, pnid.pid);
if (!pnid || !bcrypt.compareSync(hashedPassword, pnid.password)) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing password'
});
}
} else {
pnid = await getPNIDByBearerAuth(refreshToken);
if (!pnid) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing refresh token'
});
}
}
const accessTokenOptions: TokenOptions = {
system_type: 0x1, // * WiiU
token_type: 0x1, // * OAuth Access
pid: pnid.pid,
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const refreshTokenOptions: TokenOptions = {
system_type: 0x1, // * WiiU
token_type: 0x2, // * OAuth Refresh
pid: pnid.pid,
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const accessTokenBuffer: Buffer | null = await generateToken(config.aes_key, accessTokenOptions);
const refreshTokenBuffer: Buffer | null = await generateToken(config.aes_key, refreshTokenOptions);
const accessToken: string = accessTokenBuffer ? accessTokenBuffer.toString('hex') : '';
const newRefreshToken: string = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : '';
// TODO - Handle null tokens
response.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: newRefreshToken
});
});
export default router;

View File

@@ -1,46 +1,49 @@
const router = require('express').Router();
const emailvalidator = require('email-validator');
const fs = require('fs-extra');
const moment = require('moment');
const crypto = require('crypto');
const hcaptcha = require('hcaptcha');
const bcrypt = require('bcrypt');
const Mii = require('mii-js');
const { PNID } = require('../../../../models/pnid');
const { NEXAccount } = require('../../../../models/nex-account');
const database = require('../../../../database');
const cache = require('../../../../cache');
const util = require('../../../../util');
const logger = require('../../../../../logger');
const { config, disabledFeatures } = require('../../../../config-manager');
const PNID_VALID_CHARACTERS_REGEX = /^[\w\-\.]*$/gm;
const PNID_PUNCTUATION_START_REGEX = /^[\_\-\.]/gm;
const PNID_PUNCTUATION_END_REGEX = /[\_\-\.]$/gm;
const PNID_PUNCTUATION_DUPLICATE_REGEX = /[\_\-\.]{2,}/gm;
import crypto from 'node:crypto';
import express from 'express';
import emailvalidator from 'email-validator';
import bcrypt from 'bcrypt';
import moment from 'moment';
import hcaptcha from 'hcaptcha';
import Mii from 'mii-js';
import mongoose from 'mongoose';
import { doesPNIDExist, connection as databaseConnection } from '@/database';
import { nintendoPasswordHash, sendConfirmationEmail, generateToken } from '@/util';
import { LOG_ERROR } from '@/logger';
import { PNID } from '@/models/pnid';
import { NEXAccount } from '@/models/nex-account';
import { config, disabledFeatures } from '@/config-manager';
import { TokenOptions } from '@/types/common/token-options';
import { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
const router: express.Router = express.Router();
const PNID_VALID_CHARACTERS_REGEX: RegExp = /^[\w\-.]*$/gm;
const PNID_PUNCTUATION_START_REGEX: RegExp = /^[_\-.]/gm;
const PNID_PUNCTUATION_END_REGEX: RegExp = /[_\-.]$/gm;
const PNID_PUNCTUATION_DUPLICATE_REGEX: RegExp = /[_\-.]{2,}/gm;
// 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/;
const PASSWORD_WORD_OR_NUMBER_REGEX: RegExp = /(?=.*[a-zA-Z])(?=.*\d).*/;
const PASSWORD_WORD_OR_PUNCTUATION_REGEX: RegExp = /(?=.*[a-zA-Z])(?=.*[_\-.]).*/;
const PASSWORD_NUMBER_OR_PUNCTUATION_REGEX: RegExp = /(?=.*\d)(?=.*[_\-.]).*/;
const PASSWORD_REPEATED_CHARACTER_REGEX: RegExp = /(.)\1\1/;
const DEFAULT_MII_DATA = Buffer.from('AwAAQOlVognnx0GC2/uogAOzuI0n2QAAAEBEAGUAZgBhAHUAbAB0AAAAAAAAAEBAAAAhAQJoRBgmNEYUgRIXaA0AACkAUkhQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGm9', 'base64');
const DEFAULT_MII_DATA: Buffer = Buffer.from('AwAAQOlVognnx0GC2/uogAOzuI0n2QAAAEBEAGUAZgBhAHUAbAB0AAAAAAAAAEBAAAAhAQJoRBgmNEYUgRIXaA0AACkAUkhQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGm9', 'base64');
/**
* [POST]
* Implementation of: https://api.pretendo.cc/v1/register
* Description: Creates a new user PNID
*/
router.post('/', async (request, response) => {
const { body } = request;
const email = body.email?.trim();
const username = body.username?.trim();
const miiName = body.mii_name?.trim();
const password = body.password?.trim();
const passwordConfirm = body.password_confirm?.trim();
const hCaptchaResponse = body.hCaptchaResponse?.trim();
router.post('/', async (request: express.Request, response: express.Response) => {
const email: string = request.body.email?.trim();
const username: string = request.body.username?.trim();
const miiName: string = request.body.mii_name?.trim();
const password: string = request.body.password?.trim();
const passwordConfirm: string = request.body.password_confirm?.trim();
const hCaptchaResponse: string = request.body.hCaptchaResponse?.trim();
if (!disabledFeatures.captcha) {
if (!hCaptchaResponse || hCaptchaResponse === '') {
@@ -51,7 +54,7 @@ router.post('/', async (request, response) => {
});
}
const captchaVerify = await hcaptcha.verify(config.hcaptcha.secret, hCaptchaResponse);
const captchaVerify: VerifyResponse = await hcaptcha.verify(config.hcaptcha.secret, hCaptchaResponse);
if (!captchaVerify.success) {
return response.status(400).json({
@@ -135,7 +138,7 @@ router.post('/', async (request, response) => {
});
}
const userExists = await database.doesUserExist(username);
const userExists: boolean = await doesPNIDExist(username);
if (userExists) {
return response.status(400).json({
@@ -209,7 +212,7 @@ router.post('/', async (request, response) => {
});
}
const miiNameBuffer = Buffer.from(miiName, 'utf16le'); // UTF8 to UTF16
const miiNameBuffer: Buffer = Buffer.from(miiName, 'utf16le'); // UTF8 to UTF16
if (miiNameBuffer.length > 0x14) {
return response.status(400).json({
@@ -219,14 +222,14 @@ router.post('/', async (request, response) => {
});
}
const mii = new Mii(DEFAULT_MII_DATA);
const mii: Mii = new Mii(DEFAULT_MII_DATA);
mii.miiName = miiName;
const creationDate = moment().format('YYYY-MM-DDTHH:MM:SS');
let pnid;
let nexAccount;
const creationDate: string = moment().format('YYYY-MM-DDTHH:MM:SS');
let pnid: HydratedPNIDDocument;
let nexAccount: HydratedNEXAccountDocument;
const session = await database.connection.startSession();
const session: mongoose.ClientSession = await databaseConnection().startSession();
await session.startTransaction();
try {
@@ -244,15 +247,15 @@ router.post('/', async (request, response) => {
// NN with a NNID will always use the NNID PID
// even if the provided NEX PID is different
// To fix this we make them the same PID
nexAccount.owning_pid = nexAccount.get('pid');
nexAccount.owning_pid = nexAccount.pid;
await nexAccount.save({ session });
const primaryPasswordHash = util.nintendoPasswordHash(password, nexAccount.get('pid'));
const passwordHash = await bcrypt.hash(primaryPasswordHash, 10);
const primaryPasswordHash: string = nintendoPasswordHash(password, nexAccount.pid);
const passwordHash: string = await bcrypt.hash(primaryPasswordHash, 10);
pnid = new PNID({
pid: nexAccount.get('pid'),
pid: nexAccount.pid,
creation_date: creationDate,
updated: creationDate,
username: username,
@@ -303,7 +306,7 @@ router.post('/', async (request, response) => {
await session.commitTransaction();
} catch (error) {
logger.error('[POST] /v1/register: ' + error);
LOG_ERROR('[POST] /v1/register: ' + error);
await session.abortTransaction();
@@ -318,47 +321,29 @@ router.post('/', async (request, response) => {
await session.endSession();
}
await util.sendConfirmationEmail(pnid);
await sendConfirmationEmail(pnid);
const cryptoPath = `${__dirname}/../../../../../certs/service/account`;
if (!await fs.pathExists(cryptoPath)) {
// Need to generate keys
return response.status(500).json({
app: 'api',
status: 500,
error: 'Failed to locate crypto keys. Please contact an administrator'
});
}
const publicKey = await cache.getServicePublicKey('account');
const secretKey = await cache.getServiceSecretKey('account');
const cryptoOptions = {
public_key: publicKey,
hmac_secret: secretKey
};
const accessTokenOptions = {
system_type: 0xF, // API
token_type: 0x1, // OAuth Access,
pid: pnid.get('pid'),
access_level: 0,
title_id: BigInt(0),
const accessTokenOptions: TokenOptions = {
system_type: 0x1, // * WiiU
token_type: 0x1, // * OAuth Access
pid: pnid.pid,
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const refreshTokenOptions = {
system_type: 0xF, // API
token_type: 0x2, // OAuth Refresh,
pid: pnid.get('pid'),
access_level: 0,
title_id: BigInt(0),
const refreshTokenOptions: TokenOptions = {
system_type: 0x1, // * WiiU
token_type: 0x2, // * OAuth Refresh
pid: pnid.pid,
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const accessToken = await util.generateToken(cryptoOptions, accessTokenOptions);
const refreshToken = await util.generateToken(cryptoOptions, refreshTokenOptions);
const accessTokenBuffer: Buffer | null = await generateToken(config.aes_key, accessTokenOptions);
const refreshTokenBuffer: Buffer | null = await generateToken(config.aes_key, refreshTokenOptions);
const accessToken: string = accessTokenBuffer ? accessTokenBuffer.toString('hex') : '';
const refreshToken: string = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : '';
// TODO - Handle null tokens
response.json({
access_token: accessToken,
@@ -368,4 +353,4 @@ router.post('/', async (request, response) => {
});
});
module.exports = router;
export default router;

View File

@@ -1,19 +1,23 @@
const router = require('express').Router();
const bcrypt = require('bcrypt');
const { PNID } = require('../../../../models/pnid');
const util = require('../../../../util');
import express from 'express';
import bcrypt from 'bcrypt';
import { PNID } from '@/models/pnid';
import { decryptToken, unpackToken, nintendoPasswordHash } from '@/util';
import { config } from '@/config-manager';
import { Token } from '@/types/common/token';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
const router: express.Router = express.Router();
// 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/;
const PASSWORD_WORD_OR_NUMBER_REGEX: RegExp = /(?=.*[a-zA-Z])(?=.*\d).*/;
const PASSWORD_WORD_OR_PUNCTUATION_REGEX: RegExp = /(?=.*[a-zA-Z])(?=.*[_\-.]).*/;
const PASSWORD_NUMBER_OR_PUNCTUATION_REGEX: RegExp = /(?=.*\d)(?=.*[_\-.]).*/;
const PASSWORD_REPEATED_CHARACTER_REGEX: RegExp = /(.)\1\1/;
router.post('/', async (request, response) => {
const { body } = request;
const password = body.password?.trim();
const passwordConfirm = body.password_confirm?.trim();
const token = body.token?.trim();
router.post('/', async (request: express.Request, response: express.Response) => {
const password: string = request.body.password?.trim();
const passwordConfirm: string = request.body.password_confirm?.trim();
const token: string = request.body.token?.trim();
if (!token || token === '') {
return response.status(400).json({
@@ -23,10 +27,10 @@ router.post('/', async (request, response) => {
});
}
let unpackedToken;
let unpackedToken: Token;
try {
const decryptedToken = await util.decryptToken(Buffer.from(token, 'base64'));
unpackedToken = util.unpackToken(decryptedToken);
const decryptedToken: Buffer = await decryptToken(config.aes_key, Buffer.from(token, 'base64'));
unpackedToken = unpackToken(decryptedToken);
} catch (error) {
console.log(error);
return response.status(400).json({
@@ -44,7 +48,7 @@ router.post('/', async (request, response) => {
});
}
const pnid = await PNID.findOne({ pid: unpackedToken.pid });
const pnid: HydratedPNIDDocument | null = await PNID.findOne({ pid: unpackedToken.pid });
if (!pnid) {
return response.status(400).json({
@@ -79,7 +83,7 @@ router.post('/', async (request, response) => {
});
}
if (password.toLowerCase() === pnid.get('usernameLower')) {
if (password.toLowerCase() === pnid.usernameLower) {
return response.status(400).json({
app: 'api',
status: 400,
@@ -111,8 +115,8 @@ router.post('/', async (request, response) => {
});
}
const primaryPasswordHash = util.nintendoPasswordHash(password, pnid.get('pid'));
const passwordHash = await bcrypt.hash(primaryPasswordHash, 10);
const primaryPasswordHash: string = nintendoPasswordHash(password, pnid.pid);
const passwordHash: string = await bcrypt.hash(primaryPasswordHash, 10);
pnid.password = passwordHash;
@@ -124,4 +128,4 @@ router.post('/', async (request, response) => {
});
});
module.exports = router;
export default router;

View File

@@ -1,127 +0,0 @@
const router = require('express').Router();
const joi = require('joi');
const { PNID } = require('../../../../models/pnid');
const { config } = require('../../../../config-manager');
// TODO: Extend this later with more settings
const userSchema = joi.object({
mii: joi.object({
name: joi.string(),
primary: joi.string(),
data: joi.string(),
})
});
/**
* [GET]
* Implementation of for: https://api.pretendo.cc/v1/user
* Description: Gets PNID details about the current user
*/
router.get('/', async (request, response) => {
const { pnid } = request;
if (!pnid) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing access token'
});
}
return response.json({
access_level: pnid.get('access_level'),
server_access_level: pnid.get('server_access_level'),
pid: pnid.get('pid'),
creation_date: pnid.get('creation_date'),
updated: pnid.get('updated'),
username: pnid.get('username'),
birthdate: pnid.get('birthdate'),
gender: pnid.get('gender'),
country: pnid.get('country'),
email: {
address: pnid.get('email.address'),
},
timezone: {
name: pnid.get('timezone.name')
},
mii: {
data: pnid.get('mii.data'),
name: pnid.get('mii.name'),
image_url: `${config.cdn.base_url}/mii/${pnid.get('pid')}/normal_face.png`
},
flags: {
marketing: pnid.get('flags.marketing')
},
connections: {
discord: {
id: pnid.get('connections.discord.id')
}
}
});
});
/**
* [POST]
* Implementation of for: https://api.pretendo.cc/v1/user
* Description: Updates PNID certain details about the current user
*/
router.post('/', async (request, response) => {
const { body, pnid } = request;
if (!pnid) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing access token'
});
}
const valid = userSchema.validate(body);
if (valid.error) {
return response.status(400).json({
app: 'api',
status: 400,
error: valid.error
});
}
const { pid } = pnid;
const updateData = {};
await PNID.updateOne({ pid }, { $set: updateData }).exec();
return response.json({
access_level: pnid.get('access_level'),
server_access_level: pnid.get('server_access_level'),
pid: pnid.get('pid'),
creation_date: pnid.get('creation_date'),
updated: pnid.get('updated'),
username: pnid.get('username'),
birthdate: pnid.get('birthdate'),
gender: pnid.get('gender'),
country: pnid.get('country'),
email: {
address: pnid.get('email.address'),
},
timezone: {
name: pnid.get('timezone.name')
},
mii: {
data: pnid.get('mii.data'),
name: pnid.get('mii.name'),
image_url: `${config.cdn.base_url}/mii/${pnid.get('pid')}/normal_face.png`
},
flags: {
marketing: pnid.get('flags.marketing')
},
connections: {
discord: {
id: pnid.get('connections.discord.id')
}
}
});
});
module.exports = router;

View File

@@ -0,0 +1,134 @@
import express from 'express';
import joi from 'joi';
//import { PNID } from '@/models/pnid';
import { config } from '@/config-manager';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
import { UpdateUserRequest } from '@/types/services/api/update-user-request';
const router: express.Router = express.Router();
// TODO: Extend this later with more settings
const userSchema: joi.ObjectSchema = joi.object({
mii: joi.object({
name: joi.string(),
primary: joi.string(),
data: joi.string(),
})
});
/**
* [GET]
* Implementation of for: https://api.pretendo.cc/v1/user
* Description: Gets PNID details about the current user
*/
router.get('/', async (request: express.Request, response: express.Response) => {
const pnid: HydratedPNIDDocument | null = request.pnid;
if (!pnid) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing access token'
});
}
return response.json({
access_level: pnid.access_level,
server_access_level: pnid.server_access_level,
pid: pnid.pid,
creation_date: pnid.creation_date,
updated: pnid.updated,
username: pnid.username,
birthdate: pnid.birthdate,
gender: pnid.gender,
country: pnid.country,
email: {
address: pnid.email.address,
},
timezone: {
name: pnid.timezone.name
},
mii: {
data: pnid.mii.data,
name: pnid.mii.name,
image_url: `${config.cdn.base_url}/mii/${pnid.pid}/normal_face.png`
},
flags: {
marketing: pnid.flags.marketing
},
connections: {
discord: {
id: pnid.connections.discord.id
}
}
});
});
/**
* [POST]
* Implementation of for: https://api.pretendo.cc/v1/user
* Description: Updates PNID certain details about the current user
*/
router.post('/', async (request: express.Request, response: express.Response) => {
const pnid: HydratedPNIDDocument | null = request.pnid;
const updateUserRequest: UpdateUserRequest = request.body;
if (!pnid) {
return response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing access token'
});
}
const valid: joi.ValidationResult = userSchema.validate(updateUserRequest);
if (valid.error) {
return response.status(400).json({
app: 'api',
status: 400,
error: valid.error
});
}
// TODO - Make this do something
//const pid: number = pnid.pid;
//const updateData = {};
//await PNID.updateOne({ pid }, { $set: updateData }).exec();
return response.json({
access_level: pnid.access_level,
server_access_level: pnid.server_access_level,
pid: pnid.pid,
creation_date: pnid.creation_date,
updated: pnid.updated,
username: pnid.username,
birthdate: pnid.birthdate,
gender: pnid.gender,
country: pnid.country,
email: {
address: pnid.email.address,
},
timezone: {
name: pnid.timezone.name
},
mii: {
data: pnid.mii.data,
name: pnid.mii.name,
image_url: `${config.cdn.base_url}/mii/${pnid.pid}/normal_face.png`
},
flags: {
marketing: pnid.flags.marketing
},
connections: {
discord: {
id: pnid.connections.discord.id
}
}
});
});
export default router;

View File

@@ -1,22 +0,0 @@
// handles serving assets
const express = require('express');
const subdomain = require('express-subdomain');
const logger = require('../../../logger');
const path = require('path');
// Router to handle the subdomain restriction
const assets = express.Router();
// Setup public folder
logger.info('[assets] Setting up public folder');
assets.use(express.static(path.join(__dirname, '../../assets')));
// Main router for endpoints
const router = express.Router();
// Create subdomains
logger.info('[conntest] Creating \'assets\' subdomain');
router.use(subdomain('assets', assets));
module.exports = router;

View File

@@ -0,0 +1,22 @@
// handles serving assets
import path from 'node:path';
import express from 'express';
import subdomain from 'express-subdomain';
import { LOG_INFO } from '@/logger';
// Router to handle the subdomain restriction
const assets: express.Router = express.Router();
// Setup public folder
LOG_INFO('[assets] Setting up public folder');
assets.use(express.static(path.join(__dirname, '../../assets')));
// Main router for endpoints
const router: express.Router = express.Router();
// Create subdomains
LOG_INFO('[conntest] Creating \'assets\' subdomain');
router.use(subdomain('assets', assets));
export default router;

View File

@@ -1,15 +1,15 @@
// handles conntest endpoints
const express = require('express');
const subdomain = require('express-subdomain');
const logger = require('../../../logger');
import express from 'express';
import subdomain from 'express-subdomain';
import { LOG_INFO } from '@/logger';
// Router to handle the subdomain restriction
const conntest = express.Router();
const conntest: express.Router = express.Router();
// Setup route
logger.info('[conntest] Applying imported routes');
conntest.get('/', async (request, response) => {
LOG_INFO('[conntest] Applying imported routes');
conntest.get('/', async (request: express.Request, response: express.Response) => {
response.set('Content-Type', 'text/html');
response.set('X-Organization', 'Nintendo');
@@ -23,14 +23,14 @@ conntest.get('/', async (request, response) => {
This is test.html page
</body>
</html>
`)
`);
});
// Main router for endpoints
const router = express.Router();
const router: express.Router = express.Router();
// Create subdomains
logger.info('[conntest] Creating \'conntest\' subdomain');
LOG_INFO('[conntest] Creating \'conntest\' subdomain');
router.use(subdomain('conntest', conntest));
module.exports = router;
export default router;

View File

@@ -1,20 +0,0 @@
const express = require('express');
const subdomain = require('express-subdomain');
const logger = require('../../../logger');
const routes = require('./routes');
// Router to handle the subdomain
const datastore = express.Router();
// Setup routes
logger.info('[DATASTORE] Applying imported routes');
datastore.use(routes.UPLOAD);
// Main router for endpoints
const router = express.Router();
// Create subdomains
logger.info('[DATASTORE] Creating \'datastore\' subdomain');
router.use(subdomain('datastore', datastore));
module.exports = router;

View File

@@ -0,0 +1,21 @@
import express from 'express';
import subdomain from 'express-subdomain';
import { LOG_INFO } from '@/logger';
import upload from '@/services/datastore/routes/upload';
// Router to handle the subdomain
const datastore: express.Router = express.Router();
// Setup routes
LOG_INFO('[DATASTORE] Applying imported routes');
datastore.use(upload);
// Main router for endpoints
const router: express.Router = express.Router();
// Create subdomains
LOG_INFO('[DATASTORE] Creating \'datastore\' subdomain');
router.use(subdomain('datastore', datastore));
export default router;

View File

@@ -1,3 +0,0 @@
module.exports = {
UPLOAD: require('./upload.js'),
};

View File

@@ -1,74 +0,0 @@
const crypto = require('crypto');
const router = require('express').Router();
const Dicer = require('dicer');
const fs = require('fs');
const util = require('../../../util');
const signatureSecret = fs.readFileSync(`${__dirname}/../../../../certs/nex/datastore/secret.key`);
function multipartParser(request, response, next) {
const RE_BOUNDARY = /^multipart\/.+?(?:; boundary=(?:(?:"(.+)")|(?:([^\s]+))))$/i;
const RE_FILE_NAME = /name="(.*)"/;
const boundary = RE_BOUNDARY.exec(request.header('content-type'));
const dicer = new Dicer({ boundary: boundary[1] || boundary[2] });
const files = {};
dicer.on('part', part => {
let fileBuffer = Buffer.alloc(0);
let fileName = '';
part.on('header', header => {
fileName = RE_FILE_NAME.exec(header['content-disposition'][0])[1];
});
part.on('data', data => {
fileBuffer = Buffer.concat([fileBuffer, data]);
});
part.on('end', () => {
files[fileName] = fileBuffer;
});
});
dicer.on('finish', function () {
request.files = files;
return next();
});
request.pipe(dicer);
}
router.post('/upload', multipartParser, async (request, response) => {
const {
bucket, // Space name
key, // path
file, // the file content
acl, // S3 ACL
pid, // uploading user PID
date, // upload time
signature, // data signature
} = request.files;
// Signatures only good for 1 minute
const minute = 1000 * 60;
const minuteAgo = Date.now() - minute;
if (Number(date) < Math.floor(minuteAgo / 1000)) {
return response.sendStatus(400);
}
const data = pid.toString() + bucket.toString() + key.toString() + date.toString();
const hmac = crypto.createHmac('sha256', signatureSecret).update(data).digest('hex');
console.log(hmac, signature.toString());
if (hmac !== signature.toString()) {
return response.sendStatus(400);
}
await util.uploadCDNAsset(bucket.toString(), key.toString(), file, acl.toString());
response.sendStatus(200);
});
module.exports = router;

View File

@@ -0,0 +1,99 @@
import fs from 'node:fs';
import crypto from 'node:crypto';
import express from 'express';
import Dicer from 'dicer';
import { uploadCDNAsset } from '@/util';
const router: express.Router = express.Router();
const signatureSecret: Buffer = fs.readFileSync(`${__dirname}/../../../../certs/nex/datastore/secret.key`);
function multipartParser(request: express.Request, response: express.Response, next: express.NextFunction): void {
const RE_BOUNDARY: RegExp = /^multipart\/.+?(?:; boundary=(?:(?:"(.+)")|(?:([^\s]+))))$/i;
const RE_FILE_NAME: RegExp = /name="(.*)"/;
const contentType: string | undefined = request.header('content-type');
if (!contentType) {
return next();
}
const boundary: RegExpExecArray | null = RE_BOUNDARY.exec(contentType);
if (!boundary) {
return next();
}
const dicer: Dicer = new Dicer({ boundary: boundary[1] || boundary[2] });
const files: { [key: string]: Buffer } = {};
dicer.on('part', (part: Dicer.PartStream) => {
let fileBuffer: Buffer = Buffer.alloc(0);
let fileName: string = '';
part.on('header', header => {
const contentDisposition = header['content-disposition' as keyof object];
const regexResult = RE_FILE_NAME.exec(contentDisposition);
if (regexResult) {
fileName = regexResult[0];
}
});
part.on('data', (data: Buffer | string) => {
if (data instanceof String) {
data = Buffer.from(data);
}
fileBuffer = Buffer.concat([fileBuffer, data as Buffer]);
});
part.on('end', () => {
files[fileName] = fileBuffer;
});
});
dicer.on('finish', function () {
request.files = files;
return next();
});
request.pipe(dicer);
}
router.post('/upload', multipartParser, async (request: express.Request, response: express.Response) => {
if (!request.files) {
return response.sendStatus(500);
}
const bucket: string = request.files.bucket.toString();
const key: string = request.files.key.toString();
const file: Buffer = request.files.file;
const acl: string = request.files.acl.toString();
const pid: string = request.files.pid.toString();
const date: string = request.files.date.toString();
const signature: string = request.files.signature.toString();
// Signatures only good for 1 minute
const minute: number = 1000 * 60;
const minuteAgo: number = Date.now() - minute;
if (Number(date) < Math.floor(minuteAgo / 1000)) {
return response.sendStatus(400);
}
const data: string = `${pid}${bucket}${key}${date}`;
const hmac: string = crypto.createHmac('sha256', signatureSecret).update(data).digest('hex');
console.log(hmac, signature);
if (hmac !== signature) {
return response.sendStatus(400);
}
await uploadCDNAsset(bucket, key, file, acl);
response.sendStatus(200);
});
export default router;

View File

@@ -1,30 +0,0 @@
const express = require('express');
const subdomain = require('express-subdomain');
const logger = require('../../../logger');
const { config, disabledFeatures } = require('../../config-manager');
if (!disabledFeatures.s3) {
// * s3 enabled, no need for this
module.exports = express.Router();
return;
}
const routes = require('./routes');
// Router to handle the subdomain
const localcdn = express.Router();
// Setup routes
logger.info('[LOCAL-CDN] Applying imported routes');
localcdn.use(routes.GET);
// Main router for endpoints
const router = express.Router();
// Create subdomains
logger.info(`[LOCAL-CDN] Creating '${config.cdn.subdomain}' subdomain`);
router.use(subdomain(config.cdn.subdomain, localcdn));
module.exports = router;

View File

@@ -0,0 +1,27 @@
import express from 'express';
import subdomain from 'express-subdomain';
import { config, disabledFeatures } from '@/config-manager';
import { LOG_INFO } from '@/logger';
import get from '@/services/local-cdn/routes/get';
const router: express.Router = express.Router();
if (disabledFeatures.s3) {
// * s3 disabled, setup local CDN
// * Router to handle the subdomain
const localcdn: express.Router = express.Router();
// * Setup routes
LOG_INFO('[LOCAL-CDN] Applying imported routes');
localcdn.use(get);
// * Create subdomains
LOG_INFO(`[LOCAL-CDN] Creating '${config.cdn.subdomain}' subdomain`);
router.use(subdomain(config.cdn.subdomain, localcdn));
} else {
LOG_INFO('[LOCAL-CDN] s3 enabled, skipping local CDN');
}
export default router;

View File

@@ -1,16 +0,0 @@
const router = require('express').Router();
const cache = require('../../../cache');
router.get('/*', async (request, response) => {
const filePath = request.params[0];
const file = await cache.getLocalCDNFile(filePath);
if (file) {
response.send(file);
} else {
response.sendStatus(404);
}
});
module.exports = router;

View File

@@ -0,0 +1,18 @@
import express from 'express';
import { getLocalCDNFile } from '@/cache';
const router: express.Router = express.Router();
router.get('/*', async (request: express.Request, response: express.Response) => {
const filePath: string = request.params[0];
const file: Buffer = await getLocalCDNFile(filePath);
if (file) {
response.send(file);
} else {
response.sendStatus(404);
}
});
export default router;

View File

@@ -1,3 +0,0 @@
module.exports = {
GET: require('./get.js'),
};

View File

@@ -1,26 +0,0 @@
// handles NASC endpoints
const express = require('express');
const subdomain = require('express-subdomain');
const NASCMiddleware = require('../../middleware/nasc');
const logger = require('../../../logger');
const routes = require('./routes');
// Router to handle the subdomain restriction
const nasc = express.Router();
logger.info('[NASC] Importing middleware');
nasc.use(NASCMiddleware);
// Setup routes
logger.info('[NASC] Applying imported routes');
nasc.use('/ac', routes.AC);
// Main router for endpoints
const router = express.Router();
// Create subdomains
logger.info('[NASC] Creating \'nasc\' subdomain');
router.use(subdomain('nasc', nasc));
module.exports = router;

View File

@@ -0,0 +1,27 @@
// handles NASC endpoints
import express from 'express';
import subdomain from 'express-subdomain';
import NASCMiddleware from '@/middleware/nasc';
import { LOG_INFO } from '@/logger';
import ac from '@/services/nasc/routes/ac';
// Router to handle the subdomain restriction
const nasc: express.Router = express.Router();
LOG_INFO('[NASC] Importing middleware');
nasc.use(NASCMiddleware);
// Setup routes
LOG_INFO('[NASC] Applying imported routes');
nasc.use('/ac', ac);
// Main router for endpoints
const router: express.Router = express.Router();
// Create subdomains
LOG_INFO('[NASC] Creating \'nasc\' subdomain');
router.use(subdomain('nasc', nasc));
export default router;

View File

@@ -1,106 +0,0 @@
const fs = require('fs-extra');
const express = require('express');
const util = require('../../../util');
const database = require('../../../database');
const cache = require('../../../cache');
const router = express.Router();
/**
* [POST]
* Replacement for: https://nasc.nintendowifi.net/ac
* Description: Gets a NEX server address and token
*/
router.post('/', async (request, response) => {
const requestParams = request.body;
const action = util.nintendoBase64Decode(requestParams.action).toString();
let responseData;
switch (action) {
case 'LOGIN':
responseData = await processLoginRequest(request);
break;
case 'SVCLOC':
responseData = await processServiceTokenRequest(request);
break;
}
response.status(200).send(responseData.toString());
});
/**
*
* @param {express.Request} request
*/
async function processLoginRequest(request) {
const requestParams = request.body;
const titleID = util.nintendoBase64Decode(requestParams.titleid).toString();
const { nexUser } = request;
// TODO: REMOVE AFTER PUBLIC LAUNCH
// LET EVERYONE IN THE `test` FRIENDS SERVER
// THAT WAY EVERYONE CAN GET AN ASSIGNED PID
let serverAccessLevel = 'test';
if (titleID !== '0004013000003202') {
serverAccessLevel = nexUser.get('server_access_level');
}
const server = await database.getServerByTitleId(titleID, serverAccessLevel);
if (!server || !server.service_name || !server.ip || !server.port) {
return util.nascError('110');
}
const { service_name, ip, port } = server;
const cryptoPath = `${__dirname}/../../../../certs/nex/${service_name}`;
const publicKey = await cache.getNEXPublicKey(service_name);
const secretKey = await cache.getNEXSecretKey(service_name);
const cryptoOptions = {
public_key: publicKey,
hmac_secret: secretKey
};
const tokenOptions = {
system_type: 0x2, // 3DS
token_type: 0x3, // nex token,
pid: nexUser.get('pid'),
access_level: 0,
title_id: BigInt(parseInt(titleID, 16)),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
let nexToken = await util.generateToken(cryptoOptions, tokenOptions);
nexToken = util.nintendoBase64Encode(Buffer.from(nexToken, 'base64'));
const params = new URLSearchParams({
locator: util.nintendoBase64Encode(`${ip}:${port}`),
retry: util.nintendoBase64Encode('0'),
returncd: util.nintendoBase64Encode('001'),
token: nexToken,
datetime: util.nintendoBase64Encode(Date.now().toString()),
});
return params;
}
/**
*
* @param {express.Request} request
*/
async function processServiceTokenRequest(request) {
const params = new URLSearchParams({
retry: util.nintendoBase64Encode('0'),
returncd: util.nintendoBase64Encode('007'),
servicetoken: util.nintendoBase64Encode(Buffer.alloc(64).toString()), // hard coded for now
statusdata: util.nintendoBase64Encode('Y'),
svchost: util.nintendoBase64Encode('n/a'),
datetime: util.nintendoBase64Encode(Date.now().toString()),
});
return params;
}
module.exports = router;

View File

@@ -0,0 +1,108 @@
import express from 'express';
import { nintendoBase64Encode, nintendoBase64Decode, nascError, generateToken } from '@/util';
import { getServerByTitleId } from '@/database';
import { TokenOptions } from '@/types/common/token-options';
import { NASCRequestParams } from '@/types/services/nasc/request-params';
import { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account';
import { HydratedServerDocument } from '@/types/mongoose/server';
const router: express.Router = express.Router();
/**
* [POST]
* Replacement for: https://nasc.nintendowifi.net/ac
* Description: Gets a NEX server address and token
*/
router.post('/', async (request: express.Request, response: express.Response) => {
const requestParams: NASCRequestParams = request.body;
const action: string = nintendoBase64Decode(requestParams.action).toString();
const titleID: string = nintendoBase64Decode(requestParams.titleid).toString();
const nexAccount: HydratedNEXAccountDocument | null = request.nexAccount;
let responseData: URLSearchParams = nascError('null');
if (!nexAccount) {
return response.status(200).send(responseData.toString());
}
// TODO: REMOVE AFTER PUBLIC LAUNCH
// * LET EVERYONE IN THE `test` FRIENDS SERVER
// * THAT WAY EVERYONE CAN GET AN ASSIGNED PID
let serverAccessLevel: string = 'test';
if (titleID !== '0004013000003202') {
serverAccessLevel = nexAccount.server_access_level;
}
const server: HydratedServerDocument | null = await getServerByTitleId(titleID, serverAccessLevel);
if (!server || !server.aes_key) {
return response.status(200).send( nascError('110').toString());
}
if (action === 'LOGIN' && server.port <= 0 && server.ip !== '0.0.0.0') {
// * Addresses of 0.0.0.0:0 are allowed
// * They are expected for titles with no NEX server
return response.status(200).send( nascError('110').toString());
}
switch (action) {
case 'LOGIN':
responseData = await processLoginRequest(server, nexAccount.pid, titleID);
break;
case 'SVCLOC':
responseData = await processServiceTokenRequest(server, nexAccount.pid, titleID);
break;
}
response.status(200).send(responseData.toString());
});
async function processLoginRequest(server: HydratedServerDocument, pid: number, titleID: string): Promise<URLSearchParams> {
const tokenOptions: TokenOptions = {
system_type: 0x2, // * 3DS
token_type: 0x3, // * NEX token
pid: pid,
access_level: 0,
title_id: BigInt(parseInt(titleID, 16)),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
// TODO - Handle null tokens
const nexTokenBuffer: Buffer | null = await generateToken(server.aes_key, tokenOptions);
const nexToken: string = nintendoBase64Encode(nexTokenBuffer || '');
return new URLSearchParams({
locator: nintendoBase64Encode(`${server.ip}:${server.port}`),
retry: nintendoBase64Encode('0'),
returncd: nintendoBase64Encode('001'),
token: nexToken,
datetime: nintendoBase64Encode(Date.now().toString()),
});
}
async function processServiceTokenRequest(server: HydratedServerDocument, pid: number, titleID: string): Promise<URLSearchParams> {
const tokenOptions: TokenOptions = {
system_type: 0x2, // * 3DS
token_type: 0x4, // * Service token
pid: pid,
access_level: 0,
title_id: BigInt(parseInt(titleID, 16)),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
// TODO - Handle null tokens
const serviceTokenBuffer: Buffer | null = await generateToken(server.aes_key, tokenOptions);
const serviceToken: string = nintendoBase64Encode(serviceTokenBuffer || '');
return new URLSearchParams({
retry: nintendoBase64Encode('0'),
returncd: nintendoBase64Encode('007'),
servicetoken: serviceToken,
statusdata: nintendoBase64Encode('Y'),
svchost: nintendoBase64Encode('n/a'),
datetime: nintendoBase64Encode(Date.now().toString()),
});
}
export default router;

View File

@@ -1,3 +0,0 @@
module.exports = {
AC: require('./ac')
};

View File

@@ -1,40 +0,0 @@
// handles "account.nintendo.net" endpoints
const express = require('express');
const subdomain = require('express-subdomain');
const clientHeaderCheck = require('../../middleware/client-header');
const cemuMiddleware = require('../../middleware/cemu');
const pnidMiddleware = require('../../middleware/pnid');
const logger = require('../../../logger');
const routes = require('./routes');
// Router to handle the subdomain restriction
const nnid = express.Router();
logger.info('[NNID] Importing middleware');
nnid.use(clientHeaderCheck);
nnid.use(cemuMiddleware);
nnid.use(pnidMiddleware);
// Setup routes
logger.info('[NNID] Applying imported routes');
nnid.use('/v1/api/admin', routes.ADMIN);
nnid.use('/v1/api/content', routes.CONTENT);
nnid.use('/v1/api/devices', routes.DEVICES);
nnid.use('/v1/api/miis', routes.MIIS);
nnid.use('/v1/api/oauth20', routes.OAUTH);
nnid.use('/v1/api/people', routes.PEOPLE);
nnid.use('/v1/api/provider', routes.PROVIDER);
nnid.use('/v1/api/support', routes.SUPPORT);
// Main router for endpoints
const router = express.Router();
// Create subdomains
logger.info('[NNID] Creating \'account\' subdomain');
router.use(subdomain('account', nnid));
logger.info('[NNID] Creating \'c.account\' subdomain');
router.use(subdomain('c.account', nnid));
module.exports = router;

View File

@@ -0,0 +1,48 @@
// handles "account.nintendo.net" endpoints
import express from 'express';
import subdomain from 'express-subdomain';
import clientHeaderCheck from '@/middleware/client-header';
import cemuMiddleware from '@/middleware/cemu';
import pnidMiddleware from '@/middleware/pnid';
import { LOG_INFO } from '@/logger';
import admin from '@/services/nnid/routes/admin';
import content from '@/services/nnid/routes/content';
import devices from '@/services/nnid/routes/devices';
import miis from '@/services/nnid/routes/miis';
import oauth from '@/services/nnid/routes/oauth';
import people from '@/services/nnid/routes/people';
import provider from '@/services/nnid/routes/provider';
import support from '@/services/nnid/routes/support';
// Router to handle the subdomain restriction
const nnid = express.Router();
LOG_INFO('[NNID] Importing middleware');
nnid.use(clientHeaderCheck);
nnid.use(cemuMiddleware);
nnid.use(pnidMiddleware);
// Setup routes
LOG_INFO('[NNID] Applying imported routes');
nnid.use('/v1/api/admin', admin);
nnid.use('/v1/api/content', content);
nnid.use('/v1/api/devices', devices);
nnid.use('/v1/api/miis', miis);
nnid.use('/v1/api/oauth20', oauth);
nnid.use('/v1/api/people', people);
nnid.use('/v1/api/provider', provider);
nnid.use('/v1/api/support', support);
// Main router for endpoints
const router = express.Router();
// Create subdomains
LOG_INFO('[NNID] Creating \'account\' subdomain');
router.use(subdomain('account', nnid));
LOG_INFO('[NNID] Creating \'c.account\' subdomain');
router.use(subdomain('c.account', nnid));
export default router;

View File

@@ -1,59 +0,0 @@
const router = require('express').Router();
const xmlbuilder = require('xmlbuilder');
const { PNID } = require('../../../models/pnid');
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/admin/mapped_ids
* Description: Maps between NNID usernames and PIDs
*/
router.get('/mapped_ids', async (request, response) => {
let { input: inputList, input_type: inputType, output_type: outputType } = request.query;
inputList = inputList.split(',');
inputList = inputList.filter(input => input); // remove nulls
if (inputType === 'user_id') {
inputType = 'usernameLower';
inputList = inputList.map(name => name.toLowerCase());
}
if (outputType === 'user_id') {
outputType = 'username';
}
// This is slower than PNID.where()
// but it ensures that each input
// ALWAYS has an output and filters
// out unwanted input/output types
const results = [];
const allowedTypes = ['pid', 'user_id'];
for (const input of inputList) {
const result = {
in_id: input,
out_id: ''
};
if (allowedTypes.includes(request.query.input_type) && allowedTypes.includes(request.query.output_type)) {
const query = {};
query[inputType] = input;
const searchResult = await PNID.findOne(query);
if (searchResult) {
result.out_id = searchResult.get(outputType);
}
}
results.push(result);
}
response.send(xmlbuilder.create({
mapped_ids: {
mapped_id: results
}
}).end());
});
module.exports = router;

View File

@@ -0,0 +1,106 @@
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import { getValueFromQueryString } from '@/util';
import { PNID } from '@/models/pnid';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
const router: express.Router = express.Router();
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/admin/mapped_ids
* Description: Maps between NNID usernames and PIDs
*/
router.get('/mapped_ids', async (request: express.Request, response: express.Response) => {
const inputType: string | undefined = getValueFromQueryString(request.query, 'input_type');
const outputType: string | undefined = getValueFromQueryString(request.query, 'output_type');
const input: string | undefined = getValueFromQueryString(request.query, 'input');
if (!inputType || !outputType || !input) {
return response.status(400).send(xmlbuilder.create({
errors: {
error: {
cause: 'Bad Request',
code: '1600',
message: 'Unable to process request'
}
}
}).end());
}
let inputList: string[] = input.split(',');
let queryInput: string;
let queryOutput: string;
inputList = inputList.filter(input => input); // * Remove null inputs
if (inputType === 'user_id') {
queryInput = 'usernameLower';
inputList = inputList.map(name => name.toLowerCase());
} else {
queryInput = 'pid';
}
if (outputType === 'user_id') {
queryOutput = 'username';
} else {
queryOutput = 'pid';
}
// * This is slower than PNID.where()
// * but it ensures that each input
// * ALWAYS has an output and filters
// * out unwanted input/output types
const results: {
in_id: string;
out_id: string;
}[] = [];
const allowedTypes: string[] = ['pid', 'user_id'];
for (const input of inputList) {
const result: {
in_id: string;
out_id: string;
} = {
in_id: input,
out_id: ''
};
if (allowedTypes.includes(inputType) && allowedTypes.includes(outputType)) {
const query: {
usernameLower?: string;
pid?: number;
} = {};
if (queryInput === 'usernameLower') {
query.usernameLower = input;
}
if (queryInput === 'pid') {
query.pid = Number(input);
if (isNaN(query.pid)) {
// * Bail early
results.push(result);
continue;
}
}
const searchResult: HydratedPNIDDocument | null = await PNID.findOne(query);
if (searchResult) {
result.out_id = searchResult.get(queryOutput);
}
}
results.push(result);
}
response.send(xmlbuilder.create({
mapped_ids: {
mapped_id: results
}
}).end());
});
export default router;

View File

@@ -1,16 +1,20 @@
const router = require('express').Router();
const xmlbuilder = require('xmlbuilder');
const timezones = require('../timezones.json');
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import timezones from '@/services/nnid/timezones.json';
import { RegionLanguages } from '@/types/services/nnid/region-languages';
import { RegionTimezones } from '@/types/services/nnid/region-timezones';
const router: express.Router = express.Router();
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/content/agreements/TYPE/REGION/VERSION
* Description: Sends the client requested agreement
*/
router.get('/agreements/:type/:region/:version', (request, response) => {
router.get('/agreements/:type/:region/:version', (request: express.Request, response: express.Response) => {
response.set('Content-Type', 'text/xml');
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', new Date().getTime());
response.set('X-Nintendo-Date', new Date().getTime().toString());
response.send(xmlbuilder.create({
agreements: {
@@ -124,10 +128,10 @@ router.get('/agreements/:type/:region/:version', (request, response) => {
* Replacement for: https://account.nintendo.net/v1/api/content/time_zones/COUNTRY/LANGUAGE
* Description: Sends the client the requested timezones
*/
router.get('/time_zones/:countryCode/:language', (request, response) => {
router.get('/time_zones/:countryCode/:language', (request: express.Request, response: express.Response) => {
response.set('Content-Type', 'text/xml');
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', new Date().getTime());
response.set('X-Nintendo-Date', new Date().getTime().toString());
/*
// Old method. Crashes WiiU when sending a list with over 32 entries, but otherwise works
@@ -147,10 +151,11 @@ router.get('/time_zones/:countryCode/:language', (request, response) => {
});
*/
const { countryCode, language } = request.params;
const countryCode: string = request.params.countryCode;
const language: string = request.params.language;
const regionLanguages = timezones[countryCode];
const regionTimezones = regionLanguages[language] ? regionLanguages[language] : Object.values(regionLanguages)[0];
const regionLanguages: RegionLanguages = timezones[countryCode as keyof typeof timezones];
const regionTimezones: RegionTimezones = regionLanguages[language] ? regionLanguages[language] : Object.values(regionLanguages)[0];
response.send(xmlbuilder.create({
timezones: {
@@ -159,4 +164,4 @@ router.get('/time_zones/:countryCode/:language', (request, response) => {
}).end());
});
module.exports = router;
export default router;

View File

@@ -1,15 +0,0 @@
const router = require('express').Router();
const xmlbuilder = require('xmlbuilder');
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/devices/@current/status
* Description: Unknown use
*/
router.get('/@current/status', async (request, response) => {
response.send(xmlbuilder.create({
device: ''
}).end());
});
module.exports = router;

View File

@@ -0,0 +1,18 @@
import express from 'express';
import xmlbuilder from 'xmlbuilder';
const router: express.Router = express.Router();
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/devices/@current/status
* Description: Unknown use
*/
router.get('/@current/status', async (request: express.Request, response: express.Response) => {
// TODO - Finish this
response.send(xmlbuilder.create({
device: ''
}).end());
});
export default router;

View File

@@ -1,10 +0,0 @@
module.exports = {
ADMIN: require('./admin'),
CONTENT: require('./content'),
DEVICES: require('./devices'),
MIIS: require('./miis'),
OAUTH: require('./oauth'),
PEOPLE: require('./people'),
PROVIDER: require('./provider'),
SUPPORT: require('./support'),
};

View File

@@ -1,94 +0,0 @@
const router = require('express').Router();
const xmlbuilder = require('xmlbuilder');
const { PNID } = require('../../../models/pnid');
const { config } = require('../../../config-manager');
/**
* [GET]
* Replacement for: https://account.pretendo.cc/v1/api/miis
* Description: Returns a list of NNID miis
*/
router.get('/', async (request, response) => {
const { pids } = request.query;
const results = await PNID.where('pid', pids);
const miis = [];
for (const user of results) {
const { mii } = user;
const miiImages = [
{
cached_url: `${config.cdn.base_url}/mii/${user.pid}/normal_face.png`,
id: mii.id,
url: `${config.cdn.base_url}/mii/${user.pid}/normal_face.png`,
type: 'standard'
},
{
cached_url: `${config.cdn.base_url}/mii/${user.pid}/frustrated.png`,
id: mii.id,
url: `${config.cdn.base_url}/mii/${user.pid}/frustrated.png`,
type: 'frustrated_face'
},
{
cached_url: `${config.cdn.base_url}/mii/${user.pid}/smile_open_mouth.png`,
id: mii.id,
url: `${config.cdn.base_url}/mii/${user.pid}/smile_open_mouth.png`,
type: 'happy_face'
},
{
cached_url: `${config.cdn.base_url}/mii/${user.pid}/wink_left.png`,
id: mii.id,
url: `${config.cdn.base_url}/mii/${user.pid}/wink_left.png`,
type: 'like_face'
},
{
cached_url: `${config.cdn.base_url}/mii/${user.pid}/normal_face.png`,
id: mii.id,
url: `${config.cdn.base_url}/mii/${user.pid}/normal_face.png`,
type: 'normal_face'
},
{
cached_url: `${config.cdn.base_url}/mii/${user.pid}/sorrow.png`,
id: mii.id,
url: `${config.cdn.base_url}/mii/${user.pid}/sorrow.png`,
type: 'puzzled_face'
},
{
cached_url: `${config.cdn.base_url}/mii/${user.pid}/surprised_open_mouth.png`,
id: mii.id,
url: `${config.cdn.base_url}/mii/${user.pid}/surprised_open_mouth.png`,
type: 'surprised_face'
},
{
cached_url: `${config.cdn.base_url}/mii/${user.pid}/body.png`,
id: mii.id,
url: `${config.cdn.base_url}/mii/${user.pid}/body.png`,
type: 'whole_body'
}
];
miis.push({
data: mii.data.replace(/(\r\n|\n|\r)/gm, ''),
id: mii.id,
images: {
image: miiImages
},
name: mii.name,
pid: user.pid,
primary: mii.primary ? 'Y' : 'N',
user_id: user.username
});
}
//console.log(results[0].mii.data.replace(/(\r\n|\n|\r)/gm, ''));
response.send(xmlbuilder.create({
miis: {
mii: miis
}
}).end());
});
module.exports = router;

View File

@@ -0,0 +1,131 @@
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import { getValueFromQueryString } from '@/util';
import { PNID } from '@/models/pnid';
import { config } from '@/config-manager';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
import { YesNoBoolString } from '@/types/common/yes-no-bool-string';
const router: express.Router = express.Router();
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/miis
* Description: Returns a list of NNID miis
*/
router.get('/', async (request: express.Request, response: express.Response) => {
const input: string | undefined = getValueFromQueryString(request.query, 'pids');
if (!input) {
return response.status(400).send(xmlbuilder.create({
errors: {
error: {
cause: 'Bad Request',
code: '1600',
message: 'Unable to process request'
}
}
}).end());
}
const pids: number[] = input.split(',').map(pid => Number(pid));
const results: HydratedPNIDDocument[] = await PNID.where('pid', pids);
const miis: {
data: string;
id: number;
images: {
image: {
cached_url: string;
id: number;
url: string;
type: string;
}[]
};
name: string;
pid: number;
primary: YesNoBoolString;
user_id: string;
}[] = [];
for (const user of results) {
const mii: {
name: string;
primary: boolean;
data: string;
id: number;
hash: string;
image_url: string;
image_id: number;
} = user.mii;
miis.push({
data: mii.data.replace(/(\r\n|\n|\r)/gm, ''),
id: mii.id,
images: {
image: [
{
cached_url: `${config.cdn.base_url}/mii/${user.pid}/normal_face.png`,
id: mii.id,
url: `${config.cdn.base_url}/mii/${user.pid}/normal_face.png`,
type: 'standard'
},
{
cached_url: `${config.cdn.base_url}/mii/${user.pid}/frustrated.png`,
id: mii.id,
url: `${config.cdn.base_url}/mii/${user.pid}/frustrated.png`,
type: 'frustrated_face'
},
{
cached_url: `${config.cdn.base_url}/mii/${user.pid}/smile_open_mouth.png`,
id: mii.id,
url: `${config.cdn.base_url}/mii/${user.pid}/smile_open_mouth.png`,
type: 'happy_face'
},
{
cached_url: `${config.cdn.base_url}/mii/${user.pid}/wink_left.png`,
id: mii.id,
url: `${config.cdn.base_url}/mii/${user.pid}/wink_left.png`,
type: 'like_face'
},
{
cached_url: `${config.cdn.base_url}/mii/${user.pid}/normal_face.png`,
id: mii.id,
url: `${config.cdn.base_url}/mii/${user.pid}/normal_face.png`,
type: 'normal_face'
},
{
cached_url: `${config.cdn.base_url}/mii/${user.pid}/sorrow.png`,
id: mii.id,
url: `${config.cdn.base_url}/mii/${user.pid}/sorrow.png`,
type: 'puzzled_face'
},
{
cached_url: `${config.cdn.base_url}/mii/${user.pid}/surprised_open_mouth.png`,
id: mii.id,
url: `${config.cdn.base_url}/mii/${user.pid}/surprised_open_mouth.png`,
type: 'surprised_face'
},
{
cached_url: `${config.cdn.base_url}/mii/${user.pid}/body.png`,
id: mii.id,
url: `${config.cdn.base_url}/mii/${user.pid}/body.png`,
type: 'whole_body'
}
]
},
name: mii.name,
pid: user.pid,
primary: mii.primary ? 'Y' : 'N',
user_id: user.username
});
}
response.send(xmlbuilder.create({
miis: {
mii: miis
}
}).end());
});
export default router;

View File

@@ -1,120 +0,0 @@
const router = require('express').Router();
const xmlbuilder = require('xmlbuilder');
const bcrypt = require('bcrypt');
const fs = require('fs-extra');
const database = require('../../../database');
const util = require('../../../util');
/**
* [POST]
* Replacement for: https://account.nintendo.net/v1/api/oauth20/access_token/generate
* Description: Generates an access token for a user
*/
router.post('/access_token/generate', async (request, response) => {
const { body } = request;
const { grant_type, user_id, password } = body;
if (!['password', 'refresh_token'].includes(grant_type)) {
response.status(400);
return response.send(xmlbuilder.create({
error: {
cause: 'grant_type',
code: '0004',
message: 'Invalid Grant Type'
}
}).end());
}
if (!user_id || user_id.trim() === '') {
response.status(400);
return response.send(xmlbuilder.create({
error: {
cause: 'user_id',
code: '0002',
message: 'user_id format is invalid'
}
}).end());
}
if (!password || password.trim() === '') {
response.status(400);
return response.send(xmlbuilder.create({
error: {
cause: 'password',
code: '0002',
message: 'password format is invalid'
}
}).end());
}
const pnid = await database.getUserByUsername(user_id);
if (!pnid || !await bcrypt.compare(password, pnid.password)) {
response.status(400);
return response.send(xmlbuilder.create({
error: {
code: '0106',
message: 'Invalid account ID or password'
}
}).end({ pretty: true }));
}
if (pnid.get('access_level') < 0) {
return response.status(400).send(xmlbuilder.create({
errors: {
error: {
code: '0122',
message: 'Device has been banned by game server'
}
}
}).end());
}
const cryptoPath = `${__dirname}/../../../../certs/service/account`;
if (!await fs.pathExists(cryptoPath)) {
// Need to generate keys
return response.send(xmlbuilder.create({
errors: {
error: {
code: '0000',
message: 'Could not find account access key crypto path'
}
}
}).end());
}
const accessTokenOptions = {
system_type: 0x1, // WiiU
token_type: 0x1, // OAuth Access,
pid: pnid.get('pid'),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const refreshTokenOptions = {
system_type: 0x1, // WiiU
token_type: 0x2, // OAuth Refresh,
pid: pnid.get('pid'),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
let accessToken = await util.generateToken(null, accessTokenOptions);
let refreshToken = await util.generateToken(null, refreshTokenOptions);
if (request.isCemu) {
accessToken = Buffer.from(accessToken, 'base64').toString('hex');
refreshToken = Buffer.from(refreshToken, 'base64').toString('hex');
}
response.send(xmlbuilder.create({
OAuth20: {
access_token: {
token: accessToken,
refresh_token: refreshToken,
expires_in: 3600
}
}
}).end());
});
module.exports = router;

View File

@@ -0,0 +1,111 @@
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import bcrypt from 'bcrypt';
import { getPNIDByUsername } from '@/database';
import { generateToken } from '@/util';
import { config } from '@/config-manager';
import { TokenOptions } from '@/types/common/token-options';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
const router: express.Router = express.Router();
/**
* [POST]
* Replacement for: https://account.nintendo.net/v1/api/oauth20/access_token/generate
* Description: Generates an access token for a user
*/
router.post('/access_token/generate', async (request: express.Request, response: express.Response) => {
const grantType: string = request.body?.grant_type;
const username: string = request.body?.user_id;
const password: string = request.body?.password;
if (!['password', 'refresh_token'].includes(grantType)) {
response.status(400);
return response.send(xmlbuilder.create({
error: {
cause: 'grant_type',
code: '0004',
message: 'Invalid Grant Type'
}
}).end());
}
if (!username || username.trim() === '') {
response.status(400);
return response.send(xmlbuilder.create({
error: {
cause: 'user_id',
code: '0002',
message: 'user_id format is invalid'
}
}).end());
}
if (!password || password.trim() === '') {
response.status(400);
return response.send(xmlbuilder.create({
error: {
cause: 'password',
code: '0002',
message: 'password format is invalid'
}
}).end());
}
const pnid: HydratedPNIDDocument | null = await getPNIDByUsername(username);
if (!pnid || !await bcrypt.compare(password, pnid.password)) {
response.status(400);
return response.send(xmlbuilder.create({
error: {
code: '0106',
message: 'Invalid account ID or password'
}
}).end({ pretty: true }));
}
if (pnid.access_level < 0) {
return response.status(400).send(xmlbuilder.create({
errors: {
error: {
code: '0122',
message: 'Device has been banned by game server'
}
}
}).end());
}
const accessTokenOptions: TokenOptions = {
system_type: 0x1, // * WiiU
token_type: 0x1, // * OAuth Access
pid: pnid.pid,
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const refreshTokenOptions: TokenOptions = {
system_type: 0x1, // * WiiU
token_type: 0x2, // * OAuth Refresh
pid: pnid.pid,
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const accessTokenBuffer: Buffer | null = await generateToken(config.aes_key, accessTokenOptions);
const refreshTokenBuffer: Buffer | null = await generateToken(config.aes_key, refreshTokenOptions);
const accessToken: string = accessTokenBuffer ? accessTokenBuffer.toString('hex') : '';
const refreshToken: string = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : '';
// TODO - Handle null tokens
response.send(xmlbuilder.create({
OAuth20: {
access_token: {
token: accessToken,
refresh_token: refreshToken,
expires_in: 3600
}
}
}).end());
});
export default router;

View File

@@ -1,496 +0,0 @@
const router = require('express').Router();
const xmlbuilder = require('xmlbuilder');
const moment = require('moment');
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const { PNID } = require('../../../models/pnid');
const { NEXAccount } = require('../../../models/nex-account');
const deviceCertificateMiddleware = require('../../../middleware/device-certificate');
const ratelimit = require('../../../middleware/ratelimit');
const database = require('../../../database');
const util = require('../../../util');
const logger = require('../../../../logger');
require('moment-timezone');
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/people/:USERNAME
* Description: Checks if a username is in use
*/
router.get('/:username', async (request, response) => {
const { username } = request.params;
const userExists = await database.doesUserExist(username);
if (userExists) {
response.status(400);
return response.end(xmlbuilder.create({
errors: {
error: {
code: '0100',
message: 'Account ID already exists'
}
}
}).end());
}
response.status(200);
response.end();
});
/**
* [POST]
* Replacement for: https://account.nintendo.net/v1/api/people
* Description: Registers a new NNID
*/
router.post('/', ratelimit, deviceCertificateMiddleware, async (request, response) => {
if (!request.certificate.valid) {
// TODO: Change this to a different error
response.status(400);
return response.send(xmlbuilder.create({
error: {
cause: 'Bad Request',
code: '1600',
message: 'Unable to process request'
}
}).end());
}
const person = request.body.get('person');
const userExists = await database.doesUserExist(person.get('user_id'));
if (userExists) {
response.status(400);
return response.end(xmlbuilder.create({
errors: {
error: {
code: '0100',
message: 'Account ID already exists'
}
}
}).end());
}
const creationDate = moment().format('YYYY-MM-DDTHH:MM:SS');
let pnid;
let nexAccount;
const session = await database.connection.startSession();
await session.startTransaction();
try {
nexAccount = new NEXAccount({
device_type: 'wiiu',
});
await nexAccount.generatePID();
await nexAccount.generatePassword();
// Quick hack to get the PIDs to match
// TODO: Change this maybe?
// NN with a NNID will always use the NNID PID
// even if the provided NEX PID is different
// To fix this we make them the same PID
nexAccount.owning_pid = nexAccount.get('pid');
await nexAccount.save({ session });
const primaryPasswordHash = util.nintendoPasswordHash(person.get('password'), nexAccount.get('pid'));
const passwordHash = await bcrypt.hash(primaryPasswordHash, 10);
pnid = new PNID({
pid: nexAccount.get('pid'),
creation_date: creationDate,
updated: creationDate,
username: person.get('user_id'),
usernameLower: person.get('user_id').toLowerCase(),
password: passwordHash,
birthdate: person.get('birth_date'),
gender: person.get('gender'),
country: person.get('country'),
language: person.get('language'),
email: {
address: person.get('email').get('address').toLowerCase(),
primary: person.get('email').get('primary') === 'Y',
parent: person.get('email').get('parent') === 'Y',
reachable: false,
validated: person.get('email').get('validated') === 'Y',
id: crypto.randomBytes(4).readUInt32LE()
},
region: person.get('region'),
timezone: {
name: person.get('tz_name'),
offset: (moment.tz(person.get('tz_name')).utcOffset() * 60)
},
mii: {
name: person.get('mii').get('name'),
primary: person.get('mii').get('name') === 'Y',
data: person.get('mii').get('data'),
id: crypto.randomBytes(4).readUInt32LE(),
hash: crypto.randomBytes(7).toString('hex'),
image_url: '', // deprecated, will be removed in the future
image_id: crypto.randomBytes(4).readUInt32LE()
},
flags: {
active: true,
marketing: person.get('marketing_flag') === 'Y',
off_device: person.get('off_device_flag') === 'Y'
},
identification: {
email_code: 1, // will be overwritten before saving
email_token: '' // will be overwritten before saving
}
});
await pnid.generateEmailValidationCode();
await pnid.generateEmailValidationToken();
await pnid.generateMiiImages();
await pnid.save({ session });
await session.commitTransaction();
} catch (error) {
logger.error('[POST] /v1/api/people: ' + error);
await session.abortTransaction();
response.status(400);
return response.send(xmlbuilder.create({
error: {
cause: 'Bad Request',
code: '1600',
message: 'Unable to process request'
}
}).end());
} finally {
// * This runs regardless of failure
// * Returning on catch will not prevent this from running
await session.endSession();
}
await util.sendConfirmationEmail(pnid);
response.send(xmlbuilder.create({
person: {
pid: pnid.get('pid')
}
}).end());
});
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/profile
* Description: Gets a users profile
*/
router.get('/@me/profile', async (request, response) => {
response.set('Content-Type', 'text/xml');
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', new Date().getTime());
const { pnid } = request;
const person = await database.getUserProfileJSONByPID(pnid.get('pid'));
response.send(xmlbuilder.create({
person
}, { separateArrayItems: true }).end());
});
/**
* [POST]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/devices
* Description: Gets user profile, seems to be the same as https://account.nintendo.net/v1/api/people/@me/profile
*/
router.post('/@me/devices', async (request, response) => {
response.set('Content-Type', 'text/xml');
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', new Date().getTime());
// We don't care about the device attributes
// The console ignores them and PNIDs are not tied to consoles anyway
// So the server also ignores them and does not save the ones posted here
const { pnid } = request;
const person = await database.getUserProfileJSONByPID(pnid.get('pid'));
response.send(xmlbuilder.create({
person
}).end());
});
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/devices
* Description: Returns only user devices
*/
router.get('/@me/devices', async (request, response) => {
response.set('Content-Type', 'text/xml');
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', new Date().getTime());
const { pnid, headers } = request;
response.send(xmlbuilder.create({
devices: [
{
device: {
device_id: headers['x-nintendo-device-id'],
language: headers['accept-language'],
updated: moment().format('YYYY-MM-DDTHH:MM:SS'),
pid: pnid.get('pid'),
platform_id: headers['x-nintendo-platform-id'],
region: headers['x-nintendo-region'],
serial_number: headers['x-nintendo-serial-number'],
status: 'ACTIVE',
system_version: headers['x-nintendo-system-version'],
type: 'RETAIL',
updated_by: 'USER'
}
}
]
}).end());
});
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/devices/owner
* Description: Gets user profile, seems to be the same as https://account.nintendo.net/v1/api/people/@me/profile
*/
router.get('/@me/devices/owner', async (request, response) => {
response.set('Content-Type', 'text/xml');
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', moment().add(5, 'h'));
const { pnid } = request;
const person = await database.getUserProfileJSONByPID(pnid.get('pid'));
response.send(xmlbuilder.create({
person
}).end());
});
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/devices/status
* Description: Unknown use
*/
router.get('/@me/devices/status', async (request, response) => {
response.set('Content-Type', 'text/xml');
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', moment().add(5, 'h'));
response.send(xmlbuilder.create({
device: {}
}).end());
});
/**
* [PUT]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/miis/@primary
* Description: Updates a users Mii
*/
router.put('/@me/miis/@primary', async (request, response) => {
const { pnid } = request;
const mii = request.body.get('mii');
const [name, primary, data] = [mii.get('name'), mii.get('primary'), mii.get('data')];
await pnid.updateMii({ name, primary, data });
response.send('');
});
/**
* [PUT]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/devices/@current/inactivate
* Description: Deactivates a user from a console
*/
router.put('/@me/devices/@current/inactivate', async (request, response) => {
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', new Date().getTime());
const { pnid } = request;
if (!pnid) {
response.status(400);
return response.end(xmlbuilder.create({
errors: {
error: {
cause: 'access_token',
code: '0002',
message: 'Invalid access token'
}
}
}).end());
}
response.status(200);
response.end();
});
/**
* [POST]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/deletion
* Description: Deletes a NNID
*/
router.put('/@me/deletion', async (request, response) => {
const { pnid } = request;
if (!pnid) {
response.status(400);
return response.end(xmlbuilder.create({
errors: {
error: {
cause: 'access_token',
code: '0002',
message: 'Invalid access token'
}
}
}).end());
}
await PNID.deleteOne({ pid: pnid.get('pid') });
response.send('');
});
/**
* [PUT]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/
* Description: Updates a PNIDs account details
*/
router.put('/@me', async (request, response) => {
const { pnid } = request;
const person = request.body.get('person');
if (!pnid) {
response.status(400);
return response.end(xmlbuilder.create({
errors: {
error: {
cause: 'access_token',
code: '0002',
message: 'Invalid access token'
}
}
}).end());
}
const gender = person.get('gender') ? person.get('gender') : pnid.get('gender');
const region = person.get('region') ? person.get('region') : pnid.get('region');
const timezoneName = person.get('tz_name') ? person.get('tz_name') : pnid.get('timezone.name');
const marketingFlag = person.get('marketing_flag') ? person.get('marketing_flag') === 'Y' : pnid.get('flags.marketing');
const offDeviceFlag = person.get('off_device_flag') ? person.get('off_device_flag') === 'Y' : pnid.get('flags.off_device');
if (person.get('password')) {
const primaryPasswordHash = util.nintendoPasswordHash(person.get('password'), pnid.get('pid'));
const passwordHash = await bcrypt.hash(primaryPasswordHash, 10);
pnid.password = passwordHash;
}
pnid.gender = gender;
pnid.region = region;
pnid.timezone.name = timezoneName;
pnid.timezone.offset = (moment.tz(timezoneName).utcOffset() * 60);
pnid.timezone.marketing = marketingFlag;
pnid.timezone.off_device = offDeviceFlag;
await pnid.save();
response.send('');
});
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/emails/
* Description: Gets a list (why?) of PNID emails
*/
router.get('/@me/emails', async (request, response) => {
const { pnid } = request;
if (!pnid) {
response.status(400);
return response.end(xmlbuilder.create({
errors: {
error: {
cause: 'access_token',
code: '0002',
message: 'Invalid access token'
}
}
}).end());
}
response.send(xmlbuilder.create({
emails: [
{
email: {
address: pnid.get('email.address'),
id: pnid.get('email.id'),
parent: pnid.get('email.parent') ? 'Y' : 'N',
primary: pnid.get('email.primary') ? 'Y' : 'N',
reachable: pnid.get('email.reachable') ? 'Y' : 'N',
type: 'DEFAULT', // what is this?
updated_by: 'USER', // need to actually update this
validated: pnid.get('email.validated') ? 'Y' : 'N',
validated_date: pnid.get('email.validated_date'),
}
}
]
}).end());
});
/**
* [PUT]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/emails/@primary
* Description: Updates a users email address
*/
router.put('/@me/emails/@primary', async (request, response) => {
const { pnid } = request;
const email = request.body.get('email');
if (!pnid) {
response.status(400);
return response.end(xmlbuilder.create({
errors: {
error: {
cause: 'access_token',
code: '0002',
message: 'Invalid access token'
}
}
}).end());
}
pnid.set('email.address', email.get('address').toLowerCase());
pnid.set('email.reachable', false);
pnid.set('email.validated', false);
pnid.set('email.validated_date', '');
pnid.set('email.id', crypto.randomBytes(4).readUInt32LE());
await pnid.generateEmailValidationCode();
await pnid.generateEmailValidationToken();
await pnid.save();
await util.sendConfirmationEmail(pnid);
response.send('');
});
module.exports = router;

View File

@@ -0,0 +1,678 @@
import crypto from 'node:crypto';
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import bcrypt from 'bcrypt';
import moment from 'moment';
import mongoose from 'mongoose';
import deviceCertificateMiddleware from '@/middleware/device-certificate';
import ratelimit from '@/middleware/ratelimit';
import { connection as databaseConnection, doesPNIDExist, getPNIDProfileJSONByPID } from '@/database';
import { getValueFromHeaders, nintendoPasswordHash, sendConfirmationEmail } from '@/util';
import { PNID } from '@/models/pnid';
import { NEXAccount } from '@/models/nex-account';
import { LOG_ERROR } from '@/logger';
import timezones from '@/services/nnid/timezones.json';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
import { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account';
import { RegionLanguages } from '@/types/services/nnid/region-languages';
import { RegionTimezone, RegionTimezones } from '@/types/services/nnid/region-timezones';
import { Person } from '@/types/services/nnid/person';
import { PNIDProfile } from '@/types/services/nnid/pnid-profile';
const router: express.Router = express.Router();
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/people/:USERNAME
* Description: Checks if a username is in use
*/
router.get('/:username', async (request: express.Request, response: express.Response) => {
const username: string = request.params.username;
const userExists: boolean = await doesPNIDExist(username);
if (userExists) {
response.status(400);
return response.end(xmlbuilder.create({
errors: {
error: {
code: '0100',
message: 'Account ID already exists'
}
}
}).end());
}
response.status(200);
response.end();
});
/**
* [POST]
* Replacement for: https://account.nintendo.net/v1/api/people
* Description: Registers a new NNID
*/
router.post('/', ratelimit, deviceCertificateMiddleware, async (request: express.Request, response: express.Response) => {
if (!request.certificate || !request.certificate.valid) {
// TODO: Change this to a different error
response.status(400);
return response.send(xmlbuilder.create({
error: {
cause: 'Bad Request',
code: '1600',
message: 'Unable to process request'
}
}).end());
}
const person: Person = request.body.person;
const userExists: boolean = await doesPNIDExist(person.user_id);
if (userExists) {
response.status(400);
return response.end(xmlbuilder.create({
errors: {
error: {
code: '0100',
message: 'Account ID already exists'
}
}
}).end());
}
const creationDate: string = moment().format('YYYY-MM-DDTHH:MM:SS');
let pnid: HydratedPNIDDocument;
let nexAccount: HydratedNEXAccountDocument;
const session: mongoose.ClientSession = await databaseConnection().startSession();
await session.startTransaction();
try {
nexAccount = new NEXAccount({
device_type: 'wiiu',
});
await nexAccount.generatePID();
await nexAccount.generatePassword();
// Quick hack to get the PIDs to match
// TODO: Change this maybe?
// NN with a NNID will always use the NNID PID
// even if the provided NEX PID is different
// To fix this we make them the same PID
nexAccount.owning_pid = nexAccount.pid;
await nexAccount.save({ session });
const primaryPasswordHash: string = nintendoPasswordHash(person.password, nexAccount.pid);
const passwordHash: string = await bcrypt.hash(primaryPasswordHash, 10);
const countryCode: string = person.country;
const language: string = person.language;
const timezoneName: string = person.tz_name;
const regionLanguages: RegionLanguages = timezones[countryCode as keyof typeof timezones];
const regionTimezones: RegionTimezones = regionLanguages[language] ? regionLanguages[language] : Object.values(regionLanguages)[0];
let timezone: RegionTimezone | undefined = regionTimezones.find(tz => tz.area === timezoneName);
if (!timezone) {
// TODO - Change this, handle the error
timezone = {
area: 'America/New_York',
language: 'en',
name: 'Eastern Time (US &amp; Canada)',
order: '11',
utc_offset: '-14400'
};
}
pnid = new PNID({
pid: nexAccount.pid,
creation_date: creationDate,
updated: creationDate,
username: person.user_id,
usernameLower: person.user_id.toLowerCase(),
password: passwordHash,
birthdate: person.birth_date,
gender: person.gender,
country: countryCode,
language: language,
email: {
address: person.email.address.toLowerCase(),
primary: person.email.primary === 'Y',
parent: person.email.parent === 'Y',
reachable: false,
validated: person.email.validated === 'Y',
id: crypto.randomBytes(4).readUInt32LE()
},
region: person.region,
timezone: {
name: timezoneName,
offset: Number(timezone.utc_offset)
},
mii: {
name: person.mii.name,
primary: person.mii.name === 'Y',
data: person.mii.data,
id: crypto.randomBytes(4).readUInt32LE(),
hash: crypto.randomBytes(7).toString('hex'),
image_url: '', // deprecated, will be removed in the future
image_id: crypto.randomBytes(4).readUInt32LE()
},
flags: {
active: true,
marketing: person.marketing_flag === 'Y',
off_device: person.off_device_flag === 'Y'
},
identification: {
email_code: 1, // will be overwritten before saving
email_token: '' // will be overwritten before saving
}
});
await pnid.generateEmailValidationCode();
await pnid.generateEmailValidationToken();
await pnid.generateMiiImages();
await pnid.save({ session });
await session.commitTransaction();
} catch (error) {
LOG_ERROR('[POST] /v1/api/people: ' + error);
await session.abortTransaction();
response.status(400);
return response.send(xmlbuilder.create({
error: {
cause: 'Bad Request',
code: '1600',
message: 'Unable to process request'
}
}).end());
} finally {
// * This runs regardless of failure
// * Returning on catch will not prevent this from running
await session.endSession();
}
await sendConfirmationEmail(pnid);
response.send(xmlbuilder.create({
person: {
pid: pnid.pid
}
}).end());
});
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/profile
* Description: Gets a users profile
*/
router.get('/@me/profile', async (request: express.Request, response: express.Response) => {
response.set('Content-Type', 'text/xml');
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', new Date().getTime().toString());
const pnid: HydratedPNIDDocument | null = request.pnid;
if (!pnid) {
// TODO - Research this error more
return response.status(404).send(xmlbuilder.create({
errors: {
error: {
cause: '',
code: '0008',
message: 'Not Found'
}
}
}).end());
}
const person: PNIDProfile | null = await getPNIDProfileJSONByPID(pnid.pid);
if (!person) {
// TODO - Research this error more
return response.status(404).send(xmlbuilder.create({
errors: {
error: {
cause: '',
code: '0008',
message: 'Not Found'
}
}
}).end());
}
response.send(xmlbuilder.create({
person
}, { separateArrayItems: true }).end());
});
/**
* [POST]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/devices
* Description: Gets user profile, seems to be the same as https://account.nintendo.net/v1/api/people/@me/profile
*/
router.post('/@me/devices', async (request: express.Request, response: express.Response) => {
response.set('Content-Type', 'text/xml');
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', new Date().getTime().toString());
// We don't care about the device attributes
// The console ignores them and PNIDs are not tied to consoles anyway
// So the server also ignores them and does not save the ones posted here
// TODO - CHANGE THIS. WE NEED TO SAVE CONSOLE DETAILS !!!
const pnid: HydratedPNIDDocument | null = request.pnid;
if (!pnid) {
// TODO - Research this error more
return response.status(404).send(xmlbuilder.create({
errors: {
error: {
cause: '',
code: '0008',
message: 'Not Found'
}
}
}).end());
}
const person: PNIDProfile | null = await getPNIDProfileJSONByPID(pnid.pid);
if (!person) {
// TODO - Research this error more
return response.status(404).send(xmlbuilder.create({
errors: {
error: {
cause: '',
code: '0008',
message: 'Not Found'
}
}
}).end());
}
response.send(xmlbuilder.create({
person
}).end());
});
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/devices
* Description: Returns only user devices
*/
router.get('/@me/devices', async (request: express.Request, response: express.Response) => {
response.set('Content-Type', 'text/xml');
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', new Date().getTime().toString());
const pnid: HydratedPNIDDocument | null = request.pnid;
const deviceId: string | undefined = getValueFromHeaders(request.headers, 'x-nintendo-device-id');
const acceptLanguage: string | undefined = getValueFromHeaders(request.headers, 'accept-language');
const platformId: string | undefined = getValueFromHeaders(request.headers, 'x-nintendo-platform-id');
const region: string | undefined = getValueFromHeaders(request.headers, 'x-nintendo-region');
const serialNumber: string | undefined = getValueFromHeaders(request.headers, 'x-nintendo-serial-number');
const systemVersion: string | undefined = getValueFromHeaders(request.headers, 'x-nintendo-system-version');
if (!deviceId || !acceptLanguage || !platformId || !region || !serialNumber || !systemVersion) {
// TODO - Research these error more
return response.status(400).send(xmlbuilder.create({
errors: {
error: {
cause: 'Bad Request',
code: '1600',
message: 'Unable to process request'
}
}
}).end());
}
if (!pnid) {
// TODO - Research this error more
return response.status(404).send(xmlbuilder.create({
errors: {
error: {
cause: '',
code: '0008',
message: 'Not Found'
}
}
}).end());
}
response.send(xmlbuilder.create({
devices: [
{
device: {
device_id: deviceId,
language: acceptLanguage,
updated: moment().format('YYYY-MM-DDTHH:MM:SS'),
pid: pnid.pid,
platform_id: platformId,
region: region,
serial_number: serialNumber,
status: 'ACTIVE',
system_version: systemVersion,
type: 'RETAIL',
updated_by: 'USER'
}
}
]
}).end());
});
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/devices/owner
* Description: Gets user profile, seems to be the same as https://account.nintendo.net/v1/api/people/@me/profile
*/
router.get('/@me/devices/owner', async (request: express.Request, response: express.Response) => {
response.set('Content-Type', 'text/xml');
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', moment().add(5, 'h').toString());
const pnid: HydratedPNIDDocument | null = request.pnid;
if (!pnid) {
// TODO - Research this error more
return response.status(404).send(xmlbuilder.create({
errors: {
error: {
cause: '',
code: '0008',
message: 'Not Found'
}
}
}).end());
}
const person: PNIDProfile | null = await getPNIDProfileJSONByPID(pnid.pid);
if (!person) {
// TODO - Research this error more
return response.status(404).send(xmlbuilder.create({
errors: {
error: {
cause: '',
code: '0008',
message: 'Not Found'
}
}
}).end());
}
response.send(xmlbuilder.create({
person
}).end());
});
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/devices/status
* Description: Unknown use
*/
router.get('/@me/devices/status', async (_request: express.Request, response: express.Response) => {
response.set('Content-Type', 'text/xml');
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', moment().add(5, 'h').toString());
response.send(xmlbuilder.create({
device: {}
}).end());
});
/**
* [PUT]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/miis/@primary
* Description: Updates a users Mii
*/
router.put('/@me/miis/@primary', async (request: express.Request, response: express.Response) => {
const pnid: HydratedPNIDDocument | null = request.pnid;
if (!pnid) {
// TODO - Research this error more
return response.status(404).send(xmlbuilder.create({
errors: {
error: {
cause: '',
code: '0008',
message: 'Not Found'
}
}
}).end());
}
const mii: {
name: string;
primary: string;
data: string;
} = request.body.mii;
// TODO - Better checks
const name: string = mii.name;
const primary: string = mii.primary;
const data: string = mii.data;
await pnid.updateMii({ name, primary, data });
response.send('');
});
/**
* [PUT]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/devices/@current/inactivate
* Description: Deactivates a user from a console
*/
router.put('/@me/devices/@current/inactivate', async (request: express.Request, response: express.Response) => {
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', new Date().getTime().toString());
const pnid: HydratedPNIDDocument | null = request.pnid;
if (!pnid) {
response.status(400);
return response.end(xmlbuilder.create({
errors: {
error: {
cause: 'access_token',
code: '0002',
message: 'Invalid access token'
}
}
}).end());
}
response.status(200);
response.end();
});
/**
* [POST]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/deletion
* Description: Deletes a NNID
*/
router.put('/@me/deletion', async (request: express.Request, response: express.Response) => {
const pnid: HydratedPNIDDocument | null = request.pnid;
if (!pnid) {
response.status(400);
return response.end(xmlbuilder.create({
errors: {
error: {
cause: 'access_token',
code: '0002',
message: 'Invalid access token'
}
}
}).end());
}
response.send('');
});
/**
* [PUT]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/
* Description: Updates a PNIDs account details
*/
router.put('/@me', async (request: express.Request, response: express.Response) => {
const pnid: HydratedPNIDDocument | null = request.pnid;
const person: Person = request.body.person;
if (!pnid) {
response.status(400);
return response.end(xmlbuilder.create({
errors: {
error: {
cause: 'access_token',
code: '0002',
message: 'Invalid access token'
}
}
}).end());
}
const gender: string = person.gender ? person.gender : pnid.gender;
const region: number = person.region ? person.region : pnid.region;
const countryCode: string = person.country ? person.country : pnid.country;
const language: string = person.language ? person.language : pnid.language;
const timezoneName: string = person.tz_name ? person.tz_name : pnid.timezone.name;
const marketingFlag: boolean = person.marketing_flag ? person.marketing_flag === 'Y' : pnid.flags.marketing;
const offDeviceFlag: boolean = person.off_device_flag ? person.off_device_flag === 'Y' : pnid.flags.off_device;
const regionLanguages: RegionLanguages = timezones[countryCode as keyof typeof timezones];
const regionTimezones: RegionTimezones = regionLanguages[language] ? regionLanguages[language] : Object.values(regionLanguages)[0];
let timezone: RegionTimezone | undefined = regionTimezones.find(tz => tz.area === timezoneName);
if (!timezone) {
// TODO - Change this, handle the error
timezone = {
area: 'America/New_York',
language: 'en',
name: 'Eastern Time (US &amp; Canada)',
order: '11',
utc_offset: '-14400'
};
}
if (person.password) {
const primaryPasswordHash: string = nintendoPasswordHash(person.password, pnid.pid);
const passwordHash: string = await bcrypt.hash(primaryPasswordHash, 10);
pnid.password = passwordHash;
}
pnid.gender = gender;
pnid.region = region;
pnid.timezone.name = timezoneName;
pnid.timezone.offset = Number(timezone.utc_offset);
pnid.timezone.marketing = marketingFlag;
pnid.timezone.off_device = offDeviceFlag;
await pnid.save();
response.send('');
});
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/emails/
* Description: Gets a list (why?) of PNID emails
*/
router.get('/@me/emails', async (request: express.Request, response: express.Response) => {
const pnid: HydratedPNIDDocument | null = request.pnid;
if (!pnid) {
response.status(400);
return response.end(xmlbuilder.create({
errors: {
error: {
cause: 'access_token',
code: '0002',
message: 'Invalid access token'
}
}
}).end());
}
response.send(xmlbuilder.create({
emails: [
{
email: {
address: pnid.email.address,
id: pnid.email.id,
parent: pnid.email.parent ? 'Y' : 'N',
primary: pnid.email.primary ? 'Y' : 'N',
reachable: pnid.email.reachable ? 'Y' : 'N',
type: 'DEFAULT', // what is this?
updated_by: 'USER', // need to actually update this
validated: pnid.email.validated ? 'Y' : 'N',
validated_date: pnid.email.validated_date,
}
}
]
}).end());
});
/**
* [PUT]
* Replacement for: https://account.nintendo.net/v1/api/people/@me/emails/@primary
* Description: Updates a users email address
*/
router.put('/@me/emails/@primary', async (request: express.Request, response: express.Response) => {
const pnid: HydratedPNIDDocument | null = request.pnid;
const email: {
address: string;
} = request.body.email;
if (!pnid || !email || !email.address) {
response.status(400);
return response.end(xmlbuilder.create({
errors: {
error: {
cause: 'access_token',
code: '0002',
message: 'Invalid access token'
}
}
}).end());
}
// TODO - Better email check
pnid.email.address = email.address.toLowerCase();
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 pnid.save();
await sendConfirmationEmail(pnid);
response.send('');
});
export default router;

View File

@@ -1,172 +0,0 @@
const router = require('express').Router();
const xmlbuilder = require('xmlbuilder');
const fs = require('fs-extra');
const { NEXAccount } = require('../../../models/nex-account');
const util = require('../../../util');
const database = require('../../../database');
const cache = require('../../../cache');
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/provider/service_token/@me
* Description: Gets a service token
*/
router.get('/service_token/@me', async (request, response) => {
const { pnid } = request;
const titleId = request.headers['x-nintendo-title-id'];
const serverAccessLevel = pnid.get('server_access_level');
const server = await database.getServerByTitleId(titleId, serverAccessLevel);
if (!server) {
return response.send(xmlbuilder.create({
errors: {
error: {
code: '1021',
message: 'The requested game server was not found'
}
}
}).end());
}
const { service_name, service_type, device } = server;
const cryptoPath = `${__dirname}/../../../../certs/${service_type}/${service_name}`;
if (!await fs.pathExists(cryptoPath)) {
// Need to generate keys
return response.send(xmlbuilder.create({
errors: {
error: {
code: '1021',
message: 'The requested game server was not found'
}
}
}).end());
}
const publicKey = await cache.getServicePublicKey(service_name);
const secretKey = await cache.getServiceSecretKey(service_name);
const cryptoOptions = {
public_key: publicKey,
hmac_secret: secretKey
};
const tokenOptions = {
system_type: device,
token_type: 0x4, // service token,
pid: pnid.get('pid'),
access_level: pnid.get('access_level'),
title_id: BigInt(parseInt(titleId, 16)),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
let serviceToken = await util.generateToken(cryptoOptions, tokenOptions);
if (request.isCemu) {
serviceToken = Buffer.from(serviceToken, 'base64').toString('hex');
}
response.send(xmlbuilder.create({
service_token: {
token: serviceToken
}
}).end());
});
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/provider/nex_token/@me
* Description: Gets a NEX server address and token
*/
router.get('/nex_token/@me', async (request, response) => {
const { game_server_id: gameServerID } = request.query;
const { pnid } = request;
if (!gameServerID) {
return response.send(xmlbuilder.create({
errors: {
error: {
code: '0118',
message: 'Unique ID and Game Server ID are not linked'
}
}
}).end());
}
const serverAccessLevel = pnid.get('server_access_level');
const server = await database.getServer(gameServerID, serverAccessLevel);
if (!server) {
return response.send(xmlbuilder.create({
errors: {
error: {
code: '1021',
message: 'The requested game server was not found'
}
}
}).end());
}
const { service_name, service_type, ip, port, device } = server;
const titleId = request.headers['x-nintendo-title-id'];
const cryptoPath = `${__dirname}/../../../../certs/${service_type}/${service_name}`;
if (!await fs.pathExists(cryptoPath)) {
// Need to generate keys
return response.send(xmlbuilder.create({
errors: {
error: {
code: '1021',
message: 'The requested game server was not found'
}
}
}).end());
}
const publicKey = await cache.getNEXPublicKey(service_name);
const secretKey= await cache.getNEXSecretKey(service_name);
const cryptoOptions = {
public_key: publicKey,
hmac_secret: secretKey
};
const tokenOptions = {
system_type: device,
token_type: 0x3, // nex token,
pid: pnid.get('pid'),
access_level: pnid.get('access_level'),
title_id: BigInt(parseInt(titleId, 16)),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const nexUser = await NEXAccount.findOne({
owning_pid: pnid.get('pid')
});
if (!nexUser) {
response.status(404);
return response.send('<errors><error><cause/><code>0008</code><message>Not Found</message></error></errors>');
}
let nexToken = await util.generateToken(cryptoOptions, tokenOptions);
if (request.isCemu) {
nexToken = Buffer.from(nexToken, 'base64').toString('hex');
}
response.send(xmlbuilder.create({
nex_token: {
host: ip,
nex_password: nexUser.get('password'),
pid: nexUser.get('pid'),
port: port,
token: nexToken
}
}).end());
});
module.exports = router;

View File

@@ -0,0 +1,192 @@
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import { getServerByTitleId, getServerByGameServerId } from '@/database';
import { generateToken, getValueFromHeaders, getValueFromQueryString } from '@/util';
import { NEXAccount } from '@/models/nex-account';
import { TokenOptions } from '@/types/common/token-options';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
import { HydratedServerDocument } from '@/types/mongoose/server';
import { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account';
const router: express.Router = express.Router();
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/provider/service_token/@me
* Description: Gets a service token
*/
router.get('/service_token/@me', async (request: express.Request, response: express.Response) => {
const pnid: HydratedPNIDDocument | null = request.pnid;
if (!pnid) {
response.status(400);
return response.end(xmlbuilder.create({
errors: {
error: {
cause: 'access_token',
code: '0002',
message: 'Invalid access token'
}
}
}).end());
}
const titleID: string | undefined = getValueFromHeaders(request.headers, 'x-nintendo-title-id');
if (!titleID) {
// TODO - Research this error more
return response.send(xmlbuilder.create({
errors: {
error: {
code: '1021',
message: 'The requested game server was not found'
}
}
}).end());
}
const serverAccessLevel: string = pnid.server_access_level;
const server: HydratedServerDocument | null = await getServerByTitleId(titleID, serverAccessLevel);
if (!server || !server.aes_key) {
return response.send(xmlbuilder.create({
errors: {
error: {
code: '1021',
message: 'The requested game server was not found'
}
}
}).end());
}
const tokenOptions: TokenOptions = {
system_type: server.device,
token_type: 0x4, // * Service token
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(parseInt(titleID, 16)),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const serviceTokenBuffer: Buffer | null = await generateToken(server.aes_key, tokenOptions);
let serviceToken: string = serviceTokenBuffer ? serviceTokenBuffer.toString('base64') : '';
if (request.isCemu) {
serviceToken = Buffer.from(serviceToken, 'base64').toString('hex');
}
response.send(xmlbuilder.create({
service_token: {
token: serviceToken
}
}).end());
});
/**
* [GET]
* Replacement for: https://account.nintendo.net/v1/api/provider/nex_token/@me
* Description: Gets a NEX server address and token
*/
router.get('/nex_token/@me', async (request: express.Request, response: express.Response) => {
const pnid: HydratedPNIDDocument | null = request.pnid;
if (!pnid) {
response.status(400);
return response.end(xmlbuilder.create({
errors: {
error: {
cause: 'access_token',
code: '0002',
message: 'Invalid access token'
}
}
}).end());
}
const nexAccount: HydratedNEXAccountDocument | null = await NEXAccount.findOne({
owning_pid: pnid.pid
});
if (!nexAccount) {
return response.status(404).send(xmlbuilder.create({
errors: {
error: {
cause: '',
code: '0008',
message: 'Not Found'
}
}
}).end());
}
const gameServerID: string | undefined = getValueFromQueryString(request.query, 'game_server_id');
if (!gameServerID) {
return response.send(xmlbuilder.create({
errors: {
error: {
code: '0118',
message: 'Unique ID and Game Server ID are not linked'
}
}
}).end());
}
const serverAccessLevel: string = pnid.server_access_level;
const server: HydratedServerDocument | null = await getServerByGameServerId(gameServerID, serverAccessLevel);
if (!server || !server.aes_key) {
return response.send(xmlbuilder.create({
errors: {
error: {
code: '1021',
message: 'The requested game server was not found'
}
}
}).end());
}
const titleID: string | undefined = getValueFromHeaders(request.headers, 'x-nintendo-title-id');
if (!titleID) {
// TODO - Research this error more
return response.send(xmlbuilder.create({
errors: {
error: {
code: '1021',
message: 'The requested game server was not found'
}
}
}).end());
}
const tokenOptions: TokenOptions = {
system_type: server.device,
token_type: 0x3, // nex token,
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(parseInt(titleID, 16)),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const nexTokenBuffer: Buffer | null = await generateToken(server.aes_key, tokenOptions);
let nexToken: string = nexTokenBuffer ? nexTokenBuffer.toString('base64') : '';
if (request.isCemu) {
nexToken = Buffer.from(nexToken || '', 'base64').toString('hex');
}
response.send(xmlbuilder.create({
nex_token: {
host: server.ip,
nex_password: nexAccount.password,
pid: nexAccount.pid,
port: server.port,
token: nexToken
}
}).end());
});
export default router;

View File

@@ -1,17 +1,20 @@
const router = require('express').Router();
const dns = require('dns');
const xmlbuilder = require('xmlbuilder');
const moment = require('moment');
const util = require('../../../util');
const database = require('../../../database');
import dns from 'node:dns';
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import moment from 'moment';
import { getPNIDByPID } from '@/database';
import { sendEmailConfirmedEmail, sendConfirmationEmail, sendForgotPasswordEmail } from '@/util';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
const router: express.Router = express.Router();
/**
* [POST]
* Replacement for: https://account.nintendo.net/v1/api/support/validate/email
* Description: Verifies a provided email address is valid
*/
router.post('/validate/email', async (request, response) => {
const { email } = request.body;
router.post('/validate/email', async (request: express.Request, response: express.Response) => {
const email: string = request.body.email;
if (!email) {
return response.send(xmlbuilder.create({
@@ -25,9 +28,9 @@ router.post('/validate/email', async (request, response) => {
}).end());
}
const domain = email.split('@')[1];
const domain: string = email.split('@')[1];
dns.resolveMx(domain, (error) => {
dns.resolveMx(domain, (error: NodeJS.ErrnoException | null) => {
if (error) {
return response.send(xmlbuilder.create({
errors: {
@@ -49,10 +52,11 @@ router.post('/validate/email', async (request, response) => {
* Replacement for: https://account.nintendo.net/v1/api/support/email_confirmation/:pid/:code
* Description: Verifies a users email via 6 digit code
*/
router.put('/email_confirmation/:pid/:code', async (request, response) => {
const { pid, code } = request.params;
router.put('/email_confirmation/:pid/:code', async (request: express.Request, response: express.Response) => {
const code: string = request.params.code;
const pid: number = Number(request.params.pid);
const pnid = await database.getUserByPID(pid);
const pnid: HydratedPNIDDocument | null = await getPNIDByPID(pid);
if (!pnid) {
return response.status(400).send(xmlbuilder.create({
@@ -65,7 +69,7 @@ router.put('/email_confirmation/:pid/:code', async (request, response) => {
}).end());
}
if (pnid.get('identification.email_code') !== code) {
if (pnid.identification.email_code !== code) {
return response.status(400).send(xmlbuilder.create({
errors: {
error: {
@@ -76,15 +80,15 @@ router.put('/email_confirmation/:pid/:code', async (request, response) => {
}).end());
}
const validatedDate = moment().format('YYYY-MM-DDTHH:MM:SS');
const validatedDate: string = moment().format('YYYY-MM-DDTHH:MM:SS');
pnid.set('email.reachable', true);
pnid.set('email.validated', true);
pnid.set('email.validated_date', validatedDate);
pnid.email.reachable = true;
pnid.email.validated = true;
pnid.email.validated_date = validatedDate;
await pnid.save();
await util.sendEmailConfirmedEmail(pnid);
await sendEmailConfirmedEmail(pnid);
response.status(200).send('');
});
@@ -94,10 +98,10 @@ router.put('/email_confirmation/:pid/:code', async (request, response) => {
* Replacement for: https://account.nintendo.net/v1/api/support/resend_confirmation
* Description: Resends a users confirmation email
*/
router.get('/resend_confirmation', async (request, response) => {
const pid = request.headers['x-nintendo-pid'];
router.get('/resend_confirmation', async (request: express.Request, response: express.Response) => {
const pid: number = Number(request.headers['x-nintendo-pid']);
const pnid = await database.getUserByPID(pid);
const pnid: HydratedPNIDDocument | null = await getPNIDByPID(pid);
if (!pnid) {
// TODO - Unsure if this is the right error
@@ -111,7 +115,7 @@ router.get('/resend_confirmation', async (request, response) => {
}).end());
}
await util.sendConfirmationEmail(pnid);
await sendConfirmationEmail(pnid);
response.status(200).send('');
});
@@ -122,10 +126,10 @@ router.get('/resend_confirmation', async (request, response) => {
* Description: Sends the user a password reset email
* NOTE: On NN this was a temp password that expired after 24 hours. We do not do that
*/
router.get('/forgotten_password/:pid', async (request, response) => {
const { pid } = request.params;
router.get('/forgotten_password/:pid', async (request: express.Request, response: express.Response) => {
const pid: number = Number(request.params.pid);
const pnid = await database.getUserByPID(pid);
const pnid: HydratedPNIDDocument | null = await getPNIDByPID(pid);
if (!pnid) {
// TODO - Better errors
@@ -140,9 +144,9 @@ router.get('/forgotten_password/:pid', async (request, response) => {
}).end());
}
await util.sendForgotPasswordEmail(pnid);
await sendForgotPasswordEmail(pnid);
response.status(200).send('');
});
module.exports = router;
export default router;

View File

@@ -0,0 +1,48 @@
import mongoose from 'mongoose';
export interface Config {
http: {
port: number;
};
mongoose: {
connection_string: string;
options: mongoose.ConnectOptions;
};
redis: {
client: {
url: string;
}
};
email: {
host: string;
port: number;
secure: boolean;
auth: {
user: string;
pass: string;
};
from: string;
};
s3: {
endpoint: string;
key: string;
secret: string;
};
hcaptcha: {
secret: string;
};
cdn: {
subdomain: string;
disk_path: string;
base_url: string;
};
website_base: string;
aes_key: string;
}
export interface DisabledFeatures {
redis: boolean;
email: boolean;
captcha: boolean;
s3: boolean
}

View File

@@ -0,0 +1,16 @@
export interface MailerOptions {
to: string;
subject: string;
username: string;
paragraph?: string;
preview?: string;
text: string;
link?: {
href: string;
text: string;
};
confirmation?: {
href: string;
code: string;
};
}

View File

@@ -0,0 +1,3 @@
export interface SafeQs {
[key: string]: string | undefined
}

View File

@@ -0,0 +1,4 @@
export interface SignatureSize {
SIZE: number;
PADDING_SIZE: number;
}

View File

@@ -0,0 +1,8 @@
export interface TokenOptions {
system_type: number;
token_type: number;
pid: number;
access_level?: number;
title_id?: bigint;
expire_time: bigint;
}

View File

@@ -0,0 +1,8 @@
export interface Token {
system_type: number;
token_type: number;
pid: number;
access_level?: number;
title_id?: bigint;
expire_time: bigint;
}

View File

@@ -0,0 +1 @@
export type YesNoBoolString = 'Y' | 'N';

16
src/types/express-subdomain.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
// * Credit to https://github.com/bmullan91/express-subdomain/pull/61 for the types!
declare module 'express-subdomain'{
import type { Request, Response, Router } from 'express';
/**
* @description The subdomain function.
* @param subdomain The subdomain to listen on.
* @param fn The listener function, takes a response and request.
* @returns A function call to the value passed as FN, or void (the next function).
*/
export default function subdomain(
subdomain: string,
fn: Router | ((req: Request, res: Response) => void | any)
): (req: Request, res: Response, next: () => void) => void | typeof fn;
}

15
src/types/express.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
import NintendoCertificate from '@/nintendo-certificate';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
import { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account';
declare global {
namespace Express {
interface Request {
pnid: HydratedPNIDDocument | null;
nexAccount: HydratedNEXAccountDocument | null;
isCemu?: boolean;
files?: Record<string, any>;
certificate?: NintendoCertificate;
}
}
}

34
src/types/image-pixels.d.ts vendored Normal file
View File

@@ -0,0 +1,34 @@
declare module 'image-pixels' {
import { NdArray } from 'ndarray';
export interface ImagePixelsOptions {
source: ImageSource;
shape: [number, number];
width: number;
height: number;
type: string;
mime: string;
clip: [number, number, number, number] | {
x?: number;
y?: number;
width?: number;
height?: number;
};
cache: boolean;
}
export type ImageSource = string |
HTMLImageElement | SVGImageElement | HTMLVideoElement | CSSImageValue |
typeof Image | ImageData | ImageBitmap |
File | Blob |
//Canvas | Context2D |
//WebGLContext |
Buffer | ArrayBuffer | Uint8Array | Uint8ClampedArray |
Float32Array | Float64Array | Array<number> | Array<Array<number>> |
NdArray |
ImagePixelsOptions;
export default function pixels(
src: ImageSource | Promise<ImageSource>
): ImageData;
}

Some files were not shown because too many files have changed in this diff Show More