feat: begin gRPC 2.2.4 port. missing CTR

some of these changes seem like they need database changes as well, that needs to be talked about
This commit is contained in:
Jonathan Barrow 2025-10-14 14:49:07 -04:00
parent 22f3016f50
commit 4186968576
No known key found for this signature in database
GPG Key ID: 2A7DAA6DED5A77E5
14 changed files with 1098 additions and 0 deletions

View File

@ -0,0 +1,33 @@
import { Status, ServerError } from 'nice-grpc';
import { getTaskFileByDataID } from '@/database';
import { hasPermission } from '@/services/grpc/boss/v2/middleware/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/boss/v2/middleware/authentication-middleware';
import type { CallContext } from 'nice-grpc';
import type { DeleteFileRequest } from '@pretendonetwork/grpc/boss/v2/delete_file';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
export async function deleteFile(request: DeleteFileRequest, context: CallContext & AuthenticationCallContextExt): Promise<Empty> {
if (!hasPermission(context, '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,37 @@
import { Status, ServerError } from 'nice-grpc';
import { getTask } from '@/database';
import { hasPermission } from '@/services/grpc/boss/v2/middleware/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/boss/v2/middleware/authentication-middleware';
import type { CallContext } from 'nice-grpc';
import type { DeleteTaskRequest } from '@pretendonetwork/grpc/boss/v2/delete_task';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
export async function deleteTask(request: DeleteTaskRequest, context: CallContext & AuthenticationCallContextExt): Promise<Empty> {
if (!hasPermission(context, '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,28 @@
import { listKnownBOSSApps } from '@/services/grpc/boss/v2/list-known-boss-apps';
import { listTasks } from '@/services/grpc/boss/v2/list-tasks';
import { registerTask } from '@/services/grpc/boss/v2/register-task';
import { updateTask } from '@/services/grpc/boss/v2/update-task';
import { deleteTask } from '@/services/grpc/boss/v2/delete-task';
import { deleteFile } from '@/services/grpc/boss/v2/delete-file';
import { listFilesWUP } from '@/services/grpc/boss/v2/list-files-wup';
import { uploadFileWUP } from '@/services/grpc/boss/v2/upload-file-wup';
import { listFilesCTR } from '@/services/grpc/boss/v2/list-files-ctr';
// import { uploadFileCTR } from '@/services/grpc/boss/v2/upload-file-ctr';
// import { updateFileMetadataCTR } from '@/services/grpc/boss/v2/update-file-metadata-ctr';
import { updateFileMetadataWUP } from '@/services/grpc/boss/v2/update-file-metadata-wup';
import type { BossServiceImplementation } from '@pretendonetwork/grpc/boss/v2/boss_service';
export const bossServiceImplementationV2: BossServiceImplementation = {
listKnownBOSSApps,
listTasks,
registerTask,
updateTask,
deleteTask,
deleteFile,
listFilesWUP,
uploadFileWUP,
listFilesCTR,
// uploadFileCTR,
// updateFileMetadataCTR,
updateFileMetadataWUP
};

View File

@ -0,0 +1,64 @@
import { Status, ServerError } from 'nice-grpc';
import { isValidCountryCode, isValidLanguage } from '@/util';
import { getTaskFiles } from '@/database';
import type { ListFilesCTRRequest, ListFilesCTRResponse } from '@pretendonetwork/grpc/boss/v2/list_files_ctr';
const BOSS_APP_ID_FILTER_REGEX = /^[A-Za-z0-9]*$/;
export async function listFilesCTR(request: ListFilesCTRRequest): Promise<ListFilesCTRResponse> {
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 (!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 && !isValidCountryCode(country)) {
throw new ServerError(Status.INVALID_ARGUMENT, `${country} is not a valid country`);
}
if (language && !isValidLanguage(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,
attributes: {
attribute1: file.attribute1,
attribute2: file.attribute2,
attribute3: file.attribute3,
description: file.password
},
creatorPid: file.creator_pid,
name: file.name,
hash: file.hash,
serialNumber: 0, // TODO - Don't stub this
payloadContents: [], // TODO - Don't stub this
size: file.size,
createdTimestamp: file.created,
updatedTimestamp: file.updated
}))
};
}

View File

@ -0,0 +1,67 @@
import { Status, ServerError } from 'nice-grpc';
import { isValidCountryCode, isValidLanguage } from '@/util';
import { getTaskFiles } from '@/database';
import type { ListFilesWUPRequest, ListFilesWUPResponse } from '@pretendonetwork/grpc/boss/v2/list_files_wup';
const BOSS_APP_ID_FILTER_REGEX = /^[A-Za-z0-9]*$/;
export async function listFilesWUP(request: ListFilesWUPRequest): Promise<ListFilesWUPResponse> {
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 (!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 && !isValidCountryCode(country)) {
throw new ServerError(Status.INVALID_ARGUMENT, `${country} is not a valid country`);
}
if (language && !isValidLanguage(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,
attributes: {
attribute1: file.attribute1,
attribute2: file.attribute2,
attribute3: file.attribute3,
description: file.password
},
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,
conditionPlayed: 0n, // TODO - Don't stub this
autoDelete: false, // TODO - Don't stub this
createdTimestamp: file.created,
updatedTimestamp: file.updated
}))
};
}

View File

@ -0,0 +1,407 @@
import type { ListKnownBOSSAppsResponse } from '@pretendonetwork/grpc/boss/v2/list_known_boss_apps';
export async function listKnownBOSSApps(): Promise<ListKnownBOSSAppsResponse> {
return {
apps: [
{
bossAppId: 'WJDaV6ePVgrS0TRa',
titleId: BigInt(0x0005003010016000),
titleRegion: 'UNK',
name: 'Unknown',
tasks: ['olvinfo']
},
{
bossAppId: 'VFoY6V7u7UUq1EG5',
titleId: BigInt(0x0005003010016100),
titleRegion: 'UNK',
name: 'Unknown',
tasks: ['olvinfo']
},
{
bossAppId: '8MNOVprfNVAJjfCM',
titleId: BigInt(0x0005003010016200),
titleRegion: 'UNK',
name: 'Unknown',
tasks: ['olvinfo']
},
{
bossAppId: 'v1cqzWykBKUg0rHQ',
titleId: BigInt(0x000500301001900A),
titleRegion: 'JPN',
name: 'Miiverse Post All',
tasks: ['solv']
},
{
bossAppId: 'bieC9ACJlisFg5xS',
titleId: BigInt(0x000500301001910A),
titleRegion: 'USA',
name: 'Miiverse Post All',
tasks: ['solv']
},
{
bossAppId: 'tOaQcoBLtPTgVN3Y',
titleId: BigInt(0x000500301001920A),
titleRegion: 'EUR',
name: 'Miiverse Post All',
tasks: ['solv']
},
{
bossAppId: 'HX8a16MMNn6i1z0Y',
titleId: BigInt(0x000500301001400A),
titleRegion: 'JPN',
name: 'Nintendo eShop',
tasks: ['wood1', 'woodBGM']
},
{
bossAppId: '07E3nY6lAwlwrQRo',
titleId: BigInt(0x000500301001410A),
titleRegion: 'USA',
name: 'Nintendo eShop',
tasks: ['wood1', 'woodBGM']
},
{
bossAppId: '8UsM86l8xgkjFk8z',
titleId: BigInt(0x000500301001420A),
titleRegion: 'EUR',
name: 'Nintendo eShop',
tasks: ['wood1', 'woodBGM']
},
{
bossAppId: 'IXmFUqR2qenXfF61',
titleId: BigInt(0x0005001010066000),
titleRegion: 'ALL',
name: 'ECO Process',
tasks: ['promo1', 'promo2', 'promo3', 'push']
},
{
bossAppId: 'BMQAm5iUVtPsJVsU',
titleId: BigInt(0x000500101004D000),
titleRegion: 'JPN',
name: 'Notifications',
tasks: ['sysmsg1', 'sysmsg2']
},
{
bossAppId: 'LRmanFo4Tx3kEGDp',
titleId: BigInt(0x000500101004D100),
titleRegion: 'USA',
name: 'Notifications',
tasks: ['sysmsg1', 'sysmsg2']
},
{
bossAppId: 'TZr27FE8wzKiEaTO',
titleId: BigInt(0x000500101004D200),
titleRegion: 'EUR',
name: 'Notifications',
tasks: ['sysmsg1', 'sysmsg2']
},
{
bossAppId: 'JnIrm9c4E9JBmxBo',
titleId: BigInt(0x0005000010185200),
titleRegion: 'JPN',
name: 'NewスーパーマリオブラザーズU 無料お試し版 (New SUPER MARIO BROS. U (Trial))',
tasks: ['news']
},
{
bossAppId: 'dadlI27Ww8H2d56x',
titleId: BigInt(0x0005000010101C00),
titleRegion: 'JPN',
name: 'NewスーパーマリオブラザーズU (New SUPER MARIO BROS. U)',
tasks: ['news']
},
{
bossAppId: 'RaPn5saabzliYrpo',
titleId: BigInt(0x0005000010101D00),
titleRegion: 'USA',
name: 'New SUPER MARIO BROS. U',
tasks: ['news']
},
{
bossAppId: '14VFIK3rY2SP0WRE',
titleId: BigInt(0x0005000010101E00),
titleRegion: 'EUR',
name: 'New SUPER MARIO BROS. U',
tasks: ['news']
},
{
bossAppId: 'RbEQ44t2AocC4rvu',
titleId: BigInt(0x000500001014B700),
titleRegion: 'USA',
name: 'New SUPER MARIO BROS. U + New SUPER LUIGI U',
tasks: ['news']
},
{
bossAppId: '287gv3WZdxo1QRhl',
titleId: BigInt(0x000500001014B800),
titleRegion: 'EUR',
name: 'New SUPER MARIO BROS. U + New SUPER LUIGI U',
tasks: ['news']
},
{
bossAppId: 'bb6tOEckvgZ50ciH',
titleId: BigInt(0x0005000010162B00),
titleRegion: 'JPN',
name: 'スプラトゥーン (Splatoon)',
tasks: ['optdat2', 'schdat2', 'schdata']
},
{
bossAppId: 'rjVlM7hUXPxmYQJh',
titleId: BigInt(0x0005000010176900),
titleRegion: 'USA',
name: 'Splatoon',
tasks: ['optdat2', 'schdat2', 'schdata', 'optdata2', 'schdata2']
},
{
bossAppId: 'zvGSM4kOrXpkKnpT',
titleId: BigInt(0x0005000010176A00),
titleRegion: 'EUR',
name: 'Splatoon',
tasks: ['optdat2', 'schdat2', 'schdata', 'optdata']
},
{
bossAppId: 'm8KJPtmPweiPuETE',
titleId: BigInt(0x000500001012F100),
titleRegion: 'JPN',
name: 'Wii Sports Club',
tasks: ['sp1_ans']
},
{
bossAppId: 'pO72Hi5uqf5yuNd8',
titleId: BigInt(0x0005000010144D00),
titleRegion: 'USA',
name: 'Wii Sports Club',
tasks: ['sp1_ans']
},
{
bossAppId: '4m8Xme1wKgzwslTJ',
titleId: BigInt(0x0005000010144E00),
titleRegion: 'EUR',
name: 'Wii Sports Club',
tasks: ['sp1_ans']
},
{
bossAppId: 'ESLqtAhxS8KQU4eu',
titleId: BigInt(0x000500001018DB00),
titleRegion: 'JPN',
name: 'Super Mario Maker (スーパーマリオメーカー)',
tasks: ['CHARA']
},
{
bossAppId: 'vGwChBW1ExOoHDsm',
titleId: BigInt(0x000500001018DC00),
titleRegion: 'USA',
name: 'Super Mario Maker',
tasks: ['CHARA']
},
{
bossAppId: 'IeUc4hQsKKe9rJHB',
titleId: BigInt(0x000500001018DD00),
titleRegion: 'EUA',
name: 'Super Mario Maker',
tasks: ['CHARA']
},
{
bossAppId: '4krJA4Gx3jF5nhQf',
titleId: BigInt(0x000500001012BC00),
titleRegion: 'JPN',
name: 'ピクミン3 (PIKMIN 3)',
tasks: ['histgrm']
},
{
bossAppId: '9jRZEoWYLc3OG9a8',
titleId: BigInt(0x000500001012BD00),
titleRegion: 'USA',
name: 'PIKMIN 3',
tasks: ['histgrm']
},
{
bossAppId: 'VWqUTspR5YtjDjxa',
titleId: BigInt(0x000500001012BE00),
titleRegion: 'EUR',
name: 'PIKMIN 3',
tasks: ['histgrm']
},
{
bossAppId: 'Ge1KtMu8tYlf4AUM',
titleId: BigInt(0x0005000010192000),
titleRegion: 'JPN',
name: '太鼓の達人 特盛り! (Taiko no Tatsujin Tokumori!)',
tasks: ['notice1']
},
{
bossAppId: 'gycVtTzCouZmukZ6',
titleId: BigInt(0x0005000010110E00),
titleRegion: 'JPN',
name: '大乱闘スマッシュブラザーズ for Wii U (Super Smash Bros. for Wii U)',
tasks: ['NEWS', 'amiibo']
},
{
bossAppId: 'o2Ug1pIp9Uhri6Nh',
titleId: BigInt(0x0005000010144F00),
titleRegion: 'USA',
name: 'Super Smash Bros. for Wii U',
tasks: ['amiibo', 'NEWS', 'friend', 'CONQ']
},
{
bossAppId: 'n6rAJ1nnfC1Sgcpl',
titleId: BigInt(0x0005000010145000),
titleRegion: 'EUR',
name: 'Super Smash Bros. for Wii U',
tasks: ['amiibo', 'NEWS', 'friend', 'CONQ']
},
{
bossAppId: 'CHUN6T1m7Xk4EBg4',
titleId: BigInt(0x00050000101DFF00),
titleRegion: 'JPN',
name: 'プチコンBIG (Petitcom BIG)',
tasks: ['ptcbnws']
},
{
bossAppId: 'zyXdCW9jGdi9rjaz',
titleId: BigInt(0x0005000010142200),
titleRegion: 'JPN',
name: 'NewスーパールイージU (New SUPER LUIGI U)',
tasks: ['news']
},
{
bossAppId: 'jPHLlJr2fJyTzffp',
titleId: BigInt(0x0005000010142300),
titleRegion: 'USA',
name: 'New SUPER LUIGI U',
tasks: ['news']
},
{
bossAppId: 'YsXB6IRGSI56tPxl',
titleId: BigInt(0x0005000010142400),
titleRegion: 'EUR',
name: 'New SUPER LUIGI U',
tasks: ['news']
},
{
bossAppId: 'Lbqp9Sg1i0xUzFFa',
titleId: BigInt(0x0005000010113800),
titleRegion: 'EUR',
name: 'Zen Pinball 2',
tasks: ['PTS']
},
{
bossAppId: 'DwU7n0FidGrLNiOo',
titleId: BigInt(0x000500001014D900),
titleRegion: 'JPN',
name: 'ぷよぷよテトリス (PUYOPUYOTETRIS)',
tasks: ['boss1', 'boss2', 'boss3']
},
{
bossAppId: 'yIUkFmuGVkGP8pDb',
titleId: BigInt(0x0005000010132200),
titleRegion: 'JPN',
name: '太鼓の達人 Uば~じょん! (Taiko no Tatsujin Wii U version!)',
tasks: ['notice1']
},
{
bossAppId: 'v4WRObSzD7VU3dcJ',
titleId: BigInt(0x00050000101D3000),
titleRegion: 'JPN',
name: '太鼓の達人 あつめて★ともだち大作戦! (Taiko no Tatsujin Atsumete★ TomodachiDaisakusen!)',
tasks: ['notice1']
},
{
bossAppId: '3zDjXIA57bSceyaw',
titleId: BigInt(0x00050000101BEC00),
titleRegion: 'USA',
name: 'Star Fox Guard',
tasks: ['param']
},
{
bossAppId: 'NL38jhExI2CQqhWd',
titleId: BigInt(0x00050000101CDB00),
titleRegion: 'JPN',
name: 'Splatoon Pre-Launch Review',
tasks: ['schdata']
},
{
bossAppId: 'sE6KwEpQYyg6tdU7',
titleId: BigInt(0x00050000101CDC00),
titleRegion: 'USA',
name: 'Splatoon Pre-Launch Review',
tasks: ['schdata']
},
{
bossAppId: 'pTKZ9q5KrCP3gBag',
titleId: BigInt(0x00050000101CDD00),
titleRegion: 'EUR',
name: 'Splatoon Pre-Launch Review',
tasks: ['schdata']
},
{
bossAppId: 'CJT88RO008LAnD51',
titleId: BigInt(0x0005000010170600),
titleRegion: 'JPN',
name: '仮面ライダー バトライド・ウォーⅡ プレミアムTVMOVIEサウンドED. (KAMEN RIDER BATTRIDE WAR Ⅱ PREMIUM TV&MOVIE SOUND ED.)',
tasks: ['PE_GAK', 'PE_ZNG']
},
{
bossAppId: 'FyyMFzEByuQJc6sJ',
titleId: BigInt(0x0005000010135200),
titleRegion: 'USA',
name: 'Star Wars Pinball',
tasks: ['PTS']
},
{
bossAppId: 'A4yyXWKZZUToFtrt',
titleId: BigInt(0x0005000010132A00),
titleRegion: 'EUR',
name: 'Star Wars Pinball',
tasks: ['PTS']
},
{
bossAppId: 'HauaFQ1sPsnQ6rBj',
titleId: BigInt(0x0005000010171F00),
titleRegion: 'USA',
name: 'Pushmo World',
tasks: ['annouce']
},
{
bossAppId: 'qDUeFmk0Az71nHyD',
titleId: BigInt(0x0005000010110900),
titleRegion: 'JPN',
name: 'NINJA GAIDEN 3: Razor\'s Edge',
tasks: ['DLCINFO']
},
{
bossAppId: 'yVsSPM2E0DEOxroT',
titleId: BigInt(0x0005000010110A00),
titleRegion: 'USA',
name: 'NINJA GAIDEN 3: Razor\'s Edge',
tasks: ['DLCINFO']
},
{
bossAppId: 'Xw6OvZkQofQ3O8Bi',
titleId: BigInt(0x0005000010110B00),
titleRegion: 'EUR',
name: 'Ninja Gaiden 3: Razor\'s Edge',
tasks: ['DLCINFO']
},
{
bossAppId: 'LUQX5swEjBUPQ8nR',
titleId: BigInt(0x0005000010110200),
titleRegion: 'USA',
name: 'WARRIORS OROCHI 3 Hyper(NA)',
tasks: ['OR2H000']
},
{
bossAppId: 'y4pXrgLe0JGao3No',
titleId: BigInt(0x0005000010112B00),
titleRegion: 'EUR',
name: 'WARRIORS OROCHI 3 Hyper(EU)',
tasks: ['OR2H000']
},
{
bossAppId: 'j01mRJ9sNe00MWPC',
titleId: BigInt(0x0005000010170700),
titleRegion: 'JPN',
name: '仮面ライダー バトライド・ウォーⅡ (KAMEN RIDER BATTRIDE WAR Ⅱ)',
tasks: ['CHR_GAK', 'CHR_ZNG']
}
]
};
}

View File

@ -0,0 +1,22 @@
import { getAllTasks } from '@/database';
import type { ListTasksResponse } from '@pretendonetwork/grpc/boss/v2/list_tasks';
export async function listTasks(): Promise<ListTasksResponse> {
const tasks = await getAllTasks(false);
return {
tasks: tasks.map(task => ({
deleted: task.deleted,
id: task.id,
inGameId: task.in_game_id,
bossAppId: task.boss_app_id,
creatorPid: task.creator_pid,
status: task.status,
interval: 0, // TODO - Don't stub this
titleId: BigInt(parseInt(task.title_id, 16)),
description: task.description,
createdTimestamp: task.created,
updatedTimestamp: task.updated
}))
};
}

View File

@ -0,0 +1,16 @@
import { Status, ServerError } from 'nice-grpc';
import { config } from '@/config-manager';
import type { ServerMiddlewareCall, CallContext } from 'nice-grpc';
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,52 @@
import { Status, ServerError } from 'nice-grpc';
import { getUserDataByToken } from '@/util';
import type { ServerMiddlewareCall, CallContext } from 'nice-grpc';
import type { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc';
import type { PNIDPermissionFlags } from '@pretendonetwork/grpc/account/pnid_permission_flags';
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();
try {
let user: GetUserDataResponse | null = null;
if (token) {
user = await getUserDataByToken(token);
if (!user) {
throw new ServerError(Status.UNAUTHENTICATED, 'User could not be found');
}
}
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);
}
}
export function hasPermission(ctx: AuthenticationCallContextExt, perm: keyof PNIDPermissionFlags): boolean {
if (!ctx.user) {
return true; // Non users are always allowed
}
if (!ctx.user.permissions) {
return false; // No permissions, no entry
}
return ctx.user.permissions[perm];
}

View File

@ -0,0 +1,78 @@
import { ServerError, Status } from 'nice-grpc';
import { getTask } from '@/database';
import { Task } from '@/models/task';
import { hasPermission } from '@/services/grpc/boss/v2/middleware/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/boss/v2/middleware/authentication-middleware';
import type { CallContext } from 'nice-grpc';
import type { RegisterTaskRequest, RegisterTaskResponse } from '@pretendonetwork/grpc/boss/v2/register_task';
const BOSS_APP_ID_FILTER_REGEX = /^[A-Za-z0-9]*$/;
export async function registerTask(request: RegisterTaskRequest, context: CallContext & AuthenticationCallContextExt): Promise<RegisterTaskResponse> {
if (!hasPermission(context, '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.toString(16).toLowerCase().padStart(16, '0');
const description = request.description.trim();
if (!taskID) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing task ID');
}
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}`);
}
// * BOSS tasks have 2 IDs
// * - 1: The ID which is registered in-game
// * - 2: The ID which is registered on the server
// * The in-game task ID can be any length, but the
// * ID registered on the server is capped at 7 characters.
// * When querying tasks in the API, the server ignores
// * all characters after the 7th. For example, Splatoon
// * registers task optdata2 in-game, but the server
// * tracks it as task optdata
const task = await Task.create({
id: taskID.slice(0, 7),
in_game_id: taskID,
boss_app_id: bossAppID,
creator_pid: context.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,
inGameId: task.in_game_id,
bossAppId: task.boss_app_id,
creatorPid: task.creator_pid,
status: task.status,
interval: 0, // TODO - Don't stub this
titleId: BigInt(parseInt(task.title_id, 16)),
description: task.description,
createdTimestamp: task.created,
updatedTimestamp: task.updated
}
};
}

View File

@ -0,0 +1,59 @@
import { Status, ServerError } from 'nice-grpc';
import { getTaskFileByDataID } from '@/database';
import { isValidFileNotifyCondition, isValidFileType } from '@/util';
import { hasPermission } from '@/services/grpc/boss/v2/middleware/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/boss/v2/middleware/authentication-middleware';
import type { CallContext } from 'nice-grpc';
import type { UpdateFileMetadataWUPRequest } from '@pretendonetwork/grpc/boss/v2/update_file_metadata_wup';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
export async function updateFileMetadataWUP(request: UpdateFileMetadataWUPRequest, context: CallContext & AuthenticationCallContextExt): Promise<Empty> {
if (!hasPermission(context, '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`);
}
}
file.task_id = updateData.taskId.slice(0, 7);
file.boss_app_id = updateData.bossAppId;
file.supported_countries = updateData.supportedCountries;
file.supported_languages = updateData.supportedLanguages;
file.password = updateData.attributes ? updateData.attributes.description : file.password;
file.attribute1 = updateData.attributes ? updateData.attributes.attribute1 : file.attribute1;
file.attribute2 = updateData.attributes ? updateData.attributes.attribute2 : file.attribute2;
file.attribute3 = updateData.attributes ? updateData.attributes.attribute3 : file.attribute3;
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,54 @@
import { Status, ServerError } from 'nice-grpc';
import { getTask } from '@/database';
import { hasPermission } from '@/services/grpc/boss/v2/middleware/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/boss/v2/middleware/authentication-middleware';
import type { CallContext } from 'nice-grpc';
import type { UpdateTaskRequest } from '@pretendonetwork/grpc/boss/v2/update_task';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
export async function updateTask(request: UpdateTaskRequest, context: CallContext & AuthenticationCallContextExt): Promise<Empty> {
if (!hasPermission(context, '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`);
}
if (updateData.id) {
task.id = updateData.id.slice(0, 7);
task.in_game_id = updateData.id;
}
task.boss_app_id = updateData.bossAppId ? updateData.bossAppId : task.boss_app_id;
task.title_id = updateData.titleId ? updateData.titleId.toString(16).toLowerCase().padStart(16, '0') : task.title_id;
task.status = updateData.status ? updateData.status : task.status;
task.description = updateData.description ? updateData.description : task.description;
task.updated = BigInt(Date.now());
await task.save();
return {};
}

View File

@ -0,0 +1,174 @@
import { Status, ServerError } from 'nice-grpc';
import { encryptWiiU } from '@pretendonetwork/boss-crypto';
import { isValidCountryCode, isValidFileNotifyCondition, isValidFileType, isValidLanguage, md5 } from '@/util';
import { getTask, getTaskFile } from '@/database';
import { File } from '@/models/file';
import { config } from '@/config-manager';
import { uploadCDNFile } from '@/cdn';
import { hasPermission } from '@/services/grpc/boss/v2/middleware/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/boss/v2/middleware/authentication-middleware';
import type { CallContext } from 'nice-grpc';
import type { UploadFileWUPRequest, UploadFileWUPResponse } from '@pretendonetwork/grpc/boss/v2/upload_file_wup';
const BOSS_APP_ID_FILTER_REGEX = /^[A-Za-z0-9]*$/;
export async function uploadFileWUP(request: UploadFileWUPRequest, context: CallContext & AuthenticationCallContextExt): Promise<UploadFileWUPResponse> {
if (!hasPermission(context, '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 (!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}`);
}
for (const country of supportedCountries) {
if (!isValidCountryCode(country)) {
throw new ServerError(Status.INVALID_ARGUMENT, `${country} is not a valid country`);
}
}
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
const key = `${bossAppID}/${taskID}/${contentHash}`;
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
await uploadCDNFile('taskFile', 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.slice(0, 7),
boss_app_id: bossAppID,
file_key: key,
supported_countries: supportedCountries,
supported_languages: supportedLanguages,
creator_pid: context.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,
attributes: {
attribute1: file.attribute1,
attribute2: file.attribute2,
attribute3: file.attribute3,
description: file.password
},
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,
conditionPlayed: 0n, // TODO - Don't stub this
autoDelete: false, // TODO - Don't stub this
createdTimestamp: file.created,
updatedTimestamp: file.updated
}
};
}

View File

@ -3,6 +3,12 @@ import { BOSSDefinition as BossServiceDefinitionV1 } from '@pretendonetwork/grpc
import { apiKeyMiddleware as apiKeyMiddlewareV1 } from '@/services/grpc/boss/v1/middleware/api-key-middleware';
import { authenticationMiddleware as authenticationMiddlewareV1 } from '@/services/grpc/boss/v1/middleware/authentication-middleware';
import { bossServiceImplementationV1 } from '@/services/grpc/boss/v1/implementation';
import { BossServiceDefinition as BossServiceDefinitionV2 } from '@pretendonetwork/grpc/boss/v2/boss_service';
import { apiKeyMiddleware as apiKeyMiddlewareV2 } from '@/services/grpc/boss/v2/middleware/api-key-middleware';
import { authenticationMiddleware as authenticationMiddlewareV2 } from '@/services/grpc/boss/v2/middleware/authentication-middleware';
import { bossServiceImplementationV2 } from '@/services/grpc/boss/v2/implementation';
import { config } from '@/config-manager';
import type { Server } from 'nice-grpc';
@ -10,6 +16,7 @@ export async function startGRPCServer(): Promise<void> {
const server: Server = createServer();
server.with(apiKeyMiddlewareV1).with(authenticationMiddlewareV1).add(BossServiceDefinitionV1, bossServiceImplementationV1);
server.with(apiKeyMiddlewareV2).with(authenticationMiddlewareV2).add(BossServiceDefinitionV2, bossServiceImplementationV2);
await server.listen(`${config.grpc.boss.address}:${config.grpc.boss.port}`);
}