Merge pull request #44 from PretendoNetwork/dev
Some checks failed
Build and Publish Docker Image / Build and Publish Docker Image (amd64) (push) Has been cancelled
Build and Publish Docker Image / Build and Publish Docker Image (arm64) (push) Has been cancelled

This commit is contained in:
Daniel López Guimaraes 2025-12-08 22:33:36 +00:00 committed by GitHub
commit 57af042654
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 6422 additions and 316 deletions

View File

@ -11,39 +11,41 @@ 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_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` |
| 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_MAX_RECEIVE_MESSAGE_LENGTH_MB` | The maximum size, in megabytes, a message sent to the server can be | 4 |
| `PN_BOSS_CONFIG_GRPC_MAX_SEND_MESSAGE_LENGTH_MB` | The maximum size, in megabytes, a message sent to the client can be | 4 |
| `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.
@ -61,7 +63,7 @@ npm run build
Configurations are loaded through environment variables. `.env` files are supported.
| Environment variable | Description | |
| --------------------------- | ------------------------------------------------------------------------------------------- | -------- |
|-----------------------------|---------------------------------------------------------------------------------------------|----------|
| `PN_BOSS_CLI_GRPC_HOST` | The Host that the BOSS GRPC server is on. Example: `localhost:5678` | Required |
| `PN_BOSS_CLI_GRPC_APIKEY` | Master API key of the BOSS GRPC server. | Required |
| `PN_BOSS_CLI_WIIU_AES_KEY` | The BOSS WiiU AES key, needs to be dumped from a console | Optional |

134
package-lock.json generated
View File

@ -10,9 +10,9 @@
"license": "AGPL-3.0-only",
"dependencies": {
"@aws-sdk/client-s3": "^3.723.0",
"@pretendonetwork/boss-crypto": "^1.0.0",
"@pretendonetwork/grpc": "^1.0.6",
"@typegoose/auto-increment": "^4.13.0",
"@pretendonetwork/boss-crypto": "^1.2.2",
"@pretendonetwork/grpc": "^2.3.5",
"@typegoose/auto-increment": "^4.13.1",
"commander": "^14.0.0",
"cron": "^4.3.3",
"dotenv": "^16.4.7",
@ -358,6 +358,7 @@
"version": "3.723.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.723.0.tgz",
"integrity": "sha512-9IH90m4bnHogBctVna2FnXaIGVORncfdxcqeEIovOxjIJJyHDmEAtA7B91dAM4sruddTbVzOYnqfPVst3odCbA==",
"peer": true,
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
@ -410,6 +411,7 @@
"version": "3.723.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.723.0.tgz",
"integrity": "sha512-YyN8x4MI/jMb4LpHsLf+VYqvbColMK8aZeGWVk2fTFsmt8lpTYGaGC1yybSwGX42mZ4W8ucu8SAYSbUraJZEjA==",
"peer": true,
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
@ -917,6 +919,12 @@
"node": ">=18.0.0"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.0.tgz",
"integrity": "sha512-fdRs9PSrBF7QUntpZpq6BTw58fhgGJojgg39m9oFOJGZT+nip9b0so5cYY1oWl5pvemDLr0cPPsH46vwThEbpQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@emnapi/core": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz",
@ -2022,6 +2030,12 @@
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -2033,9 +2047,10 @@
}
},
"node_modules/@pretendonetwork/boss-crypto": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pretendonetwork/boss-crypto/-/boss-crypto-1.0.0.tgz",
"integrity": "sha512-ybd3sB356v5Azxj99R62+7kytgAzfUYuXRJbdOznGL6infgCJ056TjTadN4V48m7t+3f6sPOUgo9YWUFNxlLLg=="
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@pretendonetwork/boss-crypto/-/boss-crypto-1.2.2.tgz",
"integrity": "sha512-sGlMiXGIThWfbs85xdWuJdgmW6YV6aQ8znh3vWWwG08BwpzzqgflluFYKhy6P0rpiMUJQuDAWK1IcVIsjD1lhw==",
"license": "LGPL-3.0-only"
},
"node_modules/@pretendonetwork/eslint-config": {
"version": "0.1.1",
@ -2057,12 +2072,14 @@
}
},
"node_modules/@pretendonetwork/grpc": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-1.0.6.tgz",
"integrity": "sha512-kTK4lO8AdrQ5GOvYdJ7sqvIP3ubn5TGqGGqjVpgCTSiVBvBmlnz3fQkoDHmYw2WeA0CNtUx2dROG3Juiy5t7BQ==",
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-2.3.5.tgz",
"integrity": "sha512-FU0uvhZr8gFgiIi+gBtD6+5or34bHcd9X+ZVpRdc7IiupW5V+uxiXigJKX4Vd46QC412y+BEGofCjM1bBlTJhg==",
"license": "AGPL-3.0-only",
"dependencies": {
"long": "^5.2.1",
"protobufjs": "^7.2.3"
"@bufbuild/protobuf": "^2.2.2",
"nice-grpc-common": "^2.0.2",
"typescript": "^5.7.2"
}
},
"node_modules/@protobufjs/aspromise": {
@ -3146,9 +3163,9 @@
}
},
"node_modules/@typegoose/auto-increment": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/@typegoose/auto-increment/-/auto-increment-4.13.0.tgz",
"integrity": "sha512-saOwqB66duV+rntkME/027A8opjgzmV3pBY8+zoJ4mGSc3FVGad6CSr56x4oqd15p39XtWH1UNZaS5Bzp6O6Ow==",
"version": "4.13.1",
"resolved": "https://registry.npmjs.org/@typegoose/auto-increment/-/auto-increment-4.13.1.tgz",
"integrity": "sha512-Dv0jlgBo4GkAApZmSxNGhD6eF3LP+1veQGvmNG2/tCNJvCVdTBUlC8ZNdUdTEzQlRRVq5p53qJH18irc5sX2jA==",
"license": "MIT",
"dependencies": {
"loglevel": "^1.9.2",
@ -3374,6 +3391,7 @@
"integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.39.1",
"@typescript-eslint/types": "8.39.1",
@ -3987,6 +4005,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -5030,6 +5049,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@ -5097,6 +5117,7 @@
"integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@ -5307,6 +5328,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -5807,15 +5829,6 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true
},
"node_modules/fast-redact": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz",
"integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
@ -7388,6 +7401,7 @@
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.18.1.tgz",
"integrity": "sha512-K0RfrUXXufqNRZZjvAGdyjydB91SnbWxlwFYi5t7zN2DxVWFD3c6puia0/7xfBwZm6RCpYOVdYFlRFpoDWiC+w==",
"license": "MIT",
"peer": true,
"dependencies": {
"bson": "^6.10.4",
"kareem": "2.6.3",
@ -7818,13 +7832,13 @@
}
},
"node_modules/pino": {
"version": "9.9.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.9.1.tgz",
"integrity": "sha512-40SszWPOPwGhUIJ3zj0PsbMNV1bfg8nw5Qp/tP2FE2p3EuycmhDeYimKOMBAu6rtxcSw2QpjJsuK5A6v+en8Yw==",
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
"integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"fast-redact": "^3.1.1",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^2.0.0",
"pino-std-serializers": "^7.0.0",
@ -9133,6 +9147,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -9467,7 +9482,7 @@
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz",
"integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -9564,6 +9579,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@ -10170,6 +10186,7 @@
"version": "3.723.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.723.0.tgz",
"integrity": "sha512-9IH90m4bnHogBctVna2FnXaIGVORncfdxcqeEIovOxjIJJyHDmEAtA7B91dAM4sruddTbVzOYnqfPVst3odCbA==",
"peer": true,
"requires": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
@ -10216,6 +10233,7 @@
"version": "3.723.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.723.0.tgz",
"integrity": "sha512-YyN8x4MI/jMb4LpHsLf+VYqvbColMK8aZeGWVk2fTFsmt8lpTYGaGC1yybSwGX42mZ4W8ucu8SAYSbUraJZEjA==",
"peer": true,
"requires": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
@ -10622,6 +10640,11 @@
"tslib": "^2.6.2"
}
},
"@bufbuild/protobuf": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.0.tgz",
"integrity": "sha512-fdRs9PSrBF7QUntpZpq6BTw58fhgGJojgg39m9oFOJGZT+nip9b0so5cYY1oWl5pvemDLr0cPPsH46vwThEbpQ=="
},
"@emnapi/core": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz",
@ -11263,6 +11286,11 @@
"@noble/hashes": "^1.1.5"
}
},
"@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="
},
"@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -11271,9 +11299,9 @@
"optional": true
},
"@pretendonetwork/boss-crypto": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pretendonetwork/boss-crypto/-/boss-crypto-1.0.0.tgz",
"integrity": "sha512-ybd3sB356v5Azxj99R62+7kytgAzfUYuXRJbdOznGL6infgCJ056TjTadN4V48m7t+3f6sPOUgo9YWUFNxlLLg=="
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@pretendonetwork/boss-crypto/-/boss-crypto-1.2.2.tgz",
"integrity": "sha512-sGlMiXGIThWfbs85xdWuJdgmW6YV6aQ8znh3vWWwG08BwpzzqgflluFYKhy6P0rpiMUJQuDAWK1IcVIsjD1lhw=="
},
"@pretendonetwork/eslint-config": {
"version": "0.1.1",
@ -11292,12 +11320,13 @@
}
},
"@pretendonetwork/grpc": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-1.0.6.tgz",
"integrity": "sha512-kTK4lO8AdrQ5GOvYdJ7sqvIP3ubn5TGqGGqjVpgCTSiVBvBmlnz3fQkoDHmYw2WeA0CNtUx2dROG3Juiy5t7BQ==",
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-2.3.5.tgz",
"integrity": "sha512-FU0uvhZr8gFgiIi+gBtD6+5or34bHcd9X+ZVpRdc7IiupW5V+uxiXigJKX4Vd46QC412y+BEGofCjM1bBlTJhg==",
"requires": {
"long": "^5.2.1",
"protobufjs": "^7.2.3"
"@bufbuild/protobuf": "^2.2.2",
"nice-grpc-common": "^2.0.2",
"typescript": "^5.7.2"
}
},
"@protobufjs/aspromise": {
@ -12063,9 +12092,9 @@
}
},
"@typegoose/auto-increment": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/@typegoose/auto-increment/-/auto-increment-4.13.0.tgz",
"integrity": "sha512-saOwqB66duV+rntkME/027A8opjgzmV3pBY8+zoJ4mGSc3FVGad6CSr56x4oqd15p39XtWH1UNZaS5Bzp6O6Ow==",
"version": "4.13.1",
"resolved": "https://registry.npmjs.org/@typegoose/auto-increment/-/auto-increment-4.13.1.tgz",
"integrity": "sha512-Dv0jlgBo4GkAApZmSxNGhD6eF3LP+1veQGvmNG2/tCNJvCVdTBUlC8ZNdUdTEzQlRRVq5p53qJH18irc5sX2jA==",
"requires": {
"loglevel": "^1.9.2",
"tslib": "^2.8.1"
@ -12261,6 +12290,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.1.tgz",
"integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==",
"dev": true,
"peer": true,
"requires": {
"@typescript-eslint/scope-manager": "8.39.1",
"@typescript-eslint/types": "8.39.1",
@ -12601,7 +12631,8 @@
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true
"dev": true,
"peer": true
},
"acorn-jsx": {
"version": "5.3.2",
@ -13325,6 +13356,7 @@
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
"dev": true,
"peer": true,
"requires": {
"@esbuild/aix-ppc64": "0.25.9",
"@esbuild/android-arm": "0.25.9",
@ -13375,6 +13407,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz",
"integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==",
"dev": true,
"peer": true,
"requires": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@ -13556,6 +13589,7 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"peer": true,
"requires": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -13877,11 +13911,6 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true
},
"fast-redact": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz",
"integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A=="
},
"fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
@ -14894,6 +14923,7 @@
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.18.1.tgz",
"integrity": "sha512-K0RfrUXXufqNRZZjvAGdyjydB91SnbWxlwFYi5t7zN2DxVWFD3c6puia0/7xfBwZm6RCpYOVdYFlRFpoDWiC+w==",
"peer": true,
"requires": {
"bson": "^6.10.4",
"kareem": "2.6.3",
@ -15191,12 +15221,12 @@
"dev": true
},
"pino": {
"version": "9.9.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.9.1.tgz",
"integrity": "sha512-40SszWPOPwGhUIJ3zj0PsbMNV1bfg8nw5Qp/tP2FE2p3EuycmhDeYimKOMBAu6rtxcSw2QpjJsuK5A6v+en8Yw==",
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
"integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
"requires": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"fast-redact": "^3.1.1",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^2.0.0",
"pino-std-serializers": "^7.0.0",
@ -16075,7 +16105,8 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true
"dev": true,
"peer": true
}
}
},
@ -16296,7 +16327,7 @@
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz",
"integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==",
"dev": true
"peer": true
},
"typescript-eslint": {
"version": "8.39.1",
@ -16353,6 +16384,7 @@
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
"integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==",
"dev": true,
"peer": true,
"requires": {
"@unrs/resolver-binding-android-arm-eabi": "1.11.1",
"@unrs/resolver-binding-android-arm64": "1.11.1",

View File

@ -14,9 +14,9 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.723.0",
"@pretendonetwork/boss-crypto": "^1.0.0",
"@pretendonetwork/grpc": "^1.0.6",
"@typegoose/auto-increment": "^4.13.0",
"@pretendonetwork/boss-crypto": "^1.2.2",
"@pretendonetwork/grpc": "^2.3.5",
"@typegoose/auto-increment": "^4.13.1",
"commander": "^14.0.0",
"cron": "^4.3.3",
"dotenv": "^16.4.7",

View File

@ -20,6 +20,11 @@ const listCmd = new Command('ls')
if (key === 'name') {
return prettyTrunc(value, 20);
}
if (key === 'titleId') {
return value.toString(16).toLowerCase().padStart(16, '0');
}
return value;
}
});
@ -41,7 +46,7 @@ const viewCmd = new Command('view')
const obj = {
appId: app.bossAppId,
name: app.name,
titleId: app.titleId,
titleId: app.titleId.toString(16).toLowerCase().padStart(16, '0'),
titleRegion: app.titleRegion,
knownTasks: app.tasks
};

View File

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

205
src/cli/files-3ds.cmd.ts Normal file
View File

@ -0,0 +1,205 @@
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('<app_id>', 'BOSS app to search in')
.argument('<task_id>', 'Task to search in')
.option('-c, --country [country]', 'Country to filter with')
.option('-l, --language [language]', 'Language to filter with')
.option('-a, --any', 'Shows any file regardless of country and language requirements')
.action(commandHandler<[string, string]>(async (cmd): Promise<void> => {
const [appId, taskId] = cmd.args;
const opts = cmd.opts<{ country?: string; language?: string; any: boolean }>();
const ctx = getCliContext();
const { files } = await ctx.grpc.listFilesCTR({
bossAppId: appId,
taskId: taskId,
country: opts.country,
language: opts.language,
any: opts.any
});
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('<app_id>', 'BOSS app that contains the task')
.argument('<task_id>', 'Task that contains the task file')
.argument('<id>', 'Task file ID to lookup', BigInt)
.action(commandHandler<[string, string, bigint]>(async (cmd): Promise<void> => {
const [appId, taskId, dataId] = cmd.args;
const ctx = getCliContext();
const { files } = await ctx.grpc.listFilesCTR({
bossAppId: appId,
taskId: taskId,
any: true
});
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('<app_id>', 'BOSS app that contains the task')
.argument('<task_id>', 'Task that contains the task file')
.argument('<id>', 'Task file ID to lookup', BigInt)
.option('-d, --decrypt', 'Decrypt the file before return')
.action(commandHandler<[string, string, bigint]>(async (cmd): Promise<void> => {
const [appId, taskId, dataId] = cmd.args;
const ctx = getCliContext();
const { files } = await ctx.grpc.listFilesCTR({
bossAppId: appId,
taskId: taskId,
any: true
});
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.supportedLanguages[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 ?? Buffer.alloc(0);
}
await pipeline(Readable.from(buffer), process.stdout);
}));
const createCmd = new Command('create')
.description('Create a new task file')
.argument('<app_id>', 'BOSS app to store the task file in')
.argument('<task_id>', 'Task to store the task file in')
.requiredOption('--name <name>', 'Name of the task file')
.requiredOption('--title-id <titleId>', 'Target title ID of the payload')
.requiredOption('--content-datatype <contentDatatype>', 'Content datatype of the payload')
.requiredOption('--ns-data-id <nsDataId>', 'NS Data ID of the payload')
.requiredOption('--version <version>', 'Version of the payload')
.requiredOption('--file <file>', 'Path of the file to upload')
.option('--country <country...>', 'Countries for this task file')
.option('--lang <language...>', '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')
.option('-n, --no-payload', 'Make this task file have no payload contents')
.action(commandHandler<[string, string]>(async (cmd): Promise<void> => {
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; payload: boolean }>();
const fileBuf = opts.payload ? await fs.readFile(opts.file) : Buffer.alloc(0);
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: opts.payload
? [{
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('<app_id>', 'BOSS app that contains the task')
.argument('<task_id>', 'Task that contains the task file')
.argument('<id>', 'Task file ID to delete', BigInt)
.action(commandHandler<[string, string, bigint]>(async (cmd): Promise<void> => {
// 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);

View File

@ -4,6 +4,7 @@ import { Readable } from 'node:stream';
import { request } from 'undici';
import { Command } from 'commander';
import { decryptWiiU } from '@pretendonetwork/boss-crypto';
import { PlatformType } from '@pretendonetwork/grpc/boss/v2/platform_type';
import { commandHandler, getCliContext } from './utils';
import { logOutputList, logOutputObject } from './output';
@ -11,12 +12,19 @@ const listCmd = new Command('ls')
.description('List all task files in BOSS')
.argument('<app_id>', 'BOSS app to search in')
.argument('<task_id>', 'Task to search in')
.option('-c, --country [country]', 'Country to filter with')
.option('-l, --language [language]', 'Language to filter with')
.option('-a, --any', 'Shows any file regardless of country and language requirements')
.action(commandHandler<[string, string]>(async (cmd): Promise<void> => {
const [appId, taskId] = cmd.args;
const opts = cmd.opts<{ country?: string; language?: string; any: boolean }>();
const ctx = getCliContext();
const { files } = await ctx.grpc.listFiles({
const { files } = await ctx.grpc.listFilesWUP({
bossAppId: appId,
taskId: taskId
taskId: taskId,
country: opts.country,
language: opts.language,
any: opts.any
});
logOutputList(files, {
format: cmd.format,
@ -38,9 +46,10 @@ const viewCmd = new Command('view')
.action(commandHandler<[string, string, bigint]>(async (cmd): Promise<void> => {
const [appId, taskId, dataId] = cmd.args;
const ctx = getCliContext();
const { files } = await ctx.grpc.listFiles({
const { files } = await ctx.grpc.listFilesWUP({
bossAppId: appId,
taskId: taskId
taskId: taskId,
any: true
});
const file = files.find(v => v.dataId === dataId);
if (!file) {
@ -76,9 +85,10 @@ const downloadCmd = new Command('download')
.action(commandHandler<[string, string, bigint]>(async (cmd): Promise<void> => {
const [appId, taskId, dataId] = cmd.args;
const ctx = getCliContext();
const { files } = await ctx.grpc.listFiles({
const { files } = await ctx.grpc.listFilesWUP({
bossAppId: appId,
taskId: taskId
taskId: taskId,
any: true
});
const file = files.find(v => v.dataId === dataId);
if (!file) {
@ -122,20 +132,30 @@ const createCmd = new Command('create')
.requiredOption('--file <file>', 'Path of the file to upload')
.option('--country <country...>', 'Countries for this task file')
.option('--lang <language...>', '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 <type...>', 'Add entry to NotifyNew')
.option('--notify-led', 'Enable NotifyLED')
.action(commandHandler<[string, string]>(async (cmd): Promise<void> => {
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.uploadFile({
const { file } = await ctx.grpc.uploadFileWUP({
taskId: taskId,
bossAppId: appId,
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,
@ -160,13 +180,14 @@ const deleteCmd = new Command('delete')
const ctx = getCliContext();
await ctx.grpc.deleteFile({
bossAppId: appId,
dataId: dataId
dataId: dataId,
platformType: PlatformType.PLATFORM_TYPE_WUP
});
console.log(`Deleted task file with ID ${dataId}`);
}));
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)

View File

@ -3,6 +3,7 @@ import fs from 'fs/promises';
import { Command } from 'commander';
import { xml2js } from 'xml-js';
import { decryptWiiU } from '@pretendonetwork/boss-crypto';
import { PlatformType } from '@pretendonetwork/grpc/boss/v2/platform_type';
import { getCliContext } from './utils';
import { seedFolder } from './root';
import type { CliContext } from './utils';
@ -31,7 +32,7 @@ export async function uploadFileIfChanged(ops: UploadFileOptions): Promise<void>
fileContents = decryptedContents.content;
}
const allExistingTaskFiles = await ops.ctx.grpc.listFiles({
const allExistingTaskFiles = await ops.ctx.grpc.listFilesWUP({
bossAppId: ops.bossAppId,
taskId: ops.taskId
});
@ -40,11 +41,12 @@ export async function uploadFileIfChanged(ops: UploadFileOptions): Promise<void>
console.warn(`${ops.dataId}: File already uploaded, reuploading`);
await ops.ctx.grpc.deleteFile({
bossAppId: ops.bossAppId,
dataId: BigInt(ops.dataId)
dataId: BigInt(ops.dataId),
platformType: PlatformType.PLATFORM_TYPE_WUP
});
}
await ops.ctx.grpc.uploadFile({
await ops.ctx.grpc.uploadFileWUP({
bossAppId: ops.bossAppId,
taskId: ops.taskId,
name: ops.fileXml.Filename._text,
@ -78,20 +80,11 @@ export async function processTasksheet(ctx: CliContext, taskFiles: string[], fil
await ctx.grpc.registerTask({
bossAppId: bossAppId,
id: taskName,
titleId: xmlContents.TaskSheet.TitleId._text,
country: 'This value isnt used'
titleId: BigInt(parseInt(xmlContents.TaskSheet.TitleId._text, 16)),
status: xmlContents.TaskSheet.ServiceStatus._text
});
console.log(`${filename}: Created task`);
}
await ctx.grpc.updateTask({
bossAppId: bossAppId,
id: taskName,
updateData: {
titleId: xmlContents.TaskSheet.TitleId._text,
status: xmlContents.TaskSheet.ServiceStatus._text
}
});
console.log(`${filename}: Updated title ID and status`);
let xmlFiles = xmlContents.TaskSheet.Files?.File ?? [];
if (!Array.isArray(xmlFiles)) {

View File

@ -38,7 +38,7 @@ const viewCmd = new Command('view')
taskId: task.id,
inGameId: task.inGameId,
description: task.description,
titleId: task.titleId,
titleId: task.titleId.toString(16).toLowerCase().padStart(16, '0'),
bossAppId: task.bossAppId,
creatorPid: task.creatorPid,
status: task.status,
@ -54,17 +54,20 @@ const createCmd = new Command('create')
.argument('<app_id>', 'BOSS app to store the task in')
.requiredOption('--id <id>', 'Id of the task')
.requiredOption('--title-id <titleId>', 'Title ID for the task')
.option('--status [status]', 'Initial status of the task')
.option('--interval [interval]', 'Interval of the task')
.option('--desc [desc]', 'Description of the task')
.action(commandHandler<[string]>(async (cmd): Promise<void> => {
const [appId] = cmd.args;
const ctx = getCliContext();
const opts = cmd.opts<{ id: string; titleId: string; desc?: string }>();
const opts = cmd.opts<{ id: string; titleId: string; status?: string; interval?: string; desc?: string }>();
const { task } = await ctx.grpc.registerTask({
bossAppId: appId,
id: opts.id,
titleId: opts.titleId,
description: opts.desc ?? '',
country: 'This value isnt used'
titleId: BigInt(parseInt(opts.titleId, 16)),
status: opts.status ?? 'open',
interval: Number(opts.interval ?? 0),
description: opts.desc ?? ''
});
if (!task) {
console.log(`Failed to create task!`);

View File

@ -1,20 +1,27 @@
import { BOSSDefinition } from '@pretendonetwork/grpc/boss/boss_service';
import { BossServiceDefinition } from '@pretendonetwork/grpc/boss/v2/boss_service';
import { createChannel, createClient, Metadata } from 'nice-grpc';
import dotenv from 'dotenv';
import type { BOSSClient } from '@pretendonetwork/grpc/boss/boss_service';
import type { BossServiceClient } from '@pretendonetwork/grpc/boss/v2/boss_service';
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: BOSSClient;
grpc: BossServiceClient;
getNpdiUrl: () => NpdiUrl;
getNpdlUrl: () => NpdlUrl;
getWiiUKeys: () => WiiUKeys;
get3DSKeys: () => CtrKeys;
};
export function getCliContext(): CliContext {
@ -30,7 +37,7 @@ export function getCliContext(): CliContext {
}
const channel = createChannel(grpcHost);
const client: BOSSClient = createClient(BOSSDefinition, channel, {
const client: BossServiceClient = createClient(BossServiceDefinition, channel, {
'*': {
metadata: new Metadata({
'X-API-Key': grpcKey
@ -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
};
}
};
}

View File

@ -45,6 +45,8 @@ export const config = {
}
},
grpc: {
max_receive_message_length: Number(process.env.PN_BOSS_CONFIG_GRPC_MAX_RECEIVE_MESSAGE_LENGTH_MB?.trim() || '4'),
max_send_message_length: Number(process.env.PN_BOSS_CONFIG_GRPC_MAX_SEND_MESSAGE_LENGTH_MB?.trim() || '4'),
boss: {
address: process.env.PN_BOSS_CONFIG_GRPC_BOSS_SERVER_ADDRESS?.trim() || '',
port: Number(process.env.PN_BOSS_CONFIG_GRPC_BOSS_SERVER_PORT?.trim() || ''),

View File

@ -2,12 +2,14 @@ import mongoose from 'mongoose';
import { CECData } from '@/models/cec-data';
import { CECSlot } from '@/models/cec-slot';
import { Task } from '@/models/task';
import { File } from '@/models/file';
import { FileCTR } from '@/models/file-ctr';
import { FileWUP } from '@/models/file-wup';
import { config } from '@/config-manager';
import type { HydratedCECDataDocument } from '@/types/mongoose/cec-data';
import type { HydratedCECSlotDocument, ICECSlot } from '@/types/mongoose/cec-slot';
import type { HydratedTaskDocument, ITask } from '@/types/mongoose/task';
import type { HydratedFileDocument, IFile } from '@/types/mongoose/file';
import type { HydratedFileCTRDocument, IFileCTR } from '@/types/mongoose/file-ctr';
import type { HydratedFileWUPDocument, IFileWUP } from '@/types/mongoose/file-wup';
const connection_string: string = config.mongoose.connection_string;
@ -52,10 +54,10 @@ export function getTask(bossAppID: string, taskID: string): Promise<HydratedTask
});
}
export function getTaskFiles(allowDeleted: boolean, bossAppID: string, taskID: string, country?: string, language?: string): Promise<HydratedFileDocument[]> {
export function getCTRTaskFiles(allowDeleted: boolean, bossAppID: string, taskID: string, country?: string, language?: string, any: boolean = false): Promise<HydratedFileCTRDocument[]> {
verifyConnected();
const filter: mongoose.FilterQuery<IFile> = {
const filter: mongoose.FilterQuery<IFileCTR> = {
task_id: taskID.slice(0, 7),
boss_app_id: bossAppID,
$and: []
@ -72,6 +74,10 @@ export function getTaskFiles(allowDeleted: boolean, bossAppID: string, taskID: s
{ supported_countries: country }
]
});
} else if (!any) {
filter.$and?.push({
supported_countries: { $eq: [] }
});
}
if (language) {
@ -81,19 +87,23 @@ export function getTaskFiles(allowDeleted: boolean, bossAppID: string, taskID: s
{ supported_languages: language }
]
});
} else if (!any) {
filter.$and?.push({
supported_languages: { $eq: [] }
});
}
if (filter.$and?.length === 0) {
delete filter.$and;
}
return File.find(filter);
return FileCTR.find(filter);
}
export function getTaskFilesWithAttributes(allowDeleted: boolean, bossAppID: string, taskID: string, country?: string, language?: string, attribute1?: string, attribute2?: string, attribute3?: string): Promise<HydratedFileDocument[]> {
export function getWUPTaskFiles(allowDeleted: boolean, bossAppID: string, taskID: string, country?: string, language?: string, any: boolean = false): Promise<HydratedFileWUPDocument[]> {
verifyConnected();
const filter: mongoose.FilterQuery<IFile> = {
const filter: mongoose.FilterQuery<IFileWUP> = {
task_id: taskID.slice(0, 7),
boss_app_id: bossAppID,
$and: []
@ -110,6 +120,10 @@ export function getTaskFilesWithAttributes(allowDeleted: boolean, bossAppID: str
{ supported_countries: country }
]
});
} else if (!any) {
filter.$and?.push({
supported_countries: { $eq: [] }
});
}
if (language) {
@ -119,31 +133,81 @@ export function getTaskFilesWithAttributes(allowDeleted: boolean, bossAppID: str
{ supported_languages: language }
]
});
} else if (!any) {
filter.$and?.push({
supported_languages: { $eq: [] }
});
}
if (filter.$and?.length === 0) {
delete filter.$and;
}
return FileWUP.find(filter);
}
export function getCTRTaskFilesWithAttributes(allowDeleted: boolean, bossAppID: string, taskID: string, country?: string, language?: string, attribute1?: string, attribute2?: string, attribute3?: string): Promise<HydratedFileCTRDocument[]> {
verifyConnected();
const filter: mongoose.FilterQuery<IFileCTR> = {
task_id: taskID.slice(0, 7),
boss_app_id: bossAppID,
$and: []
};
if (!allowDeleted) {
filter.deleted = false;
}
if (country) {
filter.$and?.push({
$or: [
{ supported_countries: { $eq: [] } },
{ supported_countries: country }
]
});
} else {
filter.$and?.push({
supported_countries: { $eq: [] }
});
}
if (language) {
filter.$and?.push({
$or: [
{ supported_languages: { $eq: [] } },
{ supported_languages: language }
]
});
} else {
filter.$and?.push({
supported_languages: { $eq: [] }
});
}
if (attribute1) {
filter.attribute1 = attribute1;
filter.attributes.attribute1 = attribute1;
}
if (attribute2) {
filter.attribute2 = attribute2;
filter.attributes.attribute2 = attribute2;
}
if (attribute3) {
filter.attribute3 = attribute3;
filter.attributes.attribute3 = attribute3;
}
if (filter.$and?.length === 0) {
delete filter.$and;
}
return File.find(filter);
return FileCTR.find(filter);
}
export function getTaskFile(bossAppID: string, taskID: string, name: string, country?: string, language?: string): Promise<HydratedFileDocument | null> {
export function getCTRTaskFile(bossAppID: string, taskID: string, name: string, country?: string, language?: string): Promise<HydratedFileCTRDocument | null> {
verifyConnected();
const filter: mongoose.FilterQuery<IFile> = {
const filter: mongoose.FilterQuery<IFileCTR> = {
deleted: false,
boss_app_id: bossAppID,
task_id: taskID.slice(0, 7),
@ -158,6 +222,10 @@ export function getTaskFile(bossAppID: string, taskID: string, name: string, cou
{ supported_countries: country }
]
});
} else {
filter.$and?.push({
supported_countries: { $eq: [] }
});
}
if (language) {
@ -167,19 +235,68 @@ export function getTaskFile(bossAppID: string, taskID: string, name: string, cou
{ supported_languages: language }
]
});
} else {
filter.$and?.push({
supported_languages: { $eq: [] }
});
}
if (filter.$and?.length === 0) {
delete filter.$and;
}
return File.findOne<HydratedFileDocument>(filter);
return FileCTR.findOne<HydratedFileCTRDocument>(filter);
}
export function getTaskFileByDataID(dataID: bigint): Promise<HydratedFileDocument | null> {
export function getWUPTaskFile(bossAppID: string, taskID: string, name: string, country?: string, language?: string): Promise<HydratedFileWUPDocument | null> {
verifyConnected();
return File.findOne<HydratedFileDocument>({
const filter: mongoose.FilterQuery<IFileWUP> = {
deleted: false,
boss_app_id: bossAppID,
task_id: taskID.slice(0, 7),
name: name,
$and: []
};
if (country) {
filter.$and?.push({
$or: [
{ supported_countries: { $eq: [] } },
{ supported_countries: country }
]
});
} else {
filter.$and?.push({
supported_countries: { $eq: [] }
});
}
if (language) {
filter.$and?.push({
$or: [
{ supported_languages: { $eq: [] } },
{ supported_languages: language }
]
});
} else {
filter.$and?.push({
supported_languages: { $eq: [] }
});
}
return FileWUP.findOne<HydratedFileWUPDocument>(filter);
}
export function getCTRTaskFileBySerialNumber(serialNumber: bigint): Promise<HydratedFileCTRDocument | null> {
verifyConnected();
return FileCTR.findOne<HydratedFileCTRDocument>({
deleted: false,
serial_number: serialNumber
});
}
export function getWUPTaskFileByDataID(dataID: bigint): Promise<HydratedFileWUPDocument | null> {
verifyConnected();
return FileWUP.findOne<HydratedFileWUPDocument>({
deleted: false,
data_id: Number(dataID)
});

48
src/models/file-ctr.ts Normal file
View File

@ -0,0 +1,48 @@
import mongoose from 'mongoose';
import { AutoIncrementID } from '@typegoose/auto-increment';
import type { IFileCTR, IFileCTRMethods, FileCTRModel } from '@/types/mongoose/file-ctr';
const FileCTRSchema = new mongoose.Schema<IFileCTR, FileCTRModel, IFileCTRMethods>({
deleted: {
type: Boolean,
default: false
},
creator_pid: Number,
hash: String,
file_key: String,
size: BigInt,
task_id: String,
boss_app_id: String,
supported_countries: [String],
supported_languages: [String],
attributes: {
attribute1: String,
attribute2: String,
attribute3: String,
description: String
},
name: String,
serial_number: BigInt, // * This is effectively the predecessor of the Wii U DataID
payload_contents: [{
title_id: BigInt,
content_datatype: Number,
ns_data_id: Number, // * Should payload contents be put in their own collection with their own autoincrementing IDs?
version: Number,
size: Number
}],
flags: {
mark_arrived_privileged: Boolean
},
created: BigInt,
updated: BigInt
}, { id: false });
FileCTRSchema.plugin(AutoIncrementID, {
startAt: 50000, // * Start very high to avoid conflicts with Nintendo Data IDs
field: 'serial_number'
});
FileCTRSchema.index({ task_id: 1, boss_app_id: 1 });
FileCTRSchema.index({ task_id: 1, boss_app_id: 1, name: 1 });
export const FileCTR = mongoose.model<IFileCTR, FileCTRModel>('FileCTR', FileCTRSchema, 'files-ctr');

43
src/models/file-wup.ts Normal file
View File

@ -0,0 +1,43 @@
import mongoose from 'mongoose';
import { AutoIncrementID } from '@typegoose/auto-increment';
import type { IFileWUP, IFileWUPMethods, FileWUPModel } from '@/types/mongoose/file-wup';
const FileWUPSchema = new mongoose.Schema<IFileWUP, FileWUPModel, IFileWUPMethods>({
deleted: {
type: Boolean,
default: false
},
file_key: String,
data_id: BigInt,
task_id: String,
boss_app_id: String,
supported_countries: [String],
supported_languages: [String],
attributes: {
attribute1: String,
attribute2: String,
attribute3: String,
description: String
},
creator_pid: Number,
name: String,
type: String,
hash: String,
size: BigInt,
notify_on_new: [String],
notify_led: Boolean,
condition_played: BigInt,
auto_delete: Boolean, // * We don't know what this does, but it exists on WUP tasks. So track it
created: BigInt,
updated: BigInt
}, { id: false });
FileWUPSchema.plugin(AutoIncrementID, {
startAt: 50000, // * Start very high to avoid conflicts with Nintendo Data IDs
field: 'data_id'
});
FileWUPSchema.index({ task_id: 1, boss_app_id: 1 });
FileWUPSchema.index({ task_id: 1, boss_app_id: 1, name: 1 });
export const FileWUP = mongoose.model<IFileWUP, FileWUPModel>('FileWUP', FileWUPSchema, 'files-wup');

View File

@ -1,39 +0,0 @@
import mongoose from 'mongoose';
import { AutoIncrementID } from '@typegoose/auto-increment';
import type { IFile, IFileMethods, FileModel } from '@/types/mongoose/file';
const FileSchema = new mongoose.Schema<IFile, FileModel, IFileMethods>({
deleted: {
type: Boolean,
default: false
},
file_key: String,
data_id: Number, // TODO - Wait until https://github.com/typegoose/auto-increment/pull/21 is merged and then change this to BigInt
task_id: String,
boss_app_id: String,
supported_countries: [String],
supported_languages: [String],
password: String,
attribute1: String,
attribute2: String,
attribute3: String,
creator_pid: Number,
name: String,
type: String,
hash: String,
size: BigInt,
notify_on_new: [String],
notify_led: Boolean,
created: BigInt,
updated: BigInt
}, { id: false });
FileSchema.plugin(AutoIncrementID, {
startAt: 50000, // * Start very high to avoid conflicts with Nintendo Data IDs
field: 'data_id'
});
FileSchema.index({ task_id: 1, boss_app_id: 1 });
FileSchema.index({ task_id: 1, boss_app_id: 1, name: 1 });
export const File = mongoose.model<IFile, FileModel>('File', FileSchema);

View File

@ -15,6 +15,7 @@ const TaskSchema = new mongoose.Schema<ITask, TaskModel, ITaskMethods>({
required: true,
enum: ['open', 'close']
},
interval: Number,
title_id: String,
description: String,
created: BigInt,

View File

@ -1,22 +0,0 @@
import { listKnownBOSSApps } from '@/services/grpc/boss/list-known-boss-apps';
import { listTasks } from '@/services/grpc/boss/list-tasks';
import { registerTask } from '@/services/grpc/boss/register-task';
import { updateTask } from '@/services/grpc/boss/update-task';
import { deleteTask } from '@/services/grpc/boss/delete-task';
import { listFiles } from '@/services/grpc/boss/list-files';
import { uploadFile } from '@/services/grpc/boss/upload-file';
import { updateFileMetadata } from '@/services/grpc/boss/update-file-metadata';
import { deleteFile } from '@/services/grpc/boss/delete-file';
import type { BOSSServiceImplementation } from '@pretendonetwork/grpc/boss/boss_service';
export const implementation: BOSSServiceImplementation = {
listKnownBOSSApps,
listTasks,
registerTask,
updateTask,
deleteTask,
listFiles,
uploadFile,
updateFileMetadata,
deleteFile
};

View File

@ -1,10 +1,10 @@
import { Status, ServerError } from 'nice-grpc';
import { getTaskFileByDataID } from '@/database';
import { hasPermission } from '@/services/grpc/boss/middleware/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/boss/middleware/authentication-middleware';
import { getWUPTaskFileByDataID } from '@/database';
import { hasPermission } from '@/services/grpc/boss/v1/middleware/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/boss/v1/middleware/authentication-middleware';
import type { CallContext } from 'nice-grpc';
import type { DeleteFileRequest } from '@pretendonetwork/grpc/boss/delete_file';
import type { Empty } from '@pretendonetwork/grpc/boss/google/protobuf/empty';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
export async function deleteFile(request: DeleteFileRequest, context: CallContext & AuthenticationCallContextExt): Promise<Empty> {
if (!hasPermission(context, 'deleteBossFiles')) {
@ -18,7 +18,7 @@ export async function deleteFile(request: DeleteFileRequest, context: CallContex
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing file data ID');
}
const file = await getTaskFileByDataID(dataID);
const file = await getWUPTaskFileByDataID(dataID);
if (!file || file.boss_app_id !== bossAppID) {
throw new ServerError(Status.INVALID_ARGUMENT, `File ${dataID} not found for BOSS app ${bossAppID}`);

View File

@ -1,10 +1,10 @@
import { Status, ServerError } from 'nice-grpc';
import { getTask } from '@/database';
import { hasPermission } from '@/services/grpc/boss/middleware/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/boss/middleware/authentication-middleware';
import { hasPermission } from '@/services/grpc/boss/v1/middleware/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/boss/v1/middleware/authentication-middleware';
import type { CallContext } from 'nice-grpc';
import type { DeleteTaskRequest } from '@pretendonetwork/grpc/boss/delete_task';
import type { Empty } from '@pretendonetwork/grpc/boss/google/protobuf/empty';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
export async function deleteTask(request: DeleteTaskRequest, context: CallContext & AuthenticationCallContextExt): Promise<Empty> {
if (!hasPermission(context, 'deleteBossTasks')) {

View File

@ -0,0 +1,22 @@
import { listKnownBOSSApps } from '@/services/grpc/boss/v1/list-known-boss-apps';
import { listTasks } from '@/services/grpc/boss/v1/list-tasks';
import { registerTask } from '@/services/grpc/boss/v1/register-task';
import { updateTask } from '@/services/grpc/boss/v1/update-task';
import { deleteTask } from '@/services/grpc/boss/v1/delete-task';
import { listFiles } from '@/services/grpc/boss/v1/list-files';
import { uploadFile } from '@/services/grpc/boss/v1/upload-file';
import { updateFileMetadata } from '@/services/grpc/boss/v1/update-file-metadata';
import { deleteFile } from '@/services/grpc/boss/v1/delete-file';
import type { BOSSServiceImplementation } from '@pretendonetwork/grpc/boss/boss_service';
export const bossServiceImplementationV1: BOSSServiceImplementation = {
listKnownBOSSApps,
listTasks,
registerTask,
updateTask,
deleteTask,
listFiles,
uploadFile,
updateFileMetadata,
deleteFile
};

View File

@ -1,6 +1,6 @@
import { Status, ServerError } from 'nice-grpc';
import { isValidCountryCode, isValidLanguage } from '@/util';
import { getTaskFiles } from '@/database';
import { getWUPTaskFiles } from '@/database';
import type { ListFilesRequest, ListFilesResponse } from '@pretendonetwork/grpc/boss/list_files';
const BOSS_APP_ID_FILTER_REGEX = /^[A-Za-z0-9]*$/;
@ -35,7 +35,7 @@ export async function listFiles(request: ListFilesRequest): Promise<ListFilesRes
throw new ServerError(Status.INVALID_ARGUMENT, `${language} is not a valid language`);
}
const files = await getTaskFiles(false, bossAppID, taskID, country, language);
const files = await getWUPTaskFiles(false, bossAppID, taskID, country, language);
return {
files: files.map(file => ({
@ -45,10 +45,10 @@ export async function listFiles(request: ListFilesRequest): Promise<ListFilesRes
bossAppId: file.boss_app_id,
supportedCountries: file.supported_countries,
supportedLanguages: file.supported_languages,
password: file.password,
attribute1: file.attribute1,
attribute2: file.attribute2,
attribute3: file.attribute3,
password: file.attributes.description,
attribute1: file.attributes.attribute1,
attribute2: file.attributes.attribute2,
attribute3: file.attributes.attribute3,
creatorPid: file.creator_pid,
name: file.name,
type: file.type,

View File

@ -1,8 +1,8 @@
import { ServerError, Status } from 'nice-grpc';
import { getTask } from '@/database';
import { Task } from '@/models/task';
import { hasPermission } from '@/services/grpc/boss/middleware/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/boss/middleware/authentication-middleware';
import { hasPermission } from '@/services/grpc/boss/v1/middleware/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/boss/v1/middleware/authentication-middleware';
import type { CallContext } from 'nice-grpc';
import type { RegisterTaskRequest, RegisterTaskResponse } from '@pretendonetwork/grpc/boss/register_task';
@ -53,7 +53,7 @@ export async function registerTask(request: RegisterTaskRequest, context: CallCo
in_game_id: taskID,
boss_app_id: bossAppID,
creator_pid: context.user?.pid,
status: 'open', // TODO - Make this configurable
status: 'open',
title_id: titleID,
description: description,
created: Date.now(),

View File

@ -1,11 +1,11 @@
import { Status, ServerError } from 'nice-grpc';
import { getTaskFileByDataID } from '@/database';
import { getWUPTaskFileByDataID } from '@/database';
import { isValidFileNotifyCondition, isValidFileType } from '@/util';
import { hasPermission } from '@/services/grpc/boss/middleware/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/boss/middleware/authentication-middleware';
import { hasPermission } from '@/services/grpc/boss/v1/middleware/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/boss/v1/middleware/authentication-middleware';
import type { CallContext } from 'nice-grpc';
import type { UpdateFileMetadataRequest } from '@pretendonetwork/grpc/boss/update_file_metadata';
import type { Empty } from '@pretendonetwork/grpc/boss/google/protobuf/empty';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
export async function updateFileMetadata(request: UpdateFileMetadataRequest, context: CallContext & AuthenticationCallContextExt): Promise<Empty> {
if (!hasPermission(context, 'updateBossFiles')) {
@ -23,7 +23,7 @@ export async function updateFileMetadata(request: UpdateFileMetadataRequest, con
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing file update data');
}
const file = await getTaskFileByDataID(dataID);
const file = await getWUPTaskFileByDataID(dataID);
if (!file || file.deleted) {
throw new ServerError(Status.INVALID_ARGUMENT, `File ${dataID} not found`);
@ -43,10 +43,10 @@ export async function updateFileMetadata(request: UpdateFileMetadataRequest, con
file.boss_app_id = updateData.bossAppId;
file.supported_countries = updateData.supportedCountries;
file.supported_languages = updateData.supportedLanguages;
file.password = updateData.password;
file.attribute1 = updateData.attribute1;
file.attribute2 = updateData.attribute2;
file.attribute3 = updateData.attribute3;
file.attributes.description = updateData.password;
file.attributes.attribute1 = updateData.attribute1;
file.attributes.attribute2 = updateData.attribute2;
file.attributes.attribute3 = updateData.attribute3;
file.name = updateData.name;
file.type = updateData.type;
file.notify_on_new = updateData.notifyOnNew;

View File

@ -1,10 +1,10 @@
import { Status, ServerError } from 'nice-grpc';
import { getTask } from '@/database';
import { hasPermission } from '@/services/grpc/boss/middleware/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/boss/middleware/authentication-middleware';
import { hasPermission } from '@/services/grpc/boss/v1/middleware/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/boss/v1/middleware/authentication-middleware';
import type { CallContext } from 'nice-grpc';
import type { UpdateTaskRequest } from '@pretendonetwork/grpc/boss/update_task';
import type { Empty } from '@pretendonetwork/grpc/boss/google/protobuf/empty';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
export async function updateTask(request: UpdateTaskRequest, context: CallContext & AuthenticationCallContextExt): Promise<Empty> {
if (!hasPermission(context, 'updateBossTasks')) {
@ -41,6 +41,7 @@ export async function updateTask(request: UpdateTaskRequest, context: CallContex
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 : task.title_id;
task.status = updateData.status ? updateData.status : task.status;

View File

@ -1,12 +1,12 @@
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 { getTask, getWUPTaskFile } from '@/database';
import { FileWUP } from '@/models/file-wup';
import { config } from '@/config-manager';
import { uploadCDNFile } from '@/cdn';
import { hasPermission } from '@/services/grpc/boss/middleware/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/boss/middleware/authentication-middleware';
import { hasPermission } from '@/services/grpc/boss/v1/middleware/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/boss/v1/middleware/authentication-middleware';
import type { CallContext } from 'nice-grpc';
import type { UploadFileRequest, UploadFileResponse } from '@pretendonetwork/grpc/boss/upload_file';
@ -113,7 +113,7 @@ export async function uploadFile(request: UploadFileRequest, context: CallContex
throw new ServerError(Status.ABORTED, message);
}
let file = await getTaskFile(bossAppID, taskID, name);
let file = await getWUPTaskFile(bossAppID, taskID, name);
if (file) {
file.deleted = true;
@ -122,12 +122,18 @@ export async function uploadFile(request: UploadFileRequest, context: CallContex
await file.save();
}
file = await File.create({
file = await FileWUP.create({
task_id: taskID.slice(0, 7),
boss_app_id: bossAppID,
file_key: key,
supported_countries: supportedCountries,
supported_languages: supportedLanguages,
attributes: {
attribute1: request.attribute1,
attribute2: request.attribute2,
attribute3: request.attribute3,
description: request.password
},
creator_pid: context.user?.pid,
name: name,
type: type,
@ -152,10 +158,10 @@ export async function uploadFile(request: UploadFileRequest, context: CallContex
bossAppId: file.boss_app_id,
supportedCountries: file.supported_countries,
supportedLanguages: file.supported_languages,
password: file.password,
attribute1: file.attribute1,
attribute2: file.attribute2,
attribute3: file.attribute3,
password: file.attributes.description,
attribute1: file.attributes.attribute1,
attribute2: file.attributes.attribute2,
attribute3: file.attributes.attribute3,
creatorPid: file.creator_pid,
name: file.name,
type: file.type,

View File

@ -0,0 +1,44 @@
import { Status, ServerError } from 'nice-grpc';
import { PlatformType } from '@pretendonetwork/grpc/boss/v2/platform_type';
import { getCTRTaskFileBySerialNumber, getWUPTaskFileByDataID } 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';
import type { HydratedFileCTRDocument } from '@/types/mongoose/file-ctr';
import type { HydratedFileWUPDocument } from '@/types/mongoose/file-wup';
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');
}
let file: HydratedFileCTRDocument | HydratedFileWUPDocument | null;
if (request.platformType === PlatformType.PLATFORM_TYPE_CTR) {
file = await getCTRTaskFileBySerialNumber(dataID);
} else if (request.platformType === PlatformType.PLATFORM_TYPE_WUP) {
file = await getWUPTaskFileByDataID(dataID);
} else {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid platform type');
}
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,69 @@
import { Status, ServerError } from 'nice-grpc';
import { isValidCountryCode, isValidLanguage } from '@/util';
import { getCTRTaskFiles } 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();
const any = request.any;
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 getCTRTaskFiles(false, bossAppID, taskID, country, language, any);
return {
files: files.map(file => ({
deleted: file.deleted,
dataId: file.serial_number,
taskId: file.task_id,
bossAppId: file.boss_app_id,
supportedCountries: file.supported_countries,
supportedLanguages: file.supported_languages,
attributes: file.attributes,
creatorPid: file.creator_pid,
name: file.name,
hash: file.hash,
serialNumber: file.serial_number,
payloadContents: file.payload_contents.map(payloadContentInfo => ({
titleId: payloadContentInfo.title_id,
contentDatatype: payloadContentInfo.content_datatype,
nsDataId: payloadContentInfo.ns_data_id,
version: payloadContentInfo.version,
size: payloadContentInfo.size
})),
size: file.size,
createdTimestamp: file.created,
updatedTimestamp: file.updated,
flags: {
markArrivedPrivileged: file.flags.mark_arrived_privileged
}
}))
};
}

View File

@ -0,0 +1,63 @@
import { Status, ServerError } from 'nice-grpc';
import { isValidCountryCode, isValidLanguage } from '@/util';
import { getWUPTaskFiles } 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();
const any = request.any;
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 getWUPTaskFiles(false, bossAppID, taskID, country, language, any);
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: file.attributes,
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: file.condition_played,
autoDelete: file.auto_delete,
createdTimestamp: file.created,
updatedTimestamp: file.updated
}))
};
}

File diff suppressed because it is too large Load Diff

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,85 @@
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 status = request.status;
const interval = request.interval;
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}`);
}
if (status !== 'open' && status !== 'close') {
throw new ServerError(Status.INVALID_ARGUMENT, `Status ${status} is invalid`);
}
// * 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,
interval,
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: task.interval,
titleId: BigInt(parseInt(task.title_id, 16)),
description: task.description,
createdTimestamp: task.created,
updatedTimestamp: task.updated
}
};
}

