diff --git a/src/services/grpc/boss/v2/delete-file.ts b/src/services/grpc/boss/v2/delete-file.ts new file mode 100644 index 0000000..462194e --- /dev/null +++ b/src/services/grpc/boss/v2/delete-file.ts @@ -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 { + 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 {}; +} diff --git a/src/services/grpc/boss/v2/delete-task.ts b/src/services/grpc/boss/v2/delete-task.ts new file mode 100644 index 0000000..6f5129a --- /dev/null +++ b/src/services/grpc/boss/v2/delete-task.ts @@ -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 { + 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 {}; +} diff --git a/src/services/grpc/boss/v2/implementation.ts b/src/services/grpc/boss/v2/implementation.ts new file mode 100644 index 0000000..86d45da --- /dev/null +++ b/src/services/grpc/boss/v2/implementation.ts @@ -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 +}; diff --git a/src/services/grpc/boss/v2/list-files-ctr.ts b/src/services/grpc/boss/v2/list-files-ctr.ts new file mode 100644 index 0000000..1b7bd9c --- /dev/null +++ b/src/services/grpc/boss/v2/list-files-ctr.ts @@ -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 { + 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 + })) + }; +} diff --git a/src/services/grpc/boss/v2/list-files-wup.ts b/src/services/grpc/boss/v2/list-files-wup.ts new file mode 100644 index 0000000..80f429c --- /dev/null +++ b/src/services/grpc/boss/v2/list-files-wup.ts @@ -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 { + 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 + })) + }; +} diff --git a/src/services/grpc/boss/v2/list-known-boss-apps.ts b/src/services/grpc/boss/v2/list-known-boss-apps.ts new file mode 100644 index 0000000..7e7f157 --- /dev/null +++ b/src/services/grpc/boss/v2/list-known-boss-apps.ts @@ -0,0 +1,407 @@ +import type { ListKnownBOSSAppsResponse } from '@pretendonetwork/grpc/boss/v2/list_known_boss_apps'; + +export async function listKnownBOSSApps(): Promise { + 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: '太鼓の達人 Wii 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: '仮面ライダー バトライド・ウォーⅡ プレミアムTV&MOVIEサウンド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'] + } + ] + }; +} diff --git a/src/services/grpc/boss/v2/list-tasks.ts b/src/services/grpc/boss/v2/list-tasks.ts new file mode 100644 index 0000000..5164e2d --- /dev/null +++ b/src/services/grpc/boss/v2/list-tasks.ts @@ -0,0 +1,22 @@ +import { getAllTasks } from '@/database'; +import type { ListTasksResponse } from '@pretendonetwork/grpc/boss/v2/list_tasks'; + +export async function listTasks(): Promise { + 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 + })) + }; +} diff --git a/src/services/grpc/boss/v2/middleware/api-key-middleware.ts b/src/services/grpc/boss/v2/middleware/api-key-middleware.ts new file mode 100644 index 0000000..211c2cc --- /dev/null +++ b/src/services/grpc/boss/v2/middleware/api-key-middleware.ts @@ -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( + call: ServerMiddlewareCall, + context: CallContext +): AsyncGenerator { + 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); +} diff --git a/src/services/grpc/boss/v2/middleware/authentication-middleware.ts b/src/services/grpc/boss/v2/middleware/authentication-middleware.ts new file mode 100644 index 0000000..7456dd4 --- /dev/null +++ b/src/services/grpc/boss/v2/middleware/authentication-middleware.ts @@ -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( + call: ServerMiddlewareCall, + context: CallContext +): AsyncGenerator { + 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]; +} diff --git a/src/services/grpc/boss/v2/register-task.ts b/src/services/grpc/boss/v2/register-task.ts new file mode 100644 index 0000000..e39df3e --- /dev/null +++ b/src/services/grpc/boss/v2/register-task.ts @@ -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 { + 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 + } + }; +} diff --git a/src/services/grpc/boss/v2/update-file-metadata-wup.ts b/src/services/grpc/boss/v2/update-file-metadata-wup.ts new file mode 100644 index 0000000..4fd7e72 --- /dev/null +++ b/src/services/grpc/boss/v2/update-file-metadata-wup.ts @@ -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 { + 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 {}; +} diff --git a/src/services/grpc/boss/v2/update-task.ts b/src/services/grpc/boss/v2/update-task.ts new file mode 100644 index 0000000..34cd712 --- /dev/null +++ b/src/services/grpc/boss/v2/update-task.ts @@ -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 { + 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 {}; +} diff --git a/src/services/grpc/boss/v2/upload-file-wup.ts b/src/services/grpc/boss/v2/upload-file-wup.ts new file mode 100644 index 0000000..26959b0 --- /dev/null +++ b/src/services/grpc/boss/v2/upload-file-wup.ts @@ -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 { + 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 + } + }; +} diff --git a/src/services/grpc/server.ts b/src/services/grpc/server.ts index 2c09231..9696505 100644 --- a/src/services/grpc/server.ts +++ b/src/services/grpc/server.ts @@ -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 { 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}`); }