diff --git a/Dockerfile b/Dockerfile index 0eab34a..4b3d7d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,8 +39,8 @@ RUN chown node:node ${app_dir} ENV NODE_ENV=production USER node -COPY --chown=node:node update-rotation.mjs ${app_dir} COPY --chown=node:node ./boss ${app_dir} +COPY --chown=node:node seeding ${app_dir} COPY --chown=node:node package.json . COPY --from=dependencies --chown=node:node ${app_dir}/node_modules ${app_dir}/node_modules diff --git a/README.md b/README.md index 748995e..365318c 100644 --- a/README.md +++ b/README.md @@ -11,37 +11,38 @@ Handles all BOSS (Background Online Storage Service) related tasks for the Prete Configurations are loaded through environment variables. `.env` files are supported. -| Environment variable | Description | Default | -| -------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------- | -| `PN_BOSS_CONFIG_HTTP_PORT` | The HTTP port the server listens on | None | -| `PN_BOSS_CONFIG_LOG_FORMAT` | What logging format to use, possible options: `pretty` or `json` | `pretty` | -| `PN_BOSS_CONFIG_LOG_LEVEL` | What log level to use | `info` | -| `PN_BOSS_CONFIG_BOSS_WIIU_AES_KEY` | The BOSS WiiU AES key, needs to be dumped from a console | None | -| `PN_BOSS_CONFIG_BOSS_WIIU_HMAC_KEY` | The BOSS WiiU HMAC key, needs to be dumped from a console | None | -| `PN_BOSS_CONFIG_BOSS_3DS_AES_KEY` | The BOSS 3DS AES key, needs to be dumped from a console | None | -| `PN_BOSS_CONFIG_MONGO_CONNECTION_STRING` | MongoDB connection string | None | -| `PN_BOSS_CONFIG_GRPC_BOSS_SERVER_ADDRESS` | Address for the GRPC server to listen on | None | -| `PN_BOSS_CONFIG_GRPC_BOSS_SERVER_PORT` | Port for the GRPC server to listen on | None | -| `PN_BOSS_CONFIG_GRPC_BOSS_SERVER_API_KEY` | API key that services will use to connect to the BOSS GRPC server | None | -| `PN_BOSS_CONFIG_GRPC_ACCOUNT_SERVER_ADDRESS` | Address of the account GRPC server | None | -| `PN_BOSS_CONFIG_GRPC_ACCOUNT_SERVER_PORT` | Port of the account GRPC server | None | -| `PN_BOSS_CONFIG_GRPC_ACCOUNT_SERVER_API_KEY` | API key of the account GRPC server | None | -| `PN_BOSS_CONFIG_GRPC_FRIENDS_SERVER_ADDRESS` | Address of the friends GRPC server | None | -| `PN_BOSS_CONFIG_GRPC_FRIENDS_SERVER_PORT` | Port of the friends GRPC server | None | -| `PN_BOSS_CONFIG_GRPC_FRIENDS_SERVER_API_KEY` | API key of the friends GRPC server | None | -| `PN_BOSS_CONFIG_S3_ENDPOINT` | S3 server endpoint | None | -| `PN_BOSS_CONFIG_S3_REGION` | S3 server region | None | -| `PN_BOSS_CONFIG_S3_BUCKET` | S3 server bucket | None | -| `PN_BOSS_CONFIG_S3_ACCESS_KEY` | S3 access key | None | -| `PN_BOSS_CONFIG_S3_ACCESS_SECRET` | S3 access key secret | None | -| `PN_BOSS_CONFIG_CDN_DISK_PATH` | Storage path for the CDN, use as alternative for S3 | None | -| `PN_BOSS_CONFIG_STREETPASS_RELAY_ENABLED` | Should Streetpass Relay be enabled? | `false` | -| `PN_BOSS_CONFIG_DOMAINS_NPDI` | What domain should the NPDI component use? | `npdi.cdn.pretendo.cc` | -| `PN_BOSS_CONFIG_DOMAINS_NPDL` | What domain should the NPDL component use? | `npdl.cdn.pretendo.cc` | -| `PN_BOSS_CONFIG_DOMAINS_NPFL` | What domain should the NPFL component use? | `npfl.c.app.pretendo.cc` | -| `PN_BOSS_CONFIG_DOMAINS_NPPL` | What domain should the NPPL component use? | `nppl.app.pretendo.cc,nppl.c.app.pretendo.cc` | -| `PN_BOSS_CONFIG_DOMAINS_NPTS` | What domain should the NPTS component use? | `npts.app.pretendo.cc` | -| `PN_BOSS_CONFIG_DOMAINS_SPR` | What domain should the SPR component use? | `service.spr.app.pretendo.cc` | +| Environment variable | Description | Default | +| ------------------------------------------------ | ----------------------------------------------------------------- | --------------------------------------------- | +| `PN_BOSS_CONFIG_HTTP_PORT` | The HTTP port the server listens on | None | +| `PN_BOSS_CONFIG_LOG_FORMAT` | What logging format to use, possible options: `pretty` or `json` | `pretty` | +| `PN_BOSS_CONFIG_LOG_LEVEL` | What log level to use | `info` | +| `PN_BOSS_CONFIG_BOSS_WIIU_AES_KEY` | The BOSS WiiU AES key, needs to be dumped from a console | None | +| `PN_BOSS_CONFIG_BOSS_WIIU_HMAC_KEY` | The BOSS WiiU HMAC key, needs to be dumped from a console | None | +| `PN_BOSS_CONFIG_BOSS_3DS_AES_KEY` | The BOSS 3DS AES key, needs to be dumped from a console | None | +| `PN_BOSS_CONFIG_MONGO_CONNECTION_STRING` | MongoDB connection string | None | +| `PN_BOSS_CONFIG_GRPC_BOSS_SERVER_ADDRESS` | Address for the GRPC server to listen on | None | +| `PN_BOSS_CONFIG_GRPC_BOSS_SERVER_PORT` | Port for the GRPC server to listen on | None | +| `PN_BOSS_CONFIG_GRPC_BOSS_SERVER_API_KEY` | API key that services will use to connect to the BOSS GRPC server | None | +| `PN_BOSS_CONFIG_GRPC_ACCOUNT_SERVER_ADDRESS` | Address of the account GRPC server | None | +| `PN_BOSS_CONFIG_GRPC_ACCOUNT_SERVER_PORT` | Port of the account GRPC server | None | +| `PN_BOSS_CONFIG_GRPC_ACCOUNT_SERVER_API_KEY` | API key of the account GRPC server | None | +| `PN_BOSS_CONFIG_GRPC_FRIENDS_SERVER_ADDRESS` | Address of the friends GRPC server | None | +| `PN_BOSS_CONFIG_GRPC_FRIENDS_SERVER_PORT` | Port of the friends GRPC server | None | +| `PN_BOSS_CONFIG_GRPC_FRIENDS_SERVER_API_KEY` | API key of the friends GRPC server | None | +| `PN_BOSS_CONFIG_S3_ENDPOINT` | S3 server endpoint | None | +| `PN_BOSS_CONFIG_S3_REGION` | S3 server region | None | +| `PN_BOSS_CONFIG_S3_BUCKET` | S3 server bucket | None | +| `PN_BOSS_CONFIG_S3_ACCESS_KEY` | S3 access key | None | +| `PN_BOSS_CONFIG_S3_ACCESS_SECRET` | S3 access key secret | None | +| `PN_BOSS_CONFIG_CDN_DISK_PATH` | Storage path for the CDN, use as alternative for S3 | None | +| `PN_BOSS_CONFIG_STREETPASS_RELAY_ENABLED` | Should Streetpass Relay be enabled? | `false` | +| `PN_BOSS_CONFIG_STREETPASS_RELAY_CLEAN_OLD_DATA` | Should old Streetpass Relay data be automatically cleaned up? | `false` | +| `PN_BOSS_CONFIG_DOMAINS_NPDI` | What domain should the NPDI component use? | `npdi.cdn.pretendo.cc` | +| `PN_BOSS_CONFIG_DOMAINS_NPDL` | What domain should the NPDL component use? | `npdl.cdn.pretendo.cc` | +| `PN_BOSS_CONFIG_DOMAINS_NPFL` | What domain should the NPFL component use? | `npfl.c.app.pretendo.cc` | +| `PN_BOSS_CONFIG_DOMAINS_NPPL` | What domain should the NPPL component use? | `nppl.app.pretendo.cc,nppl.c.app.pretendo.cc` | +| `PN_BOSS_CONFIG_DOMAINS_NPTS` | What domain should the NPTS component use? | `npts.app.pretendo.cc` | +| `PN_BOSS_CONFIG_DOMAINS_SPR` | What domain should the SPR component use? | `service.spr.app.pretendo.cc` | ## S3 server The S3 server is optional, you can set `PN_BOSS_CONFIG_CDN_DISK_PATH` if you want to use a local folder as CDN source instead. @@ -67,4 +68,20 @@ Configurations are loaded through environment variables. `.env` files are suppor | `PN_BOSS_CLI_WIIU_HMAC_KEY` | The BOSS WiiU HMAC key, needs to be dumped from a console | Optional | | `PN_BOSS_CLI_NPDI_URL` | The URL of the NPDI part the BOSS HTTP server, only needed when downloading | Optional | | `PN_BOSS_CLI_NPDI_HOST` | The Host header for the NPDI requests. Use when you don't have NPDI exposed to the internet | Optional | - \ No newline at end of file + +## Common CLI operations + +```sh +# Download taskfile and decrypt +./boss file ls # View list of files and their IDs +./boss file view --decrypt > output.txt # Download file and decrypt +``` + +```sh +# Update splatoon rotations +# Run the following for all of these BOSS app ids: +# - bb6tOEckvgZ50ciH +# - rjVlM7hUXPxmYQJh +# - zvGSM4kOrXpkKnpT +./boss file create schdat2 --name VSSetting.byaml --type AppData --notify-new app --file +``` diff --git a/package-lock.json b/package-lock.json index 4289cbb..6621140 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@pretendonetwork/grpc": "^1.0.6", "@typegoose/auto-increment": "^3.6.1", "commander": "^14.0.0", + "cron": "^4.3.3", "dicer": "^0.3.1", "dotenv": "^16.4.7", "express": "^5.1.0", @@ -3237,6 +3238,12 @@ "@types/node": "*" } }, + "node_modules/@types/luxon": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -4583,6 +4590,19 @@ "node": ">=6.6.0" } }, + "node_modules/cron": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.3.3.tgz", + "integrity": "sha512-B/CJj5yL3sjtlun6RtYHvoSB26EmQ2NUmhq9ZiJSyKIM4K/fqfh9aelDFlIayD2YMeFZqWLi9hHV+c+pq2Djkw==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.7.0", + "luxon": "~3.7.0" + }, + "engines": { + "node": ">=18.x" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -7127,6 +7147,15 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.18", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz", @@ -12115,6 +12144,11 @@ "@types/node": "*" } }, + "@types/luxon": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==" + }, "@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -12965,6 +12999,15 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==" }, + "cron": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.3.3.tgz", + "integrity": "sha512-B/CJj5yL3sjtlun6RtYHvoSB26EmQ2NUmhq9ZiJSyKIM4K/fqfh9aelDFlIayD2YMeFZqWLi9hHV+c+pq2Djkw==", + "requires": { + "@types/luxon": "~3.7.0", + "luxon": "~3.7.0" + } + }, "cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -14710,6 +14753,11 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, + "luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==" + }, "magic-string": { "version": "0.30.18", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz", diff --git a/package.json b/package.json index f95f07a..aa13358 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@pretendonetwork/grpc": "^1.0.6", "@typegoose/auto-increment": "^3.6.1", "commander": "^14.0.0", + "cron": "^4.3.3", "dicer": "^0.3.1", "dotenv": "^16.4.7", "express": "^5.1.0", diff --git a/src/cdn.ts b/src/cdn.ts index 110558c..e4b4245 100644 --- a/src/cdn.ts +++ b/src/cdn.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import { Stream } from 'node:stream'; import { buffer as bufferConsumer } from 'node:stream/consumers'; import fs from 'fs-extra'; -import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3 } from '@aws-sdk/client-s3'; +import { DeleteObjectCommand, DeleteObjectsCommand, GetObjectCommand, PutObjectCommand, S3 } from '@aws-sdk/client-s3'; import { config, disabledFeatures } from '@/config-manager'; import { fileStatOrNull } from './util'; import { logger } from './logger'; @@ -117,6 +117,29 @@ export async function deleteCDNFile(namespace: CDNNamespace, key: string): Promi })); } +export async function bulkDeleteCdnFiles(namespace: CDNNamespace, keys: string[]): Promise { + if (keys.length === 0) { + return; + } + if (keys.length > 1000) { + throw new Error('Cannot bulk delete more than 1000 CDN files in one batch'); + } + + if (!s3) { + await Promise.allSettled(keys.map(v => deleteCDNFile(namespace, v))); + return; + } + + const fullKeys = keys.map(v => buildKey(namespace, v)); + await s3.send(new DeleteObjectsCommand({ + Delete: { + Objects: fullKeys.map(v => ({ Key: v })), + Quiet: true + }, + Bucket: config.cdn.s3.bucket + })); +} + export function streamFileToResponse(response: Response, stream: Readable, size: number | null, headers: Record = {}): void { response.setHeaders(new Headers(headers)); diff --git a/src/cli/files.cmd.ts b/src/cli/files.cmd.ts index 0dd73b7..51ac755 100644 --- a/src/cli/files.cmd.ts +++ b/src/cli/files.cmd.ts @@ -113,7 +113,9 @@ const createCmd = new Command('create') .option('--country ', 'Countries for this task file') .option('--lang ', 'Languages for this task file') .option('--name-as-id', 'Force the name as the data ID') - .action(async (appId: string, taskId: string, opts: { name: string; country: string[]; lang: string[]; nameAsId?: boolean; type: string; file: string }) => { + .option('--notify-new ', 'Add entry to NotifyNew') + .option('--notify-led', 'Enable NotifyLED') + .action(async (appId: string, taskId: string, opts: { name: string; country: string[]; notifyNew: string[]; notifyLed: boolean; lang: string[]; nameAsId?: boolean; type: string; file: string }) => { const fileBuf = await fs.readFile(opts.file); const ctx = getCliContext(); const { file } = await ctx.grpc.uploadFile({ @@ -124,7 +126,9 @@ const createCmd = new Command('create') supportedLanguages: opts.lang, type: opts.type, nameEqualsDataId: opts.nameAsId ?? false, - data: fileBuf + data: fileBuf, + notifyOnNew: opts.notifyNew, + notifyLed: opts.notifyLed }); if (!file) { console.log(`Failed to create file!`); diff --git a/src/config-manager.ts b/src/config-manager.ts index e37fc1b..c8e5e23 100644 --- a/src/config-manager.ts +++ b/src/config-manager.ts @@ -75,7 +75,8 @@ export const config = { disk_path: process.env.PN_BOSS_CONFIG_CDN_DISK_PATH?.trim() || '' }, spr: { - enabled: process.env.PN_BOSS_CONFIG_STREETPASS_RELAY_ENABLED?.trim().toLowerCase() === 'true' + enabled: process.env.PN_BOSS_CONFIG_STREETPASS_RELAY_ENABLED?.trim().toLowerCase() === 'true', + cleanOldData: process.env.PN_BOSS_CONFIG_STREETPASS_RELAY_CLEAN_OLD_DATA?.trim().toLowerCase() === 'true' }, domains: { npdi: (process.env.PN_BOSS_CONFIG_DOMAINS_NPDI || 'npdi.cdn.pretendo.cc').split(','), diff --git a/src/database.ts b/src/database.ts index 60d83ce..0c41186 100644 --- a/src/database.ts +++ b/src/database.ts @@ -216,3 +216,30 @@ export async function getRandomCECData(pids: number[], gameID: number): Promise< return null; } + +export async function deleteOldCECData(olderThan: Date, limit: number): Promise<{ id: string; file_key: string }[]> { + verifyConnected(); + + const toDelete = await CECData.find({ + created: { + $lt: olderThan.getTime() + } + }).limit(limit).sort({ created: 1 }).select({ file_key: 1 }); + const ids = toDelete.map(v => v.id); + + await CECData.deleteMany({ + _id: { + $in: ids + } + }); + + // Remove slot if their newest data is what we've just deleted + // This is safe because everything older than the deleted data is also gone + await CECSlot.deleteMany({ + latest_data_id: { + $in: ids + } + }); + + return toDelete.map(v => ({ id: v.id, file_key: v.file_key })); +} diff --git a/src/models/cec-slot.ts b/src/models/cec-slot.ts index 027a2c9..65fac50 100644 --- a/src/models/cec-slot.ts +++ b/src/models/cec-slot.ts @@ -8,5 +8,6 @@ const CECSlotSchema = new mongoose.Schema('CECSlot', CECSlotSchema); diff --git a/src/scheduled.ts b/src/scheduled.ts new file mode 100644 index 0000000..2fa7c37 --- /dev/null +++ b/src/scheduled.ts @@ -0,0 +1,49 @@ +import { CronJob } from 'cron'; +import { logger } from './logger'; +import { deleteOldCECData } from './database'; +import { config } from './config-manager'; +import { bulkDeleteCdnFiles } from './cdn'; + +async function runCleanSprData(): Promise { + const maxAgeMs = 14 * 24 * 60 * 60 * 1000; // 14 days + const timestampInPast = new Date(Date.now() - maxAgeMs); + const processingLimit = 1000; // S3 only allows 1k objects at a time + let totalRemoved = 0; + + logger.info('Starting SPR data cleanup'); + let hasDataToDelete = true; + while (hasDataToDelete) { + const deletedData = await deleteOldCECData(timestampInPast, processingLimit); + logger.info(`Deleted one batch of ${deletedData.length} CEC data objects, preparing CDN removal`); + + await bulkDeleteCdnFiles('spr', deletedData.map(v => v.file_key)); + logger.info(`CDN removal processed!`); + + totalRemoved += deletedData.length; + hasDataToDelete = deletedData.length === processingLimit; + } + + logger.success(`Completed cleanup of ${totalRemoved}`); +} + +function registerSchedule(schedule: string, name: string, fn: () => void | Promise): void { + CronJob.from({ + cronTime: schedule, + onTick: async () => { + try { + const result = fn(); + await result; + } catch (err) { + logger.error(`Error in schedule ${name}: ${err}`); + } + }, + start: true + }); + logger.info(`Added schedule ${name} for ${schedule}`); +} + +export async function setupScheduler(): Promise { + if (config.spr.cleanOldData) { + registerSchedule('0 2 * * *', 'clean-spr-data', runCleanSprData); + } +} diff --git a/src/server.ts b/src/server.ts index b4acf0f..307721e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,6 +12,7 @@ import npdi from '@/services/npdi'; import npfl from '@/services/npfl'; import npdl from '@/services/npdl'; import spr from '@/services/spr'; +import { setupScheduler } from './scheduled'; process.title = 'Pretendo - BOSS'; process.on('SIGTERM', () => { @@ -73,6 +74,9 @@ async function main(): Promise { await connectDatabase(); logger.success('Database connected'); + await setupScheduler(); + logger.success('Scheduler started'); + await startGRPCServer(); logger.success(`gRPC server started at address ${config.grpc.boss.address}:${config.grpc.boss.port}`); diff --git a/update-rotation.mjs b/update-rotation.mjs deleted file mode 100644 index 6569fa1..0000000 --- a/update-rotation.mjs +++ /dev/null @@ -1,157 +0,0 @@ -/* eslint-disable no-undef -- Tis a script */ - -import path from 'path'; -import crypto from 'crypto'; -import readline from 'readline'; -import fs from 'fs-extra'; -import dotenv from 'dotenv'; -import xml from 'xml-js'; -import { encryptWiiU } from '@pretendonetwork/boss-crypto'; - -dotenv.config(); - -function md5(input) { - 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 { PN_BOSS_CONFIG_BOSS_WIIU_AES_KEY, PN_BOSS_CONFIG_BOSS_WIIU_HMAC_KEY } = process.env; - -if (!PN_BOSS_CONFIG_BOSS_WIIU_AES_KEY || md5(PN_BOSS_CONFIG_BOSS_WIIU_AES_KEY) !== BOSS_WIIU_AES_KEY_MD5_HASH) { - console.error('PN_BOSS_CONFIG_BOSS_WIIU_AES_KEY is not set or does not match the expected value'); - process.exit(1); -} -if (!PN_BOSS_CONFIG_BOSS_WIIU_HMAC_KEY || md5(PN_BOSS_CONFIG_BOSS_WIIU_HMAC_KEY) !== BOSS_WIIU_HMAC_KEY_MD5_HASH) { - console.error('PN_BOSS_CONFIG_BOSS_WIIU_HMAC_KEY is not set or does not match the expected value'); - process.exit(1); -} - -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout -}); - -const askQuestion = (question) => { - return new Promise((resolve) => { - rl.question(question, (answer) => { - resolve(answer); - }); - }); -}; - -const rootDir = import.meta.dirname; -const sourceFile = path.join(rootDir, 'VSSetting.byaml'); -const checksumFile = path.join(rootDir, 'cdn/VSSetting.byaml.checksum'); - -const exists = await fs.exists(sourceFile); -if (!exists) { - console.error('Source VSSetting.byaml file does not exist'); - process.exit(1); -} - -const sourceFileContents = await fs.readFile(sourceFile); - -const stat = await fs.stat(sourceFile); -if (stat.mtime.toDateString() !== new Date().toDateString()) { - const answer = await askQuestion(`The source file was not updated today (Updated ${stat.mtime.toDateString()}). Do you want to continue? (y/n) `); - if (answer.toLowerCase() !== 'y') { - process.exit(0); - } -} - -const checksumExists = await fs.exists(checksumFile); -const newChecksum = crypto.createHash('sha256').update(sourceFileContents).digest('hex'); -console.log(`Checksum for source file is ${newChecksum}`); -if (checksumExists) { - const checksum = await fs.readFile(checksumFile, 'utf8'); - - if (checksum === newChecksum) { - const answer = await askQuestion('The source file has not changed since the last run. Do you want to continue? (y/n) '); - if (answer.toLowerCase() !== 'y') { - process.exit(0); - } - } -} - -const titles = [ - 'bb6tOEckvgZ50ciH', - 'rjVlM7hUXPxmYQJh', - 'zvGSM4kOrXpkKnpT' -]; - -async function backupFile(filePath) { - const copyFilePath = path.join(path.dirname(filePath), `${path.basename(filePath)}.bak`); - const exists = await fs.exists(filePath); - if (!exists) { - console.log(`File ${filePath} does not exist, skipping backup...`); - return; - } - await fs.copyFile(filePath, copyFilePath); - console.log(`Backup created of ${filePath} at ${copyFilePath}`); -} - -for (const title of titles) { - console.log(`\n --- Processing ${title} ---`); - const decryptedDir = path.join(rootDir, `cdn/content/decrypted/${title}`); - const encryptedDir = path.join(rootDir, `cdn/content/encrypted/${title}`); - const taskSheetDir = path.join(rootDir, `cdn/tasksheet/1/${title}`); - await fs.ensureDir(decryptedDir); - await fs.ensureDir(encryptedDir); - await fs.ensureDir(taskSheetDir); - - const decryptedFilePath = path.join(decryptedDir, 'VSSetting.byaml'); - await backupFile(decryptedFilePath); - await fs.copyFile(sourceFile, decryptedFilePath); - - const encryptedContents = encryptWiiU(decryptedFilePath, PN_BOSS_CONFIG_BOSS_WIIU_AES_KEY, PN_BOSS_CONFIG_BOSS_WIIU_HMAC_KEY); - const hash = crypto.createHash('md5').update(encryptedContents).digest('hex'); - console.log(`Hash for title ${title} is ${hash}`); - - const encryptedFilePath = path.join(encryptedDir, hash); - await fs.writeFile(encryptedFilePath, encryptedContents); - console.log(`Encrypted file created at ${encryptedFilePath}`); - - const taskSheetFilePath = path.join(taskSheetDir, 'schdat2'); - await backupFile(taskSheetFilePath); - - const tasksheetContents = await fs.readFile(taskSheetFilePath, 'utf8'); - const xmlContents = xml.xml2js(tasksheetContents, { compact: true }); - - const dataId = parseInt(xmlContents.TaskSheet.Files.File.DataId._text); - if (isNaN(dataId)) { - console.error(`DataId for title ${title} is not a number, skipping...`); - continue; - } - console.log(`DataId for title ${title} is ${dataId}`); - const newDataId = dataId + 1; - console.log(`New DataId for title ${title} is ${newDataId}`); - - xmlContents.TaskSheet.Files.File.DataId._text = newDataId.toString(); - - const size = encryptedContents.length; - const oldSize = parseInt(xmlContents.TaskSheet.Files.File.Size._text); - if (size === oldSize) { - console.log(`Size for title ${title} is already updated, skipping update...`); - } else { - console.log(`Old size for title ${title} is ${oldSize}`); - console.log(`New size for title ${title} is ${size}`); - xmlContents.TaskSheet.Files.File.Size._text = size.toString(); - } - - const oldUrl = xmlContents.TaskSheet.Files.File.Url._text; - const newUrl = `https://npdi.cdn.pretendo.cc/p01/data/1/${title}/${newDataId}/${hash}`; - console.log(`Old URL for title ${title} is ${oldUrl}`); - console.log(`New URL for title ${title} is ${newUrl}`); - xmlContents.TaskSheet.Files.File.Url._text = newUrl; - - const newXmlContents = xml.js2xml(xmlContents, { spaces: 2, compact: true }); - await fs.writeFile(taskSheetFilePath, newXmlContents); - console.log(`Tasksheet file updated at ${taskSheetFilePath}`); -} - -rl.close(); - -console.log('All tasks completed successfully!'); -await fs.writeFile(checksumFile, newChecksum);