View File

@ -0,0 +1,55 @@
import { Status, ServerError } from 'nice-grpc';
import { getCTRTaskFileBySerialNumber } 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 { UpdateFileMetadataCTRRequest } from '@pretendonetwork/grpc/boss/v2/update_file_metadata_ctr';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
export async function updateFileMetadataCTR(request: UpdateFileMetadataCTRRequest, context: CallContext & AuthenticationCallContextExt): Promise<Empty> {
if (!hasPermission(context, 'updateBossFiles')) {
throw new ServerError(Status.PERMISSION_DENIED, 'PNID not authorized to update file metadata');
}
const serialNumber = request.dataId;
const updateData = request.updateData;
if (!serialNumber) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing file serial number');
}
if (!updateData) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing file update data');
}
const file = await getCTRTaskFileBySerialNumber(serialNumber);
if (!file || file.deleted) {
throw new ServerError(Status.INVALID_ARGUMENT, `File ${serialNumber} not found`);
}
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.attributes.attribute1 = updateData.attributes ? updateData.attributes.attribute1 : file.attributes.attribute1;
file.attributes.attribute2 = updateData.attributes ? updateData.attributes.attribute2 : file.attributes.attribute2;
file.attributes.attribute3 = updateData.attributes ? updateData.attributes.attribute3 : file.attributes.attribute3;
file.attributes.description = updateData.attributes ? updateData.attributes.description : file.attributes.description;
file.name = updateData.name;
file.updated = BigInt(Date.now());
if (updateData.payloadContents.length !== 0) {
file.payload_contents = updateData.payloadContents.map(payload => ({
title_id: payload.titleId,
content_datatype: payload.contentDatatype,
ns_data_id: payload.nsDataId,
version: payload.version,
size: payload.size
}));
}
await file.save();
return {};
}

