BOSS 2.0 - Full TS and microservice rewrite. Still pulls from local until admin panel updates

This commit is contained in:
Jonathan Barrow 2023-08-25 21:11:44 -04:00
parent 8daf7ef041
commit 335439db7d
No known key found for this signature in database
GPG Key ID: E86E9FE9049C741F
32 changed files with 6051 additions and 107 deletions

3
.gitignore vendored
View File

@ -59,4 +59,5 @@ typings/
# custom
.vscode
dist
dist
cdn/storage

4485
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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
View 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
});
}

View 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();
}

View 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
View 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
View 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
View 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;
}
}

View File

@ -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);

View 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 {};
}

View 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 {};
}

View 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
};

View 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
}))
};
}

View 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' ]
}
]
};
}

View 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
}))
};
}

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: 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);
}

View File

@ -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);
}
}

View 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
}
};
}

View 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 {};
}

View 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 {};
}

View 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,
}
};
}

View 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}`);
}

View File

@ -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();

View File

@ -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;
};
}

View File

@ -0,0 +1,5 @@
export type UserAgentInfo = {
deviceID?: number;
localFriendCodeSeed?: number;
userPID: number;
};

10
src/types/express.d.ts vendored Normal file
View 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;
}
}
}

View 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>

View 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>

View File

@ -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);
}

View File

@ -13,11 +13,7 @@
"noEmitOnError": true,
"paths": {
"@/*": ["./*"]
},
"typeRoots": [
"node_modules/@types",
"node_modules/@hcaptcha"
]
}
},
"include": ["src"]
}