Merge pull request #165 from PretendoNetwork/dev
Some checks failed
Build and Publish Docker Image / build-publish (push) Has been cancelled

Merge Dev to Master
This commit is contained in:
William Oldham 2025-03-22 23:02:04 +00:00 committed by GitHub
commit 89243ea686
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
171 changed files with 57434 additions and 9077 deletions

View File

@ -1,6 +1,5 @@
.git
config.json
logs
certs
cdn
node_modules
.git
.env
node_modules
dist
logs

View File

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

View File

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

56
.github/workflows/docker.yml vendored Normal file
View File

@ -0,0 +1,56 @@
name: Build and Publish Docker Image
on:
push:
pull_request:
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-publish:
env:
SHOULD_PUSH_IMAGE: ${{ (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev')) || github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Set up QEMU for Docker
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log into the container registry
if: ${{ env.SHOULD_PUSH_IMAGE == 'true' }}
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/dev' }}
type=sha
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
push: ${{ env.SHOULD_PUSH_IMAGE }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

7
.gitignore vendored
View File

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

View File

@ -1,16 +1,47 @@
FROM node:18-alpine
RUN apk add --no-cache python3 make gcc g++
WORKDIR /app
COPY "docker/entrypoint.sh" ./
COPY package*.json ./
RUN npm install bcrypt && npm rebuild bcrypt --build-from-source
RUN npm install
COPY . ./
VOLUME [ "/app/config.json", "/app/certs" ]
CMD ["sh", "entrypoint.sh"]
# syntax=docker/dockerfile:1
ARG app_dir="/home/node/app"
# * Base Node.js image
FROM node:20-alpine AS base
ARG app_dir
WORKDIR ${app_dir}
# * Installing production dependencies
FROM base AS dependencies
RUN --mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=package-lock.json,target=package-lock.json \
--mount=type=cache,target=/root/.npm \
npm ci --omit=dev
# * Installing development dependencies and building the application
FROM base AS build
RUN --mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=package-lock.json,target=package-lock.json \
--mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
# * Running the final application
FROM base AS final
ARG app_dir
RUN mkdir -p ${app_dir}/logs && chown node:node ${app_dir}/logs
ENV NODE_ENV=production
USER node
COPY package.json .
COPY --from=dependencies ${app_dir}/node_modules ${app_dir}/node_modules
COPY --from=build ${app_dir}/dist ${app_dir}/dist
CMD ["node", "."]

View File

@ -1,7 +1,6 @@
# Account server
## What is this?
The account server is a replacement for several account-based services used by the WiiU and 3DS. It replaces the NNID api as well as NASC for the 3DS. It also contains a dedicated PNID api service for getting details of PNIDs outside of the consoles
Replacement for several account-based services used by the WiiU and 3DS. It replaces the NNID api as well as NASC for the 3DS. It also contains a dedicated PNID api service for getting details of PNIDs outside of the consoles (used by the website)
## Setup
TODO
See [SETUP.md](SETUP.md) for how to self host

91
SETUP.md Normal file
View File

@ -0,0 +1,91 @@
# Setup
- [Required software](#required-software)
- [NodeJS](#nodejs)
- [MongoDB](#mongodb)
- [Optional features](#optional-features)
- [Redis (optional)](#redis-optional)
- [Email (optional)](#email-optional)
- [Amazon s3 server (optional)](#amazon-s3-server-optional)
- [hCaptcha (optional)](#hcaptcha-optional)
- [Configuration](#configuration)
## Required software
- [NodeJS](https://nodejs.org/)
- [MongoDB](https://www.mongodb.com)
### NodeJS
Download and install the latest LTS version of [NodeJS](https://nodejs.org/). If using a Linux based operating system, using [nvm](https://github.com/nvm-sh/nvm) is the recommended method. _Tested on NodeJS version v18.20.5_
### MongoDB
Download and install the latest version of [MongoDB](https://www.mongodb.com)
The server assumes that MongoDB is running as a replica set, here's how to configure a basic replica set:
1. Open /etc/mongod.conf with your preferred editor.
2. Add/modify the `replication` section with the following:
```conf
replication:
replSetName: "rs0"
```
3. Restart MongoDB and open a shell with `mongosh`.
4. Initiate the replica set with `rs.initiate()` and check its status with `rs.status()`.
## Optional features
- [Redis](https://redis.io/) file caching
- Email address for sending automatic emails (tested with gmail)
- Amazon s3, or compatible, server for CDN methods
- [hCaptcha](https://hcaptcha.com/) for website API captcha verification
### Redis (optional)
Redis can be used to cache files read from disk. If Redis is not configured, then an in-memory object store is used instead.
### Email (optional)
Events such as account creation, email verification, etc, support sending emails to users. To enable email sending, you will need to use Amazon SES. Consult the Amazon SES documentation for more details.
### Amazon s3 server (optional)
Certain endpoints expect URLs for static CDN assets, such as pre-rendered Mii images. An [Amazon s3](https://aws.amazon.com/s3/) or compatible server, such as [Spaces by DigitalOcean](https://www.digitalocean.com/products/spaces), [Cloudflare R2](https://www.cloudflare.com/products/r2/), or [Backblaze B2](https://www.backblaze.com/b2/docs/), can optionally be used to store and upload these assets. If an s3 server is not configured, CDN contents will be stored on disk and served from this server. See [Configuration](#configuration) for more details.
### hCaptcha (optional)
The Pretendo Network website uses this server as an API for querying user information. Certain endpoints are considered more secure than others, such as registration, and can optionally be protected using [hCaptcha](https://hcaptcha.com/). If hCaptcha is not configured, no endpoints on the public facing API will be protected.
## Configuration
Configurations are loaded through environment variables. `.env` files are supported. All configuration options will be gone over, both required and optional. There also exists an example `.env` file
| Name | Description | Optional |
|-----------------------------------------------|--------------------------------------------------------------------------------------------------|----------|
| `PN_ACT_CONFIG_HTTP_PORT` | The HTTP port the server listens on | No |
| `PN_ACT_CONFIG_MONGO_CONNECTION_STRING` | MongoDB connection string | No |
| `PN_ACT_CONFIG_MONGOOSE_CONNECT_OPTIONS_PATH` | Path to a `.json` file containing Mongoose connection options | Yes |
| `PN_ACT_CONFIG_REDIS_URL` | Redis URL | Yes |
| `PN_ACT_CONFIG_EMAIL_SES_REGION` | Amazon SES Region | Yes |
| `PN_ACT_CONFIG_EMAIL_SES_ACCESS_KEY` | Amazon SES Access Key | Yes |
| `PN_ACT_CONFIG_EMAIL_SES_SECRET_KEY` | Amazon SES Access Secret | Yes |
| `PN_ACT_CONFIG_EMAIL_FROM` | Email "from" address | Yes |
| `PN_ACT_CONFIG_S3_ENDPOINT` | s3 server endpoint | Yes |
| `PN_ACT_CONFIG_S3_ACCESS_KEY` | s3 secret key | Yes |
| `PN_ACT_CONFIG_S3_ACCESS_SECRET` | s3 secret | Yes |
| `PN_ACT_CONFIG_HCAPTCHA_SECRET` | hCaptcha secret (in the form `0x...`) | Yes |
| `PN_ACT_CONFIG_CDN_SUBDOMAIN` | Subdomain used to serve CDN contents if s3 is disabled | Yes |
| `PN_ACT_CONFIG_CDN_DISK_PATH` | File system path used to store CDN contents if s3 is disabled | Yes |
| `PN_ACT_CONFIG_CDN_BASE_URL` | URL for serving CDN contents (usually the same as s3 endpoint) | No |
| `PN_ACT_CONFIG_WEBSITE_BASE` | Website URL | Yes |
| `PN_ACT_CONFIG_AES_KEY` | AES-256 key used for encrypting tokens | No |
| `PN_ACT_CONFIG_DATASTORE_SIGNATURE_SECRET` | HMAC secret key (16 bytes in hex format) used to sign uploaded DataStore files | No |
| `PN_ACT_CONFIG_GRPC_MASTER_API_KEY_ACCOUNT` | Master API key to interact with the account gRPC service | No |
| `PN_ACT_CONFIG_GRPC_MASTER_API_KEY_API` | Master API key to interact with the API gRPC service | No |
| `PN_ACT_CONFIG_GRPC_PORT` | gRPC server port | No |
| `PN_ACT_CONFIG_STRIPE_SECRET_KEY` | Stripe API key. Used to cancel subscriptions when scrubbing PNIDs | Yes |
| `PN_ACT_CONFIG_SERVER_ENVIRONMENT` | Server environment. Currently only used by the Wii U Account Settings app. `prod`/`test`/`dev` | Yes |

View File

@ -1,79 +0,0 @@
const prompt = require('prompt');
const crypto = require('crypto');
const database = require('./src/database');
const { PNID } = require('./src/models/pnid');
prompt.message = '';
const properties = [
'username',
'email',
{
name: 'password',
hidden: true
}
];
prompt.get(properties, (error, { username, email, password }) => {
const date = new Date().toISOString();
// Sample Mii data
const miiData = 'AwAAQOlVognnx0GC2X0LLQOzuI0n2QAAAUBiAGUAbABsAGEAAABFAAAAAAAAAEBAEgCBAQRoQxggNEYUgRIXaA0AACkDUkhQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP6G';
const document = {
pid: 1,
creation_date: date.split('.')[0],
updated: date,
username: username,
password: password,
birthdate: '1990-01-01',
gender: 'M',
country: 'US',
language: 'en',
email: {
address: email,
primary: true,
parent: true,
reachable: true,
validated: true,
id: crypto.randomBytes(4).readUInt32LE()
},
region: 0x310B0000,
timezone: {
name: 'America/New_York',
offset: -14400
},
mii: {
name: 'bella',
primary: true,
data: miiData,
id: crypto.randomBytes(4).readUInt32LE(),
hash: crypto.randomBytes(7).toString('hex'),
image_url: '', // Deprecated, will be removed
image_id: crypto.randomBytes(4).readUInt32LE()
},
flags: {
active: true,
marketing: false,
off_device: true
},
validation: {
// These values are temp and will be overwritten before the document saves
// These values are only being defined to get around the `E11000 duplicate key error collection` error
email_code: 1,
email_token: ''
}
};
const newUser = new PNID(document);
newUser.save(async (error, newUser) => {
if (error) {
throw error;
}
console.log(newUser);
console.log('New user created');
});
});
database.connect().then(prompt.start);

View File

@ -1,28 +0,0 @@
#!/bin/sh
# this doesnt check game server specific certs, only static file paths
files='config.json'
for file in $files; do
if [ ! -f $file ]; then
echo "$PWD/$file file does not exist. Please mount and try again."
exit 1
fi
done
# check for keys
keys='certs/nex/datastore/secret.key certs/service/account/secret.key certs/service/account/aes.key certs/service/account/private.pem certs/service/account/public.pem'
for file in $keys; do
if [ ! -f "$file" ]; then
if [ x"${GENERATE_NEW_KEYS}" = "x" ]; then
echo "$PWD/$file file does not exist. Please mount and try again."
exit 1
else
echo "$PWD/$file file does not exist. Generating a temporary one"
node generate-keys.js nex datastore
node generate-keys.js account
fi
fi
done
exec node src/server.js

View File

@ -1,40 +0,0 @@
{
"http": {
"port": 7070
},
"mongoose": {
"connection_string": "mongodb://localhost:27017/database_name",
"options": {
"useNewUrlParser": true
}
},
"redis": {
"client": {
"url": "redis://localhost:6379"
}
},
"email": {
"host": "smtp.gmail.com",
"port": 587,
"secure": false,
"auth": {
"user": "username",
"pass": "password"
},
"from": "Company Name <user@company.net>"
},
"s3": {
"endpoint": "nyc3.digitaloceanspaces.com",
"key": "ACCESS_KEY",
"secret": "ACCESS_SECRET"
},
"hcaptcha": {
"secret": "0x0000000000000000000000000000000000000000"
},
"cdn": {
"base_url": "https://local-cdn.example.com",
"subdomain": "local-cdn",
"disk_path": "/home/jon/pretend-cdn"
},
"website_base": "https://example.com"
}

View File

@ -6,10 +6,11 @@ PN_ACT_CONFIG_MONGOOSE_OPTION_useUnifiedTopology=true
PN_ACT_CONFIG_REDIS_URL=redis://localhost:6379
PN_ACT_CONFIG_EMAIL_HOST=smtp.gmail.com
PN_ACT_CONFIG_EMAIL_PORT=587
PN_ACT_CONFIG_EMAIL_SECURE=false
PN_ACT_CONFIG_EMAIL_USERNAME=username
PN_ACT_CONFIG_EMAIL_PASSWORD=password
PN_ACT_CONFIG_EMAIL_SES_REGION=region
PN_ACT_CONFIG_EMAIL_SES_ACCESS_KEY=ACCESS_KEY
PN_ACT_CONFIG_EMAIL_SES_SECRET_KEY=ACCESS_SECRET
PN_ACT_CONFIG_EMAIL_FROM=Company Name <user@company.net>
PN_ACT_CONFIG_S3_BUCKET=BUCKET_NAME
PN_ACT_CONFIG_S3_ENDPOINT=nyc3.digitaloceanspaces.com
PN_ACT_CONFIG_S3_ACCESS_KEY=ACCESS_KEY
PN_ACT_CONFIG_S3_ACCESS_SECRET=ACCESS_SECRET
@ -17,4 +18,7 @@ PN_ACT_CONFIG_HCAPTCHA_SECRET=0x0000000000000000000000000000000000000000
PN_ACT_CONFIG_CDN_BASE_URL=https://local-cdn.example.com
PN_ACT_CONFIG_CDN_SUBDOMAIN=local-cdn
PN_ACT_CONFIG_CDN_DISK_PATH=/home/jon/pretend-cdn
PN_ACT_CONFIG_WEBSITE_BASE=https://example.com
PN_ACT_CONFIG_WEBSITE_BASE=https://example.com
PN_ACT_CONFIG_AES_KEY=abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789
PN_ACT_CONFIG_GRPC_API_KEY=apikey
PN_ACT_CONFIG_SERVER_ENVIRONMENT=prod

View File

@ -1,145 +0,0 @@
const NodeRSA = require('node-rsa');
const crypto = require('crypto');
const fs = require('fs-extra');
const yesno = require('yesno');
const logger = require('./logger');
require('colors');
const ALLOWED_CHARS_REGEX = /[^a-zA-Z0-9_-]/g;
async function main() {
const args = process.argv.slice(2);
if (args.length < 1) {
logger.error('Must pass in type and optional name');
usage();
return;
}
let [type, name] = args;
type = type.toLowerCase().trim();
if (name) {
name = name.toLowerCase().trim();
if (ALLOWED_CHARS_REGEX.test(name)) {
logger.error(`Invalid name. Names must only contain [^a-zA-Z0-9_-]. Got ${name}`);
return;
}
}
if (!['nex', 'service', 'account'].includes(type)) {
logger.error(`Invalid type. Expected nex, service, or account. Got ${type}`);
usage();
return;
}
if (type !== 'account' && (!name || name === '')) {
logger.error('If type is not account, a name MUST be passed');
usage();
return;
}
if (type === 'service' && name === 'account') {
logger.error('Cannot use service name \'account\'. Reserved');
usage();
return;
}
let path;
if (type === 'account') {
path = `${__dirname}/certs/service/account`;
} else {
path = `${__dirname}/certs/${type}/${name}`;
}
if (fs.pathExistsSync(path)) {
const overwrite = await yesno({
question: 'Keys found for type name, overwrite existing keys?'
});
if (!overwrite) {
logger.info('Not overwriting existing keys. Exiting program');
return;
}
}
const publicKeyPath = `${path}/public.pem`;
const privateKeyPath = `${path}/private.pem`;
const aesKeyPath = `${path}/aes.key`;
const secretKeyPath = `${path}/secret.key`;
// Ensure the output directories exist
logger.info('Creating output directories...');
fs.ensureDirSync(path);
logger.success('Created output directories!');
const key = new NodeRSA({ b: 1024 }, null, {
environment: 'browser',
encryptionScheme: {
'hash': 'sha256',
}
});
// Generate new key pair
logger.info('Generating RSA key pair...');
logger.warn('(this may take a while)')
key.generateKeyPair(1024);
logger.success('Generated RSA key pair!');
// Export the keys
logger.info('Exporting public key...');
const publicKey = key.exportKey('public');
logger.success('Exported public key!');
// Saving public key
logger.info('Saving public key to disk...');
fs.writeFileSync(publicKeyPath, publicKey);
logger.success(`Saved public key to ${publicKeyPath}!`);
logger.info('Exporting private key...');
const privateKey = key.exportKey('private');
logger.success('Exported private key!');
// Saving private key
logger.info('Saving private key to disk...');
fs.writeFileSync(privateKeyPath, privateKey);
logger.success(`Saved private key to ${privateKeyPath}!`);
// Generate new AES key
logger.info('Generating AES key...');
const aesKey = crypto.randomBytes(16);
logger.success('Generated AES key!');
// Saving AES key
logger.info('Saving AES key to disk...');
fs.writeFileSync(aesKeyPath, aesKey.toString('hex'));
logger.success(`Saved AES key to ${aesKeyPath}!`);
// Create HMAC secret key
logger.info('Generating HMAC secret...');
const secret = crypto.randomBytes(16);
logger.success('Generated RSA key pair!');
logger.info('Saving HMAC secret to disk...');
fs.writeFileSync(secretKeyPath, secret.toString('hex'));
logger.success(`Saved HMAC secret to ${secretKeyPath}!`);
logger.success('Keys generated successfully');
}
// Display usage information
function usage() {
console.log('Usage: node generate-keys.js type [name]');
console.log('Types:');
console.log(' - nex');
console.log(' - service');
console.log(' - account');
console.log('Name: Service or NEX server name. Not used in account type');
}
main().catch(logger.error);

View File

@ -0,0 +1,34 @@
const database = require('../dist/database');
const { NEXAccount } = require('../dist/models/nex-account');
database.connect().then(async function () {
const nexAccountsTotal = await NEXAccount.find({});
const nexAccounts3DS = await NEXAccount.find({ device_type: '3ds' });
const nexAccountsWiiU = await NEXAccount.find({ device_type: 'wiiu' });
const nexAccountsToBeChanged = await NEXAccount.find({
device_type: {
$exists: false
}
});
console.log('NEX accounts (Total):', nexAccountsTotal.length);
console.log('NEX accounts (3DS):', nexAccounts3DS.length);
console.log('NEX accounts (WiiU):', nexAccountsWiiU.length);
console.log('NEX accounts (To be changed):', nexAccountsToBeChanged.length);
for (const nexAccount of nexAccountsToBeChanged) {
if (nexAccount.owning_pid !== nexAccount.pid) {
// 3DS account
nexAccount.device_type = '3ds';
} else {
// WiiU account
nexAccount.device_type = 'wiiu';
}
await nexAccount.save();
}
console.log('Migrated accounts');
process.exit(0);
});

View File

@ -1,32 +0,0 @@
const database = require('../../src/database');
const { NEXAccount } = require('../../src/models/nex-account');
database.connect().then(async function () {
const nexAccounts = await NEXAccount.find({});
const nexAccounts3DS = await NEXAccount.find({ device_type: '3ds' });
const nexAccountsWiiU = await NEXAccount.find({ device_type: 'wiiu' });
console.log('NEX accounts:', nexAccounts.length);
console.log('NEX accounts (3DS):', nexAccounts3DS.length);
console.log('NEX accounts (WiiU):', nexAccountsWiiU.length);
for (const nexAccount of nexAccounts) {
let deviceType = '';
if (nexAccount.owning_pid !== nexAccount.pid) {
// 3DS account
deviceType = '3ds';
} else {
// WiiU account
deviceType = 'wiiu';
}
nexAccount.device_type = deviceType;
await nexAccount.save();
}
console.log('Migrated accounts');
process.exit(0);
});

View File

@ -0,0 +1,36 @@
const crypto = require('node:crypto');
const database = require('../dist/database');
const { NEXAccount } = require('../dist/models/nex-account');
database.connect().then(async function () {
const nexAccountsToBeChanged = await NEXAccount.find({
device_type: '3ds',
friend_code: {
$exists: false
}
});
for (const nexAccount of nexAccountsToBeChanged) {
if (!nexAccount.friend_code) {
const pid = nexAccount.pid;
const pidBuffer = Buffer.alloc(4);
pidBuffer.writeUInt32LE(pid);
const hash = crypto.createHash('sha1').update(pidBuffer);
const pidHash = hash.digest();
const checksum = pidHash[0] >> 1;
const hex = checksum.toString(16) + pid.toString(16);
const int = parseInt(hex, 16);
const friendCode = int.toString().padStart(12, '0').match(/.{1,4}/g).join('-');
nexAccount.friend_code = friendCode;
await nexAccount.save();
}
}
console.log('Migrated accounts');
process.exit(0);
});

View File

@ -0,0 +1,24 @@
const crypto = require('node:crypto');
const database = require('../dist/database');
const { Server } = require('../dist/models/server');
database.connect().then(async function () {
const servers = await Server.find({
aes_key: {
$exists: false
}
});
for (const server of servers) {
if (!server.aes_key) {
server.aes_key = crypto.randomBytes(32).toString('hex');
await server.save();
}
}
console.log('Migrated accounts');
process.exit(0);
});

11219
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,16 @@
{
"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": "eslint .",
"lint:fix": "eslint --fix .",
"build": "npm run lint && npm run clean && npx tsc && npx tsc-alias && npm run copy-static",
"clean": "rimraf ./dist",
"copy-static": "copyfiles -e \"src/**/*.ts\" -u 1 \"src/**/*\" dist",
"start": "node .",
"start:dev": "NODE_ENV=development node ."
"start:dev": "cross-env NODE_ENV=development node ."
},
"repository": {
"type": "git",
@ -20,43 +24,65 @@
},
"homepage": "https://github.com/PretendoNetwork/account#readme",
"dependencies": {
"aws-sdk": "^2.978.0",
"@aws-sdk/client-s3": "^3.657.0",
"@aws-sdk/client-ses": "^3.515.0",
"@inquirer/prompts": "^7.2.0",
"@pretendonetwork/grpc": "^1.0.5",
"bcrypt": "^5.0.0",
"buffer-crc32": "^0.2.13",
"colors": "^1.4.0",
"cors": "^2.8.5",
"crc": "^4.3.2",
"dicer": "^0.2.5",
"dotenv": "^16.0.3",
"ejs": "^3.1.10",
"email-validator": "^2.0.4",
"express": "^4.17.1",
"express-form-data": "^2.0.17",
"express-rate-limit": "^5.3.0",
"express-subdomain": "^1.0.5",
"formidable": "^1.2.2",
"express-rate-limit": "^6.7.0",
"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",
"is-valid-hostname": "^1.0.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": "^4.0.0",
"morgan": "^1.9.1",
"multer": "^1.4.3",
"nice-grpc": "^2.1.4",
"node-rsa": "^1.0.7",
"nodemailer": "^6.4.2",
"redis": "^4.3.1",
"stripe": "^12.3.0",
"tga": "^1.0.4",
"typescript-is": "^0.19.0",
"validator": "^13.7.0",
"xmlbuilder": "^13.0.2",
"xmlbuilder2": "0.0.4"
"xmlbuilder2": "0.0.4",
"zod": "^3.21.4"
},
"devDependencies": {
"node-rsa": "^1.0.7",
"prompt": "^1.0.0",
"yesno": "^0.4.0"
"@hcaptcha/types": "^1.0.3",
"@types/bcrypt": "^5.0.0",
"@types/buffer-crc32": "^0.2.2",
"@types/cors": "^2.8.13",
"@types/dicer": "^0.2.2",
"@types/express": "^4.17.17",
"@types/fs-extra": "^11.0.1",
"@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",
"@typescript-eslint/eslint-plugin": "^5.54.1",
"@typescript-eslint/parser": "^5.54.1",
"copyfiles": "^2.4.1",
"cross-env": "^7.0.3",
"eslint": "^8.35.0",
"ndarray": "^1.0.19",
"typescript": "^4.9.5"
}
}

38
pnid-scrub.js Normal file
View File

@ -0,0 +1,38 @@
const { connect } = require('./dist/database');
const { PNID } = require('./dist/models/pnid');
const { confirm, input } = require('@inquirer/prompts');
async function bootstrap() {
await connect();
const pnidName = await input({ message: 'What PNID do you want to delete?' });
const pnid = await PNID.findOne({ username:pnidName.trim() });
if (!pnid) {
console.log("Could not find PNID");
process.exit(1);
}
console.log('Before:', pnid);
if (pnid.deleted) {
console.log("PNID is already marked as deleted");
process.exit(1);
}
const confirmed = await confirm({ message: 'Do you want to delete this PNID',default: false });
if (!confirmed) {
console.log("Aborted");
process.exit(1);
}
await pnid.scrub();
await pnid.save();
console.log('After:', pnid);
if (pnid.deleted) console.log("SUCCESSFULLY DELETED");
else console.log("COULD NOT DELETE");
process.exit(0);
}
bootstrap();

View File

@ -179,7 +179,7 @@
</tr>
<tr>
<td class="notice" style="color: #8990c1; font-size: 12px;">
Note: this email message was auto-generated, please do not respond. For further assistance, please join our <a href="https://invite.gg/pretendo" style="text-decoration: none; color: #ffffff; ">Discord server</a>.
Note: this email message was auto-generated, please do not respond. For further assistance, please join our <a href="https://invite.gg/pretendo" style="text-decoration: none; color: #ffffff; ">Discord server</a> or make a post on our <a href="https://forum.pretendo.network" style="text-decoration: none; color: #ffffff; ">Forum</a>.
</td>
</tr>
<tr>

View File

@ -146,7 +146,7 @@
</tr>
<tr>
<td class="notice" style="color: #8990c1; font-size: 12px;">
Note: this email message was auto-generated, please do not respond. For further assistance, please join our <a href="https://invite.gg/pretendo" style="text-decoration: none; color: #ffffff; ">Discord server</a>.
Note: this email message was auto-generated, please do not respond. For further assistance, please join our <a href="https://invite.gg/pretendo" style="text-decoration: none; color: #ffffff; ">Discord server</a> or make a post on our <a href="https://forum.pretendo.network" style="text-decoration: none; color: #ffffff; ">Forum</a>.
</td>
</tr>
<tr>

View File

@ -0,0 +1,307 @@
html {
background: #FF0000;
}
body {
font-family: Poppins, Arial, Helvetica, sans-serif;
font-size: 15px;
width: 1280px;
margin: 0;
overflow: auto;
box-sizing: border-box;
background: #EAEAEA;
}
button:active,
.server-selection input:checked + label {
box-shadow: inset 0 0px 10px 0 rgba(66, 45, 120, 0.75) !important;
color: #9D6FF3;
}
header {
position: relative;
color: #FFF;
height: 70px;
line-height: 70px;
border-bottom: 2px solid #4F2E8C;
background: -webkit-gradient(linear, left top, left bottom, from(#9D6FF3), to(#673DB6));
z-index: 19;
}
header h1 {
margin: 0;
font-size: 30px;
text-align: center;
font-weight: normal;
}
button,
.button,
input[type="submit"] {
font-family: Poppins, Arial, Helvetica, sans-serif;
color: #45297A;
min-width: 200px;
-webkit-box-sizing: content-box;
box-sizing: content-box;
-webkit-box-align: center;
-webkit-box-pack: center;
min-height: 60px;
text-align: center;
font-size: 28px;
background: -webkit-gradient(linear, left top, left bottom, from(#FFF), color-stop(0.5, #FFF), color-stop(0.8, #F6F6F6), color-stop(0.95, #F5F5F5), to(#BBB)) 0 0;
border-radius: 12px;
cursor: pointer;
border: 0;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.4);
margin: 5px;
}
.body-content {
margin: 30px 60px 0 220px;
}
div.group h2 {
font-weight: normal;
}
div.group {
width: 485px;
display: inline-block;
margin: 0 5px;
}
div.body-content > h1 {
margin: 15px 0 0 0;
}
div.group > * {
margin: 5px 0;
font-size: 30px;
}
div.group > p.content,
div.group > input[type="text"],
div.group > input[type="date"],
div.group > textarea,
div.group > select {
background: #FFF;
color: #6A6C75;
padding: 10px;
border-radius: 8px;
font-size: 25px;
box-shadow: none;
border: none;
width: 100%;
height: 58px;
box-sizing: border-box;
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
text-indent: 1px;
text-overflow: '';
line-height: 38px;
resize: none;
}
div.group > input[type="text"],
div.group > input[type="date"] {
color: #9D6FF3;
}
div.group > select {
content: ' ';
background-color: #FFF;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' fill='%23673DB6' viewBox='0 0 256 256'%3E%3Cpath d='M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z'%3E%3C/path%3E%3C/svg%3E");
background-size: 40px;
background-position: 440px center;
background-repeat: no-repeat;
color: #9D6FF3;
cursor: pointer;
}
div.group > span {
color: #000;
}
div.group > svg {
fill: #9D6FF3;
border: none;
height: 40px;
width: 40px;
padding: 0px;
margin-bottom: -10px;
margin-left: 5px;
-webkit-box-sizing: border-box;
-webkit-user-select: none;
box-sizing: border-box;
user-select: none;
}
.account-info {
float: left;
width: 220px;
-webkit-box-sizing: content-box;
-webkit-box-align: center;
-webkit-box-pack: center;
box-sizing: content-box;
}
.account-info > img {
display: inline-block;
width: 128px;
height: 128px;
overflow: hidden;
border-radius: 100%;
background: #E4DBF2;
margin: 30px 30px 0 46px;
box-shadow: 0-1px 2px rgba(0, 0, 0, 0.4);
}
.account-info > .content {
text-align: center;
color: #673DB6;
margin: 0;
}
.account-info > h3.content {
font-weight: normal;
color: #9D6FF3;
font-size: 20px;
}
.account-info .access-level-banned {
background: rgba(255, 63, 0, 0.3);
color: #FF3F00;
border-color: #FF3F00;
}
.account-info .tier-level-1 {
background: #934D4D;
color: #FF8484;
border-color: #FF8484;
}
.account-info .tier-level-2 {
background: #316C59;
color: #59C9A5;
border-color: #59C9A5;
}
.account-info .tier-level-3 {
background: #6B5E84;
color: #CAB1FB;
border-color: #CAB1FB;
}
.account-info .access-level-1 {
background: #3B918C;
color: #64F7EF;
border-color: #64F7EF;
}
.account-info .access-level-2 {
background: #917235;
color: #FFC759;
border-color: #FFC759;
}
.account-info .access-level-3 {
background: #3A973C;
color: #5AFF15;
border-color: #5AFF15;
}
.account-info .tier-name {
margin: 5px auto;
line-height: 1.2em;
border-radius: 1.2em;
border-width: 2px;
border-style: solid;
padding: 4px 16px;
width: min-content;
display: inline-block;
}
div.group > p.content > button {
min-width: 100px;
min-height: 20px;
display: block;
width: 100px;
float: right;
}
div.radio {
display: inline-block;
}
.server-selection input {
opacity: 0;
position: absolute;
width: 130px;
height: 70px;
cursor: pointer;
}
div.radio label {
display: inline-block;
color: #6A6C75;
padding: 0 10px;
min-width: 110px;
-webkit-box-sizing: content-box;
box-sizing: content-box;
-webkit-box-align: center;
-webkit-box-pack: center;
text-align: center;
background: -webkit-gradient(linear, left top, left bottom, from(#FFF), color-stop(0.5, #FFF), color-stop(0.8, #F6F6F6), color-stop(0.95, #F5F5F5), to(#BBB)) 0 0;
cursor: pointer;
border: 0;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.4);
margin: 5px;
font-weight: normal;
font-size: 15px;
-o-border-radius: 10px;
-webkit-border-radius: 10px;
-moz-border-radius: 10px;
-ms-border-radius: 10px;
border-radius: 10px;
}
div.radio label h2 {
display: inline-block;
font-weight: normal;
}
div.radio label svg {
margin: -6px 0;
}
header .fixed-bottom-button.left {
padding: 0 60px 0 40px !important;
}
.fixed-bottom-button.left {
right: auto;
left: 0;
border-radius: 0 40px 0 0;
}
header .fixed-bottom-button {
min-width: 120px;
padding: 0 40px 0 60px;
}
.fixed-bottom-button,
input[type="submit"] {
position: fixed;
bottom: 0;
right: 0;
height: 85px;
line-height: 100px;
padding: 0 40px 0 60px;
background: -webkit-gradient(linear, left top, left bottom, from(#FFF), color-stop(0.5, #FFF), to(#E6E6E6)) 0 0;
font-size: 28px;
z-index: 20;
border-radius: 40px 0 0 0;
margin: 0;
border: none;
}

View File

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

61
src/cache.ts Normal file
View File

@ -0,0 +1,61 @@
import fs from 'fs-extra';
import * as redis from 'redis';
import { config, disabledFeatures } from '@/config-manager';
let client: redis.RedisClientType;
const memoryCache: Record<string, Buffer> = {};
const LOCAL_CDN_BASE = `${__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.alloc(0);
if (disabledFeatures.redis) {
cachedFile = memoryCache[fileName] || null;
} else {
const redisValue = 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 = await getCachedFile(`local_cdn:${name}`, encoding);
if (file === null) {
if (await fs.pathExists(`${LOCAL_CDN_BASE}/${name}`)) {
const fileBuffer = await fs.readFile(`${LOCAL_CDN_BASE}/${name}`, { encoding });
file = Buffer.from(fileBuffer);
await setLocalCDNFile(name, file);
}
}
return file;
}
export async function setLocalCDNFile(name: string, value: Buffer): Promise<void> {
await setCachedFile(`local_cdn:${name}`, value);
}

View File

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

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

@ -0,0 +1,273 @@
import fs from 'fs-extra';
import mongoose from 'mongoose';
import dotenv from 'dotenv';
import isValidHostname from 'is-valid-hostname';
import { LOG_INFO, LOG_WARN, LOG_ERROR, formatHostnames } from '@/logger';
import { type Config, domainServices, optionalDomainServices } from '@/types/common/config';
dotenv.config();
export const disabledFeatures = {
redis: false,
email: false,
captcha: false,
s3: false,
datastore: false
};
const hexadecimalStringRegex = /^[0-9a-f]+$/i;
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: {
ses: {
region: process.env.PN_ACT_CONFIG_EMAIL_SES_REGION || '',
key: process.env.PN_ACT_CONFIG_EMAIL_SES_ACCESS_KEY || '',
secret: process.env.PN_ACT_CONFIG_EMAIL_SES_SECRET_KEY || ''
},
from: process.env.PN_ACT_CONFIG_EMAIL_FROM || ''
},
s3: {
bucket: process.env.PN_ACT_CONFIG_S3_BUCKET || '',
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 || '',
region: process.env.PN_ACT_CONFIG_S3_REGION || '',
forcePathStyle: process.env.PN_ACT_CONFIG_S3_FORCE_PATH_STYLE === 'true'
},
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 || '',
grpc: {
master_api_keys: {
account: process.env.PN_ACT_CONFIG_GRPC_MASTER_API_KEY_ACCOUNT || '',
api: process.env.PN_ACT_CONFIG_GRPC_MASTER_API_KEY_API || '',
},
port: Number(process.env.PN_ACT_CONFIG_GRPC_PORT || ''),
},
server_environment: process.env.PN_ACT_CONFIG_SERVER_ENVIRONMENT || '',
datastore: {
signature_secret: process.env.PN_ACT_CONFIG_DATASTORE_SIGNATURE_SECRET || ''
},
domains: {
api: (process.env.PN_ACT_CONFIG_DOMAINS_API || 'api.pretendo.cc').split(','),
assets: (process.env.PN_ACT_CONFIG_DOMAINS_ASSETS || 'assets.pretendo.cc').split(','),
cbvc: (process.env.PN_ACT_CONFIG_DOMAINS_CBVC || 'cbvc.cdn.pretendo.cc').split(','),
conntest: (process.env.PN_ACT_CONFIG_DOMAINS_CONNTEST || 'conntest.pretendo.cc').split(','),
datastore: (process.env.PN_ACT_CONFIG_DOMAINS_DATASTORE || 'datastore.pretendo.cc').split(','),
local_cdn: (process.env.PN_ACT_CONFIG_DOMAINS_LOCAL_CDN || '').split(','),
nasc: (process.env.PN_ACT_CONFIG_DOMAINS_NASC || 'nasc.pretendo.cc').split(','),
nnas: (process.env.PN_ACT_CONFIG_DOMAINS_NNAS || 'c.account.pretendo.cc,account.pretendo.cc').split(','),
}
};
if (process.env.PN_ACT_CONFIG_STRIPE_SECRET_KEY) {
config.stripe = {
secret_key: process.env.PN_ACT_CONFIG_STRIPE_SECRET_KEY
};
}
// * Add the old config option for backwards compatibility
if (config.cdn.subdomain) {
config.domains.local_cdn.push(config.cdn.subdomain);
}
let configValid = true;
LOG_INFO('Config loaded, checking integrity');
for (const service of domainServices) {
const validDomains: string[] = [];
const invalidDomains: string[] = [];
const uniqueDomains = [...new Set(config.domains[service])];
for (const domain of uniqueDomains) {
isValidHostname(domain) ? validDomains.push(domain) : invalidDomains.push(domain);
}
if (validDomains.length === 0 && !optionalDomainServices.includes(service)) {
LOG_ERROR(`No valid domains found for ${service}. Set the PN_ACT_CONFIG_DOMAINS_${service.toUpperCase()} environment variable to a valid domain`);
configValid = false;
}
if (invalidDomains.length) {
LOG_WARN(`Invalid domain(s) skipped for ${service}: ${formatHostnames(invalidDomains)}`);
}
config.domains[service] = validDomains;
}
if (!config.http.port) {
LOG_ERROR('Failed to find HTTP port. Set the PN_ACT_CONFIG_HTTP_PORT environment variable');
configValid = false;
}
if (!config.mongoose.connection_string) {
LOG_ERROR('Failed to find MongoDB connection string. Set the PN_ACT_CONFIG_MONGO_CONNECTION_STRING environment variable');
configValid = false;
}
if (!config.cdn.base_url) {
LOG_ERROR('Failed to find asset CDN base URL. Set the PN_ACT_CONFIG_CDN_BASE_URL environment variable');
configValid = false;
}
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.ses.region) {
LOG_WARN('Failed to find AWS SES region. Disabling feature. To enable feature set the PN_ACT_CONFIG_EMAIL_SES_REGION environment variable');
disabledFeatures.email = true;
}
if (!config.email.ses.key) {
LOG_WARN('Failed to find AWS SES access key. Disabling feature. To enable feature set the PN_ACT_CONFIG_EMAIL_SES_ACCESS_KEY environment variable');
disabledFeatures.email = true;
}
if (!config.email.ses.secret) {
LOG_WARN('Failed to find AWS SES secret key. Disabling feature. To enable feature set the PN_ACT_CONFIG_EMAIL_SES_SECRET_KEY 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');
configValid = false;
}
}
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.bucket) {
LOG_WARN('Failed to find S3 bucket config. Disabling feature. To enable feature set the PN_ACT_CONFIG_S3_BUCKET environment variable');
disabledFeatures.s3 = 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 (!config.s3.region) {
LOG_WARN('Failed to find S3 region config. Disabling feature. To enable feature set the PN_ACT_CONFIG_S3_REGION environment variable');
disabledFeatures.s3 = true;
}
if (!config.server_environment) {
LOG_WARN('Failed to find server environment. To change the environment, set the PN_ACT_CONFIG_SERVER_ENVIRONMENT environment variable. Defaulting to prod');
config.server_environment = 'prod';
}
if (disabledFeatures.s3) {
if (config.domains.local_cdn.length === 0) {
LOG_ERROR('S3 file storage is disabled and no CDN domain was set. Set the PN_ACT_CONFIG_DOMAINS_LOCAL_CDN environment variable');
configValid = false;
}
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');
configValid = false;
}
if (configValid) {
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 domain being one of ${formatHostnames(config.domains.local_cdn)}`);
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');
configValid = false;
}
if (!config.grpc.master_api_keys.account) {
LOG_ERROR('Master gRPC API key for the account service is not set. Set the PN_ACT_CONFIG_GRPC_MASTER_API_KEY_ACCOUNT environment variable');
configValid = false;
}
if (!config.grpc.master_api_keys.api) {
LOG_ERROR('Master gRPC API key for the api service is not set. Set the PN_ACT_CONFIG_GRPC_MASTER_API_KEY_API environment variable');
configValid = false;
}
if (!config.grpc.port) {
LOG_ERROR('Failed to find gRPC port. Set the PN_ACT_CONFIG_GRPC_PORT environment variable');
configValid = false;
}
if (!config.stripe?.secret_key) {
LOG_WARN('Failed to find Stripe api key! If a PNID is deleted with an active subscription, the subscription will *NOT* be canceled! Set the PN_ACT_CONFIG_STRIPE_SECRET_KEY environment variable to enable');
}
if (!config.datastore.signature_secret) {
LOG_WARN('Datastore signature secret key is not set. Disabling feature. To enable feature set the PN_ACT_CONFIG_DATASTORE_SIGNATURE_SECRET environment variable');
disabledFeatures.datastore = true;
} else {
if (config.datastore.signature_secret.length !== 32 || !hexadecimalStringRegex.test(config.datastore.signature_secret)) {
LOG_ERROR('Datastore signature secret key must be a 32-character hexadecimal string.');
configValid = false;
}
}
if (!configValid) {
LOG_ERROR('Config is invalid. Exiting');
process.exit(0);
}

View File

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

309
src/database.ts Normal file
View File

@ -0,0 +1,309 @@
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 { IDeviceAttribute } from '@/types/mongoose/device-attribute';
import { HydratedServerDocument } from '@/types/mongoose/server';
import { PNIDProfile } from '@/types/services/nnas/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 = config.mongoose.connection_string;
const options = config.mongoose.options;
// TODO - Extend this later with more settings
const discordConnectionSchema = joi.object({
id: joi.string()
});
const accessModeOrder: Record<string, string[]> = {
prod: ['prod'],
test: ['test', 'prod'],
dev: ['dev', 'test', 'prod']
};
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 = Buffer.from(token, 'base64').toString();
const parts = decoded.split(' ');
const username = parts[0];
const password = parts[1];
const pnid = await getPNIDByUsername(username);
if (!pnid) {
return null;
}
const hashedPassword = nintendoPasswordHash(password, pnid.pid);
if (!bcrypt.compareSync(hashedPassword, pnid.password)) {
return null;
}
return pnid;
}
export async function getPNIDByTokenAuth(token: string): Promise<HydratedPNIDDocument | null> {
verifyConnected();
try {
const decryptedToken = decryptToken(Buffer.from(token, 'hex'));
const unpackedToken = unpackToken(decryptedToken);
const pnid = await getPNIDByPID(unpackedToken.pid);
if (pnid) {
const expireTime = 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 = await getPNIDByPID(pid);
if (!pnid) {
return null;
}
const device = 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 = attribute.name;
const value = attribute.value;
const created_date = 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> {
const searchModes = accessModeOrder[accessMode] ?? accessModeOrder.prod; // Default to prod if invalid mode
const servers = await Server.find({
game_server_id: gameServerID,
access_mode: { $in: searchModes }
});
for (const mode of searchModes) {
const server = servers.find(s => s.access_mode === mode);
if (server) return server;
}
return null;
}
export async function getServerByTitleID(titleID: string, accessMode: string): Promise<HydratedServerDocument | null> {
const searchModes = accessModeOrder[accessMode] ?? accessModeOrder.prod;
const servers = await Server.find({
title_ids: titleID,
access_mode: { $in: searchModes }
});
for (const mode of searchModes) {
const server = servers.find(s => s.access_mode === mode);
if (server) return server;
}
return null;
}
export async function getServerByClientID(clientID: string, accessMode: string): Promise<HydratedServerDocument | null> {
const searchModes = accessModeOrder[accessMode] ?? accessModeOrder.prod;
const servers = await Server.find({
client_id: clientID,
access_mode: { $in: searchModes }
});
for (const mode of searchModes) {
const server = servers.find(s => s.access_mode === mode);
if (server) return server;
}
return null;
}
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 = discordConnectionSchema.validate(data);
if (valid.error) {
return {
app: 'api',
status: 400,
error: 'Invalid or missing connection data'
};
}
await PNID.updateOne({ pid: pnid.pid }, {
$set: {
'connections.discord.id': data.id
}
});
return {
app: 'api',
status: 200
};
}
export async function removePNIDConnection(pnid: HydratedPNIDDocument, type: string): Promise<ConnectionResponse | undefined> {
// * Add more connections later?
if (type === 'discord') {
return await removePNIDConnectionDiscord(pnid);
}
}
export async function removePNIDConnectionDiscord(pnid: HydratedPNIDDocument): Promise<ConnectionResponse> {
await PNID.updateOne({ pid: pnid.pid }, {
$set: {
'connections.discord.id': ''
}
});
return {
app: 'api',
status: 200
};
}

View File

@ -1,7 +1,9 @@
const fs = require('fs-extra');
require('colors');
import fs from 'fs-extra';
import colors from 'colors';
const root = __dirname;
colors.enable();
const root = process.env.PN_ACT_LOGGER_PATH ? process.env.PN_ACT_LOGGER_PATH : `${__dirname}/..`;
fs.ensureDirSync(`${root}/logs`);
const streams = {
@ -10,9 +12,9 @@ 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) {
export function LOG_SUCCESS(input: string): void {
const time = new Date();
input = `[${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}] [SUCCESS]: ${input}`;
streams.success.write(`${input}\n`);
@ -20,7 +22,7 @@ function success(input) {
console.log(`${input}`.green.bold);
}
function error(input) {
export function LOG_ERROR(input: string): void {
const time = new Date();
input = `[${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}] [ERROR]: ${input}`;
streams.error.write(`${input}\n`);
@ -28,7 +30,7 @@ function error(input) {
console.log(`${input}`.red.bold);
}
function warn(input) {
export function LOG_WARN(input: string): void {
const time = new Date();
input = `[${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}] [WARN]: ${input}`;
streams.warn.write(`${input}\n`);
@ -36,7 +38,7 @@ function warn(input) {
console.log(`${input}`.yellow.bold);
}
function info(input) {
export function LOG_INFO(input: string): void {
const time = new Date();
input = `[${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}] [INFO]: ${input}`;
streams.info.write(`${input}\n`);
@ -44,9 +46,6 @@ function info(input) {
console.log(`${input}`.cyan.bold);
}
module.exports = {
success,
error,
warn,
info
};
export function formatHostnames(hostnames: string[]): string {
return hostnames.map(d => `'${d}'`).join(', ');
}

View File

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

58
src/mailer.ts Normal file
View File

@ -0,0 +1,58 @@
import path from 'node:path';
import fs from 'node:fs';
import nodemailer from 'nodemailer';
import * as aws from '@aws-sdk/client-ses';
import { config, disabledFeatures } from '@/config-manager';
import { MailerOptions } from '@/types/common/mailer-options';
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: nodemailer.Transporter;
if (!disabledFeatures.email) {
const ses = new aws.SES({
apiVersion: '2010-12-01',
region: config.email.ses.region,
credentials: {
accessKeyId: config.email.ses.key,
secretAccessKey: config.email.ses.secret
}
});
transporter = transporter = nodemailer.createTransport({
SES: {
ses,
aws
}
});
}
export async function sendMail(options: MailerOptions): Promise<void> {
if (!disabledFeatures.email) {
const { to, subject, username, paragraph, preview, text, link, confirmation } = options;
let html = confirmation ? confirmationEmailTemplate : genericEmailTemplate;
html = html.replace(/{{username}}/g, username);
html = html.replace(/{{paragraph}}/g, paragraph || '');
html = html.replace(/{{preview}}/g, preview || '');
html = html.replace(/{{confirmation-href}}/g, confirmation?.href || '');
html = html.replace(/{{confirmation-code}}/g, confirmation?.code || '');
if (link) {
const { href, text } = link;
const button = `<tr><td width="100%" height="16px" style="line-height: 16px;">&nbsp;</td></tr><tr><td class="confirm-link" bgcolor="#673db6" style="font-size: 14px; font-weight: 700; border-radius: 10px; padding: 12px" align="center"><a href="${href}" style="text-decoration: none; color: #ffffff; " width="100%">${text}</a></td></tr>`;
html = html.replace(/<!--{{buttonPlaceholder}}-->/g, button);
}
await transporter.sendMail({
from: config.email.from,
to,
subject,
text,
html
});
}
}

View File

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

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

@ -0,0 +1,24 @@
import express from 'express';
import { getValueFromHeaders } from '@/util';
import { getPNIDByTokenAuth } from '@/database';
async function APIMiddleware(request: express.Request, _response: express.Response, next: express.NextFunction): Promise<void> {
const authHeader = getValueFromHeaders(request.headers, 'authorization');
if (!authHeader || !(authHeader.startsWith('Bearer'))) {
return next();
}
try {
const token = authHeader.split(' ')[1];
const pnid = await getPNIDByTokenAuth(token);
request.pnid = pnid;
} catch (error) {
// TODO - Log error
}
return next();
}
export default APIMiddleware;

View File

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

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

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

View File

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

View File

@ -0,0 +1,42 @@
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import { getValueFromHeaders } from '@/util';
const VALID_CLIENT_ID_SECRET_PAIRS: Record<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 = getValueFromHeaders(request.headers, 'x-nintendo-client-id');
const clientSecret = getValueFromHeaders(request.headers, 'x-nintendo-client-secret');
if (
!clientID ||
!clientSecret ||
!VALID_CLIENT_ID_SECRET_PAIRS[clientID] ||
clientSecret !== VALID_CLIENT_ID_SECRET_PAIRS[clientID]
) {
response.send(xmlbuilder.create({
errors: {
error: {
cause: 'client_id',
code: '0004',
message: 'API application invalid or incorrect application credentials'
}
}
}).end());
return;
}
return next();
}
export default nintendoClientHeaderCheck;

View File

@ -0,0 +1,158 @@
import crypto from 'node:crypto';
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import { Device } from '@/models/device';
import { getValueFromHeaders } from '@/util';
async function consoleStatusVerificationMiddleware(request: express.Request, response: express.Response, next: express.NextFunction): Promise<void> {
if (!request.certificate || !request.certificate.valid) {
response.status(400).send(xmlbuilder.create({
error: {
code: '0110',
message: 'Unlinked device'
}
}).end());
return;
}
const deviceIDHeader = getValueFromHeaders(request.headers, 'x-nintendo-device-id');
if (!deviceIDHeader) {
response.status(400).send(xmlbuilder.create({
error: {
code: '0002',
message: 'deviceId format is invalid'
}
}).end());
return;
}
const deviceID = Number(deviceIDHeader);
if (isNaN(deviceID)) {
response.status(400).send(xmlbuilder.create({
error: {
code: '0002',
message: 'deviceId format is invalid'
}
}).end());
return;
}
const serialNumber = getValueFromHeaders(request.headers, 'x-nintendo-serial-number');
// TODO - Verify serial numbers somehow?
// * This is difficult to do safely because serial numbers are
// * inherently insecure.
// * Information about their structure can be found here:
// * https://www.3dbrew.org/wiki/Serials
// * Given this, anyone can generate a valid serial number which
// * passes these checks, even if the serial number isn't real.
// * The 3DS also futher complicates things, as it never sends
// * the complete serial number. The 3DS omits the check digit,
// * meaning any attempt to verify the serial number of a 3DS
// * family of console will ALWAYS fail. Nintendo likely just
// * has a database of all known serials which they are able to
// * compare against. We are not so lucky
if (!serialNumber) {
response.status(400).send(xmlbuilder.create({
error: {
code: '0002',
message: 'serialNumber format is invalid'
}
}).end());
return;
}
let device = await Device.findOne({
serial: serialNumber,
});
const certificateHash = crypto.createHash('sha256').update(request.certificate._certificate).digest('base64');
if (!device && request.certificate.consoleType === '3ds') {
// * A 3DS console document will ALWAYS be created by NASC before
// * Hitting the NNAS server. NASC stores the serial number at
// * the time the device document was created. Therefore we can
// * know that serial tampering happened on the 3DS if this fails
// * to find a device document.
response.status(400).send(xmlbuilder.create({
error: {
code: '0002',
message: 'serialNumber format is invalid'
}
}).end());
return;
} else if (device && !device.certificate_hash && request.certificate.consoleType === '3ds') {
device.certificate_hash = certificateHash;
await device.save();
}
device = await Device.findOne({
certificate_hash: certificateHash,
});
if (!device) {
// * Device must be a fresh Wii U
device = await Device.create({
model: 'wup',
device_id: deviceID,
serial: serialNumber,
linked_pids: [],
certificate_hash: certificateHash
});
}
if (device.serial !== serialNumber) {
// TODO - Change this to a different error
response.status(400).send(xmlbuilder.create({
error: {
cause: 'Bad Request',
code: '1600',
message: 'Unable to process request'
}
}).end());
return;
}
const certificateDeviceID = parseInt(request.certificate.certificateName.slice(2).split('-')[0], 16);
if (deviceID !== certificateDeviceID) {
// TODO - Change this to a different error
response.status(400).send(xmlbuilder.create({
error: {
cause: 'Bad Request',
code: '1600',
message: 'Unable to process request'
}
}).end());
return;
}
if (device.access_level < 0) {
response.status(400).send(xmlbuilder.create({
errors: {
error: {
code: '0012',
message: 'Device has been banned by game server' // TODO - This is not the right error message
}
}
}).end());
return;
}
request.device = device;
return next();
}
export default consoleStatusVerificationMiddleware;

View File

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

View File

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

View File

@ -0,0 +1,14 @@
import express from 'express';
export function restrictHostnames<TFn extends express.Router>(
allowedHostnames: string[],
fn: TFn
): (request: express.Request, response: express.Response, next: () => void) => void | TFn {
return (request: express.Request, response: express.Response, next: () => void) => {
if (allowedHostnames.includes(request.hostname)) {
return fn(request, response, next);
}
return next();
};
}

View File

@ -1,213 +0,0 @@
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');
async function NASCMiddleware(request, response, next) {
const requestParams = request.body;
if (!requestParams.action ||
!requestParams.fcdcert ||
!requestParams.csnum ||
!requestParams.macadr ||
!requestParams.titleid ||
!requestParams.servertype
) {
return response.status(200).send(util.nascError('null')); // This is what Nintendo sends
}
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 macAddressHash = crypto.createHash('sha256').update(macAddress).digest('base64');
const fcdcertHash = crypto.createHash('sha256').update(fcdcert).digest('base64');
let pid;
let pidHmac;
let password;
if (requestParams.userid) {
pid = util.nintendoBase64Decode(requestParams.userid).toString();
}
if (requestParams.uidhmac) {
pidHmac = util.nintendoBase64Decode(requestParams.uidhmac).toString();
}
if (requestParams.passwd) {
password = util.nintendoBase64Decode(requestParams.passwd).toString();
}
if (action !== 'LOGIN' && action !== 'SVCLOC') {
return response.status(200).send(util.nascError('null')); // This is what Nintendo sends
}
const cert = new NintendoCertificate(fcdcert);
if (!cert.valid) {
return response.status(200).send(util.nascError('121'));
}
if (!validNintendoMACAddress(macAddress)) {
return response.status(200).send(util.nascError('null'));
}
let model;
switch (serialNumber[0]) {
case 'C':
model = 'ctr';
break;
case 'S':
model = 'spr';
break;
case 'A':
model = 'ftr';
break;
case 'Y':
model = 'ktr';
break;
case 'Q':
model = 'red';
break;
case 'N':
model = 'jan';
break;
}
if (!model) {
return response.status(200).send(util.nascError('null'));
}
let device = await Device.findOne({
model,
serial: serialNumber,
environment,
mac_hash: macAddressHash,
fcdcert_hash: fcdcertHash,
});
if (device) {
if (device.get('access_level') < 0) {
return response.status(200).send(util.nascError('102'));
}
if (pid) {
const linkedPIDs = device.get('linked_pids');
if (!linkedPIDs.includes(pid)) {
return response.status(200).send(util.nascError('102'));
}
}
}
if (titleID === '0004013000003202') {
if (password && !pid && !pidHmac) {
// Register new user
const session = await database.connection.startSession();
await session.startTransaction();
try {
// Create new NEX account
const nexAccount = await new NEXAccount({
device_type: '3ds',
password
});
await nexAccount.generatePID();
await nexAccount.save({ session });
pid = nexAccount.get('pid');
// Set password
if (!device) {
device = new Device({
is_emulator: false,
model,
serial: serialNumber,
environment,
mac_hash: macAddressHash,
fcdcert_hash: fcdcertHash,
linked_pids: [pid]
});
} else {
device.linked_pids.push(pid);
}
await device.save({ session });
await session.commitTransaction();
} catch (error) {
logger.error('[NASC] REGISTER ACCOUNT: ' + error);
await session.abortTransaction();
// 3DS expects 200 even on error
return response.status(200).send(util.nascError('102'));
} finally {
// * This runs regardless of failure
// * Returning on catch will not prevent this from running
await session.endSession();
}
}
}
const nexUser = await NEXAccount.findOne({ pid });
if (!nexUser || nexUser.get('access_level') < 0) {
return response.status(200).send(util.nascError('102'));
}
request.nexUser = nexUser;
return next();
}
// https://www.adminsub.net/mac-address-finder/nintendo
// Saves us from doing an OUI lookup each time
const NINTENDO_VENDER_OUIS = [
'ECC40D', 'E84ECE', 'E0F6B5', 'E0E751', 'E00C7F', 'DC68EB',
'D86BF7', 'D4F057', 'CCFB65', 'CC9E00', 'B8AE6E', 'B88AEC',
'B87826', 'A4C0E1', 'A45C27', 'A438CC', '9CE635', '98E8FA',
'98B6E9', '98415C', '9458CB', '8CCDE8', '8C56C5', '7CBB8A',
'78A2A0', '7048F7', '64B5C6', '606BFF', '5C521E', '58BDA3',
'582F40', '48A5E7', '40F407', '40D28A', '34AF2C', '342FBD',
'2C10C1', '182A7B', '0403D6', '002709', '002659', '0025A0',
'0024F3', '002444', '00241E', '0023CC', '002331', '0022D7',
'0022AA', '00224C', '0021BD', '002147', '001FC5', '001F32',
'001EA9', '001E35', '001DBC', '001CBE', '001BEA', '001B7A',
'001AE9', '0019FD', '00191D', '0017AB', '001656', '0009BF',
'ECC40D', 'E84ECE', 'E0F6B5', 'E0E751', 'E00C7F', 'DC68EB',
'D86BF7', 'D4F057', 'CCFB65', 'CC9E00', 'B8AE6E', 'B88AEC',
'B87826', 'A4C0E1', 'A45C27', 'A438CC', '9CE635', '98E8FA',
'98B6E9', '98415C', '9458CB', '8CCDE8', '8C56C5', '7CBB8A',
'78A2A0', '7048F7', '64B5C6', '606BFF', '5C521E', '58BDA3',
'582F40', '48A5E7', '40F407', '40D28A', '34AF2C', '342FBD',
'2C10C1', '182A7B', '0403D6', '002709', '002659', '0025A0',
'0024F3', '002444', '00241E', '0023CC', '002331', '0022D7',
'0022AA', '00224C', '0021BD', '002147', '001FC5', '001F32',
'001EA9', '001E35', '001DBC', '001CBE', '001BEA', '001B7A',
'001AE9', '0019FD', '00191D', '0017AB', '001656', '0009BF'
];
// TODO: Make something better
const MAC_REGEX = /^[0-9a-fA-F]{12}$/;
// Maybe should later parse more data out
function validNintendoMACAddress(macAddress) {
if (!NINTENDO_VENDER_OUIS.includes(macAddress.substring(0, 6).toUpperCase())) {
return false;
}
return MAC_REGEX.test(macAddress);
}
module.exports = NASCMiddleware;

274
src/middleware/nasc.ts Normal file
View File

@ -0,0 +1,274 @@
import crypto from 'node:crypto';
import express from 'express';
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';
async function NASCMiddleware(request: express.Request, response: express.Response, next: express.NextFunction): Promise<void> {
const requestParams: NASCRequestParams = request.body;
if (!requestParams.action ||
!requestParams.fcdcert ||
!requestParams.csnum ||
!requestParams.macadr ||
!requestParams.titleid ||
!requestParams.servertype
) {
response.status(200).send(nascError('null').toString()); // * This is what Nintendo sends
return;
}
const action = nintendoBase64Decode(requestParams.action).toString();
const fcdcert = nintendoBase64Decode(requestParams.fcdcert);
const serialNumber = nintendoBase64Decode(requestParams.csnum).toString();
const macAddress = nintendoBase64Decode(requestParams.macadr).toString();
const titleID = nintendoBase64Decode(requestParams.titleid).toString();
const environment = nintendoBase64Decode(requestParams.servertype).toString();
const macAddressHash = crypto.createHash('sha256').update(macAddress).digest('base64');
const fcdcertHash = crypto.createHash('sha256').update(fcdcert).digest('base64');
let pid = 0; // * Real PIDs are always positive and non-zero
let pidHmac = '';
let password = '';
if (requestParams.userid) {
pid = Number(nintendoBase64Decode(requestParams.userid).toString());
}
if (requestParams.uidhmac) {
pidHmac = nintendoBase64Decode(requestParams.uidhmac).toString();
}
if (requestParams.passwd) {
password = nintendoBase64Decode(requestParams.passwd).toString();
}
if (action !== 'LOGIN' && action !== 'SVCLOC') {
response.status(200).send(nascError('null').toString()); // * This is what Nintendo sends
return;
}
const cert = new NintendoCertificate(fcdcert);
if (!cert.valid) {
response.status(200).send(nascError('121').toString());
return;
}
if (!validNintendoMACAddress(macAddress)) {
response.status(200).send(nascError('null').toString());
return;
}
let model = '';
switch (serialNumber[0]) {
case 'C':
model = 'ctr';
break;
case 'S':
model = 'spr';
break;
case 'A':
model = 'ftr';
break;
case 'Y':
model = 'ktr';
break;
case 'Q':
model = 'red';
break;
case 'N':
model = 'jan';
break;
}
if (!model) {
response.status(200).send(nascError('null').toString());
return;
}
let nexAccount = null;
if (pid) {
nexAccount = await NEXAccount.findOne({ pid });
// TODO - 102 is a DEVICE ban. Is there an error for ACCOUNT bans?
if (!nexAccount || nexAccount.access_level < 0) {
response.status(200).send(nascError('102').toString());
return;
}
}
let device = await Device.findOne({
fcdcert_hash: fcdcertHash,
});
if (device) {
if (device.access_level < 0) {
response.status(200).send(nascError('102').toString());
return;
}
if (pid) {
const linkedPIDs = device.linked_pids;
// * If a user performs a system transfer from
// * a console to another using a Nintendo account
// * during the transfer and both consoles have
// * a Pretendo account, the new device won't have
// * the user's PID.
// *
// * So, the linked PIDs won't have the user's PID
// * anymore.
if (!linkedPIDs.includes(pid)) {
device.linked_pids.push(pid);
await device.save();
}
}
if (device.serial !== serialNumber) {
// * 150 is a custom error code
response.status(200).send(nascError('150').toString());
return;
}
}
// * Workaround for edge case on system transfers
// * if a console that has a Pretendo account performs
// * a system transfer using the Nintendo account to
// * another that doesn't have a Pretendo account.
// *
// * This would make the Pretendo account to not have
// * a device on the database.
if (!device && pid) {
device = new Device({
model,
serial: serialNumber,
environment,
mac_hash: macAddressHash,
fcdcert_hash: fcdcertHash,
linked_pids: [pid]
});
await device.save();
}
if (titleID === '0004013000003202') {
if (password && !pid && !pidHmac) {
// * Register new user
const session = await databaseConnection().startSession();
await session.startTransaction();
try {
// * Create new NEX account
nexAccount = new NEXAccount({
device_type: '3ds',
password
});
await nexAccount.generatePID();
await nexAccount.save({ session });
pid = nexAccount.pid;
const pidBuffer = Buffer.alloc(4);
pidBuffer.writeUInt32LE(pid);
const hash = crypto.createHash('sha1').update(pidBuffer);
const pidHash = hash.digest();
const checksum = pidHash[0] >> 1;
const hex = checksum.toString(16) + pid.toString(16);
const int = parseInt(hex, 16);
const friendCode = int.toString().padStart(12, '0').match(/.{1,4}/g)!.join('-');
nexAccount.friend_code = friendCode;
await nexAccount.save({ session });
// * Set password
if (!device) {
device = new Device({
model,
serial: serialNumber,
environment,
mac_hash: macAddressHash,
fcdcert_hash: fcdcertHash,
linked_pids: [pid]
});
} else {
device.linked_pids.push(pid);
}
await device.save({ session });
await session.commitTransaction();
} catch (error) {
LOG_ERROR('[NASC] REGISTER ACCOUNT: ' + error);
await session.abortTransaction();
// * 151 is a custom error code
response.status(200).send(nascError('151').toString());
return;
} finally {
// * This runs regardless of failure
// * Returning on catch will not prevent this from running
await session.endSession();
}
}
}
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 = [
'ECC40D', 'E84ECE', 'E0F6B5', 'E0E751', 'E00C7F', 'DC68EB',
'D86BF7', 'D4F057', 'CCFB65', 'CC9E00', 'B8AE6E', 'B88AEC',
'B87826', 'A4C0E1', 'A45C27', 'A438CC', '9CE635', '98E8FA',
'98B6E9', '98415C', '9458CB', '8CCDE8', '8C56C5', '7CBB8A',
'78A2A0', '7048F7', '64B5C6', '606BFF', '5C521E', '58BDA3',
'582F40', '48A5E7', '40F407', '40D28A', '34AF2C', '342FBD',
'2C10C1', '182A7B', '0403D6', '002709', '002659', '0025A0',
'0024F3', '002444', '00241E', '0023CC', '002331', '0022D7',
'0022AA', '00224C', '0021BD', '002147', '001FC5', '001F32',
'001EA9', '001E35', '001DBC', '001CBE', '001BEA', '001B7A',
'001AE9', '0019FD', '00191D', '0017AB', '001656', '0009BF',
'ECC40D', 'E84ECE', 'E0F6B5', 'E0E751', 'E00C7F', 'DC68EB',
'D86BF7', 'D4F057', 'CCFB65', 'CC9E00', 'B8AE6E', 'B88AEC',
'B87826', 'A4C0E1', 'A45C27', 'A438CC', '9CE635', '98E8FA',
'98B6E9', '98415C', '9458CB', '8CCDE8', '8C56C5', '7CBB8A',
'78A2A0', '7048F7', '64B5C6', '606BFF', '5C521E', '58BDA3',
'582F40', '48A5E7', '40F407', '40D28A', '34AF2C', '342FBD',
'2C10C1', '182A7B', '0403D6', '002709', '002659', '0025A0',
'0024F3', '002444', '00241E', '0023CC', '002331', '0022D7',
'0022AA', '00224C', '0021BD', '002147', '001FC5', '001F32',
'001EA9', '001E35', '001DBC', '001CBE', '001BEA', '001B7A',
'001AE9', '0019FD', '00191D', '0017AB', '001656', '0009BF'
];
// TODO - Make something better
const MAC_REGEX = /^[0-9a-fA-F]{12}$/;
// * Maybe should later parse more data out
function validNintendoMACAddress(macAddress: string): boolean {
if (!NINTENDO_VENDER_OUIS.includes(macAddress.substring(0, 6).toUpperCase())) {
return false;
}
return MAC_REGEX.test(macAddress);
}
export default NASCMiddleware;

View File

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

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

@ -0,0 +1,87 @@
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import { getValueFromHeaders } from '@/util';
import { getPNIDByBasicAuth, getPNIDByTokenAuth } from '@/database';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
async function PNIDMiddleware(request: express.Request, response: express.Response, next: express.NextFunction): Promise<void> {
const authHeader = getValueFromHeaders(request.headers, 'authorization');
if (!authHeader || !(authHeader.startsWith('Bearer') || authHeader.startsWith('Basic'))) {
return next();
}
const parts = authHeader.split(' ');
const type = parts[0];
let token = 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 getPNIDByTokenAuth(token);
}
if (!pnid) {
if (type === 'Bearer') {
response.status(401).send(xmlbuilder.create({
errors: {
error: {
cause: 'access_token',
code: '0005',
message: 'Invalid access token'
}
}
}).end());
return;
}
response.status(401).send(xmlbuilder.create({
errors: {
error: {
code: '1105',
message: 'Email address, username, or password, is not valid'
}
}
}).end());
return;
}
if (pnid.deleted) {
response.status(400).send(xmlbuilder.create({
errors: {
error: {
code: '0112',
message: pnid.username
}
}
}).end());
return;
}
if (pnid.access_level < 0) {
response.status(400).send(xmlbuilder.create({
errors: {
error: {
code: '0108',
message: 'Account has been banned'
}
}
}).end());
return;
}
request.pnid = pnid;
return next();
}
export default PNIDMiddleware;

View File

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

View File

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

View File

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

View File

@ -0,0 +1,55 @@
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 = getValueFromHeaders(request.headers, 'content-type');
const contentLength = getValueFromHeaders(request.headers, 'content-length');
let body = '';
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) {
// TODO - This is not a real error code, check to see if better one exists
return response.status(401).send(xmlbuilder.create({
errors: {
error: {
code: '0004',
message: 'XML parse error'
}
}
}).end());
}
next();
});
} else {
next();
}
}
export default XMLMiddleware;

View File

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

View File

@ -1,58 +0,0 @@
const { Schema, model } = require('mongoose');
const DeviceAttributeSchema = new Schema({
created_date: String,
name: String,
value: String,
});
const DeviceAttribute = model('DeviceAttribute', DeviceAttributeSchema);
const DeviceSchema = new Schema({
is_emulator: {
type: Boolean,
default: false
},
model: {
type: String,
enum: [
'wup', // Nintendo Wii U
'ctr', // Nintendo 3DS
'spr', // Nintendo 3DS XL
'ftr', // Nintendo 2DS
'ktr', // New Nintendo 3DS
'red', // New Nintendo 3DS XL
'jan' // New Nintendo 2DS XL
]
},
device_id: Number,
device_type: Number,
serial: String,
device_attributes: [DeviceAttributeSchema],
soap: {
token: String,
account_id: Number,
},
// 3DS-specific stuff
environment: String,
mac_hash: String,
fcdcert_hash: String,
linked_pids: [Number],
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
}
});
const Device = model('Device', DeviceSchema);
module.exports = {
DeviceSchema,
Device,
DeviceAttributeSchema,
DeviceAttribute
};

47
src/models/device.ts Normal file
View File

@ -0,0 +1,47 @@
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<IDeviceAttribute, DeviceAttributeModel, IDeviceAttributeMethods>({
created_date: String,
name: String,
value: String
});
export const DeviceSchema = new Schema<IDevice, DeviceModel, IDeviceMethods>({
model: {
type: String,
enum: [
'wup', // * Nintendo Wii U
'ctr', // * Nintendo 3DS
'spr', // * Nintendo 3DS XL
'ftr', // * Nintendo 2DS
'ktr', // * New Nintendo 3DS
'red', // * New Nintendo 3DS XL
'jan' // * New Nintendo 2DS XL
]
},
device_id: Number,
device_type: Number,
serial: String,
device_attributes: [DeviceAttributeSchema],
soap: {
token: String,
account_id: Number,
},
environment: String,
mac_hash: String, // * 3DS-specific
fcdcert_hash: String, // * 3DS-specific
linked_pids: [Number],
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
},
certificate_hash: String
});
export const Device = model<IDevice, DeviceModel>('Device', DeviceSchema);

View File

@ -1,11 +1,12 @@
const { Schema, model } = require('mongoose');
const uniqueValidator = require('mongoose-unique-validator');
import { Schema, model } from 'mongoose';
import uniqueValidator from 'mongoose-unique-validator';
import { INEXAccount, INEXAccountMethods, NEXAccountModel } from '@/types/mongoose/nex-account';
const NEXAccountSchema = new Schema({
const NEXAccountSchema = new Schema<INEXAccount, NEXAccountModel, INEXAccountMethods>({
device_type: {
type: String,
enum: [
// Only track the family here not the model
// * Only track the family here not the model
'wiiu',
'3ds',
]
@ -18,12 +19,13 @@ const NEXAccountSchema = new Schema({
owning_pid: Number,
access_level: {
type: Number,
default: 0 // 0: standard, 1: tester, 2: mod?, 3: dev
default: 0 // * 0: standard, 1: tester, 2: mod?, 3: dev
},
server_access_level: {
type: String,
default: 'prod' // everyone is in production by default
default: 'prod' // * everyone is in production by default
},
friend_code: String
});
NEXAccountSchema.plugin(uniqueValidator, { message: '{PATH} already in use.' });
@ -36,21 +38,23 @@ 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
NEXAccountSchema.method('generatePID', async function generatePID(): Promise<void> {
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 pid = Math.floor(Math.random() * (max - min + 1) + min);
const inuse = 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() {
NEXAccountSchema.method('generatePassword', function generatePassword(): void {
function character(): string | number {
const offset = Math.floor(Math.random() * 62);
if (offset < 10) return offset;
if (offset < 36) return String.fromCharCode(offset + 55);
@ -60,15 +64,10 @@ NEXAccountSchema.methods.generatePassword = function () {
const output = [];
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 = model<INEXAccount, NEXAccountModel>('NEXAccount', NEXAccountSchema);

View File

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

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

@ -0,0 +1,293 @@
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 Stripe from 'stripe';
import { DeviceSchema } from '@/models/device';
import { uploadCDNAsset } from '@/util';
import { LOG_ERROR, LOG_WARN } from '@/logger';
import { IPNID, IPNIDMethods, PNIDModel } from '@/types/mongoose/pnid';
import { PNIDPermissionFlag } from '@/types/common/permission-flags';
import { config } from '@/config-manager';
let stripe: Stripe;
if (config.stripe?.secret_key) {
stripe = new Stripe(config.stripe.secret_key, {
apiVersion: '2022-11-15',
typescript: true,
});
}
const PNIDSchema = new Schema<IPNID, PNIDModel, IPNIDMethods>({
deleted: {
type: Boolean,
default: false
},
permissions: {
type: BigInt,
default: 0n
},
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: Number
},
region: Number,
timezone: {
name: String,
offset: Number
},
mii: {
name: String,
primary: Boolean,
data: String,
id: Number,
hash: String,
image_url: String,
image_id: Number,
},
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
},
stripe: {
customer_id: String,
subscription_id: String,
price_id: String,
tier_level: Number,
tier_name: String,
latest_webhook_timestamp: Number
}
}
}, { 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 = 1000000000; // * The console (WiiU) seems to not accept PIDs smaller than this
const max = 1799999999;
const pid = Math.floor(Math.random() * (max - min + 1) + min);
const inuse = 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 = 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 = crypto.randomBytes(32).toString('hex');
const inuse = 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 }: { name: string; primary: string; data: string; }): 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 = this.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, Uint8Array.from(pngData.data), false);
const userMiiKey = `mii/${this.pid}`;
await uploadCDNAsset(config.s3.bucket, `${userMiiKey}/standard.tga`, tga, 'public-read');
await uploadCDNAsset(config.s3.bucket, `${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 uploadCDNAsset(config.s3.bucket, `${userMiiKey}/${expression}.png`, miiStudioExpressionImageData, 'public-read');
}
const miiStudioBodyUrl = mii.studioUrl({
type: 'all_body',
width: 270,
instanceCount: 1,
});
const miiStudioBodyImageData = await got(miiStudioBodyUrl).buffer();
await uploadCDNAsset(config.s3.bucket, `${userMiiKey}/body.png`, miiStudioBodyImageData, 'public-read');
});
PNIDSchema.method('scrub', async function scrub() {
// * Remove all personal info from a PNID
// * Username and PID remain so thye do not get assigned again
if (this.connections?.stripe?.subscription_id) {
try {
if (stripe) {
await stripe.subscriptions.del(this.connections.stripe.subscription_id);
} else {
LOG_WARN(`SCRUBBING USER DATA FOR USER ${this.username}. HAS STRIPE SUBSCRIPTION ${this.connections.stripe.subscription_id}, BUT STRIPE CLIENT NOT ENABLED. SUBSCRIPTION NOT CANCELED`);
}
} catch (error) {
LOG_ERROR(`ERROR REMOVING ${this.username} STRIPE SUBSCRIPTION. ${error}`);
}
}
await this.updateMii({
name: 'Default',
primary: 'Y',
data: 'AwAAQOlVognnx0GC2/uogAOzuI0n2QAAAEBEAGUAZgBhAHUAbAB0AAAAAAAAAEBAAAAhAQJoRBgmNEYUgRIXaA0AACkAUkhQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGm9'
});
this.deleted = true;
this.access_level = 0;
this.server_access_level = 'prod';
this.creation_date = '';
this.birthdate = '';
this.gender = '';
this.country = '';
this.language = '';
this.email.address = '';
this.email.primary = false;
this.email.parent = false;
this.email.reachable = false;
this.email.validated = false;
this.email.validated_date = '';
this.email.id = 0;
this.region = 0;
this.timezone.name = '';
this.timezone.offset = 0;
this.mii.id = 0;
this.mii.hash = '';
this.mii.image_url = '';
this.mii.image_id = 0;
this.flags.active = false;
this.flags.marketing = false;
this.flags.off_device = false;
this.connections.discord.id = '';
this.connections.stripe.customer_id = '';
this.connections.stripe.subscription_id = '';
this.connections.stripe.price_id = '';
this.connections.stripe.tier_level = 0;
this.connections.stripe.tier_name = '';
this.connections.stripe.latest_webhook_timestamp = 0;
});
PNIDSchema.method('hasPermission', function hasPermission(flag: PNIDPermissionFlag): boolean {
return (this.permissions & flag) === flag;
});
PNIDSchema.method('addPermission', function addPermission(flag: PNIDPermissionFlag): void {
this.permissions |= flag;
});
PNIDSchema.method('clearPermission', function clearPermission(flag: PNIDPermissionFlag): void {
this.permissions &= ~flag;
});
export const PNID = model<IPNID, PNIDModel>('PNID', PNIDSchema);

View File

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

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

@ -0,0 +1,21 @@
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>({
client_id: String,
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 = model<IServer, ServerModel>('Server', ServerSchema);

View File

@ -1,7 +1,6 @@
// Parse Nintendo certificates
const crypto = require('crypto');
const NodeRSA = require('node-rsa');
import crypto from 'node:crypto';
import NodeRSA from 'node-rsa';
import { SignatureSize } from '@/types/common/signature-size';
const WIIU_DEVICE_PUB_PEM = `-----BEGIN PUBLIC KEY-----
MFIwEAYHKoZIzj0CAQYFK4EEABsDPgAEAP1WBBgs8XUJIQDDCK5IOZEbb5+h1TqV
@ -42,46 +41,61 @@ const CTR_LFCS_B_PUB = Buffer.from([
0xAF, 0x07, 0xEB, 0x9C, 0xBF, 0xA9, 0xC9
]);
// Signature options
// * 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;
consoleType: string;
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);
this.consoleType = '';
if (certificate) {
if (certificate instanceof Buffer) {
@ -91,19 +105,19 @@ class NintendoCertificate {
}
this._parseCertificateData();
this._verifySignature();
}
}
_parseCertificateData() {
_parseCertificateData(): void {
if (this._certificate.length === 0x110) {
// Assume fcdcert (3DS LFCS)
// * Assume fcdcert (3DS LFCS)
this.consoleType = '3ds';
this.signature = this._certificate.subarray(0x0, 0x100);
this._certificateBody = this._certificate.subarray(0x100);
this._verifySignatureLFCS();
} else {
// Assume regular certificate
// * Assume regular certificate
this.signatureType = this._certificate.readUInt32BE(0x00);
const signatureTypeSizes = this._signatureTypeSizes(this.signatureType);
@ -114,12 +128,20 @@ class NintendoCertificate {
this.issuer = this._certificate.subarray(0x80, 0xC0).toString().split('\0')[0];
this.keyType = this._certificate.readUInt32BE(0xC0);
this.certificateName = this._certificate.subarray(0xC4, 0x104).toString().split('\0')[0];
this.ngKeyId = this._certificate.readUInt32BE(0x104);
this.ngKeyID = this._certificate.readUInt32BE(0x104);
this.publicKeyData = this._certificate.subarray(0x108);
if (this.issuer === 'Root-CA00000003-MS00000012') {
this.consoleType = 'wiiu';
} else {
this.consoleType = '3ds';
}
this._verifySignature();
}
}
_signatureTypeSizes(signatureType) {
_signatureTypeSizes(signatureType: number): SignatureSize {
switch (signatureType) {
case 0x10000:
return SIGNATURE_SIZES.RSA_4096_SHA1;
@ -138,7 +160,7 @@ class NintendoCertificate {
}
}
_verifySignature() {
_verifySignature(): void {
switch (this.keyType) {
case 0x0:
this._verifySignatureRSA4096();
@ -157,7 +179,7 @@ class NintendoCertificate {
}
}
_verifySignatureRSA4096() {
_verifySignatureRSA4096(): void {
const publicKey = new NodeRSA();
publicKey.importKey({
@ -168,7 +190,7 @@ class NintendoCertificate {
this.valid = publicKey.verify(this._certificateBody, this.signature);
}
_verifySignatureRSA2048() {
_verifySignatureRSA2048(): void {
const publicKey = new NodeRSA();
publicKey.importKey({
@ -179,21 +201,21 @@ class NintendoCertificate {
this.valid = publicKey.verify(this._certificateBody, this.signature);
}
// Huge thanks to Myria for helping get ECDSA working
// 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';
// * Huge thanks to Myria for helping get ECDSA working
// * with Nodes native crypto module and getting the keys
// * from bytes to PEM!
// * https://github.com/Myriachan
_verifySignatureECDSA(): void {
const pem = this.consoleType === 'wiiu' ? WIIU_DEVICE_PUB_PEM : CTR_DEVICE_PUB_PEM;
const key = {
key: pem,
dsaEncoding: 'ieee-p1363' as crypto.DSAEncoding
};
this.valid = crypto.verify('sha256', this._certificateBody, key, this.signature);
}
_verifySignatureLFCS() {
_verifySignatureLFCS(): void {
const publicKey = new NodeRSA();
publicKey.importKey({
@ -205,4 +227,4 @@ class NintendoCertificate {
}
}
module.exports = NintendoCertificate;
export default NintendoCertificate;

View File

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

122
src/server.ts Normal file
View File

@ -0,0 +1,122 @@
process.title = 'Pretendo - Account';
process.on('uncaughtException', (err, origin) => {
console.log(err);
console.log(origin);
});
process.on('SIGTERM', () => {
process.exit(0);
});
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 { startGRPCServer } from '@/services/grpc/server';
import { fullUrl, getValueFromHeaders } from '@/util';
import { LOG_INFO, LOG_SUCCESS, LOG_WARN } from '@/logger';
import conntest from '@/services/conntest';
import cbvc from '@/services/cbvc';
import nnas from '@/services/nnas';
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, disabledFeatures } from '@/config-manager';
const app = express();
// * START APPLICATION
app.set('view engine', 'ejs');
app.set('views', __dirname + '/views');
// * 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(cbvc);
app.use(nnas);
app.use(nasc);
app.use(api);
app.use(localcdn);
app.use(assets);
if (!disabledFeatures.datastore) {
app.use(datastore);
}
// * 404 handler
LOG_INFO('Creating 404 status handler');
app.use((request: express.Request, response: express.Response): void => {
const url = fullUrl(request);
let deviceID = 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): void => {
const status = error.status || 500;
const url = fullUrl(request);
let deviceID = getValueFromHeaders(request.headers, 'X-Nintendo-Device-ID');
if (!deviceID) {
deviceID = 'Unknown';
}
LOG_WARN(`HTTP ${status} at ${url} from ${deviceID}: ${error.message}`);
response.status(status).json({
app: 'api',
status,
error: error.message
});
});
async function main(): Promise<void> {
// * Starts the server
LOG_INFO('Starting server');
await connectDatabase();
LOG_SUCCESS('Database connected');
await connectCache();
LOG_SUCCESS('Cache enabled');
await startGRPCServer();
LOG_SUCCESS(`gRPC server started on port ${config.grpc.port}`);
app.listen(config.http.port, () => {
LOG_SUCCESS(`HTTP server started on port ${config.http.port}`);
});
}
main().catch(console.error);

View File

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

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

@ -0,0 +1,36 @@
import express from 'express';
import cors from 'cors';
import APIMiddleware from '@/middleware/api';
import { formatHostnames, LOG_INFO } from '@/logger';
import { V1 } from '@/services/api/routes';
import { config } from '@/config-manager';
import { restrictHostnames } from '@/middleware/host-limit';
// * Router to handle the subdomain restriction
const api = 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();
// * Create domains
LOG_INFO(`[USER API] Registering api router with domains: ${formatHostnames(config.domains.api)}`);
router.use(restrictHostnames(config.domains.api, api));
export default router;

View File

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

View File

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

View File

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

View File

@ -0,0 +1,105 @@
import express from 'express';
import { addPNIDConnection, removePNIDConnection } from '@/database';
const router = express.Router();
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: express.Request, response: express.Response): Promise<void> => {
const data = request.body?.data;
const pnid = request.pnid;
const type = request.params.type;
if (!pnid) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing access token'
});
return;
}
if (!data) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing connection data'
});
return;
}
if (!VALID_CONNECTION_TYPES.includes(type)) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing connection type'
});
return;
}
let result = 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): Promise<void> => {
const pnid = request.pnid;
const type = request.params.type;
if (!pnid) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing access token'
});
return;
}
if (!VALID_CONNECTION_TYPES.includes(type)) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing connection type'
});
return;
}
let result = await removePNIDConnection(pnid, type);
if (!result) {
result = {
app: 'api',
status: 500,
error: 'Unknown server error'
};
}
response.status(result.status).json(result);
});
export default router;

View File

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

View File

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

View File

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

View File

@ -0,0 +1,70 @@
import express from 'express';
import validator from 'validator';
import hcaptcha from 'hcaptcha';
import { getPNIDByEmailAddress, getPNIDByUsername } from '@/database';
import { sendForgotPasswordEmail } from '@/util';
import { config, disabledFeatures } from '@/config-manager';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
const router = express.Router();
router.post('/', async (request: express.Request, response: express.Response): Promise<void> => {
const input = request.body?.input;
const hCaptchaResponse = request.body.hCaptchaResponse?.trim();
if (!disabledFeatures.captcha) {
if (!hCaptchaResponse || hCaptchaResponse === '') {
response.status(400).json({
app: 'api',
status: 400,
error: 'Must fill in captcha',
});
return;
}
const captchaVerify = await hcaptcha.verify(
config.hcaptcha.secret,
hCaptchaResponse
);
if (!captchaVerify.success) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Captcha verification failed',
});
return;
}
}
if (!input || input.trim() === '') {
response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing input'
});
return;
}
let pnid: HydratedPNIDDocument | null;
if (validator.isEmail(input)) {
pnid = await getPNIDByEmailAddress(input);
} else {
pnid = await getPNIDByUsername(input);
}
if (pnid) {
await sendForgotPasswordEmail(pnid);
}
response.json({
app: 'api',
status: 200
});
});
export default router;

View File

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

View File

@ -0,0 +1,146 @@
import express from 'express';
import bcrypt from 'bcrypt';
import { getPNIDByUsername, getPNIDByTokenAuth } from '@/database';
import { nintendoPasswordHash, generateToken} from '@/util';
import { config } from '@/config-manager';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
const 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): Promise<void> => {
const grantType = request.body?.grant_type;
const username = request.body?.username;
const password = request.body?.password;
const refreshToken = request.body?.refresh_token;
if (!['password', 'refresh_token'].includes(grantType)) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid grant type'
});
return;
}
if (grantType === 'password' && (!username || username.trim() === '')) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing username'
});
return;
}
if (grantType === 'password' && (!password || password.trim() === '')) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing password'
});
return;
}
if (grantType === 'refresh_token' && (!refreshToken || refreshToken.trim() === '')) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing refresh token'
});
return;
}
let pnid: HydratedPNIDDocument | null;
if (grantType === 'password') {
pnid = await getPNIDByUsername(username);
if (!pnid) {
response.status(400).json({
app: 'api',
status: 400,
error: 'User not found'
});
return;
}
const hashedPassword = nintendoPasswordHash(password, pnid.pid);
if (!pnid || !bcrypt.compareSync(hashedPassword, pnid.password)) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing password'
});
return;
}
} else {
pnid = await getPNIDByTokenAuth(refreshToken);
if (!pnid) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing refresh token'
});
return;
}
}
if (pnid.deleted) {
response.status(400).json({
app: 'api',
status: 400,
error: 'User not found'
});
return;
}
const accessTokenOptions = {
system_type: 0x3, // * API
token_type: 0x1, // * OAuth Access
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const refreshTokenOptions = {
system_type: 0x3, // * API
token_type: 0x2, // * OAuth Refresh
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const accessTokenBuffer = await generateToken(config.aes_key, accessTokenOptions);
const refreshTokenBuffer = await generateToken(config.aes_key, refreshTokenOptions);
const accessToken = accessTokenBuffer ? accessTokenBuffer.toString('hex') : '';
const newRefreshToken = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : '';
// TODO - Handle null tokens
response.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: newRefreshToken
});
});
export default router;

View File

@ -1,28 +1,31 @@
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 { 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 { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
// This sucks
const router = express.Router();
const PNID_VALID_CHARACTERS_REGEX = /^[\w\-.]*$/;
const PNID_PUNCTUATION_START_REGEX = /^[_\-.]/;
const PNID_PUNCTUATION_END_REGEX = /[_\-.]$/;
const PNID_PUNCTUATION_DUPLICATE_REGEX = /[_\-.]{2,}/;
// * 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_WORD_OR_PUNCTUATION_REGEX = /(?=.*[a-zA-Z])(?=.*[_\-.]).*/;
const PASSWORD_NUMBER_OR_PUNCTUATION_REGEX = /(?=.*\d)(?=.*[_\-.]).*/;
const PASSWORD_REPEATED_CHARACTER_REGEX = /(.)\1\1/;
const DEFAULT_MII_DATA = Buffer.from('AwAAQOlVognnx0GC2/uogAOzuI0n2QAAAEBEAGUAZgBhAHUAbAB0AAAAAAAAAEBAAAAhAQJoRBgmNEYUgRIXaA0AACkAUkhQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGm9', 'base64');
@ -32,201 +35,241 @@ const DEFAULT_MII_DATA = Buffer.from('AwAAQOlVognnx0GC2/uogAOzuI0n2QAAAEBEAGUAZg
* 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): Promise<void> => {
const email = request.body.email?.trim();
const username = request.body.username?.trim();
const miiName = request.body.mii_name?.trim();
const password = request.body.password?.trim();
const passwordConfirm = request.body.password_confirm?.trim();
const hCaptchaResponse = request.body.hCaptchaResponse?.trim();
if (!disabledFeatures.captcha) {
if (!hCaptchaResponse || hCaptchaResponse === '') {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Must fill in captcha'
});
return;
}
const captchaVerify = await hcaptcha.verify(config.hcaptcha.secret, hCaptchaResponse);
if (!captchaVerify.success) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Captcha verification failed'
});
return;
}
}
if (!email || email === '') {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Must enter an email address'
});
return;
}
if (!emailvalidator.validate(email)) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid email address'
});
return;
}
if (!username || username === '') {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Must enter a username'
});
return;
}
if (username.length < 6) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Username is too short'
});
return;
}
if (username.length > 16) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Username is too long'
});
return;
}
if (!PNID_VALID_CHARACTERS_REGEX.test(username)) {
console.log(Buffer.from(username));
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Username contains invalid characters'
});
return;
}
if (PNID_PUNCTUATION_START_REGEX.test(username)) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Username cannot begin with punctuation characters'
});
return;
}
if (PNID_PUNCTUATION_END_REGEX.test(username)) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Username cannot end with punctuation characters'
});
return;
}
if (PNID_PUNCTUATION_DUPLICATE_REGEX.test(username)) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Two or more punctuation characters cannot be used in a row'
});
return;
}
const userExists = await database.doesUserExist(username);
const userExists = await doesPNIDExist(username);
if (userExists) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'PNID already in use'
});
return;
}
if (!miiName || miiName === '') {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Must enter a Mii name'
});
return;
}
if (!password || password === '') {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Must enter a password'
});
return;
}
if (password.length < 6) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Password is too short'
});
return;
}
if (password.length > 16) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Password is too long'
});
return;
}
if (password.toLowerCase() === username.toLowerCase()) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Password cannot be the same as username'
});
return;
}
if (!PASSWORD_WORD_OR_NUMBER_REGEX.test(password) && !PASSWORD_WORD_OR_PUNCTUATION_REGEX.test(password) && !PASSWORD_NUMBER_OR_PUNCTUATION_REGEX.test(password)) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Password must have combination of letters, numbers, and/or punctuation characters'
});
return;
}
if (PASSWORD_REPEATED_CHARACTER_REGEX.test(password)) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Password may not have 3 repeating characters'
});
return;
}
if (password !== passwordConfirm) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Passwords do not match'
});
return;
}
const miiNameBuffer = Buffer.from(miiName, 'utf16le'); // UTF8 to UTF16
const miiNameBuffer = Buffer.from(miiName, 'utf16le'); // * UTF8 to UTF16
if (miiNameBuffer.length > 0x14) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Mii name too long'
});
return;
}
const mii = new Mii(DEFAULT_MII_DATA);
mii.miiName = miiName;
const creationDate = moment().format('YYYY-MM-DDTHH:MM:SS');
let pnid;
let nexAccount;
let pnid: HydratedPNIDDocument;
let nexAccount: HydratedNEXAccountDocument;
const session = await database.connection.startSession();
const session = await databaseConnection().startSession();
await session.startTransaction();
try {
@ -239,59 +282,59 @@ router.post('/', async (request, response) => {
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');
// * 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 = util.nintendoPasswordHash(password, nexAccount.get('pid'));
const primaryPasswordHash = nintendoPasswordHash(password, nexAccount.pid);
const passwordHash = await bcrypt.hash(primaryPasswordHash, 10);
pnid = new PNID({
pid: nexAccount.get('pid'),
pid: nexAccount.pid,
creation_date: creationDate,
updated: creationDate,
username: username,
usernameLower: username.toLowerCase(),
password: passwordHash,
birthdate: '1990-01-01', // TODO: Change this
gender: 'M', // TODO: Change this
country: 'US', // TODO: Change this
language: 'en', // TODO: Change this
birthdate: '1990-01-01', // TODO - Change this
gender: 'M', // TODO - Change this
country: 'US', // TODO - Change this
language: 'en', // TODO - Change this
email: {
address: email.toLowerCase(),
primary: true, // TODO: Change this
parent: true, // TODO: Change this
reachable: false, // TODO: Change this
validated: false, // TODO: Change this
primary: true, // TODO - Change this
parent: true, // TODO - Change this
reachable: false, // TODO - Change this
validated: false, // TODO - Change this
id: crypto.randomBytes(4).readUInt32LE()
},
region: 0x310B0000, // TODO: Change this
region: 0x310B0000, // TODO - Change this
timezone: {
name: 'America/New_York', // TODO: Change this
offset: -14400 // TODO: Change this
name: 'America/New_York', // TODO - Change this
offset: -14400 // TODO - Change this
},
mii: {
name: miiName,
primary: true, // TODO: Change this
primary: true, // TODO - Change this
data: mii.encode().toString('base64'),
id: crypto.randomBytes(4).readUInt32LE(),
hash: crypto.randomBytes(7).toString('hex'),
image_url: '', // deprecated, will be removed in the future
image_url: '', // * deprecated, will be removed in the future
image_id: crypto.randomBytes(4).readUInt32LE()
},
flags: {
active: true, // TODO: Change this
marketing: true, // TODO: Change this
off_device: true // TODO: Change this
active: true, // TODO - Change this
marketing: true, // TODO - Change this
off_device: true // TODO - Change this
},
identification: {
email_code: 1, // will be overwritten before saving
email_token: '' // will be overwritten before saving
email_code: 1, // * will be overwritten before saving
email_token: '' // * will be overwritten before saving
}
});
@ -302,63 +345,52 @@ router.post('/', async (request, response) => {
await pnid.save({ session });
await session.commitTransaction();
} catch (error) {
logger.error('[POST] /v1/register: ' + error);
} catch (error: any) {
LOG_ERROR('[POST] /v1/register: ' + error);
if (error.stack) console.error(error.stack);
await session.abortTransaction();
return response.status(400).json({
response.status(500).json({
app: 'api',
status: 400,
error: 'Password must have combination of letters, numbers, and/or punctuation characters'
status: 500,
error: 'Internal server error'
});
return;
} finally {
// * This runs regardless of failure
// * Returning on catch will not prevent this from running
await session.endSession();
}
await util.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
};
await sendConfirmationEmail(pnid);
const accessTokenOptions = {
system_type: 0xF, // API
token_type: 0x1, // OAuth Access,
pid: pnid.get('pid'),
access_level: 0,
system_type: 0x3, // * API
token_type: 0x1, // * OAuth Access
pid: pnid.pid,
access_level: pnid.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: 0,
system_type: 0x3, // * API
token_type: 0x2, // * OAuth Refresh
pid: pnid.pid,
access_level: pnid.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);
const accessTokenBuffer = await generateToken(config.aes_key, accessTokenOptions);
const refreshTokenBuffer = await generateToken(config.aes_key, refreshTokenOptions);
const accessToken = accessTokenBuffer ? accessTokenBuffer.toString('hex') : '';
const refreshToken = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : '';
// TODO - Handle null tokens
response.json({
access_token: accessToken,
@ -368,4 +400,4 @@ router.post('/', async (request, response) => {
});
});
module.exports = router;
export default router;

View File

@ -1,117 +1,140 @@
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 { Token } from '@/types/common/token';
// This sucks
const 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_WORD_OR_PUNCTUATION_REGEX = /(?=.*[a-zA-Z])(?=.*[_\-.]).*/;
const PASSWORD_NUMBER_OR_PUNCTUATION_REGEX = /(?=.*\d)(?=.*[_\-.]).*/;
const PASSWORD_REPEATED_CHARACTER_REGEX = /(.)\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): Promise<void> => {
const password = request.body.password?.trim();
const passwordConfirm = request.body.password_confirm?.trim();
const token = request.body.token?.trim();
if (!token || token === '') {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Missing token'
});
return;
}
let unpackedToken;
let unpackedToken: Token;
try {
const decryptedToken = await util.decryptToken(Buffer.from(token, 'base64'));
unpackedToken = util.unpackToken(decryptedToken);
const decryptedToken = await decryptToken(Buffer.from(token, 'hex'));
unpackedToken = unpackToken(decryptedToken);
} catch (error) {
console.log(error);
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid token'
});
return;
}
if (unpackedToken.expire_time < Date.now()) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Token expired'
});
return;
}
const pnid = await PNID.findOne({ pid: unpackedToken.pid });
if (!pnid) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid token. No user found'
});
return;
}
if (!password || password === '') {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Must enter a password'
});
return;
}
if (password.length < 6) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Password is too short'
});
return;
}
if (password.length > 16) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Password is too long'
});
return;
}
if (password.toLowerCase() === pnid.get('usernameLower')) {
return response.status(400).json({
if (password.toLowerCase() === pnid.usernameLower) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Password cannot be the same as username'
});
return;
}
if (!PASSWORD_WORD_OR_NUMBER_REGEX.test(password) && !PASSWORD_WORD_OR_PUNCTUATION_REGEX.test(password) && !PASSWORD_NUMBER_OR_PUNCTUATION_REGEX.test(password)) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Password must have combination of letters, numbers, and/or punctuation characters'
});
return;
}
if (PASSWORD_REPEATED_CHARACTER_REGEX.test(password)) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Password may not have 3 repeating characters'
});
return;
}
if (password !== passwordConfirm) {
return response.status(400).json({
response.status(400).json({
app: 'api',
status: 400,
error: 'Passwords do not match'
});
return;
}
const primaryPasswordHash = util.nintendoPasswordHash(password, pnid.get('pid'));
const primaryPasswordHash = nintendoPasswordHash(password, pnid.pid);
const passwordHash = await bcrypt.hash(primaryPasswordHash, 10);
pnid.password = passwordHash;
@ -124,4 +147,4 @@ router.post('/', async (request, response) => {
});
});
module.exports = router;
export default router;