View File

@ -0,0 +1,58 @@
import { Status, ServerError } from 'nice-grpc';
import { getWUPTaskFileByDataID } 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 getWUPTaskFileByDataID(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.attributes = updateData.attributes ? updateData.attributes : file.attributes;
file.name = updateData.name;
file.type = updateData.type;
file.notify_on_new = updateData.notifyOnNew;
file.notify_led = updateData.notifyLed;
file.condition_played = updateData.conditionPlayed;
file.auto_delete = updateData.autoDelete;
file.updated = BigInt(Date.now());
await file.save();
return {};
}

View File

@ -0,0 +1,55 @@
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' && updateData.status !== 'close') {
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.interval = updateData.interval ? updateData.interval : task.interval;
task.description = updateData.description ? updateData.description : task.description;
task.updated = BigInt(Date.now());
await task.save();
return {};
}

View File

@ -0,0 +1,176 @@
import { Status, ServerError } from 'nice-grpc';
import { CTR_BOSS_FLAGS, encrypt3DS } from '@pretendonetwork/boss-crypto';
import { isValidCountryCode, isValidLanguage, md5 } from '@/util';
import { connection as databaseConnection, getTask, getCTRTaskFile } from '@/database';
import { FileCTR } from '@/models/file-ctr';
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 { UploadFileCTRRequest, UploadFileCTRResponse } from '@pretendonetwork/grpc/boss/v2/upload_file_ctr';
import type { HydratedFileCTRDocument } from '@/types/mongoose/file-ctr';
const BOSS_APP_ID_FILTER_REGEX = /^[A-Za-z0-9]*$/;
export async function uploadFileCTR(request: UploadFileCTRRequest, context: CallContext & AuthenticationCallContextExt): Promise<UploadFileCTRResponse> {
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 payloads = request.payloadContents;
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 (!request.attributes) {
request.attributes = {
attribute1: '',
attribute2: '',
attribute3: '',
description: ''
};
}
const session = await databaseConnection().startSession();
await session.startTransaction();
let file: HydratedFileCTRDocument | null;
try {
// * Create the FileCTR first since encrypt3DS relies on the serial number
file = await getCTRTaskFile(bossAppID, taskID, name);
if (file) {
file.deleted = true;
file.updated = BigInt(Date.now());
await file.save({ session });
}
[file] = await FileCTR.create([{
creator_pid: context.user?.pid,
// * hash: String,
// * file_key: String,
// * size: BigInt,
task_id: taskID,
boss_app_id: bossAppID,
supported_countries: supportedCountries,
supported_languages: supportedLanguages,
attributes: request.attributes,
name: name,
payload_contents: payloads.map(payload => ({
title_id: payload.titleId,
content_datatype: payload.contentDatatype,
ns_data_id: payload.nsDataId,
version: payload.version,
size: payload.content.length
})),
flags: {
mark_arrived_privileged: request.flags?.markArrivedPrivileged || false
},
created: Date.now(),
updated: Date.now()
}], { session });
const cryptoOptions = payloads.map(payload => ({
program_id: payload.titleId,
content_datatype: payload.contentDatatype,
ns_data_id: payload.nsDataId,
version: payload.version,
content: payload.content
}));
let flags = 0n;
if (request.flags?.markArrivedPrivileged) {
flags |= CTR_BOSS_FLAGS.MARK_ARRIVED_PRIVILEGED;
}
// TODO - Somehow support pre-encrypted content?
const encryptedData = encrypt3DS(config.crypto.ctr.aes_key, file.serial_number, cryptoOptions, flags);
const contentHash = md5(encryptedData);
const key = `${bossAppID}/${taskID}/${contentHash}`;
await uploadCDNFile('taskFile', key, encryptedData);
file.hash = contentHash;
file.file_key = key;
file.size = BigInt(encryptedData.length);
await file.save({ session });
await session.commitTransaction();
} catch (error: unknown) {
let message = 'Unknown file upload error';
if (error instanceof Error) {
message = error.message;
}
throw new ServerError(Status.ABORTED, message);
} finally {
await session.endSession();
}
return {
file: {
deleted: file.deleted,
dataId: file.serial_number,
taskId: file.task_id,
bossAppId: file.boss_app_id,
supportedCountries: file.supported_countries,
supportedLanguages: file.supported_languages,
attributes: file.attributes,
creatorPid: file.creator_pid,
name: file.name,
hash: file.hash,
serialNumber: file.serial_number,
payloadContents: file.payload_contents.map(payloadContentInfo => ({
titleId: payloadContentInfo.title_id,
contentDatatype: payloadContentInfo.content_datatype,
nsDataId: payloadContentInfo.ns_data_id,
version: payloadContentInfo.version,
size: payloadContentInfo.size
})),
size: file.size,
createdTimestamp: file.created,
updatedTimestamp: file.updated,
flags: {
markArrivedPrivileged: file.flags.mark_arrived_privileged
}
}
};
}

