mirror of
https://github.com/PretendoNetwork/account.git
synced 2026-09-12 21:38:12 -05:00
Merge pull request #65 from PretendoNetwork/ts-migration
TypeScript migration
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
*.js
|
||||
@@ -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
7
.gitignore
vendored
@@ -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
7220
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
51
package.json
51
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
212
src/cache.js
212
src/cache.js
@@ -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
61
src/cache.ts
Normal 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);
|
||||
}
|
||||
@@ -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
171
src/config-manager.ts
Normal 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);
|
||||
}
|
||||
274
src/database.js
274
src/database.js
@@ -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
272
src/database.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;"> </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
43
src/mailer.ts
Normal 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;"> </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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
21
src/middleware/api.ts
Normal 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;
|
||||
@@ -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
11
src/middleware/cemu.ts
Normal 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;
|
||||
@@ -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;
|
||||
43
src/middleware/client-header.ts
Normal file
43
src/middleware/client-header.ts
Normal 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;
|
||||
@@ -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;
|
||||
17
src/middleware/device-certificate.ts
Normal file
17
src/middleware/device-certificate.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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
76
src/middleware/pnid.ts
Normal 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;
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
18
src/middleware/ratelimit.ts
Normal file
18
src/middleware/ratelimit.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
57
src/middleware/xml-parser.ts
Normal file
57
src/middleware/xml-parser.ts
Normal 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;
|
||||
214
src/mii.js
214
src/mii.js
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
212
src/models/pnid.ts
Normal 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);
|
||||
@@ -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
20
src/models/server.ts
Normal 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);
|
||||
@@ -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;
|
||||
@@ -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
109
src/server.ts
Normal 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);
|
||||
@@ -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
37
src/services/api/index.ts
Normal 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;
|
||||
@@ -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'),
|
||||
}
|
||||
};
|
||||
17
src/services/api/routes/index.ts
Normal file
17
src/services/api/routes/index.ts
Normal 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
|
||||
};
|
||||
@@ -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;
|
||||
98
src/services/api/routes/v1/connections.ts
Normal file
98
src/services/api/routes/v1/connections.ts
Normal 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;
|
||||
@@ -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;
|
||||
45
src/services/api/routes/v1/email.ts
Normal file
45
src/services/api/routes/v1/email.ts
Normal 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;
|
||||
@@ -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;
|
||||
38
src/services/api/routes/v1/forgotPassword.ts
Normal file
38
src/services/api/routes/v1/forgotPassword.ts
Normal 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;
|
||||
@@ -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;
|
||||
119
src/services/api/routes/v1/login.ts
Normal file
119
src/services/api/routes/v1/login.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
134
src/services/api/routes/v1/user.ts
Normal file
134
src/services/api/routes/v1/user.ts
Normal 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;
|
||||
@@ -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;
|
||||
22
src/services/assets/index.ts
Normal file
22
src/services/assets/index.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
21
src/services/datastore/index.ts
Normal file
21
src/services/datastore/index.ts
Normal 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;
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
UPLOAD: require('./upload.js'),
|
||||
};
|
||||
@@ -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;
|
||||
99
src/services/datastore/routes/upload.ts
Normal file
99
src/services/datastore/routes/upload.ts
Normal 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;
|
||||
@@ -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;
|
||||
27
src/services/local-cdn/index.ts
Normal file
27
src/services/local-cdn/index.ts
Normal 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;
|
||||
@@ -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;
|
||||
18
src/services/local-cdn/routes/get.ts
Normal file
18
src/services/local-cdn/routes/get.ts
Normal 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;
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
GET: require('./get.js'),
|
||||
};
|
||||
@@ -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;
|
||||
27
src/services/nasc/index.ts
Normal file
27
src/services/nasc/index.ts
Normal 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;
|
||||
@@ -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;
|
||||
108
src/services/nasc/routes/ac.ts
Normal file
108
src/services/nasc/routes/ac.ts
Normal 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;
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
AC: require('./ac')
|
||||
};
|
||||
@@ -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;
|
||||
48
src/services/nnid/index.ts
Normal file
48
src/services/nnid/index.ts
Normal 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;
|
||||
@@ -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;
|
||||
106
src/services/nnid/routes/admin.ts
Normal file
106
src/services/nnid/routes/admin.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
18
src/services/nnid/routes/devices.ts
Normal file
18
src/services/nnid/routes/devices.ts
Normal 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;
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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;
|
||||
131
src/services/nnid/routes/miis.ts
Normal file
131
src/services/nnid/routes/miis.ts
Normal 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;
|
||||
@@ -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;
|
||||
111
src/services/nnid/routes/oauth.ts
Normal file
111
src/services/nnid/routes/oauth.ts
Normal 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;
|
||||
@@ -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;
|
||||
678
src/services/nnid/routes/people.ts
Normal file
678
src/services/nnid/routes/people.ts
Normal 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 & 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 & 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;
|
||||
@@ -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;
|
||||
192
src/services/nnid/routes/provider.ts
Normal file
192
src/services/nnid/routes/provider.ts
Normal 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;
|
||||
@@ -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;
|
||||
48
src/types/common/config.ts
Normal file
48
src/types/common/config.ts
Normal 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
|
||||
}
|
||||
16
src/types/common/mailer-options.ts
Normal file
16
src/types/common/mailer-options.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
3
src/types/common/safe-qs.ts
Normal file
3
src/types/common/safe-qs.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface SafeQs {
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
4
src/types/common/signature-size.ts
Normal file
4
src/types/common/signature-size.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface SignatureSize {
|
||||
SIZE: number;
|
||||
PADDING_SIZE: number;
|
||||
}
|
||||
8
src/types/common/token-options.ts
Normal file
8
src/types/common/token-options.ts
Normal 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;
|
||||
}
|
||||
8
src/types/common/token.ts
Normal file
8
src/types/common/token.ts
Normal 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;
|
||||
}
|
||||
1
src/types/common/yes-no-bool-string.ts
Normal file
1
src/types/common/yes-no-bool-string.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type YesNoBoolString = 'Y' | 'N';
|
||||
16
src/types/express-subdomain.d.ts
vendored
Normal file
16
src/types/express-subdomain.d.ts
vendored
Normal 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
15
src/types/express.d.ts
vendored
Normal 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
34
src/types/image-pixels.d.ts
vendored
Normal 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
Reference in New Issue
Block a user