View File

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

View File

@ -0,0 +1,232 @@
import express from 'express';
import { z } from 'zod';
import Mii from 'mii-js';
import { config } from '@/config-manager';
import { PNID } from '@/models/pnid';
import { UpdateUserRequest } from '@/types/services/api/update-user-request';
const router = express.Router();
// TODO - Extend this later with more settings
const userSchema = z.object({
mii: z.object({
name: z.string().trim(),
primary: z.enum(['Y', 'N']),
data: z.string(),
}).optional(),
environment: z.enum(['prod', 'test', 'dev']).optional()
});
/**
* [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): Promise<void> => {
const pnid = request.pnid;
if (!pnid) {
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
},
stripe: {
tier_name: pnid.connections.stripe.tier_name,
tier_level: pnid.connections.stripe.tier_level
}
}
});
});
/**
* [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): Promise<void> => {
const pnid = request.pnid;
const updateUserRequest: UpdateUserRequest = request.body;
if (!pnid) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Invalid or missing access token'
});
return;
}
const result = userSchema.safeParse(updateUserRequest);
if (!result.success) {
response.status(400).json({
app: 'api',
status: 400,
error: result.error
});
return;
}
if (result.data.mii) {
const miiNameBuffer = Buffer.from(result.data.mii.name, 'utf16le'); // * UTF8 to UTF16
if (miiNameBuffer.length < 1) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Mii name too short'
});
return;
}
if (miiNameBuffer.length > 0x14) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Mii name too long'
});
return;
}
try {
const miiDataBuffer = Buffer.from(result.data.mii.data, 'base64');
if (miiDataBuffer.length < 0x60) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Mii data too short'
});
return;
}
if (miiDataBuffer.length > 0x60) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Mii data too long'
});
return;
}
const mii = new Mii(miiDataBuffer);
mii.validate();
} catch (_) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Failed to decode Mii data'
});
return;
}
await pnid.updateMii({
name: result.data.mii.name,
primary: result.data.mii.primary,
data: result.data.mii.data
});
}
const updateData: Record<string, any> = {};
if (result.data.environment) {
const environment = result.data.environment;
if (environment === 'test' && pnid.access_level < 1) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Do not have permission to enter this environment'
});
return;
}
if (environment === 'dev' && pnid.access_level < 3) {
response.status(400).json({
app: 'api',
status: 400,
error: 'Do not have permission to enter this environment'
});
return;
}
updateData.server_access_level = environment;
}
await PNID.updateOne({ pid: pnid.pid }, { $set: updateData }).exec();
response.json({
access_level: pnid.access_level,
server_access_level: pnid.server_access_level,
pid: pnid.pid,
creation_date: pnid.creation_date,
updated: pnid.updated,
username: pnid.username,
birthdate: pnid.birthdate,
gender: pnid.gender,
country: pnid.country,
email: {
address: pnid.email.address,
},
timezone: {
name: pnid.timezone.name
},
mii: {
data: pnid.mii.data,
name: pnid.mii.name,
image_url: `${config.cdn.base_url}/mii/${pnid.pid}/normal_face.png`
},
flags: {
marketing: pnid.flags.marketing
},
connections: {
discord: {
id: pnid.connections.discord.id
}
}
});
});
export default router;

View File

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

View File

@ -0,0 +1,23 @@
// * handles serving assets
import path from 'node:path';
import express from 'express';
import { LOG_INFO, formatHostnames } from '@/logger';
import { config } from '@/config-manager';
import { restrictHostnames } from '@/middleware/host-limit';
// * Router to handle the subdomain restriction
const assets = 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();
// * Create domains
LOG_INFO(`[assets] Creating assets router with domains: ${formatHostnames(config.domains.assets)}`);
router.use(restrictHostnames(config.domains.assets, assets));
export default router;

View File

@ -0,0 +1,32 @@
// * handles CBVC (CTR Browser Version Check?) endpoints
import express from 'express';
import { LOG_INFO, formatHostnames } from '@/logger';
import { config } from '@/config-manager';
import { restrictHostnames } from '@/middleware/host-limit';
// * Router to handle the subdomain restriction
const cbvc = express.Router();
// * Setup route
LOG_INFO('[CBVC] Applying imported routes');
cbvc.get('/:consoleType/:unknown/:region', (request: express.Request, response: express.Response): void => {
response.set('Content-Type', 'text/plain');
// * https://www.3dbrew.org/wiki/Internet_Browser#Forced_system-update
// * The returned value is a number which the Internet Browser then compares
// * with its own version number. If the version number isn't higher than the
// * returned value, it will show a console update message.
// *
// * Return 0 and allow any browser to connect.
response.send('0');
});
// * Main router for endpoints
const router = express.Router();
// * Create domains
LOG_INFO(`[CBVC] Creating cbvc router with domains: ${formatHostnames(config.domains.cbvc)}`);
router.use(restrictHostnames(config.domains.cbvc, cbvc));
export default router;

View File

@ -1,36 +0,0 @@
// handles conntest endpoints
const express = require('express');
const subdomain = require('express-subdomain');
const logger = require('../../../logger');
// Router to handle the subdomain restriction
const conntest = express.Router();
// Setup route
logger.info('[conntest] Applying imported routes');
conntest.get('/', async (request, response) => {
response.set('Content-Type', 'text/html');
response.set('X-Organization', 'Nintendo');
response.send(`
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<head>
<title>HTML Page</title>
</head>
<body bgcolor="#FFFFFF">
This is test.html page
</body>
</html>
`)
});
// Main router for endpoints
const router = express.Router();
// Create subdomains
logger.info('[conntest] Creating \'conntest\' subdomain');
router.use(subdomain('conntest', conntest));
module.exports = router;

View File

@ -0,0 +1,37 @@
// * handles conntest endpoints
import express from 'express';
import { LOG_INFO, formatHostnames } from '@/logger';
import { restrictHostnames } from '@/middleware/host-limit';
import { config } from '@/config-manager';
// * Router to handle the subdomain restriction
const conntest = express.Router();
// * Setup route
LOG_INFO('[conntest] Applying imported routes');
conntest.get('/', (request: express.Request, response: express.Response): void => {
response.set('Content-Type', 'text/html');
response.set('X-Organization', 'Nintendo');
response.send(`
<!DOCTYPE html PUBLIC "-// *W3C// *DTD XHTML 1.0 Transitional// *EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<head>
<title>HTML Page</title>
</head>
<body bgcolor="#FFFFFF">
This is test.html page
</body>
</html>
`);
});
// * Main router for endpoints
const router = express.Router();
// * Create domains
LOG_INFO(`[conntest] Creating conntest router with domains: ${formatHostnames(config.domains.conntest)}`);
router.use(restrictHostnames(config.domains.conntest, conntest));
export default router;

View File

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

View File

@ -0,0 +1,22 @@
import express from 'express';
import { LOG_INFO, formatHostnames } from '@/logger';
import upload from '@/services/datastore/routes/upload';
import { restrictHostnames } from '@/middleware/host-limit';
import { config } from '@/config-manager';
// * Router to handle the subdomain
const datastore = express.Router();
// * Setup routes
LOG_INFO('[DATASTORE] Applying imported routes');
datastore.use(upload);
// * Main router for endpoints
const router = express.Router();
// * Create domains
LOG_INFO(`[DATASTORE] Creating datastore router with domains: ${formatHostnames(config.domains.datastore)}`);
router.use(restrictHostnames(config.domains.datastore, datastore));
export default router;

View File

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

View File

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

View File

@ -0,0 +1,100 @@
import crypto from 'node:crypto';
import express from 'express';
import Dicer from 'dicer';
import { uploadCDNAsset } from '@/util';
import { config } from '@/config-manager';
const router = express.Router();
function multipartParser(request: express.Request, response: express.Response, next: express.NextFunction): void {
const RE_BOUNDARY = /^multipart\/.+?(?:; boundary=(?:(?:"(.+)")|(?:([^\s]+))))$/i;
const RE_FILE_NAME = /name="(.*)"/;
const contentType = request.header('content-type');
if (!contentType) {
return next();
}
const boundary = RE_BOUNDARY.exec(contentType);
if (!boundary) {
return next();
}
const dicer = new Dicer({ boundary: boundary[1] || boundary[2] });
const files: Record<string, Buffer> = {};
dicer.on('part', (part: Dicer.PartStream) => {
let fileBuffer = Buffer.alloc(0);
let fileName = '';
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): Promise<void> => {
if (!request.files) {
response.sendStatus(500);
return;
}
const bucket = request.files.bucket.toString();
const key = request.files.key.toString();
const file = request.files.file;
const acl = request.files.acl.toString();
const pid = request.files.pid.toString();
const date = request.files.date.toString();
const signature = request.files.signature.toString();
// * Signatures only good for 1 minute
const minute = 1000 * 60;
const minuteAgo = Date.now() - minute;
if (Number(date) < Math.floor(minuteAgo / 1000)) {
response.sendStatus(400);
return;
}
const data = `${pid}${bucket}${key}${date}`;
const hmac = crypto.createHmac('sha256', config.datastore.signature_secret).update(data).digest('hex');
console.log(hmac, signature);
if (hmac !== signature) {
response.sendStatus(400);
return;
}
await uploadCDNAsset(bucket, key, file, acl);
response.sendStatus(200);
});
export default router;

View File

@ -0,0 +1,15 @@
import { Status, ServerMiddlewareCall, CallContext, ServerError } from 'nice-grpc';
import { config } from '@/config-manager';
export async function* apiKeyMiddleware<Request, Response>(
call: ServerMiddlewareCall<Request, Response>,
context: CallContext,
): AsyncGenerator<Response, Response | void, undefined> {
const apiKey = context.metadata.get('X-API-Key');
if (!apiKey || apiKey !== config.grpc.master_api_keys.account) {
throw new ServerError(Status.UNAUTHENTICATED, 'Missing or invalid API key');
}
return yield* call.next(call.request, context);
}

View File

@ -0,0 +1,62 @@
import { Status, ServerError } from 'nice-grpc';
import { ExchangeTokenForUserDataRequest } from '@pretendonetwork/grpc/account/exchange_token_for_user_data';
import { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc';
import { getPNIDByTokenAuth } from '@/database';
import { PNID_PERMISSION_FLAGS } from '@/types/common/permission-flags';
import { config } from '@/config-manager';
export async function exchangeTokenForUserData(request: ExchangeTokenForUserDataRequest): Promise<GetUserDataResponse> {
if (!request.token.trim()) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
const pnid = await getPNIDByTokenAuth(request.token);
if (!pnid) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
return {
deleted: pnid.deleted,
pid: pnid.pid,
username: pnid.username,
accessLevel: pnid.access_level,
serverAccessLevel: pnid.server_access_level,
mii: {
name: pnid.mii.name,
data: pnid.mii.data,
url: `${config.cdn.base_url}/mii/${pnid.pid}/standard.tga`,
},
creationDate: pnid.creation_date,
birthdate: pnid.birthdate,
gender: pnid.gender,
country: pnid.country,
language: pnid.language,
emailAddress: pnid.email.address,
tierName: pnid.connections.stripe.tier_name,
permissions: {
bannedAllPermanently: pnid.hasPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_PERMANENTLY),
bannedAllTemporarily: pnid.hasPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_TEMPORARILY),
betaAccess: pnid.hasPermission(PNID_PERMISSION_FLAGS.BETA_ACCESS),
accessAdminPanel: pnid.hasPermission(PNID_PERMISSION_FLAGS.ACCESS_ADMIN_PANEL),
createServerConfigs: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_SERVER_CONFIGS),
modifyServerConfigs: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_SERVER_CONFIGS),
deployServer: pnid.hasPermission(PNID_PERMISSION_FLAGS.DEPLOY_SERVER),
modifyPnids: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_PNIDS),
modifyNexAccounts: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_NEX_ACCOUNTS),
modifyConsoles: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_CONSOLES),
banPnids: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_PNIDS),
banNexAccounts: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_NEX_ACCOUNTS),
banConsoles: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_CONSOLES),
moderateMiiverse: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODERATE_MIIVERSE),
createApiKeys: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_API_KEYS),
createBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_BOSS_TASKS),
updateBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_TASKS),
deleteBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_TASKS),
uploadBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPLOAD_BOSS_FILES),
updateBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_FILES),
deleteBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_FILES),
updatePnidPermissions: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_PNID_PERMISSIONS)
}
};
}

View File

@ -0,0 +1,23 @@
import { Status, ServerError } from 'nice-grpc';
import {GetNEXDataRequest,GetNEXDataResponse, DeepPartial } from '@pretendonetwork/grpc/account/get_nex_data_rpc';
import { NEXAccount } from '@/models/nex-account';
export async function getNEXData(request: GetNEXDataRequest): Promise<DeepPartial<GetNEXDataResponse>> {
const nexAccount = await NEXAccount.findOne({ pid: request.pid });
if (!nexAccount) {
throw new ServerError(
Status.INVALID_ARGUMENT,
'No NEX account found',
);
}
return {
pid: nexAccount.pid,
password: nexAccount.password,
owningPid: nexAccount.owning_pid,
accessLevel: nexAccount.access_level,
serverAccessLevel: nexAccount.server_access_level,
friendCode: nexAccount.friend_code
};
}

View File

@ -0,0 +1,18 @@
import { Status, ServerError } from 'nice-grpc';
import {GetNEXPasswordRequest,GetNEXPasswordResponse, DeepPartial } from '@pretendonetwork/grpc/account/get_nex_password_rpc';
import { NEXAccount } from '@/models/nex-account';
export async function getNEXPassword(request: GetNEXPasswordRequest): Promise<DeepPartial<GetNEXPasswordResponse>> {
const nexAccount = await NEXAccount.findOne({ pid: request.pid });
if (!nexAccount) {
throw new ServerError(
Status.INVALID_ARGUMENT,
'No NEX account found',
);
}
return {
password: nexAccount.password
};
}

View File

@ -0,0 +1,60 @@
import { Status, ServerError } from 'nice-grpc';
import { GetUserDataRequest, GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc';
import { getPNIDByPID } from '@/database';
import { PNID_PERMISSION_FLAGS } from '@/types/common/permission-flags';
import { config } from '@/config-manager';
export async function getUserData(request: GetUserDataRequest): Promise<GetUserDataResponse> {
const pnid = await getPNIDByPID(request.pid);
if (!pnid) {
throw new ServerError(
Status.INVALID_ARGUMENT,
'No PNID found',
);
}
return {
deleted: pnid.deleted,
pid: pnid.pid,
username: pnid.username,
accessLevel: pnid.access_level,
serverAccessLevel: pnid.server_access_level,
mii: {
name: pnid.mii.name,
data: pnid.mii.data,
url: `${config.cdn.base_url}/mii/${pnid.pid}/standard.tga`,
},
creationDate: pnid.creation_date,
birthdate: pnid.birthdate,
gender: pnid.gender,
country: pnid.country,
language: pnid.language,
emailAddress: pnid.email.address,
tierName: pnid.connections.stripe.tier_name,
permissions: {
bannedAllPermanently: pnid.hasPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_PERMANENTLY),
bannedAllTemporarily: pnid.hasPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_TEMPORARILY),
betaAccess: pnid.hasPermission(PNID_PERMISSION_FLAGS.BETA_ACCESS),
accessAdminPanel: pnid.hasPermission(PNID_PERMISSION_FLAGS.ACCESS_ADMIN_PANEL),
createServerConfigs: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_SERVER_CONFIGS),
modifyServerConfigs: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_SERVER_CONFIGS),
deployServer: pnid.hasPermission(PNID_PERMISSION_FLAGS.DEPLOY_SERVER),
modifyPnids: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_PNIDS),
modifyNexAccounts: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_NEX_ACCOUNTS),
modifyConsoles: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_CONSOLES),
banPnids: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_PNIDS),
banNexAccounts: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_NEX_ACCOUNTS),
banConsoles: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_CONSOLES),
moderateMiiverse: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODERATE_MIIVERSE),
createApiKeys: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_API_KEYS),
createBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_BOSS_TASKS),
updateBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_TASKS),
deleteBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_TASKS),
uploadBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPLOAD_BOSS_FILES),
updateBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_FILES),
deleteBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_FILES),
updatePnidPermissions: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_PNID_PERMISSIONS)
}
};
}

View File

@ -0,0 +1,13 @@
import { getUserData } from '@/services/grpc/account/get-user-data';
import { getNEXPassword } from '@/services/grpc/account/get-nex-password';
import { getNEXData } from '@/services/grpc/account/get-nex-data';
import { updatePNIDPermissions } from '@/services/grpc/account/update-pnid-permissions';
import { exchangeTokenForUserData } from '@/services/grpc/account/exchange-token-for-user-data';
export const accountServiceImplementation = {
getUserData,
getNEXPassword,
getNEXData,
updatePNIDPermissions,
exchangeTokenForUserData
};

View File

@ -0,0 +1,159 @@
import { Status, ServerError } from 'nice-grpc';
import { UpdatePNIDPermissionsRequest } from '@pretendonetwork/grpc/account/update_pnid_permissions';
import { getPNIDByPID } from '@/database';
import { PNID_PERMISSION_FLAGS } from '@/types/common/permission-flags';
import type { Empty } from '@pretendonetwork/grpc/api/google/protobuf/empty';
export async function updatePNIDPermissions(request: UpdatePNIDPermissionsRequest): Promise<Empty> {
const pnid = await getPNIDByPID(request.pid);
if (!pnid) {
throw new ServerError(
Status.INVALID_ARGUMENT,
'No PNID found',
);
}
if (!request.permissions) {
throw new ServerError(
Status.INVALID_ARGUMENT,
'Permissions flags not found',
);
}
if (request.permissions.bannedAllPermanently === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_PERMANENTLY);
} else if (request.permissions.bannedAllPermanently === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_PERMANENTLY);
}
if (request.permissions.bannedAllTemporarily === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_TEMPORARILY);
} else if (request.permissions.bannedAllTemporarily === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_TEMPORARILY);
}
if (request.permissions.betaAccess === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.BETA_ACCESS);
} else if (request.permissions.betaAccess === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.BETA_ACCESS);
}
if (request.permissions.accessAdminPanel === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.ACCESS_ADMIN_PANEL);
} else if (request.permissions.accessAdminPanel === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.ACCESS_ADMIN_PANEL);
}
if (request.permissions.createServerConfigs === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.CREATE_SERVER_CONFIGS);
} else if (request.permissions.createServerConfigs === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.CREATE_SERVER_CONFIGS);
}
if (request.permissions.modifyServerConfigs === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.MODIFY_SERVER_CONFIGS);
} else if (request.permissions.modifyServerConfigs === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.MODIFY_SERVER_CONFIGS);
}
if (request.permissions.deployServer === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.DEPLOY_SERVER);
} else if (request.permissions.deployServer === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.DEPLOY_SERVER);
}
if (request.permissions.modifyPnids === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.MODIFY_PNIDS);
} else if (request.permissions.modifyPnids === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.MODIFY_PNIDS);
}
if (request.permissions.modifyNexAccounts === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.MODIFY_NEX_ACCOUNTS);
} else if (request.permissions.modifyNexAccounts === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.MODIFY_NEX_ACCOUNTS);
}
if (request.permissions.modifyConsoles === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.MODIFY_CONSOLES);
} else if (request.permissions.modifyConsoles === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.MODIFY_CONSOLES);
}
if (request.permissions.banPnids === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.BAN_PNIDS);
} else if (request.permissions.banPnids === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.BAN_PNIDS);
}
if (request.permissions.banNexAccounts === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.BAN_NEX_ACCOUNTS);
} else if (request.permissions.banNexAccounts === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.BAN_NEX_ACCOUNTS);
}
if (request.permissions.banConsoles === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.BAN_CONSOLES);
} else if (request.permissions.banConsoles === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.BAN_CONSOLES);
}
if (request.permissions.moderateMiiverse === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.MODERATE_MIIVERSE);
} else if (request.permissions.moderateMiiverse === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.MODERATE_MIIVERSE);
}
if (request.permissions.createApiKeys === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.CREATE_API_KEYS);
} else if (request.permissions.createApiKeys === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.CREATE_API_KEYS);
}
if (request.permissions.createBossTasks === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.CREATE_BOSS_TASKS);
} else if (request.permissions.createBossTasks === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.CREATE_BOSS_TASKS);
}
if (request.permissions.updateBossTasks === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_TASKS);
} else if (request.permissions.updateBossTasks === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_TASKS);
}
if (request.permissions.deleteBossTasks === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_TASKS);
} else if (request.permissions.deleteBossTasks === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_TASKS);
}
if (request.permissions.uploadBossFiles === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.UPLOAD_BOSS_FILES);
} else if (request.permissions.uploadBossFiles === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.UPLOAD_BOSS_FILES);
}
if (request.permissions.updateBossFiles === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_FILES);
} else if (request.permissions.updateBossFiles === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_FILES);
}
if (request.permissions.deleteBossFiles === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_FILES);
} else if (request.permissions.deleteBossFiles === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_FILES);
}
if (request.permissions.updatePnidPermissions === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.UPDATE_PNID_PERMISSIONS);
} else if (request.permissions.updatePnidPermissions === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.UPDATE_PNID_PERMISSIONS);
}
await pnid.save();
return {};
}

View File

@ -0,0 +1,15 @@
import { Status, ServerMiddlewareCall, CallContext, ServerError } from 'nice-grpc';
import { config } from '@/config-manager';
export async function* apiKeyMiddleware<Request, Response>(
call: ServerMiddlewareCall<Request, Response>,
context: CallContext,
): AsyncGenerator<Response, Response | void, undefined> {
const apiKey = context.metadata.get('X-API-Key');
if (!apiKey || apiKey !== config.grpc.master_api_keys.api) {
throw new ServerError(Status.UNAUTHENTICATED, 'Missing or invalid API key');
}
return yield* call.next(call.request, context);
}

View File

@ -0,0 +1,55 @@
import { Status, ServerMiddlewareCall, CallContext, ServerError } from 'nice-grpc';
import { getPNIDByTokenAuth } from '@/database';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
// * These paths require that a token be present
const TOKEN_REQUIRED_PATHS = [
'/api.API/GetUserData',
'/api.API/UpdateUserData',
'/api.API/ResetPassword', // * This paths token is not an authentication token, it is a password reset token
'/api.API/SetDiscordConnectionData',
'/api.API/SetStripeConnectionData',
'/api.API/RemoveConnection'
];
export type AuthenticationCallContextExt = {
pnid: HydratedPNIDDocument | null;
};
export async function* authenticationMiddleware<Request, Response>(
call: ServerMiddlewareCall<Request, Response, AuthenticationCallContextExt>,
context: CallContext,
): AsyncGenerator<Response, Response | void, undefined> {
const token = context.metadata.get('X-Token')?.trim();
if (!token && TOKEN_REQUIRED_PATHS.includes(call.method.path)) {
throw new ServerError(Status.UNAUTHENTICATED, 'Missing or invalid authentication token');
}
try {
let pnid = null;
if (token) {
pnid = await getPNIDByTokenAuth(token);
}
if (!pnid && TOKEN_REQUIRED_PATHS.includes(call.method.path)) {
throw new ServerError(Status.UNAUTHENTICATED, 'Missing or invalid authentication token');
}
return yield* call.next(call.request, {
...context,
pnid
});
} catch (error) {
let message = 'Unknown server error';
console.log(error);
if (error instanceof Error) {
message = error.message;
}
throw new ServerError(Status.INVALID_ARGUMENT, message);
}
}

View File

@ -0,0 +1,29 @@
import { Status, ServerError } from 'nice-grpc';
import validator from 'validator';
import { ForgotPasswordRequest } from '@pretendonetwork/grpc/api/forgot_password_rpc';
import { getPNIDByEmailAddress, getPNIDByUsername } from '@/database';
import { sendForgotPasswordEmail } from '@/util';
import type { Empty } from '@pretendonetwork/grpc/api/google/protobuf/empty';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
export async function forgotPassword(request: ForgotPasswordRequest): Promise<Empty> {
const input = request.emailAddressOrUsername.trim();
if (!input) {
throw new ServerError(Status.INVALID_ARGUMENT, '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);
}
return {};
}

View File

@ -0,0 +1,45 @@
import { CallContext } from 'nice-grpc';
import { GetUserDataResponse, DeepPartial } from '@pretendonetwork/grpc/api/get_user_data_rpc';
import { config } from '@/config-manager';
import type { Empty } from '@pretendonetwork/grpc/api/google/protobuf/empty';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/authentication-middleware';
export async function getUserData(_request: Empty, context: CallContext & AuthenticationCallContextExt): Promise<DeepPartial<GetUserDataResponse>> {
// * This is asserted in authentication-middleware, we know this is never null
const pnid = context.pnid!;
return {
deleted: pnid.deleted,
creationDate: pnid.creation_date,
updatedDate: pnid.updated,
pid: pnid.pid,
username: pnid.username,
accessLevel: pnid.access_level,
serverAccessLevel: pnid.server_access_level,
mii: {
name: pnid.mii.name,
data: pnid.mii.data,
url: `${config.cdn.base_url}/mii/${pnid.pid}/standard.tga`,
},
birthday: pnid.birthdate,
gender: pnid.gender,
country: pnid.country,
timezone: pnid.timezone.name,
language: pnid.language,
emailAddress: pnid.email.address,
connections: {
discord: {
id: pnid.connections.discord.id
},
stripe: {
customerId: pnid.connections.stripe.customer_id,
subscriptionId: pnid.connections.stripe.subscription_id,
priceId: pnid.connections.stripe.price_id,
tierLevel: pnid.connections.stripe.tier_level,
tierName: pnid.connections.stripe.tier_name,
latestWebhookTimestamp: BigInt(pnid.connections.stripe.latest_webhook_timestamp ?? 0)
}
},
marketingFlag: pnid.flags.marketing
};
}

View File

@ -0,0 +1,19 @@
import { register } from '@/services/grpc/api/register';
import { login } from '@/services/grpc/api/login';
import { getUserData } from '@/services/grpc/api/get-user-data';
import { updateUserData } from '@/services/grpc/api/update-user-data';
import { forgotPassword } from '@/services/grpc/api/forgot-password';
import { resetPassword } from '@/services/grpc/api/reset-password';
import { setDiscordConnectionData } from '@/services/grpc/api/set-discord-connection-data';
import { setStripeConnectionData } from '@/services/grpc/api/set-stripe-connection-data';
export const apiServiceImplementation = {
register,
login,
getUserData,
updateUserData,
forgotPassword,
resetPassword,
setDiscordConnectionData,
setStripeConnectionData
};

View File

@ -0,0 +1,89 @@
import { Status, ServerError } from 'nice-grpc';
import { LoginRequest, LoginResponse, DeepPartial } from '@pretendonetwork/grpc/api/login_rpc';
import bcrypt from 'bcrypt';
import { getPNIDByUsername, getPNIDByTokenAuth } from '@/database';
import { nintendoPasswordHash, generateToken} from '@/util';
import { config } from '@/config-manager';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
export async function login(request: LoginRequest): Promise<DeepPartial<LoginResponse>> {
const grantType = request.grantType?.trim();
const username = request.username?.trim();
const password = request.password?.trim();
const refreshToken = request.refreshToken?.trim();
if (!['password', 'refresh_token'].includes(grantType)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid grant type');
}
if (grantType === 'password' && !username) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing username');
}
if (grantType === 'password' && !password) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing password');
}
if (grantType === 'refresh_token' && !refreshToken) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing refresh token');
}
let pnid: HydratedPNIDDocument | null;
if (grantType === 'password') {
pnid = await getPNIDByUsername(username!); // * We know username will never be null here
if (!pnid) {
throw new ServerError(Status.INVALID_ARGUMENT, 'User not found');
}
const hashedPassword = nintendoPasswordHash(password!, pnid.pid); // * We know password will never be null here
if (!bcrypt.compareSync(hashedPassword, pnid.password)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Password is incorrect');
}
} else {
pnid = await getPNIDByTokenAuth(refreshToken!); // * We know refreshToken will never be null here
if (!pnid) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing refresh token');
}
}
if (pnid.deleted) {
throw new ServerError(Status.UNAUTHENTICATED, 'Account has been deleted');
}
const accessTokenOptions = {
system_type: 0x3, // * API
token_type: 0x1, // * OAuth Access
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const refreshTokenOptions = {
system_type: 0x3, // * API
token_type: 0x2, // * OAuth Refresh
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const accessTokenBuffer = await generateToken(config.aes_key, accessTokenOptions);
const refreshTokenBuffer = await generateToken(config.aes_key, refreshTokenOptions);
const accessToken = accessTokenBuffer ? accessTokenBuffer.toString('hex') : '';
const newRefreshToken = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : '';
// TODO - Handle null tokens
return {
accessToken: accessToken,
tokenType: 'Bearer',
expiresIn: 3600,
refreshToken: newRefreshToken
};
}

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