View File

@ -0,0 +1,182 @@
import { Status, ServerError } from 'nice-grpc';
import { encryptWiiU } from '@pretendonetwork/boss-crypto';
import { isValidCountryCode, isValidFileNotifyCondition, isValidFileType, isValidLanguage, md5 } from '@/util';
import { getTask, getWUPTaskFile } from '@/database';
import { FileWUP } from '@/models/file-wup';
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');
}
if (!request.attributes) {
request.attributes = {
attribute1: '',
attribute2: '',
attribute3: '',
description: ''
};
}
let encryptedData: Buffer;
try {
// TODO - Check the first few bytes of the uploaded content to see if it's encrypted already, to support pre-encrypted content?
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 getWUPTaskFile(bossAppID, taskID, name);
if (file) {
file.deleted = true;
file.updated = BigInt(Date.now());
await file.save();
}
file = await FileWUP.create({
task_id: taskID.slice(0, 7),
boss_app_id: bossAppID,
file_key: key,
supported_countries: supportedCountries,
supported_languages: supportedLanguages,
attributes: request.attributes,
creator_pid: context.user?.pid,
name: name,
type: type,
hash: contentHash,
size: BigInt(encryptedData.length),
notify_on_new: notifyOnNew,
notify_led: notifyLed,
condition_played: request.conditionPlayed,
auto_delete: request.autoDelete,
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: file.attributes,
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: request.conditionPlayed,
autoDelete: request.autoDelete,
createdTimestamp: file.created,
updatedTimestamp: file.updated
}
};
}

