mirror of
https://github.com/PretendoNetwork/BOSS.git
synced 2026-03-21 17:34:19 -05:00
BOSS 2.0 - Full TS and microservice rewrite. Still pulls from local until admin panel updates
This commit is contained in:
parent
8daf7ef041
commit
335439db7d
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -59,4 +59,5 @@ typings/
|
|||
|
||||
# custom
|
||||
.vscode
|
||||
dist
|
||||
dist
|
||||
cdn/storage
|
||||
4485
package-lock.json
generated
4485
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "boss",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
|
|
@ -13,12 +13,21 @@
|
|||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.395.0",
|
||||
"@pretendonetwork/boss-crypto": "^1.0.0",
|
||||
"@pretendonetwork/grpc": "^1.0.1",
|
||||
"@typegoose/auto-increment": "^3.4.0",
|
||||
"boss-js": "github:PretendoNetwork/boss-js",
|
||||
"cacache": "^18.0.0",
|
||||
"colors": "^1.4.0",
|
||||
"dotenv": "^10.0.0",
|
||||
"express": "^4.17.1",
|
||||
"express-subdomain": "^1.0.5",
|
||||
"morgan": "^1.10.0"
|
||||
"moment": "^2.29.4",
|
||||
"mongoose": "^7.4.3",
|
||||
"morgan": "^1.10.0",
|
||||
"nice-grpc": "^2.1.5",
|
||||
"xmlbuilder": "^15.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.17",
|
||||
|
|
|
|||
|
|
@ -1,49 +1,180 @@
|
|||
import crypto from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import fs from 'fs-extra';
|
||||
import mongoose from 'mongoose';
|
||||
import dotenv from 'dotenv';
|
||||
import { md5 } from '@/util';
|
||||
import { LOG_INFO, LOG_ERROR } from '@/logger';
|
||||
import { Config } from '@/types/common/config';
|
||||
import { LOG_INFO, LOG_WARN, LOG_ERROR } from '@/logger';
|
||||
import { DisabledFeatures, Config } from '@/types/common/config';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const BOSS_WIIU_AES_KEY_HASH = Buffer.from('5202ce5099232c3d365e28379790a919', 'hex');
|
||||
const BOSS_WIIU_HMAC_KEY_HASH = Buffer.from('b4482fef177b0100090ce0dbeb8ce977', 'hex');
|
||||
const BOSS_3DS_AES_KEY_HASH = Buffer.from('86fbc2bb4cb703b2a4c6cc9961319926', 'hex');
|
||||
// * Defined here to prevent circular dependencies
|
||||
function md5(input: crypto.BinaryLike): string {
|
||||
return crypto.createHash('md5').update(input).digest('hex');
|
||||
}
|
||||
|
||||
const BOSS_WIIU_AES_KEY_MD5_HASH = '5202ce5099232c3d365e28379790a919';
|
||||
const BOSS_WIIU_HMAC_KEY_MD5_HASH = 'b4482fef177b0100090ce0dbeb8ce977';
|
||||
const BOSS_3DS_AES_KEY_MD5_HASH = '86fbc2bb4cb703b2a4c6cc9961319926';
|
||||
|
||||
LOG_INFO('Loading config');
|
||||
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
let mongooseConnectOptionsMain: mongoose.ConnectOptions = {};
|
||||
|
||||
if (process.env.PN_BOSS_CONFIG_MONGOOSE_CONNECT_OPTIONS_PATH?.trim()) {
|
||||
mongooseConnectOptionsMain = fs.readJSONSync(process.env.PN_BOSS_CONFIG_MONGOOSE_CONNECT_OPTIONS_PATH?.trim());
|
||||
} else {
|
||||
warnings.push('No Mongoose connection options found for main connection. To add connection options, set PN_BOSS_CONFIG_MONGOOSE_CONNECT_OPTIONS_PATH to the path of your options JSON file');
|
||||
}
|
||||
|
||||
export const disabledFeatures: DisabledFeatures = {
|
||||
s3: false
|
||||
};
|
||||
|
||||
export const config: Config = {
|
||||
http: {
|
||||
port: Number(process.env.PN_BOSS_CONFIG_HTTP_PORT || '')
|
||||
port: Number(process.env.PN_BOSS_CONFIG_HTTP_PORT?.trim() || '')
|
||||
},
|
||||
crypto: {
|
||||
wup: {
|
||||
aes_key: Buffer.from(process.env.PN_BOSS_CONFIG_BOSS_WIIU_AES_KEY || ''),
|
||||
hmac_key: Buffer.from(process.env.PN_BOSS_CONFIG_BOSS_WIIU_HMAC_KEY || ''),
|
||||
aes_key: process.env.PN_BOSS_CONFIG_BOSS_WIIU_AES_KEY?.trim() || '',
|
||||
hmac_key: process.env.PN_BOSS_CONFIG_BOSS_WIIU_HMAC_KEY?.trim() || ''
|
||||
},
|
||||
ctr: {
|
||||
aes_key: Buffer.from(process.env.PN_BOSS_CONFIG_BOSS_3DS_AES_KEY || '', 'hex')
|
||||
aes_key: Buffer.from(process.env.PN_BOSS_CONFIG_BOSS_3DS_AES_KEY?.trim() || '', 'hex')
|
||||
}
|
||||
}
|
||||
},
|
||||
grpc: {
|
||||
boss: {
|
||||
address: process.env.PN_BOSS_CONFIG_GRPC_BOSS_SERVER_ADDRESS?.trim() || '',
|
||||
port: Number(process.env.PN_BOSS_CONFIG_GRPC_BOSS_SERVER_PORT?.trim() || ''),
|
||||
api_key: process.env.PN_BOSS_CONFIG_GRPC_BOSS_SERVER_API_KEY?.trim() || ''
|
||||
},
|
||||
account: {
|
||||
address: process.env.PN_BOSS_CONFIG_GRPC_ACCOUNT_SERVER_ADDRESS?.trim() || '',
|
||||
port: Number(process.env.PN_BOSS_CONFIG_GRPC_ACCOUNT_SERVER_PORT?.trim() || ''),
|
||||
api_key: process.env.PN_BOSS_CONFIG_GRPC_ACCOUNT_SERVER_API_KEY?.trim() || ''
|
||||
}
|
||||
},
|
||||
mongoose: {
|
||||
connection_string: process.env.PN_BOSS_CONFIG_MONGO_CONNECTION_STRING?.trim() || '',
|
||||
options: mongooseConnectOptionsMain
|
||||
},
|
||||
cdn: {
|
||||
download_url: process.env.PN_BOSS_CONFIG_CDN_DOWNLOAD_URL?.trim() || '',
|
||||
s3: {
|
||||
endpoint: process.env.PN_BOSS_CONFIG_S3_ENDPOINT?.trim() || '',
|
||||
region: process.env.PN_BOSS_CONFIG_S3_REGION?.trim() || '',
|
||||
bucket: process.env.PN_BOSS_CONFIG_S3_BUCKET?.trim() || '',
|
||||
key: process.env.PN_BOSS_CONFIG_S3_ACCESS_KEY?.trim() || '',
|
||||
secret: process.env.PN_BOSS_CONFIG_S3_ACCESS_SECRET?.trim() || ''
|
||||
},
|
||||
disk_path: process.env.PN_BOSS_CONFIG_CDN_DISK_PATH?.trim() || ''
|
||||
},
|
||||
};
|
||||
|
||||
LOG_INFO('Config loaded, checking integrity');
|
||||
|
||||
if (!config.http.port) {
|
||||
LOG_ERROR('Failed to find HTTP port. Set the PN_BOSS_CONFIG_HTTP_PORT environment variable');
|
||||
process.exit(0);
|
||||
errors.push('Failed to find HTTP port. Set the PN_BOSS_CONFIG_HTTP_PORT environment variable');
|
||||
}
|
||||
|
||||
if (!BOSS_WIIU_AES_KEY_HASH.equals(md5(config.crypto.wup.aes_key))) {
|
||||
LOG_ERROR('Invalid BOSS WiiU AES key. Set or correct the PN_BOSS_CONFIG_BOSS_WIIU_AES_KEY environment variable');
|
||||
process.exit(0);
|
||||
if (md5(config.crypto.wup.aes_key) !== BOSS_WIIU_AES_KEY_MD5_HASH) {
|
||||
errors.push('Invalid BOSS WiiU AES key. Set or correct the PN_BOSS_CONFIG_BOSS_WIIU_AES_KEY environment variable');
|
||||
}
|
||||
|
||||
if (!BOSS_WIIU_HMAC_KEY_HASH.equals(md5(config.crypto.wup.hmac_key))) {
|
||||
LOG_ERROR('Invalid BOSS WiiU HMAC key. Set or correct the PN_BOSS_CONFIG_BOSS_WIIU_HMAC_KEY environment variable');
|
||||
process.exit(0);
|
||||
if (md5(config.crypto.wup.hmac_key) !== BOSS_WIIU_HMAC_KEY_MD5_HASH) {
|
||||
errors.push('Invalid BOSS WiiU HMAC key. Set or correct the PN_BOSS_CONFIG_BOSS_WIIU_HMAC_KEY environment variable');
|
||||
}
|
||||
|
||||
if (!BOSS_3DS_AES_KEY_HASH.equals(md5(config.crypto.ctr.aes_key))) {
|
||||
LOG_ERROR('Invalid BOSS 3DS AES key. Set or correct the PN_BOSS_CONFIG_BOSS_3DS_AES_KEY environment variable');
|
||||
if (md5(config.crypto.ctr.aes_key) !== BOSS_3DS_AES_KEY_MD5_HASH) {
|
||||
errors.push('Invalid BOSS 3DS AES key. Set or correct the PN_BOSS_CONFIG_BOSS_3DS_AES_KEY environment variable');
|
||||
}
|
||||
|
||||
if (!config.grpc.boss.address) {
|
||||
errors.push('Failed to find BOSS server gRPC address. Set the PN_BOSS_CONFIG_GRPC_BOSS_SERVER_ADDRESS environment variable');
|
||||
}
|
||||
|
||||
if (!config.grpc.boss.port) {
|
||||
errors.push('Failed to find BOSS server gRPC port. Set the PN_BOSS_CONFIG_GRPC_BOSS_SERVER_PORT environment variable');
|
||||
}
|
||||
|
||||
if (!config.grpc.boss.api_key) {
|
||||
errors.push('Failed to find BOSS server gRPC API key. Set the PN_BOSS_CONFIG_GRPC_BOSS_SERVER_API_KEY environment variable');
|
||||
}
|
||||
|
||||
if (!config.grpc.account.address) {
|
||||
errors.push('Failed to find account server gRPC address. Set the PN_BOSS_CONFIG_GRPC_ACCOUNT_SERVER_ADDRESS environment variable');
|
||||
}
|
||||
|
||||
if (!config.grpc.account.port) {
|
||||
errors.push('Failed to find account server gRPC port. Set the PN_BOSS_CONFIG_GRPC_ACCOUNT_SERVER_PORT environment variable');
|
||||
}
|
||||
|
||||
if (!config.grpc.account.api_key) {
|
||||
errors.push('Failed to find account server gRPC API key. Set the PN_BOSS_CONFIG_GRPC_ACCOUNT_SERVER_API_KEY environment variable');
|
||||
}
|
||||
|
||||
if (!config.mongoose.connection_string) {
|
||||
errors.push('Failed to find MongoDB connection string. Set the PN_BOSS_CONFIG_MONGO_CONNECTION_STRING environment variable');
|
||||
}
|
||||
|
||||
if (!config.cdn.download_url) {
|
||||
errors.push('Failed to find CDN content download URL. Set the PN_BOSS_CONFIG_CDN_DOWNLOAD_URL environment variable');
|
||||
} else {
|
||||
const parsedURL = new URL(config.cdn.download_url);
|
||||
|
||||
if (!parsedURL.hostname.startsWith('npdi.cdn')) {
|
||||
errors.push('CDN content download URL *MUST* use the subdomain `npdi.cdn`');
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.cdn.s3.endpoint) {
|
||||
warnings.push('Failed to find s3 endpoint config. Disabling feature. To enable feature set the PN_BOSS_CONFIG_S3_ENDPOINT environment variable');
|
||||
disabledFeatures.s3 = true;
|
||||
}
|
||||
|
||||
if (!config.cdn.s3.region) {
|
||||
warnings.push('Failed to find s3 region config. Disabling feature. To enable feature set the PN_BOSS_CONFIG_S3_REGION environment variable');
|
||||
disabledFeatures.s3 = true;
|
||||
}
|
||||
|
||||
if (!config.cdn.s3.bucket) {
|
||||
warnings.push('Failed to find s3 bucket config. Disabling feature. To enable feature set the PN_BOSS_CONFIG_S3_BUCKET environment variable');
|
||||
disabledFeatures.s3 = true;
|
||||
}
|
||||
|
||||
if (!config.cdn.s3.key) {
|
||||
warnings.push('Failed to find s3 access key config. Disabling feature. To enable feature set the PN_BOSS_CONFIG_S3_ACCESS_KEY environment variable');
|
||||
disabledFeatures.s3 = true;
|
||||
}
|
||||
|
||||
if (!config.cdn.s3.secret) {
|
||||
warnings.push('Failed to find s3 secret key config. Disabling feature. To enable feature set the PN_BOSS_CONFIG_S3_ACCESS_SECRET environment variable');
|
||||
disabledFeatures.s3 = true;
|
||||
}
|
||||
|
||||
if (disabledFeatures.s3) {
|
||||
if (!config.cdn.disk_path) {
|
||||
errors.push('s3 file storage is disabled and no CDN disk path was set. Set the PN_BOSS_CONFIG_CDN_DISK_PATH environment variable');
|
||||
}
|
||||
|
||||
config.cdn.disk_path = path.resolve(config.cdn.disk_path);
|
||||
|
||||
fs.ensureDirSync(config.cdn.disk_path);
|
||||
}
|
||||
|
||||
for (const warning of warnings) {
|
||||
LOG_WARN(warning);
|
||||
}
|
||||
|
||||
if (errors.length !== 0) {
|
||||
for (const error of errors) {
|
||||
LOG_ERROR(error);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
97
src/database.ts
Normal file
97
src/database.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { Task } from '@/models/task';
|
||||
import { File } from '@/models/file';
|
||||
import { config } from '@/config-manager';
|
||||
import { HydratedTaskDocument, ITask } from '@/types/mongoose/task';
|
||||
import { HydratedFileDocument, IFile } from '@/types/mongoose/file';
|
||||
|
||||
const connection_string: string = config.mongoose.connection_string;
|
||||
const options: mongoose.ConnectOptions = config.mongoose.options;
|
||||
|
||||
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 function getAllTasks(allowDeleted: boolean): Promise<HydratedTaskDocument[]> {
|
||||
verifyConnected();
|
||||
|
||||
const filter: mongoose.FilterQuery<ITask> = {};
|
||||
|
||||
if (!allowDeleted) {
|
||||
filter.deleted = false;
|
||||
}
|
||||
|
||||
return Task.find(filter);
|
||||
}
|
||||
|
||||
export function getTask(bossAppID: string, taskID: string): Promise<HydratedTaskDocument | null> {
|
||||
verifyConnected();
|
||||
|
||||
return Task.findOne<HydratedTaskDocument>({
|
||||
deleted: false,
|
||||
id: taskID,
|
||||
boss_app_id: bossAppID
|
||||
});
|
||||
}
|
||||
|
||||
export function getTaskFiles(allowDeleted: boolean, bossAppID: string, taskID: string, country?: string, language?: string): Promise<HydratedFileDocument[]> {
|
||||
verifyConnected();
|
||||
|
||||
const filter: mongoose.FilterQuery<IFile> = {
|
||||
task_id: taskID,
|
||||
boss_app_id: bossAppID
|
||||
};
|
||||
|
||||
if (!allowDeleted) {
|
||||
filter.deleted = false;
|
||||
}
|
||||
|
||||
if (country) {
|
||||
filter.supported_countries = {
|
||||
$in: [country]
|
||||
};
|
||||
}
|
||||
|
||||
if (language) {
|
||||
filter.supported_languages = {
|
||||
$in: [language]
|
||||
};
|
||||
}
|
||||
|
||||
return File.find(filter);
|
||||
}
|
||||
|
||||
export function getTaskFile(bossAppID: string, taskID: string, name: string): Promise<HydratedFileDocument | null> {
|
||||
verifyConnected();
|
||||
|
||||
return File.findOne<HydratedFileDocument>({
|
||||
deleted: false,
|
||||
boss_app_id: bossAppID,
|
||||
task_id: taskID,
|
||||
name: name
|
||||
});
|
||||
}
|
||||
|
||||
export function getTaskFileByDataID(dataID: bigint): Promise<HydratedFileDocument | null> {
|
||||
verifyConnected();
|
||||
|
||||
return File.findOne<HydratedFileDocument>({
|
||||
deleted: false,
|
||||
data_id: dataID
|
||||
});
|
||||
}
|
||||
11
src/middleware/authentication.ts
Normal file
11
src/middleware/authentication.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import express from 'express';
|
||||
import { getUserDataByPID } from '@/util';
|
||||
|
||||
export default async function authenticationMiddleware(request: express.Request, response: express.Response, next: express.NextFunction): Promise<void> {
|
||||
if (request.pid) {
|
||||
// TODO - Handle 3DS NEX accounts
|
||||
request.pnid = await getUserDataByPID(request.pid);
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
135
src/middleware/parse-user-agent.ts
Normal file
135
src/middleware/parse-user-agent.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import express from 'express';
|
||||
import RequestException from '@/request-exception';
|
||||
import type { UserAgentInfo } from '@/types/common/user-agent-info';
|
||||
|
||||
const FIRMWARE_PATCH_REGION_WIIU_REGEX = /(\d)([JEU])/;
|
||||
|
||||
export default function parseUserAgentMiddleware(request: express.Request, response: express.Response, next: express.NextFunction): void {
|
||||
const userAgent = request.header('user-agent');
|
||||
|
||||
if (!userAgent) {
|
||||
return next(new RequestException('Missing or invalid user agent', 400));
|
||||
}
|
||||
|
||||
let result: UserAgentInfo | null = null;
|
||||
|
||||
if (userAgent.startsWith('PBOSU')) {
|
||||
result = parseWiiU(userAgent);
|
||||
} else if (userAgent.startsWith('PBOS')) {
|
||||
result = parse3DS(userAgent);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return next(new RequestException('Missing or invalid user agent', 400));
|
||||
}
|
||||
|
||||
request.pid = result.userPID;
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
function parseWiiU(userAgent: string): UserAgentInfo | null {
|
||||
const parts = userAgent.split('/');
|
||||
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [bossLibraryInfo, userInfo, firmwareVersion] = parts;
|
||||
const [bossLibraryName, bossLibraryVersion] = bossLibraryInfo.split('-');
|
||||
const [bossLibraryVersionMajor, bossLibraryVersionMinor] = bossLibraryVersion.split('.');
|
||||
const [deviceIDHex, userPIDHex, unknown] = userInfo.split('-');
|
||||
const [firmwareMajor, firmwareMinor, firmwarePatchAndRegion] = firmwareVersion.split('.');
|
||||
const [, firmwarePatch, firmwareRegion] = FIRMWARE_PATCH_REGION_WIIU_REGEX.exec(firmwarePatchAndRegion) || [];
|
||||
|
||||
if (
|
||||
bossLibraryName !== 'PBOSU' ||
|
||||
bossLibraryVersionMajor !== '4' ||
|
||||
bossLibraryVersionMinor !== '0' ||
|
||||
firmwareMajor !== '5' ||
|
||||
firmwareMinor !== '5' ||
|
||||
firmwarePatch !== '6' ||
|
||||
!['J', 'U', 'E'].includes(firmwareRegion) ||
|
||||
deviceIDHex.length !== 8 ||
|
||||
userPIDHex.length !== 8 ||
|
||||
unknown.length !== 16
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let deviceID: number;
|
||||
let userPID: number;
|
||||
|
||||
try {
|
||||
deviceID = parseInt(deviceIDHex, 16);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
userPID = parseInt(userPIDHex, 16);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
deviceID,
|
||||
userPID
|
||||
};
|
||||
}
|
||||
|
||||
function parse3DS(userAgent: string): UserAgentInfo | null {
|
||||
const parts = userAgent.split('/');
|
||||
|
||||
if (parts.length !== 5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [bossLibraryInfo, userInfo, firmwareVersion, unknownPart1, unknownPart2] = parts;
|
||||
const [bossLibraryName, bossLibraryVersion] = bossLibraryInfo.split('-');
|
||||
const [bossLibraryVersionMajor, bossLibraryVersionMinor] = bossLibraryVersion.split('.');
|
||||
const [unknownUserInfo1Hex, unknownUserInfo2Hex] = userInfo.split('-');
|
||||
const [firmwareMajor, firmwareMinor, firmwarePatchAndRegion] = firmwareVersion.split('.');
|
||||
|
||||
if (
|
||||
bossLibraryName !== 'PBOS' ||
|
||||
bossLibraryVersionMajor !== '8' ||
|
||||
bossLibraryVersionMinor !== '0' ||
|
||||
firmwareMajor !== '11' ||
|
||||
firmwareMinor !== '17' ||
|
||||
!['0-50J', '0-50U', '0-50E'].includes(firmwarePatchAndRegion) || // TODO - Make this more dynamic?
|
||||
unknownPart1 !== '62452' || // TODO - Is this right?
|
||||
!unknownPart2 || // TODO - Actually check this
|
||||
unknownUserInfo1Hex.length !== 16 ||
|
||||
unknownUserInfo2Hex.length !== 16
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let unknownUserInfo1: number;
|
||||
let unknownUserInfo2: number;
|
||||
|
||||
// TODO - Parse this data? What is this?
|
||||
try {
|
||||
unknownUserInfo1 = parseInt(unknownUserInfo1Hex, 16);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
unknownUserInfo2 = parseInt(unknownUserInfo2Hex, 16);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO - What is the upper 4 bytes?
|
||||
const localFriendCodeSeed = (unknownUserInfo1 & 0xFFFFFFFF); // * LFCS is the lower 4 bytes
|
||||
|
||||
// TODO - What is the upper 4 bytes?
|
||||
const userPID = (unknownUserInfo2 & 0xFFFFFFFF); // * PID is the lower 4 bytes
|
||||
|
||||
return {
|
||||
localFriendCodeSeed,
|
||||
userPID
|
||||
};
|
||||
}
|
||||
31
src/models/file.ts
Normal file
31
src/models/file.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { AutoIncrementID } from '@typegoose/auto-increment';
|
||||
import { IFile, IFileMethods, FileModel } from '@/types/mongoose/file';
|
||||
|
||||
const FileSchema = new mongoose.Schema<IFile, FileModel, IFileMethods>({
|
||||
deleted: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
data_id: Number, // TODO - Wait until https://github.com/typegoose/auto-increment/pull/21 is merged and then change this to BigInt
|
||||
task_id: String,
|
||||
boss_app_id: String,
|
||||
supported_countries: [String],
|
||||
supported_languages: [String],
|
||||
creator_pid: Number,
|
||||
name: String,
|
||||
type: String,
|
||||
hash: String,
|
||||
size: BigInt,
|
||||
notify_on_new: [String],
|
||||
notify_led: Boolean,
|
||||
created: BigInt,
|
||||
updated: BigInt
|
||||
}, { id: false });
|
||||
|
||||
FileSchema.plugin(AutoIncrementID, {
|
||||
startAt: 50000, // * Start very high to avoid conflicts with Nintendo Data IDs
|
||||
field: 'data_id'
|
||||
});
|
||||
|
||||
export const File: FileModel = mongoose.model<IFile, FileModel>('File', FileSchema);
|
||||
22
src/models/task.ts
Normal file
22
src/models/task.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { ITask, ITaskMethods, TaskModel } from '@/types/mongoose/task';
|
||||
|
||||
const TaskSchema = new mongoose.Schema<ITask, TaskModel, ITaskMethods>({
|
||||
deleted: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
id: String,
|
||||
boss_app_id: String,
|
||||
creator_pid: Number,
|
||||
status: {
|
||||
type: String,
|
||||
enum : ['open'] // TODO - What else is there?
|
||||
},
|
||||
title_id: String,
|
||||
description: Number,
|
||||
created: BigInt,
|
||||
updated: BigInt
|
||||
}, { id: false });
|
||||
|
||||
export const Task: TaskModel = mongoose.model<ITask, TaskModel>('Task', TaskSchema);
|
||||
9
src/request-exception.ts
Normal file
9
src/request-exception.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export default class RequestException extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number, options?: ErrorOptions | undefined) {
|
||||
super(message, options);
|
||||
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,18 +2,21 @@ process.title = 'Pretendo - BOSS';
|
|||
|
||||
import express from 'express';
|
||||
import morgan from 'morgan';
|
||||
import { connect as connectDatabase } from '@/database';
|
||||
import { startGRPCServer } from '@/services/grpc/server';
|
||||
import RequestException from '@/request-exception';
|
||||
import { LOG_INFO, LOG_SUCCESS } from '@/logger';
|
||||
import { config } from '@/config-manager';
|
||||
|
||||
import parseUserAgentMiddleware from '@/middleware/parse-user-agent';
|
||||
import authenticationMiddleware from '@/middleware/authentication';
|
||||
|
||||
import nppl from '@/services/nppl';
|
||||
import npts from '@/services/npts';
|
||||
import npdi from '@/services/npdi';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.set('etag', false);
|
||||
app.disable('x-powered-by');
|
||||
|
||||
LOG_INFO('Setting up Middleware');
|
||||
app.use(morgan('dev'));
|
||||
app.use(express.json());
|
||||
|
|
@ -21,6 +24,9 @@ app.use(express.urlencoded({
|
|||
extended: true
|
||||
}));
|
||||
|
||||
app.use(parseUserAgentMiddleware);
|
||||
app.use(authenticationMiddleware);
|
||||
|
||||
app.use(nppl);
|
||||
app.use(npts);
|
||||
app.use(npdi);
|
||||
|
|
@ -36,18 +42,37 @@ app.use((_request, response) => {
|
|||
});
|
||||
|
||||
LOG_INFO('Creating non-404 status handler');
|
||||
app.use((error: any, _request: express.Request, response: express.Response, _next: express.NextFunction) => {
|
||||
const status: number = error.status || 500;
|
||||
app.use((error: unknown, _request: express.Request, response: express.Response, _next: express.NextFunction) => {
|
||||
let status: number = 500;
|
||||
let message: string = 'Unknown error';
|
||||
|
||||
if (error instanceof RequestException) {
|
||||
status = error.status;
|
||||
message = error.message;
|
||||
}
|
||||
|
||||
console.log(error);
|
||||
|
||||
response.status(status);
|
||||
response.json({
|
||||
app: 'boss',
|
||||
status: status,
|
||||
error: error
|
||||
error: message
|
||||
});
|
||||
});
|
||||
|
||||
LOG_INFO('Starting server');
|
||||
app.listen(config.http.port, () => {
|
||||
LOG_SUCCESS(`Server started on port ${config.http.port}`);
|
||||
});
|
||||
async function main(): Promise<void> {
|
||||
LOG_INFO('Starting server');
|
||||
|
||||
await connectDatabase();
|
||||
LOG_SUCCESS('Database connected');
|
||||
|
||||
await startGRPCServer();
|
||||
LOG_SUCCESS(`gRPC server started at address ${config.grpc.boss.address}:${config.grpc.boss.port}`);
|
||||
|
||||
app.listen(config.http.port, () => {
|
||||
LOG_SUCCESS(`HTTP server started on port ${config.http.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
35
src/services/grpc/boss/delete-file.ts
Normal file
35
src/services/grpc/boss/delete-file.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { CallContext, Status, ServerError } from 'nice-grpc';
|
||||
import { DeleteFileRequest } from '../../../../../grpc-ts/dist/boss/delete_file';
|
||||
import { GetUserDataResponse } from '../../../../../grpc-ts/dist/account/get_user_data_rpc';
|
||||
import { getTaskFileByDataID } from '@/database';
|
||||
import { AuthenticationCallContextExt } from '@/services/grpc/boss/middleware/authentication-middleware';
|
||||
import { Empty } from '../../../../../grpc-ts/dist/boss/google/protobuf/empty';
|
||||
|
||||
export async function deleteFile(request: DeleteFileRequest, context: CallContext & AuthenticationCallContextExt): Promise<Empty> {
|
||||
// * This is asserted in authentication middleware, we know this is never null
|
||||
const user: GetUserDataResponse = context.user!;
|
||||
|
||||
if (!user.permissions?.deleteBossFiles) {
|
||||
throw new ServerError(Status.PERMISSION_DENIED, 'PNID not authorized to delete files');
|
||||
}
|
||||
|
||||
const dataID = request.dataId;
|
||||
const bossAppID = request.bossAppId.trim();
|
||||
|
||||
if (!dataID) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing file data ID');
|
||||
}
|
||||
|
||||
const file = await getTaskFileByDataID(dataID);
|
||||
|
||||
if (!file || file.boss_app_id !== bossAppID) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, `File ${dataID} not found for BOSS app ${bossAppID}`);
|
||||
}
|
||||
|
||||
file.deleted = true;
|
||||
file.updated = BigInt(Date.now());
|
||||
|
||||
await file.save();
|
||||
|
||||
return {};
|
||||
}
|
||||
39
src/services/grpc/boss/delete-task.ts
Normal file
39
src/services/grpc/boss/delete-task.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { CallContext, Status, ServerError } from 'nice-grpc';
|
||||
import { DeleteTaskRequest } from '../../../../../grpc-ts/dist/boss/delete_task';
|
||||
import { GetUserDataResponse } from '../../../../../grpc-ts/dist/account/get_user_data_rpc';
|
||||
import { getTask } from '@/database';
|
||||
import { AuthenticationCallContextExt } from '@/services/grpc/boss/middleware/authentication-middleware';
|
||||
import { Empty } from '../../../../../grpc-ts/dist/boss/google/protobuf/empty';
|
||||
|
||||
export async function deleteTask(request: DeleteTaskRequest, context: CallContext & AuthenticationCallContextExt): Promise<Empty> {
|
||||
// * This is asserted in authentication middleware, we know this is never null
|
||||
const user: GetUserDataResponse = context.user!;
|
||||
|
||||
if (!user.permissions?.deleteBossTasks) {
|
||||
throw new ServerError(Status.PERMISSION_DENIED, 'PNID not authorized to delete tasks');
|
||||
}
|
||||
|
||||
const taskID = request.id.trim();
|
||||
const bossAppID = request.bossAppId.trim();
|
||||
|
||||
if (!taskID) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing task ID');
|
||||
}
|
||||
|
||||
if (!bossAppID) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing BOSS app ID');
|
||||
}
|
||||
|
||||
const task = await getTask(bossAppID, taskID);
|
||||
|
||||
if (!task) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, `Task ${taskID} not found for BOSS app ${bossAppID}`);
|
||||
}
|
||||
|
||||
task.deleted = true;
|
||||
task.updated = BigInt(Date.now());
|
||||
|
||||
await task.save();
|
||||
|
||||
return {};
|
||||
}
|
||||
22
src/services/grpc/boss/implementation.ts
Normal file
22
src/services/grpc/boss/implementation.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { BOSSServiceImplementation } from '../../../../../grpc-ts/dist/boss/boss_service';
|
||||
import { listKnownBOSSApps } from '@/services/grpc/boss/list-known-boss-apps';
|
||||
import { listTasks } from '@/services/grpc/boss/list-tasks';
|
||||
import { registerTask } from '@/services/grpc/boss/register-task';
|
||||
import { updateTask } from '@/services/grpc/boss/update-task';
|
||||
import { deleteTask } from '@/services/grpc/boss/delete-task';
|
||||
import { listFiles } from '@/services/grpc/boss/list-files';
|
||||
import { uploadFile } from '@/services/grpc/boss/upload-file';
|
||||
import { updateFileMetadata } from '@/services/grpc/boss/update-file-metadata';
|
||||
import { deleteFile } from '@/services/grpc/boss/delete-file';
|
||||
|
||||
export const implementation: BOSSServiceImplementation = {
|
||||
listKnownBOSSApps,
|
||||
listTasks,
|
||||
registerTask,
|
||||
updateTask,
|
||||
deleteTask,
|
||||
listFiles,
|
||||
uploadFile,
|
||||
updateFileMetadata,
|
||||
deleteFile
|
||||
};
|
||||
70
src/services/grpc/boss/list-files.ts
Normal file
70
src/services/grpc/boss/list-files.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { Status, ServerError } from 'nice-grpc';
|
||||
import { ListFilesRequest, ListFilesResponse } from '../../../../../grpc-ts/dist/boss/list_files';
|
||||
import { getTaskFiles } from '@/database';
|
||||
|
||||
const BOSS_APP_ID_FILTER_REGEX = /^[A-Za-z0-9]*$/;
|
||||
|
||||
const VALID_COUNTRIES = [
|
||||
'US', 'JP', 'CR'
|
||||
];
|
||||
|
||||
const VALID_LANGUAGES = [
|
||||
'en', 'ja'
|
||||
];
|
||||
|
||||
export async function listFiles(request: ListFilesRequest): Promise<ListFilesResponse> {
|
||||
const taskID = request.taskId.trim();
|
||||
const bossAppID = request.bossAppId.trim();
|
||||
const country = request.country?.trim();
|
||||
const language = request.language?.trim();
|
||||
|
||||
if (!taskID) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing task ID');
|
||||
}
|
||||
|
||||
if (taskID.length < 7) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Task ID must be 1-7 characters');
|
||||
}
|
||||
|
||||
if (!bossAppID) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing BOSS app ID');
|
||||
}
|
||||
|
||||
if (bossAppID.length !== 16) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'BOSS app ID must be 16 characters');
|
||||
}
|
||||
|
||||
if (!BOSS_APP_ID_FILTER_REGEX.test(bossAppID)) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'BOSS app ID must only contain letters and numbers');
|
||||
}
|
||||
|
||||
if (country && !VALID_COUNTRIES.includes(country)) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, `${country} is not a valid country`);
|
||||
}
|
||||
|
||||
if (language && !VALID_LANGUAGES.includes(language)) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, `${language} is not a valid language`);
|
||||
}
|
||||
|
||||
const files = await getTaskFiles(false, bossAppID, taskID, country, language);
|
||||
|
||||
return {
|
||||
files: files.map(file => ({
|
||||
deleted: file.deleted,
|
||||
dataId: file.data_id,
|
||||
taskId: file.task_id,
|
||||
bossAppId: file.boss_app_id,
|
||||
supportedCountries: file.supported_countries,
|
||||
supportedLanguages: file.supported_languages,
|
||||
creatorPid: file.creator_pid,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
hash: file.hash,
|
||||
size: file.size,
|
||||
notifyOnNew: file.notify_on_new,
|
||||
notifyLed: file.notify_led,
|
||||
createdTimestamp: file.created,
|
||||
updatedTimestamp: file.updated
|
||||
}))
|
||||
};
|
||||
}
|
||||
162
src/services/grpc/boss/list-known-boss-apps.ts
Normal file
162
src/services/grpc/boss/list-known-boss-apps.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { ListKnownBOSSAppsResponse } from '../../../../../grpc-ts/dist/boss/list_known_boss_apps';
|
||||
|
||||
export async function listKnownBOSSApps(): Promise<ListKnownBOSSAppsResponse> {
|
||||
return {
|
||||
apps: [
|
||||
{
|
||||
bossAppId: 'WJDaV6ePVgrS0TRa',
|
||||
titleId: '0005003010016000',
|
||||
titleRegion: 'UNK',
|
||||
name: 'Unknown',
|
||||
tasks: [ 'olvinfo' ]
|
||||
},
|
||||
{
|
||||
bossAppId: 'VFoY6V7u7UUq1EG5',
|
||||
titleId: '0005003010016100',
|
||||
titleRegion: 'UNK',
|
||||
name: 'Unknown',
|
||||
tasks: [ 'olvinfo' ]
|
||||
},
|
||||
{
|
||||
bossAppId: '8MNOVprfNVAJjfCM',
|
||||
titleId: '0005003010016200',
|
||||
titleRegion: 'UNK',
|
||||
name: 'Unknown',
|
||||
tasks: [ 'olvinfo' ]
|
||||
},
|
||||
{
|
||||
bossAppId: 'v1cqzWykBKUg0rHQ',
|
||||
titleId: '000500301001900A',
|
||||
titleRegion: 'JPN',
|
||||
name: 'Miiverse Post All',
|
||||
tasks: [ 'solv' ]
|
||||
},
|
||||
{
|
||||
bossAppId: 'bieC9ACJlisFg5xS',
|
||||
titleId: '000500301001910A',
|
||||
titleRegion: 'USA',
|
||||
name: 'Miiverse Post All',
|
||||
tasks: [ 'solv' ]
|
||||
},
|
||||
{
|
||||
bossAppId: 'tOaQcoBLtPTgVN3Y',
|
||||
titleId: '000500301001920A',
|
||||
titleRegion: 'EUR',
|
||||
name: 'Miiverse Post All',
|
||||
tasks: [ 'solv' ]
|
||||
},
|
||||
{
|
||||
bossAppId: '07E3nY6lAwlwrQRo',
|
||||
titleId: '000500301001410A',
|
||||
titleRegion: 'USA',
|
||||
name: 'Nintendo eShop',
|
||||
tasks: [ 'wood1', 'woodBGM' ]
|
||||
},
|
||||
{
|
||||
bossAppId: '8UsM86l8xgkjFk8z',
|
||||
titleId: '000500301001420A',
|
||||
titleRegion: 'EUR',
|
||||
name: 'Nintendo eShop',
|
||||
tasks: [ 'wood1', 'woodBGM' ]
|
||||
},
|
||||
{
|
||||
bossAppId: 'IXmFUqR2qenXfF61',
|
||||
titleId: '0005001010066000',
|
||||
titleRegion: 'ALL',
|
||||
name: 'ECO Process',
|
||||
tasks: [ 'promo1', 'promo2', 'promo3', 'push' ]
|
||||
},
|
||||
{
|
||||
bossAppId: 'BMQAm5iUVtPsJVsU',
|
||||
titleId: '000500101004D000',
|
||||
titleRegion: 'JPN',
|
||||
name: 'Notifications',
|
||||
tasks: [ 'sysmsg1', 'sysmsg2' ]
|
||||
},
|
||||
{
|
||||
bossAppId: 'LRmanFo4Tx3kEGDp',
|
||||
titleId: '000500101004D100',
|
||||
titleRegion: 'USA',
|
||||
name: 'Notifications',
|
||||
tasks: [ 'sysmsg1', 'sysmsg2' ]
|
||||
},
|
||||
{
|
||||
bossAppId: 'TZr27FE8wzKiEaTO',
|
||||
titleId: '000500101004D200',
|
||||
titleRegion: 'EUR',
|
||||
name: 'Notifications',
|
||||
tasks: [ 'sysmsg1', 'sysmsg2' ]
|
||||
},
|
||||
{
|
||||
bossAppId: 'RaPn5saabzliYrpo',
|
||||
titleId: '0005000010101D00',
|
||||
titleRegion: 'USA',
|
||||
name: 'New SUPER MARIO BROS. U',
|
||||
tasks: [ 'news' ]
|
||||
},
|
||||
{
|
||||
bossAppId: 'bb6tOEckvgZ50ciH',
|
||||
titleId: '0005000010162B00',
|
||||
titleRegion: 'JPN',
|
||||
name: 'スプラトゥーン (Splatoon)',
|
||||
tasks: [ 'optdat2', 'schdat2' ]
|
||||
},
|
||||
{
|
||||
bossAppId: 'rjVlM7hUXPxmYQJh',
|
||||
titleId: '0005000010176900',
|
||||
titleRegion: 'USA',
|
||||
name: 'Splatoon',
|
||||
tasks: [ 'optdat2', 'schdat2' ]
|
||||
},
|
||||
{
|
||||
bossAppId: 'zvGSM4kOrXpkKnpT',
|
||||
titleId: '0005000010176A00',
|
||||
titleRegion: 'EUR',
|
||||
name: 'Splatoon',
|
||||
tasks: [ 'optdat2', 'schdat2' ]
|
||||
},
|
||||
{
|
||||
bossAppId: 'pO72Hi5uqf5yuNd8',
|
||||
titleId: '0005000010144D00',
|
||||
titleRegion: 'USA',
|
||||
name: 'Wii Sports Club',
|
||||
tasks: [ 'sp1_ans' ]
|
||||
},
|
||||
{
|
||||
bossAppId: 'vGwChBW1ExOoHDsm',
|
||||
titleId: '000500001018DC00',
|
||||
titleRegion: 'USA',
|
||||
name: 'Super Mario Maker',
|
||||
tasks: [ 'CHARA' ]
|
||||
},
|
||||
{
|
||||
bossAppId: 'IeUc4hQsKKe9rJHB',
|
||||
titleId: '000500001018DD00',
|
||||
titleRegion: 'EUA',
|
||||
name: 'Super Mario Maker',
|
||||
tasks: [ 'CHARA' ]
|
||||
},
|
||||
{
|
||||
bossAppId: '4krJA4Gx3jF5nhQf',
|
||||
titleId: '000500001012BC00',
|
||||
titleRegion: 'JPN',
|
||||
name: 'ピクミン3 (PIKMIN 3)',
|
||||
tasks: [ 'histgrm' ]
|
||||
},
|
||||
{
|
||||
bossAppId: '9jRZEoWYLc3OG9a8',
|
||||
titleId: '000500001012BD00',
|
||||
titleRegion: 'USA',
|
||||
name: 'PIKMIN 3',
|
||||
tasks: [ 'histgrm' ]
|
||||
},
|
||||
{
|
||||
bossAppId: 'VWqUTspR5YtjDjxa',
|
||||
titleId: '000500001012BE00',
|
||||
titleRegion: 'EUR',
|
||||
name: 'PIKMIN 3',
|
||||
tasks: [ 'histgrm' ]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
20
src/services/grpc/boss/list-tasks.ts
Normal file
20
src/services/grpc/boss/list-tasks.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { ListTasksResponse } from '../../../../../grpc-ts/dist/boss/list_tasks';
|
||||
import { getAllTasks } from '@/database';
|
||||
|
||||
export async function listTasks(): Promise<ListTasksResponse> {
|
||||
const tasks = await getAllTasks(false);
|
||||
|
||||
return {
|
||||
tasks: tasks.map(task => ({
|
||||
deleted: task.deleted,
|
||||
id: task.id,
|
||||
bossAppId: task.boss_app_id,
|
||||
creatorPid: task.creator_pid,
|
||||
status: task.status,
|
||||
titleId: task.title_id,
|
||||
description: task.description,
|
||||
createdTimestamp: task.created,
|
||||
updatedTimestamp: task.updated
|
||||
}))
|
||||
};
|
||||
}
|
||||
15
src/services/grpc/boss/middleware/api-key-middleware.ts
Normal file
15
src/services/grpc/boss/middleware/api-key-middleware.ts
Normal 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: string | undefined = context.metadata.get('X-API-Key');
|
||||
|
||||
if (!apiKey || apiKey !== config.grpc.boss.api_key) {
|
||||
throw new ServerError(Status.UNAUTHENTICATED, 'Missing or invalid API key');
|
||||
}
|
||||
|
||||
return yield* call.next(call.request, context);
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { Status, ServerMiddlewareCall, CallContext, ServerError } from 'nice-grpc';
|
||||
import { GetUserDataResponse } from '../../../../../../grpc-ts/dist/account/get_user_data_rpc';
|
||||
import { getUserDataByToken } from '@/util';
|
||||
|
||||
const TOKEN_REQUIRED_PATHS = [
|
||||
'/boss.BOSS/RegisterTask',
|
||||
'/boss.BOSS/UpdateTask',
|
||||
'/boss.BOSS/DeleteTask',
|
||||
'/boss.BOSS/UploadFile',
|
||||
'/boss.BOSS/DeleteFile',
|
||||
];
|
||||
|
||||
export type AuthenticationCallContextExt = {
|
||||
user: GetUserDataResponse | null;
|
||||
};
|
||||
|
||||
export async function* authenticationMiddleware<Request, Response>(
|
||||
call: ServerMiddlewareCall<Request, Response, AuthenticationCallContextExt>,
|
||||
context: CallContext,
|
||||
): AsyncGenerator<Response, Response | void, undefined> {
|
||||
const token: string | undefined = 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 user: GetUserDataResponse | null = null;
|
||||
|
||||
if (token) {
|
||||
user = await getUserDataByToken(token);
|
||||
}
|
||||
|
||||
if (!user && TOKEN_REQUIRED_PATHS.includes(call.method.path)) {
|
||||
throw new ServerError(Status.UNAUTHENTICATED, 'Missing or invalid authentication token');
|
||||
}
|
||||
|
||||
return yield* call.next(call.request, {
|
||||
...context,
|
||||
user
|
||||
});
|
||||
} catch (error) {
|
||||
let message: string = 'Unknown server error';
|
||||
|
||||
console.log(error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
message = error.message;
|
||||
}
|
||||
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, message);
|
||||
}
|
||||
}
|
||||
71
src/services/grpc/boss/register-task.ts
Normal file
71
src/services/grpc/boss/register-task.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { CallContext, Status, ServerError } from 'nice-grpc';
|
||||
import { RegisterTaskRequest, RegisterTaskResponse } from '../../../../../grpc-ts/dist/boss/register_task';
|
||||
import { GetUserDataResponse } from '../../../../../grpc-ts/dist/account/get_user_data_rpc';
|
||||
import { getTask } from '@/database';
|
||||
import { Task } from '@/models/task';
|
||||
import { AuthenticationCallContextExt } from '@/services/grpc/boss/middleware/authentication-middleware';
|
||||
|
||||
const BOSS_APP_ID_FILTER_REGEX = /^[A-Za-z0-9]*$/;
|
||||
|
||||
export async function registerTask(request: RegisterTaskRequest, context: CallContext & AuthenticationCallContextExt): Promise<RegisterTaskResponse> {
|
||||
// * This is asserted in authentication middleware, we know this is never null
|
||||
const user: GetUserDataResponse = context.user!;
|
||||
|
||||
if (!user.permissions?.createBossTasks) {
|
||||
throw new ServerError(Status.PERMISSION_DENIED, 'PNID not authorized to register new tasks');
|
||||
}
|
||||
|
||||
const taskID = request.id.trim();
|
||||
const bossAppID = request.bossAppId.trim();
|
||||
const titleID = request.titleId.trim().toLocaleLowerCase();
|
||||
const description = request.description.trim();
|
||||
|
||||
if (!taskID) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing task ID');
|
||||
}
|
||||
|
||||
if (taskID.length < 7) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Task ID must be 1-7 characters');
|
||||
}
|
||||
|
||||
if (!bossAppID) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing BOSS app ID');
|
||||
}
|
||||
|
||||
if (bossAppID.length !== 16) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'BOSS app ID must be 16 characters');
|
||||
}
|
||||
|
||||
if (!BOSS_APP_ID_FILTER_REGEX.test(bossAppID)) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'BOSS app ID must only contain letters and numbers');
|
||||
}
|
||||
|
||||
if (await getTask(bossAppID, taskID)) {
|
||||
throw new ServerError(Status.ALREADY_EXISTS, `Task ${taskID} already exists for BOSS app ${bossAppID}`);
|
||||
}
|
||||
|
||||
const task = await Task.create({
|
||||
id: taskID,
|
||||
boss_app_id: bossAppID,
|
||||
creator_pid: user.pid,
|
||||
status: 'open', // TODO - Make this configurable
|
||||
title_id: titleID,
|
||||
description: description,
|
||||
created: Date.now(),
|
||||
updated: Date.now()
|
||||
});
|
||||
|
||||
return {
|
||||
task: {
|
||||
deleted: task.deleted,
|
||||
id: task.id,
|
||||
bossAppId: task.boss_app_id,
|
||||
creatorPid: task.creator_pid,
|
||||
status: task.status,
|
||||
titleId: task.title_id,
|
||||
description: task.description,
|
||||
createdTimestamp: task.created,
|
||||
updatedTimestamp: task.updated
|
||||
}
|
||||
};
|
||||
}
|
||||
58
src/services/grpc/boss/update-file-metadata.ts
Normal file
58
src/services/grpc/boss/update-file-metadata.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { CallContext, Status, ServerError } from 'nice-grpc';
|
||||
import { UpdateFileMetadataRequest } from '../../../../../grpc-ts/dist/boss/update_file_metadata';
|
||||
import { GetUserDataResponse } from '../../../../../grpc-ts/dist/account/get_user_data_rpc';
|
||||
import { getTaskFileByDataID } from '@/database';
|
||||
import { AuthenticationCallContextExt } from '@/services/grpc/boss/middleware/authentication-middleware';
|
||||
import { Empty } from '../../../../../grpc-ts/dist/boss/google/protobuf/empty';
|
||||
import { isValidFileNotifyCondition, isValidFileType } from '@/util';
|
||||
|
||||
export async function updateFileMetadata(request: UpdateFileMetadataRequest, context: CallContext & AuthenticationCallContextExt): Promise<Empty> {
|
||||
// * This is asserted in authentication middleware, we know this is never null
|
||||
const user: GetUserDataResponse = context.user!;
|
||||
|
||||
if (!user.permissions?.updateBossFiles) {
|
||||
throw new ServerError(Status.PERMISSION_DENIED, 'PNID not authorized to update file metadata');
|
||||
}
|
||||
|
||||
const dataID = request.dataId;
|
||||
const updateData = request.updateData;
|
||||
|
||||
if (!dataID) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing file data ID');
|
||||
}
|
||||
|
||||
if (!updateData) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing file update data');
|
||||
}
|
||||
|
||||
const file = await getTaskFileByDataID(dataID);
|
||||
|
||||
if (!file || file.deleted) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, `File ${dataID} not found`);
|
||||
}
|
||||
|
||||
if (!isValidFileType(updateData.type)) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, `${updateData.type} is not a valid type`);
|
||||
}
|
||||
|
||||
for (const notifyCondition of updateData.notifyOnNew) {
|
||||
if (!isValidFileNotifyCondition(notifyCondition)) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, `${notifyCondition} is not a valid notify condition`);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO - Find a better way to remove "as mongoose.Types.Array<string>"
|
||||
file.task_id = updateData.taskId;
|
||||
file.boss_app_id = updateData.bossAppId;
|
||||
file.supported_countries = updateData.supportedCountries;
|
||||
file.supported_languages = updateData.supportedLanguages;
|
||||
file.name = updateData.name;
|
||||
file.type = updateData.type;
|
||||
file.notify_on_new = updateData.notifyOnNew;
|
||||
file.notify_led = updateData.notifyLed;
|
||||
file.updated = BigInt(Date.now());
|
||||
|
||||
await file.save();
|
||||
|
||||
return {};
|
||||
}
|
||||
52
src/services/grpc/boss/update-task.ts
Normal file
52
src/services/grpc/boss/update-task.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { CallContext, Status, ServerError } from 'nice-grpc';
|
||||
import { UpdateTaskRequest } from '../../../../../grpc-ts/dist/boss/update_task';
|
||||
import { GetUserDataResponse } from '../../../../../grpc-ts/dist/account/get_user_data_rpc';
|
||||
import { getTask } from '@/database';
|
||||
import { AuthenticationCallContextExt } from '@/services/grpc/boss/middleware/authentication-middleware';
|
||||
import { Empty } from '../../../../../grpc-ts/dist/boss/google/protobuf/empty';
|
||||
|
||||
export async function updateTask(request: UpdateTaskRequest, context: CallContext & AuthenticationCallContextExt): Promise<Empty> {
|
||||
// * This is asserted in authentication middleware, we know this is never null
|
||||
const user: GetUserDataResponse = context.user!;
|
||||
|
||||
if (!user.permissions?.updateBossTasks) {
|
||||
throw new ServerError(Status.PERMISSION_DENIED, 'PNID not authorized to update tasks');
|
||||
}
|
||||
|
||||
const taskID = request.id.trim();
|
||||
const bossAppID = request.bossAppId.trim();
|
||||
const updateData = request.updateData;
|
||||
|
||||
if (!taskID) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing task ID');
|
||||
}
|
||||
|
||||
if (!bossAppID) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing BOSS app ID');
|
||||
}
|
||||
|
||||
if (!updateData) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing task update data');
|
||||
}
|
||||
|
||||
const task = await getTask(bossAppID, taskID);
|
||||
|
||||
if (!task) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, `Task ${taskID} not found for BOSS app ${bossAppID}`);
|
||||
}
|
||||
|
||||
if (updateData.status !== 'open') {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, `Status ${updateData.status} is invalid`);
|
||||
}
|
||||
|
||||
task.id = updateData.id;
|
||||
task.boss_app_id = updateData.bossAppId;
|
||||
task.title_id = updateData.titleId;
|
||||
task.status = updateData.status;
|
||||
task.description = updateData.description;
|
||||
task.updated = BigInt(Date.now());
|
||||
|
||||
await task.save();
|
||||
|
||||
return {};
|
||||
}
|
||||
178
src/services/grpc/boss/upload-file.ts
Normal file
178
src/services/grpc/boss/upload-file.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { CallContext, Status, ServerError } from 'nice-grpc';
|
||||
import { UploadFileRequest, UploadFileResponse } from '../../../../../grpc-ts/dist/boss/upload_file';
|
||||
import { GetUserDataResponse } from '../../../../../grpc-ts/dist/account/get_user_data_rpc';
|
||||
import { encryptWiiU } from '@pretendonetwork/boss-crypto';
|
||||
import { isValidCountryCode, isValidFileNotifyCondition, isValidFileType, isValidLanguage, md5, uploadCDNFile } from '@/util';
|
||||
import { getTask, getTaskFile } from '@/database';
|
||||
import { File } from '@/models/file';
|
||||
import { AuthenticationCallContextExt } from '@/services/grpc/boss/middleware/authentication-middleware';
|
||||
import { config } from '@/config-manager';
|
||||
|
||||
const BOSS_APP_ID_FILTER_REGEX = /^[A-Za-z0-9]*$/;
|
||||
|
||||
export async function uploadFile(request: UploadFileRequest, context: CallContext & AuthenticationCallContextExt): Promise<UploadFileResponse> {
|
||||
// * This is asserted in authentication middleware, we know this is never null
|
||||
const user: GetUserDataResponse = context.user!;
|
||||
|
||||
if (!user.permissions?.uploadBossFiles) {
|
||||
throw new ServerError(Status.PERMISSION_DENIED, 'PNID not authorized to upload new files');
|
||||
}
|
||||
|
||||
const taskID = request.taskId.trim();
|
||||
const bossAppID = request.bossAppId.trim();
|
||||
const supportedCountries = request.supportedCountries;
|
||||
const supportedLanguages = request.supportedLanguages;
|
||||
const name = request.name.trim();
|
||||
const type = request.type.trim();
|
||||
const notifyOnNew = [...new Set(request.notifyOnNew)];
|
||||
const notifyLed = request.notifyLed;
|
||||
const data = request.data;
|
||||
const nameEqualsDataID = request.nameEqualsDataId;
|
||||
|
||||
if (!taskID) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing task ID');
|
||||
}
|
||||
|
||||
if (taskID.length < 7) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Task ID must be 1-7 characters');
|
||||
}
|
||||
|
||||
if (!bossAppID) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing BOSS app ID');
|
||||
}
|
||||
|
||||
if (bossAppID.length !== 16) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'BOSS app ID must be 16 characters');
|
||||
}
|
||||
|
||||
if (!BOSS_APP_ID_FILTER_REGEX.test(bossAppID)) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'BOSS app ID must only contain letters and numbers');
|
||||
}
|
||||
|
||||
if (!await getTask(bossAppID, taskID)) {
|
||||
throw new ServerError(Status.NOT_FOUND, `Task ${taskID} does not exist for BOSS app ${bossAppID}`);
|
||||
}
|
||||
|
||||
if (supportedCountries.length === 0) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Must provide at least 1 supported country');
|
||||
}
|
||||
|
||||
for (const country of supportedCountries) {
|
||||
if (!isValidCountryCode(country)) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, `${country} is not a valid country`);
|
||||
}
|
||||
}
|
||||
|
||||
if (supportedLanguages.length === 0) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Must provide at least 1 supported language');
|
||||
}
|
||||
|
||||
for (const language of supportedLanguages) {
|
||||
if (!isValidLanguage(language)) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, `${language} is not a valid language`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!name && !nameEqualsDataID) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Must provide a file name is enable nameEqualsDataId');
|
||||
}
|
||||
|
||||
if (!isValidFileType(type)) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, `${type} is not a valid type`);
|
||||
}
|
||||
|
||||
for (const notifyCondition of notifyOnNew) {
|
||||
if (!isValidFileNotifyCondition(notifyCondition)) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, `${notifyCondition} is not a valid notify condition`);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
throw new ServerError(Status.INVALID_ARGUMENT, 'Cannot upload empty file');
|
||||
}
|
||||
|
||||
let encryptedData: Buffer;
|
||||
|
||||
try {
|
||||
encryptedData = encryptWiiU(data, config.crypto.wup.aes_key, config.crypto.wup.hmac_key);
|
||||
} catch (error: unknown) {
|
||||
let message = 'Unknown file encryption error';
|
||||
|
||||
if (error instanceof Error) {
|
||||
message = error.message;
|
||||
}
|
||||
|
||||
throw new ServerError(Status.ABORTED, message);
|
||||
}
|
||||
|
||||
const contentHash = md5(encryptedData);
|
||||
|
||||
// * Upload file first to prevent ghost DB entries on upload failures
|
||||
try {
|
||||
// * Some tasks have file names which are dynamic.
|
||||
// * They change depending on the files data ID.
|
||||
// * Because of this, using the file name in the
|
||||
// * upload key is not viable, as it is not always
|
||||
// * known during upload
|
||||
const key = `${bossAppID}/${taskID}/${contentHash}`;
|
||||
await uploadCDNFile(key, encryptedData);
|
||||
} catch (error: unknown) {
|
||||
let message = 'Unknown file upload error';
|
||||
|
||||
if (error instanceof Error) {
|
||||
message = error.message;
|
||||
}
|
||||
|
||||
throw new ServerError(Status.ABORTED, message);
|
||||
}
|
||||
|
||||
let file = await getTaskFile(bossAppID, taskID, name);
|
||||
|
||||
if (file) {
|
||||
file.deleted = true;
|
||||
file.updated = BigInt(Date.now());
|
||||
|
||||
await file.save();
|
||||
}
|
||||
|
||||
file = await File.create({
|
||||
task_id: taskID,
|
||||
boss_app_id: bossAppID,
|
||||
supported_countries: supportedCountries,
|
||||
supported_languages: supportedLanguages,
|
||||
creator_pid: user.pid,
|
||||
name: name,
|
||||
type: type,
|
||||
hash: contentHash,
|
||||
size: BigInt(encryptedData.length),
|
||||
notify_on_new: notifyOnNew,
|
||||
notify_led: notifyLed,
|
||||
created: Date.now(),
|
||||
updated: Date.now()
|
||||
});
|
||||
|
||||
if (nameEqualsDataID) {
|
||||
file.name = file.data_id.toString(16).padStart(8, '0');
|
||||
await file.save();
|
||||
}
|
||||
|
||||
return {
|
||||
file: {
|
||||
deleted: file.deleted,
|
||||
dataId: file.data_id,
|
||||
taskId: file.task_id,
|
||||
bossAppId: file.boss_app_id,
|
||||
supportedCountries: file.supported_countries,
|
||||
supportedLanguages: file.supported_languages,
|
||||
creatorPid: file.creator_pid,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
hash: file.hash,
|
||||
size: file.size,
|
||||
notifyOnNew: file.notify_on_new,
|
||||
notifyLed: file.notify_led,
|
||||
createdTimestamp: file.created,
|
||||
updatedTimestamp: file.updated,
|
||||
}
|
||||
};
|
||||
}
|
||||
14
src/services/grpc/server.ts
Normal file
14
src/services/grpc/server.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { createServer, Server } from 'nice-grpc';
|
||||
import { BOSSDefinition } from '../../../../grpc-ts/dist/boss/boss_service';
|
||||
import { apiKeyMiddleware } from '@/services/grpc/boss/middleware/api-key-middleware';
|
||||
import { authenticationMiddleware } from '@/services/grpc/boss/middleware/authentication-middleware';
|
||||
import { implementation } from '@/services/grpc/boss/implementation';
|
||||
import { config } from '@/config-manager';
|
||||
|
||||
export async function startGRPCServer(): Promise<void> {
|
||||
const server: Server = createServer();
|
||||
|
||||
server.with(apiKeyMiddleware).with(authenticationMiddleware).add(BOSSDefinition, implementation);
|
||||
|
||||
await server.listen(`${config.grpc.boss.address}:${config.grpc.boss.port}`);
|
||||
}
|
||||
|
|
@ -1,19 +1,90 @@
|
|||
import path from 'node:path';
|
||||
import fs from 'fs-extra';
|
||||
import xmlbuilder from 'xmlbuilder';
|
||||
import moment from 'moment';
|
||||
import express from 'express';
|
||||
import subdomain from 'express-subdomain';
|
||||
|
||||
const nppl = express.Router();
|
||||
|
||||
nppl.get('/p01/policylist/1/1/:region', (_request, response) => {
|
||||
const policylistPath = path.normalize(`${__dirname}/../../cdn/policylist/policylist.xml`);
|
||||
|
||||
if (fs.existsSync(policylistPath)) {
|
||||
response.set('Content-Type', 'application/xml; charset=utf-8');
|
||||
response.sendFile(policylistPath);
|
||||
} else {
|
||||
response.sendStatus(404);
|
||||
}
|
||||
// TODO - Make this more dynamic
|
||||
response.set('Content-Type', 'application/xml; charset=utf-8');
|
||||
response.send(xmlbuilder.create({
|
||||
PolicyList: {
|
||||
MajorVersion: 1,
|
||||
MinorVersion: 0,
|
||||
ListId: 1924,
|
||||
DefaultStop: false,
|
||||
ForceVersionUp: false,
|
||||
UpdateTime: moment().utc().format('YYYY-MM-DDTHH:MM:SS+0000'),
|
||||
Priority: [
|
||||
{
|
||||
TitleId: '0005003010016000',
|
||||
TaskId: 'olvinfo',
|
||||
Level: 'EXPEDITE'
|
||||
},
|
||||
{
|
||||
TitleId: '0005003010016100',
|
||||
TaskId: 'olvinfo',
|
||||
Level: 'EXPEDITE'
|
||||
},
|
||||
{
|
||||
TitleId: '0005003010016200',
|
||||
TaskId: 'olvinfo',
|
||||
Level: 'EXPEDITE'
|
||||
},
|
||||
{
|
||||
TitleId: '000500301001600a',
|
||||
TaskId: 'olv1',
|
||||
Level: 'EXPEDITE'
|
||||
},
|
||||
{
|
||||
TitleId: '000500301001610a',
|
||||
TaskId: 'olv1',
|
||||
Level: 'EXPEDITE'
|
||||
},
|
||||
{
|
||||
TitleId: '000500301001620a',
|
||||
TaskId: 'olv1',
|
||||
Level: 'EXPEDITE'
|
||||
},
|
||||
{
|
||||
TitleId: '0005001010040000',
|
||||
TaskId: 'oltopic',
|
||||
Level: 'EXPEDITE'
|
||||
},
|
||||
{
|
||||
TitleId: '0005001010040100',
|
||||
TaskId: 'oltopic',
|
||||
Level: 'EXPEDITE'
|
||||
},
|
||||
{
|
||||
TitleId: '0005001010040200',
|
||||
TaskId: 'oltopic',
|
||||
Level: 'EXPEDITE'
|
||||
},
|
||||
{
|
||||
TitleId: '000500101005a000',
|
||||
TaskId: 'Chat',
|
||||
Level: 'EXPEDITE'
|
||||
},
|
||||
{
|
||||
TitleId: '000500101005a100',
|
||||
TaskId: 'Chat',
|
||||
Level: 'EXPEDITE'
|
||||
},
|
||||
{
|
||||
TitleId: '000500101005a200',
|
||||
TaskId: 'Chat',
|
||||
Level: 'EXPEDITE'
|
||||
},
|
||||
{
|
||||
TitleId: '000500101004c100',
|
||||
TaskId: 'plog',
|
||||
Level: 'EXPEDITE'
|
||||
}
|
||||
]
|
||||
}
|
||||
}).end({ pretty: true }));
|
||||
});
|
||||
|
||||
const router = express.Router();
|
||||
|
|
|
|||
|
|
@ -1,14 +1,47 @@
|
|||
import mongoose from 'mongoose';
|
||||
|
||||
export interface DisabledFeatures {
|
||||
s3: boolean
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
http: {
|
||||
port: number;
|
||||
};
|
||||
crypto: {
|
||||
wup: {
|
||||
aes_key: Buffer;
|
||||
hmac_key: Buffer;
|
||||
aes_key: string;
|
||||
hmac_key: string;
|
||||
};
|
||||
ctr: {
|
||||
aes_key: Buffer;
|
||||
};
|
||||
};
|
||||
grpc: {
|
||||
boss: {
|
||||
address: string;
|
||||
port: number;
|
||||
api_key: string;
|
||||
};
|
||||
account: {
|
||||
address: string;
|
||||
port: number;
|
||||
api_key: string;
|
||||
};
|
||||
};
|
||||
mongoose: {
|
||||
connection_string: string;
|
||||
options: mongoose.ConnectOptions;
|
||||
};
|
||||
cdn: {
|
||||
download_url: string;
|
||||
s3: {
|
||||
endpoint: string;
|
||||
region: string;
|
||||
bucket: string;
|
||||
key: string;
|
||||
secret: string;
|
||||
};
|
||||
disk_path: string;
|
||||
};
|
||||
}
|
||||
5
src/types/common/user-agent-info.ts
Normal file
5
src/types/common/user-agent-info.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type UserAgentInfo = {
|
||||
deviceID?: number;
|
||||
localFriendCodeSeed?: number;
|
||||
userPID: number;
|
||||
};
|
||||
10
src/types/express.d.ts
vendored
Normal file
10
src/types/express.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
pid: number;
|
||||
pnid: GetUserDataResponse | null;
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/types/mongoose/file.ts
Normal file
27
src/types/mongoose/file.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Model, HydratedDocument } from 'mongoose';
|
||||
|
||||
export interface IFile {
|
||||
deleted: boolean;
|
||||
data_id: bigint;
|
||||
task_id: string;
|
||||
boss_app_id: string;
|
||||
supported_countries: string[];
|
||||
supported_languages: string[];
|
||||
creator_pid: number;
|
||||
name: string;
|
||||
type: string;
|
||||
hash: string;
|
||||
size: bigint;
|
||||
notify_on_new: string[];
|
||||
notify_led: boolean;
|
||||
created: bigint;
|
||||
updated: bigint;
|
||||
}
|
||||
|
||||
export interface IFileMethods {}
|
||||
|
||||
interface IFileQueryHelpers {}
|
||||
|
||||
export interface FileModel extends Model<IFile, IFileQueryHelpers, IFileMethods> {}
|
||||
|
||||
export type HydratedFileDocument = HydratedDocument<IFile, IFileMethods>
|
||||
21
src/types/mongoose/task.ts
Normal file
21
src/types/mongoose/task.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Model, HydratedDocument } from 'mongoose';
|
||||
|
||||
export interface ITask {
|
||||
deleted: boolean;
|
||||
id: string;
|
||||
boss_app_id: string;
|
||||
creator_pid: number;
|
||||
status: 'open'; // TODO - Make this a union. What else is there?
|
||||
title_id: string;
|
||||
description: string;
|
||||
created: bigint;
|
||||
updated: bigint;
|
||||
}
|
||||
|
||||
export interface ITaskMethods {}
|
||||
|
||||
interface ITaskQueryHelpers {}
|
||||
|
||||
export interface TaskModel extends Model<ITask, ITaskQueryHelpers, ITaskMethods> {}
|
||||
|
||||
export type HydratedTaskDocument = HydratedDocument<ITask, ITaskMethods>
|
||||
148
src/util.ts
148
src/util.ts
|
|
@ -1,5 +1,149 @@
|
|||
import crypto from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import fs from 'fs-extra';
|
||||
import { createChannel, createClient, Metadata } from 'nice-grpc';
|
||||
import { GetObjectCommand, PutObjectCommand, S3 } from '@aws-sdk/client-s3';
|
||||
import { AccountClient, AccountDefinition } from '../../grpc-ts/dist/account/account_service';
|
||||
import { GetUserDataResponse } from '../../grpc-ts/dist/account/get_user_data_rpc';
|
||||
import { config, disabledFeatures } from '@/config-manager';
|
||||
|
||||
export function md5(input: crypto.BinaryLike): Buffer {
|
||||
return crypto.createHash('md5').update(input).digest();
|
||||
let s3: S3;
|
||||
|
||||
if (!disabledFeatures.s3) {
|
||||
s3 = new S3({
|
||||
forcePathStyle: false,
|
||||
endpoint: config.cdn.s3.endpoint,
|
||||
region: config.cdn.s3.region,
|
||||
credentials: {
|
||||
accessKeyId: config.cdn.s3.key,
|
||||
secretAccessKey: config.cdn.s3.secret
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const gRPCAccountChannel = createChannel(`${config.grpc.account.address}:${config.grpc.account.port}`);
|
||||
const gRPCAccountClient: AccountClient = createClient(AccountDefinition, gRPCAccountChannel);
|
||||
|
||||
const VALID_COUNTRIES = [
|
||||
'US', 'JP', 'CR', 'FR'
|
||||
];
|
||||
|
||||
const VALID_LANGUAGES = [
|
||||
'en', 'ja'
|
||||
];
|
||||
|
||||
const VALID_FILE_TYPES = [
|
||||
'Message', 'AppData'
|
||||
];
|
||||
|
||||
const VALID_FILE_NOTIFY_CONDITIONS = [
|
||||
'app', 'account'
|
||||
];
|
||||
|
||||
export function md5(input: crypto.BinaryLike): string {
|
||||
return crypto.createHash('md5').update(input).digest('hex');
|
||||
}
|
||||
|
||||
export function isValidCountryCode(countryCode: string): boolean {
|
||||
return VALID_COUNTRIES.includes(countryCode);
|
||||
}
|
||||
|
||||
export function isValidLanguage(language: string): boolean {
|
||||
return VALID_LANGUAGES.includes(language);
|
||||
}
|
||||
|
||||
export function isValidFileType(type: string): boolean {
|
||||
return VALID_FILE_TYPES.includes(type);
|
||||
}
|
||||
|
||||
export function isValidFileNotifyCondition(condition: string): boolean {
|
||||
return VALID_FILE_NOTIFY_CONDITIONS.includes(condition);
|
||||
}
|
||||
|
||||
export async function getUserDataByPID(pid: number): Promise<GetUserDataResponse | null> {
|
||||
try {
|
||||
return await gRPCAccountClient.getUserData({
|
||||
pid: pid
|
||||
}, {
|
||||
metadata: Metadata({
|
||||
'X-API-Key': config.grpc.account.api_key
|
||||
})
|
||||
});
|
||||
} catch {
|
||||
// TODO - Handle error
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserDataByToken(token: string): Promise<GetUserDataResponse | null> {
|
||||
try {
|
||||
return await gRPCAccountClient.exchangeTokenForUserData({
|
||||
token: token
|
||||
}, {
|
||||
metadata: Metadata({
|
||||
'X-API-Key': config.grpc.account.api_key
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
// TODO - Handle error
|
||||
console.log(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCDNFile(): Promise<void> {
|
||||
|
||||
}
|
||||
|
||||
export async function getCDNFileStream(key: string): Promise<Readable | fs.ReadStream | null> {
|
||||
try {
|
||||
if (disabledFeatures.s3) {
|
||||
return await getLocalCDNFile(key);
|
||||
} else {
|
||||
const response = await s3.send(new GetObjectCommand({
|
||||
Key: key,
|
||||
Bucket: config.cdn.s3.bucket
|
||||
}));
|
||||
|
||||
if (!response.Body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.Body as Readable;
|
||||
}
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLocalCDNFile(key: string): Promise<fs.ReadStream | null> {
|
||||
const filePath = path.join(config.cdn.disk_path, key);
|
||||
|
||||
if (await !fs.exists(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fs.createReadStream(filePath);
|
||||
}
|
||||
|
||||
export async function uploadCDNFile(key: string, data: Buffer): Promise<void> {
|
||||
if (disabledFeatures.s3) {
|
||||
await writeLocalCDNFile(key, data);
|
||||
} else {
|
||||
await s3.send(new PutObjectCommand({
|
||||
Key: key,
|
||||
Bucket: config.cdn.s3.bucket,
|
||||
Body: data,
|
||||
ACL: 'private'
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeLocalCDNFile(key: string, data: Buffer): Promise<void> {
|
||||
const filePath = path.join(config.cdn.disk_path, key);
|
||||
const folder = path.dirname(filePath);
|
||||
|
||||
await fs.ensureDir(folder);
|
||||
await fs.writeFile(filePath, data);
|
||||
}
|
||||
|
|
@ -13,11 +13,7 @@
|
|||
"noEmitOnError": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"typeRoots": [
|
||||
"node_modules/@types",
|
||||
"node_modules/@hcaptcha"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user