diff --git a/src/cli/cli.ts b/src/cli/cli.ts index b2a3ec9..c950529 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -1,6 +1,7 @@ import { program as baseProgram } from 'commander'; import { taskCmd } from './tasks.cmd'; import { fileCmd } from './files.cmd'; +import { file3DSCmd } from './files-3ds.cmd'; import { appCmd } from './apps.cmd'; import { importCmd } from './import.cmd'; @@ -11,7 +12,8 @@ const program = baseProgram .addCommand(appCmd) .addCommand(taskCmd) .addCommand(importCmd) - .addCommand(fileCmd); + .addCommand(fileCmd) + .addCommand(file3DSCmd); program.parseAsync(process.argv) .catch(console.error) diff --git a/src/cli/files-3ds.cmd.ts b/src/cli/files-3ds.cmd.ts new file mode 100644 index 0000000..a5d42e7 --- /dev/null +++ b/src/cli/files-3ds.cmd.ts @@ -0,0 +1,193 @@ +import fs from 'node:fs/promises'; +import { pipeline } from 'node:stream/promises'; +import { Readable } from 'node:stream'; +import { request } from 'undici'; +import { Command } from 'commander'; +import { decrypt3DS } from '@pretendonetwork/boss-crypto'; +import { PlatformType } from '@pretendonetwork/grpc/boss/v2/platform_type'; +import { commandHandler, getCliContext } from './utils'; +import { logOutputList, logOutputObject } from './output'; + +const listCmd = new Command('ls') + .description('List all task files in BOSS') + .argument('', 'BOSS app to search in') + .argument('', 'Task to search in') + .action(commandHandler<[string, string]>(async (cmd): Promise => { + const [appId, taskId] = cmd.args; + const ctx = getCliContext(); + const { files } = await ctx.grpc.listFilesCTR({ + bossAppId: appId, + taskId: taskId + }); + logOutputList(files, { + format: cmd.format, + onlyIncludeKeys: ['dataId', 'name', 'size'], + mapping: { + dataId: 'Data ID', + name: 'Name', + size: 'Size (bytes)' + } + }); + })); + +const viewCmd = new Command('view') + .description('Look up a specific task file') + .argument('', 'BOSS app that contains the task') + .argument('', 'Task that contains the task file') + .argument('', 'Task file ID to lookup', BigInt) + .action(commandHandler<[string, string, bigint]>(async (cmd): Promise => { + const [appId, taskId, dataId] = cmd.args; + const ctx = getCliContext(); + const { files } = await ctx.grpc.listFilesCTR({ + bossAppId: appId, + taskId: taskId + }); + const file = files.find(v => v.dataId === dataId); + if (!file) { + console.log(`Could not find task file with data ID ${dataId} in task ${taskId}`); + return; + } + logOutputObject({ + dataId: file.dataId, + name: file.name, + size: file.size, + hash: file.hash, + supportedCountries: file.supportedCountries, + supportedLanguages: file.supportedLanguages, + attributes: file.attributes, + creatorPid: file.creatorPid, + payloadContents: file.payloadContents, + flags: file.flags, + createdAt: new Date(Number(file.createdTimestamp)), + updatedAt: new Date(Number(file.updatedTimestamp)) + }, { + format: cmd.format + }); + })); + +const downloadCmd = new Command('download') + .description('Download a task file') + .argument('', 'BOSS app that contains the task') + .argument('', 'Task that contains the task file') + .argument('', 'Task file ID to lookup', BigInt) + .option('-d, --decrypt', 'Decrypt the file before return') + .action(commandHandler<[string, string, bigint]>(async (cmd): Promise => { + const [appId, taskId, dataId] = cmd.args; + const ctx = getCliContext(); + const { files } = await ctx.grpc.listFilesCTR({ + bossAppId: appId, + taskId: taskId + }); + const file = files.find(v => v.dataId === dataId); + if (!file) { + console.error(`Could not find task file with data ID ${dataId} in task ${taskId}`); + process.exit(1); + } + + const npdl = ctx.getNpdlUrl(); + const country = file.supportedCountries.length > 0 ? '/' + file.supportedCountries[0] : ''; + const language = file.supportedLanguages.length > 0 ? '/' + file.supportedCountries[0] : ''; + const { body, statusCode } = await request(`${npdl.url}/p01/nsa/${file.bossAppId}/${file.taskId}${country}${language}/${file.name}`, { + headers: { + Host: npdl.host + } + }); + if (statusCode > 299) { + console.error(`Failed to download: invalid status code (${statusCode})`); + process.exit(1); + } + + const chunks: Buffer[] = []; + for await (const chunk of body) { + chunks.push(Buffer.from(chunk)); + } + + let buffer: Buffer = Buffer.concat(chunks); + + if (cmd.opts().decrypt) { + const keys = ctx.get3DSKeys(); + const decrypted = decrypt3DS(buffer, keys.aesKey); + // TODO - Handle multiple payloads + buffer = decrypted.payload_contents[0].content; + } + + await pipeline(Readable.from(buffer), process.stdout); + })); + +const createCmd = new Command('create') + .description('Create a new task file') + .argument('', 'BOSS app to store the task file in') + .argument('', 'Task to store the task file in') + .requiredOption('--name ', 'Name of the task file') + .requiredOption('--title-id ', 'Target title ID of the payload') + .requiredOption('--content-datatype ', 'Content datatype of the payload') + .requiredOption('--ns-data-id ', 'NS Data ID of the payload') + .requiredOption('--version ', 'Version of the payload') + .requiredOption('--file ', 'Path of the file to upload') + .option('--country ', 'Countries for this task file') + .option('--lang ', 'Languages for this task file') + .option('--attribute1 [attribute1]', 'Attribute 1 for this task file') + .option('--attribute2 [attribute2]', 'Attribute 2 for this task file') + .option('--attribute3 [attribute3]', 'Attribute 3 for this task file') + .option('--desc [desc]', 'Description for this task file') + .option('-m, --mark-arrived-privileged', 'Only notify of new content to privileged titles') + .action(commandHandler<[string, string]>(async (cmd): Promise => { + const [appId, taskId] = cmd.args; + // TODO - Handle multiple payload contents + const opts = cmd.opts<{ name: string; titleId: string; contentDatatype: string; nsDataId: string; version: string; file: string; country: string[]; lang: string[]; attribute1?: string; attribute2?: string; attribute3?: string; desc?: string; markArrivedPrivileged: boolean }>(); + const fileBuf = await fs.readFile(opts.file); + const ctx = getCliContext(); + const { file } = await ctx.grpc.uploadFileCTR({ + taskId: taskId, + bossAppId: appId, + supportedCountries: opts.country, + supportedLanguages: opts.lang, + attributes: { + attribute1: opts.attribute1, + attribute2: opts.attribute2, + attribute3: opts.attribute3, + description: opts.desc + }, + name: opts.name, + payloadContents: [{ + titleId: BigInt(parseInt(opts.titleId, 16)), + contentDatatype: Number(opts.contentDatatype), + nsDataId: Number(opts.nsDataId), + version: Number(opts.version), + content: fileBuf + }], + flags: { + markArrivedPrivileged: opts.markArrivedPrivileged + } + }); + if (!file) { + console.log(`Failed to create file!`); + return; + } + console.log(`Created file with ID ${file.dataId}`); + })); + +const deleteCmd = new Command('delete') + .description('Delete a task file') + .argument('', 'BOSS app that contains the task') + .argument('', 'Task that contains the task file') + .argument('', 'Task file ID to delete', BigInt) + .action(commandHandler<[string, string, bigint]>(async (cmd): Promise => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- I want to use destructuring + const [appId, _taskId, dataId] = cmd.args; + const ctx = getCliContext(); + await ctx.grpc.deleteFile({ + bossAppId: appId, + dataId: dataId, + platformType: PlatformType.PLATFORM_TYPE_CTR + }); + console.log(`Deleted task file with ID ${dataId}`); + })); + +export const file3DSCmd = new Command('file-3ds') + .description('Manage all the 3DS task files in BOSS') + .addCommand(listCmd) + .addCommand(createCmd) + .addCommand(deleteCmd) + .addCommand(downloadCmd) + .addCommand(viewCmd); diff --git a/src/cli/files.cmd.ts b/src/cli/files.cmd.ts index f0bab7f..99469ac 100644 --- a/src/cli/files.cmd.ts +++ b/src/cli/files.cmd.ts @@ -123,12 +123,16 @@ const createCmd = new Command('create') .requiredOption('--file ', 'Path of the file to upload') .option('--country ', 'Countries for this task file') .option('--lang ', 'Languages for this task file') + .option('--attribute1 [attribute1]', 'Attribute 1 for this task file') + .option('--attribute2 [attribute2]', 'Attribute 2 for this task file') + .option('--attribute3 [attribute3]', 'Attribute 3 for this task file') + .option('--desc [desc]', 'Description for this task file') .option('--name-as-id', 'Force the name as the data ID') .option('--notify-new ', 'Add entry to NotifyNew') .option('--notify-led', 'Enable NotifyLED') .action(commandHandler<[string, string]>(async (cmd): Promise => { const [appId, taskId] = cmd.args; - const opts = cmd.opts<{ name: string; country: string[]; notifyNew: string[]; notifyLed: boolean; lang: string[]; nameAsId?: boolean; type: string; file: string }>(); + const opts = cmd.opts<{ name: string; country: string[]; notifyNew: string[]; notifyLed: boolean; lang: string[]; attribute1?: string; attribute2?: string; attribute3?: string; desc?: string; nameAsId?: boolean; type: string; file: string }>(); const fileBuf = await fs.readFile(opts.file); const ctx = getCliContext(); const { file } = await ctx.grpc.uploadFileWUP({ @@ -137,6 +141,12 @@ const createCmd = new Command('create') name: opts.name, supportedCountries: opts.country, supportedLanguages: opts.lang, + attributes: { + attribute1: opts.attribute1, + attribute2: opts.attribute2, + attribute3: opts.attribute3, + description: opts.desc + }, type: opts.type, nameEqualsDataId: opts.nameAsId ?? false, data: fileBuf, @@ -168,7 +178,7 @@ const deleteCmd = new Command('delete') })); export const fileCmd = new Command('file') - .description('Manage all the task files in BOSS') + .description('Manage all the Wii U task files in BOSS') .addCommand(listCmd) .addCommand(createCmd) .addCommand(deleteCmd) diff --git a/src/cli/utils.ts b/src/cli/utils.ts index 06ea1cb..5091855 100644 --- a/src/cli/utils.ts +++ b/src/cli/utils.ts @@ -6,15 +6,22 @@ import type { Command } from 'commander'; import type { FormatOption } from './output'; export type WiiUKeys = { aesKey: string; hmacKey: string }; +export type CtrKeys = { aesKey: string }; export type NpdiUrl = { host: string; url: string; }; +export type NpdlUrl = { + host: string; + url: string; +}; export type CliContext = { grpc: BossServiceClient; getNpdiUrl: () => NpdiUrl; + getNpdlUrl: () => NpdlUrl; getWiiUKeys: () => WiiUKeys; + get3DSKeys: () => CtrKeys; }; export function getCliContext(): CliContext { @@ -49,6 +56,15 @@ export function getCliContext(): CliContext { host: npdiHost }; }, + getNpdlUrl(): NpdiUrl { + const npdlUrl = process.env.PN_BOSS_CLI_NPDL_URL ?? 'https://npdl.cdn.pretendo.cc'; + const npdlHost = process.env.PN_BOSS_CLI_NPDL_HOST ?? new URL(npdlUrl).host; + + return { + url: npdlUrl, + host: npdlHost + }; + }, getWiiUKeys(): WiiUKeys { const aesKey = process.env.PN_BOSS_CLI_WIIU_AES_KEY ?? ''; const hmacKey = process.env.PN_BOSS_CLI_WIIU_HMAC_KEY ?? ''; @@ -63,6 +79,16 @@ export function getCliContext(): CliContext { aesKey, hmacKey }; + }, + get3DSKeys(): CtrKeys { + const aesKey = process.env.PN_BOSS_CLI_3DS_AES_KEY ?? ''; + + if (!aesKey) { + throw new Error('Missing env variable PN_BOSS_CLI_3DS_AES_KEY - needed for decryption'); + } + return { + aesKey + }; } }; }