View File

@ -1,15 +1,23 @@
import { createServer } from 'nice-grpc';
import { BOSSDefinition } from '@pretendonetwork/grpc/boss/boss_service';
import { apiKeyMiddleware } from '@/services/grpc/boss/middleware/api-key-middleware';
import { authenticationMiddleware } from '@/services/grpc/boss/middleware/authentication-middleware';
import { implementation } from '@/services/grpc/boss/implementation';
import { BOSSDefinition as BossServiceDefinitionV1 } from '@pretendonetwork/grpc/boss/boss_service';
import { BossServiceDefinition as BossServiceDefinitionV2 } from '@pretendonetwork/grpc/boss/v2/boss_service';
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 { 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';
export async function startGRPCServer(): Promise<void> {
const server: Server = createServer();
const server: Server = createServer({
'grpc.max_receive_message_length': config.grpc.max_receive_message_length * 1024 * 1024,
'grpc.max_send_message_length': config.grpc.max_send_message_length * 1024 * 1024
});
server.with(apiKeyMiddleware).with(authenticationMiddleware).add(BOSSDefinition, implementation);
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}`);
}

View File

@ -2,7 +2,7 @@ import express from 'express';
import { restrictHostnames } from '@/middleware/host-limit';
import { config } from '@/config-manager';
import { getCDNFileAsStream, streamFileToResponse } from '@/cdn';
import { getTaskFileByDataID } from '@/database';
import { getWUPTaskFileByDataID } from '@/database';
import { handleEtag, sendEtagCacheResponse } from '@/util';
const npdi = express.Router();
@ -10,7 +10,7 @@ const npdi = express.Router();
npdi.get('/p01/data/1/:bossAppId/:dataId/:fileHash', async (request, response) => {
const { dataId, fileHash, bossAppId } = request.params;
const file = await getTaskFileByDataID(BigInt(dataId));
const file = await getWUPTaskFileByDataID(BigInt(dataId));
if (!file) {
return response.sendStatus(404);
}

View File

@ -1,9 +1,9 @@
import express from 'express';
import { getTaskFile } from '@/database';
import { getCTRTaskFile } from '@/database';
import { config } from '@/config-manager';
import { restrictHostnames } from '@/middleware/host-limit';
import { getCDNFileAsStream, streamFileToResponse } from '@/cdn';
import { handleEtag, sendEtagCacheResponse } from '@/util';
import { handleEtag, isValidCountryCode, sendEtagCacheResponse } from '@/util';
const npdl = express.Router();
@ -23,7 +23,26 @@ npdl.get([
}>, response) => {
const { appID, taskID, countryCode, languageCode, fileName } = request.params;
const file = await getTaskFile(appID, taskID, fileName, countryCode, languageCode);
// * There are some special cases that we need to account for some specific 3DS task files:
// *
// * 1. The country and language being represented in a single parameter with an underscore :languageCode_:countryCode
// * 2. Only the country parameter being set instead of the language
// *
// * This isn't the standard behavior as it doesn't work for all tasks, only some of them do need it
// * (this is so unstandard that you can't officially find tasks which use an underscore with the file list endpoint).
// * I'm sure whoever designed this behavior must be the most evil person I've ever met
let country: string | undefined;
let language: string | undefined;
if (countryCode == undefined && languageCode !== undefined && languageCode.includes('_')) {
[language, country] = languageCode.split('_');
} else if (countryCode == undefined && languageCode !== undefined && isValidCountryCode(languageCode)) {
country = languageCode;
} else {
country = countryCode;
language = languageCode;
}
const file = await getCTRTaskFile(appID, taskID, fileName, country, language);
if (!file) {
response.sendStatus(404);

View File

@ -1,6 +1,6 @@
import crypto from 'node:crypto';
import express from 'express';
import { getTaskFilesWithAttributes } from '@/database';
import { getCTRTaskFilesWithAttributes } from '@/database';
import { restrictHostnames } from '@/middleware/host-limit';
import { config } from '@/config-manager';
@ -40,7 +40,7 @@ npfl.get('/p01/filelist/:appID/:taskID', async (request: express.Request<{
const attribute2 = request.query.a2;
const attribute3 = request.query.a3;
const files = await getTaskFilesWithAttributes(false, appID, taskID, country, language, attribute1, attribute2, attribute3);
const files = await getCTRTaskFilesWithAttributes(false, appID, taskID, country, language, attribute1, attribute2, attribute3);
// * https://gist.github.com/DaniElectra/ada7ecc930a84432f2045f6fcabac84f#nintendo-boss-file-list-server-npfl
// *
@ -55,11 +55,11 @@ npfl.get('/p01/filelist/:appID/:taskID', async (request: express.Request<{
// * File lines have the following fields:
// *
// * - File name
// * - Unknown (password?)
// * - Description
// * - Attribute 1
// * - Attribute 2
// * - Attribute 3
// * - File size (0 is allowed)
// * - Content size (size of the first payload content)
// * - Updated time (seconds)
// *
// * All fields of a file line are separated by a tab (\t) and are present even if no value.
@ -83,11 +83,11 @@ npfl.get('/p01/filelist/:appID/:taskID', async (request: express.Request<{
for (const file of files) {
const params = [
file.name,
file.password, // * Unsure if this is really what this is for. Team Kirby Clash Deluxe uses this for passwords though
file.attribute1,
file.attribute2,
file.attribute3,
file.size,
file.attributes.description,
file.attributes.attribute1,
file.attributes.attribute2,
file.attributes.attribute3,
file.payload_contents[0]?.size ?? 0,
file.updated / 1000n // * Expects time as seconds, not milliseconds
];
const line = `${params.join('\t')}\r\n`;

View File

@ -2,37 +2,62 @@ import express from 'express';
import xmlbuilder from 'xmlbuilder';
import { config } from '@/config-manager';
import { restrictHostnames } from '@/middleware/host-limit';
import { getTask, getTaskFile, getTaskFiles } from '@/database';
import type { HydratedFileDocument } from '@/types/mongoose/file';
import { getTask, getWUPTaskFile, getWUPTaskFiles } from '@/database';
import type { HydratedFileWUPDocument } from '@/types/mongoose/file-wup';
import type { HydratedTaskDocument } from '@/types/mongoose/task';
const npts = express.Router();
const xmlHeadSettings = { encoding: 'UTF-8', version: '1.0' };
function buildFile(task: HydratedTaskDocument, file: HydratedFileDocument): any {
return {
Filename: file.name,
DataId: file.data_id,
Type: file.type,
Url: `https://${config.domains.npdi}/p01/data/1/${task.boss_app_id}/${file.data_id}/${file.hash}`,
Size: file.size,
Notify: {
New: file.notify_on_new.join(','),
LED: file.notify_led
}
};
function buildFile(task: HydratedTaskDocument, file: HydratedFileWUPDocument, attributesMode: boolean): any {
if (attributesMode) {
return {
Filename: file.name,
Type: file.type,
Size: file.size,
Attributes: {
Attribute1: file.attributes.attribute1,
Attribute2: file.attributes.attribute2,
Attribute3: file.attributes.attribute3,
Description: file.attributes.description
}
};
} else {
return {
Filename: file.name,
DataId: file.data_id,
Type: file.type,
Url: `https://${config.domains.npdi}/p01/data/1/${task.boss_app_id}/${file.data_id}/${file.hash}`,
Size: file.size,
Notify: {
New: file.notify_on_new.join(','),
LED: file.notify_led
}
};
}
}
npts.get('/p01/tasksheet/:id/:bossAppId/:taskId', async (request, response) => {
npts.get('/p01/tasksheet/:id/:bossAppId/:taskId', async (request: express.Request<{
id: string;
bossAppId: string;
taskId: string;
}, any, any, {
c?: string;
l?: string;
mode?: string;
}>, response) => {
const { bossAppId, taskId } = request.params;
const country = request.query.c;
const language = request.query.l;
const mode = request.query.mode;
const task = await getTask(bossAppId, taskId);
if (!task) {
return response.sendStatus(404);
}
const files = await getTaskFiles(false, bossAppId, taskId);
const files = await getWUPTaskFiles(false, bossAppId, taskId, country, language);
const xmlContent = {
TaskSheet: {
@ -40,7 +65,7 @@ npts.get('/p01/tasksheet/:id/:bossAppId/:taskId', async (request, response) => {
TaskId: task.id,
ServiceStatus: task.status,
Files: {
File: files.map(f => buildFile(task, f))
File: files.map(f => buildFile(task, f, mode === 'attr'))
}
}
};
@ -49,15 +74,27 @@ npts.get('/p01/tasksheet/:id/:bossAppId/:taskId', async (request, response) => {
response.send(xmlbuilder.create(xmlContent, xmlHeadSettings).end({ pretty: true }));
});
npts.get('/p01/tasksheet/:id/:bossAppId/:taskId/:fileName', async (request, response) => {
npts.get('/p01/tasksheet/:id/:bossAppId/:taskId/:fileName', async (request: express.Request<{
id: string;
bossAppId: string;
taskId: string;
fileName: string;
}, any, any, {
c?: string;
l?: string;
mode?: string;
}>, response) => {
const { bossAppId, taskId, fileName } = request.params;
const country = request.query.c;
const language = request.query.l;
const mode = request.query.mode;
const task = await getTask(bossAppId, taskId);
if (!task) {
return response.sendStatus(404);
}
const file = await getTaskFile(bossAppId, taskId, fileName);
const file = await getWUPTaskFile(bossAppId, taskId, fileName, country, language);
if (!file) {
return response.sendStatus(404);
}
@ -68,7 +105,7 @@ npts.get('/p01/tasksheet/:id/:bossAppId/:taskId/:fileName', async (request, resp
TaskId: task.id,
ServiceStatus: task.status,
Files: {
File: buildFile(task, file)
File: buildFile(task, file, mode === 'attr')
}
}
};

View File

@ -0,0 +1,41 @@
import type { Model, HydratedDocument } from 'mongoose';
export interface IFileCTR {
deleted: boolean;
creator_pid: number;
hash: string;
file_key: string;
size: bigint;
task_id: string;
boss_app_id: string;
supported_countries: string[];
supported_languages: string[];
attributes: {
attribute1: string;
attribute2: string;
attribute3: string;
description: string;
};
name: string;
serial_number: bigint; // * This is effectively the predecessor of the Wii U DataID
payload_contents: {
title_id: bigint;
content_datatype: number;
ns_data_id: number;
version: number;
size: number;
}[];
flags: {
mark_arrived_privileged: boolean;
};
created: bigint;
updated: bigint;
}
export interface IFileCTRMethods {}
interface IFileCTRQueryHelpers {}
export type FileCTRModel = Model<IFileCTR, IFileCTRQueryHelpers, IFileCTRMethods>;
export type HydratedFileCTRDocument = HydratedDocument<IFileCTR, IFileCTRMethods>;

View File

@ -0,0 +1,36 @@
import type { Model, HydratedDocument } from 'mongoose';
export interface IFileWUP {
deleted: boolean;
file_key: string;
data_id: bigint;
task_id: string;
boss_app_id: string;
supported_countries: string[];
supported_languages: string[];
attributes: {
attribute1: string;
attribute2: string;
attribute3: string;
description: string;
};
creator_pid: number;
name: string;
type: string;
hash: string;
size: bigint;
notify_on_new: string[];
notify_led: boolean;
condition_played: bigint;
auto_delete: boolean;
created: bigint;
updated: bigint;
}
export interface IFileWUPMethods {}
interface IFileWUPQueryHelpers {}
export type FileWUPModel = Model<IFileWUP, IFileWUPQueryHelpers, IFileWUPMethods>;
export type HydratedFileWUPDocument = HydratedDocument<IFileWUP, IFileWUPMethods>;

View File

@ -1,32 +0,0 @@
import type { Model, HydratedDocument } from 'mongoose';
export interface IFile {
deleted: boolean;
file_key: string;
data_id: bigint;
task_id: string;
boss_app_id: string;
supported_countries: string[];
supported_languages: string[];
password: string;
attribute1: string;
attribute2: string;
attribute3: string;
creator_pid: number;
name: string;
type: string;
hash: string;
size: bigint;
notify_on_new: string[];
notify_led: boolean;
created: bigint;
updated: bigint;
}
export interface IFileMethods {}
interface IFileQueryHelpers {}
export type FileModel = Model<IFile, IFileQueryHelpers, IFileMethods>;
export type HydratedFileDocument = HydratedDocument<IFile, IFileMethods>;

View File

@ -6,7 +6,8 @@ export interface ITask {
in_game_id: string;
boss_app_id: string;
creator_pid: number;
status: 'open'; // TODO - Make this a union. What else is there?
status: 'open' | 'close';
interval: number;
title_id: string;
description: string;
created: bigint;