From c09b00ff35bec85a1a13684997ef5a06846f14b9 Mon Sep 17 00:00:00 2001 From: BloxerHD018 Date: Thu, 12 Feb 2026 11:56:29 +0000 Subject: [PATCH] Initial Commit --- .env.example | 16 ++ .gitignore | 3 + .gitlab-ci.yml | 43 ++++ ...a78fa04f3a3ef3895d0f72ae9469619a31178.json | 15 ++ ...4d8492b868f5ea0936d5e820057d7e3d3db06.json | 130 ++++++++++ ...3fff9163933840cc9c5cbb5dd2b4ac933473d.json | 28 ++ ...b743d4527f788aa48354c5b20db582b657f75.json | 80 ++++++ ...f5e72dd49100ff5b6cc86b8bd71513f718c6a.json | 83 ++++++ ...ff57963a3164b200c42955f1d787de8f62161.json | 16 ++ ...dba23d4822aff9c9c3e528985631027b19c55.json | 80 ++++++ ...b7734a3f4a3c3cac5a1525a84e8121b6a9c9c.json | 22 ++ ...165afce0ab7dafb9026ed320bc0964500c89d.json | 21 ++ Cargo.toml | 26 ++ Dockerfile | 23 ++ db-setup.sql | 57 +++++ src/admin_auth.rs | 71 ++++++ src/api/add_task.rs | 51 ++++ src/api/mod.rs | 2 + src/api/upload_file_wup.rs | 189 ++++++++++++++ src/crypto/mod.rs | 1 + src/crypto/wiiu.rs | 105 ++++++++ src/database.rs | 169 ++++++++++++ src/main.rs | 73 ++++++ src/models/file_wup.rs | 95 +++++++ src/models/mod.rs | 2 + src/models/task.rs | 27 ++ src/services/mod.rs | 3 + src/services/npdi.rs | 56 ++++ src/services/nppl.rs | 241 ++++++++++++++++++ src/services/npts.rs | 202 +++++++++++++++ 30 files changed, 1930 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 .sqlx/query-08d43b2c58526766fe48ad002daa78fa04f3a3ef3895d0f72ae9469619a31178.json create mode 100644 .sqlx/query-3cc1241465c9bb03d570502f8ef4d8492b868f5ea0936d5e820057d7e3d3db06.json create mode 100644 .sqlx/query-42f96f5890db3be1b4fafd30cfc3fff9163933840cc9c5cbb5dd2b4ac933473d.json create mode 100644 .sqlx/query-6c4c21b1d07887b668f00a24a00b743d4527f788aa48354c5b20db582b657f75.json create mode 100644 .sqlx/query-6d1d798566ce48b0931a283d858f5e72dd49100ff5b6cc86b8bd71513f718c6a.json create mode 100644 .sqlx/query-84520448fbf405540d5ea250c97ff57963a3164b200c42955f1d787de8f62161.json create mode 100644 .sqlx/query-962610002ca8baab7b5d5e3e722dba23d4822aff9c9c3e528985631027b19c55.json create mode 100644 .sqlx/query-c059d43146d4dc58ca32bdebd55b7734a3f4a3c3cac5a1525a84e8121b6a9c9c.json create mode 100644 .sqlx/query-c5e09123f159bc164e59e1aeb02165afce0ab7dafb9026ed320bc0964500c89d.json create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 db-setup.sql create mode 100644 src/admin_auth.rs create mode 100644 src/api/add_task.rs create mode 100644 src/api/mod.rs create mode 100644 src/api/upload_file_wup.rs create mode 100644 src/crypto/mod.rs create mode 100644 src/crypto/wiiu.rs create mode 100644 src/database.rs create mode 100644 src/main.rs create mode 100644 src/models/file_wup.rs create mode 100644 src/models/mod.rs create mode 100644 src/models/task.rs create mode 100644 src/services/mod.rs create mode 100644 src/services/npdi.rs create mode 100644 src/services/nppl.rs create mode 100644 src/services/npts.rs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5cfe46b --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +DATABASE_URL=postgresql://user:pass@127.0.0.1:5432/db + +ROCKET_ADDRESS=0.0.0.0 +ROCKET_PORT=8000 + +BOSS_CDN_URL=https://boss.example.com + +AES_KEY=KEY +HMAC_KEY=KEY + +# Can be paired with an admin auth server for extra security, +# if disabled auth will instead check to see if the sent token +# matches ADMIN_AUTH_TOKEN +USE_ADMIN_AUTH=true +ADMIN_AUTH_URL=http://admin.example.com # Only needed when USE_ADMIN_AUTH=true +ADMIN_AUTH_TOKEN=AUTH_TOKEN diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3549fae --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +Cargo.lock +.env \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..8b94dc6 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,43 @@ +default: + image: quay.io/podman/stable + +stages: + - test + - build + - retag + +test: + stage: test + image: rust:alpine3.22 + script: + - cargo test + +build-and-push-image: + stage: build + script: + - apk add --no-cache musl-dev openssl-dev openssl-libs-static protobuf-dev lld + - podman login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + - podman build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" . + - podman push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" + +push-retagged-branch: + stage: retag + script: + - podman login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + - podman pull "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" + - podman tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" + - podman push "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" + rules: + - if: $CI_PIPELINE_SOURCE == "push" + when: on_success + +push-retagged-latest: + stage: retag + script: + - podman login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + - podman pull "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" + - podman tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" "$CI_REGISTRY_IMAGE:latest" + - podman push "$CI_REGISTRY_IMAGE:latest" + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "push" + when: on_success diff --git a/.sqlx/query-08d43b2c58526766fe48ad002daa78fa04f3a3ef3895d0f72ae9469619a31178.json b/.sqlx/query-08d43b2c58526766fe48ad002daa78fa04f3a3ef3895d0f72ae9469619a31178.json new file mode 100644 index 0000000..1218e1d --- /dev/null +++ b/.sqlx/query-08d43b2c58526766fe48ad002daa78fa04f3a3ef3895d0f72ae9469619a31178.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO files (key, data)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n key = EXCLUDED.key,\n data = EXCLUDED.data;\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Bytea" + ] + }, + "nullable": [] + }, + "hash": "08d43b2c58526766fe48ad002daa78fa04f3a3ef3895d0f72ae9469619a31178" +} diff --git a/.sqlx/query-3cc1241465c9bb03d570502f8ef4d8492b868f5ea0936d5e820057d7e3d3db06.json b/.sqlx/query-3cc1241465c9bb03d570502f8ef4d8492b868f5ea0936d5e820057d7e3d3db06.json new file mode 100644 index 0000000..0c9610a --- /dev/null +++ b/.sqlx/query-3cc1241465c9bb03d570502f8ef4d8492b868f5ea0936d5e820057d7e3d3db06.json @@ -0,0 +1,130 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n deleted,\n file_key,\n data_id,\n task_id,\n boss_app_id,\n supported_countries,\n supported_languages,\n attributes AS \"attributes!: Json\",\n creator_user,\n name,\n type,\n hash,\n size,\n notify_on_new,\n notify_led,\n condition_played,\n auto_delete,\n created,\n updated\n FROM files_wup WHERE data_id = $1 AND deleted = false;", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "deleted", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "file_key", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "data_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "task_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "boss_app_id", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "supported_countries", + "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "supported_languages", + "type_info": "TextArray" + }, + { + "ordinal": 7, + "name": "attributes!: Json", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "creator_user", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "type", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "hash", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "size", + "type_info": "Int8" + }, + { + "ordinal": 13, + "name": "notify_on_new", + "type_info": "TextArray" + }, + { + "ordinal": 14, + "name": "notify_led", + "type_info": "Bool" + }, + { + "ordinal": 15, + "name": "condition_played", + "type_info": "Int8" + }, + { + "ordinal": 16, + "name": "auto_delete", + "type_info": "Bool" + }, + { + "ordinal": 17, + "name": "created", + "type_info": "Timestamp" + }, + { + "ordinal": 18, + "name": "updated", + "type_info": "Timestamp" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "3cc1241465c9bb03d570502f8ef4d8492b868f5ea0936d5e820057d7e3d3db06" +} diff --git a/.sqlx/query-42f96f5890db3be1b4fafd30cfc3fff9163933840cc9c5cbb5dd2b4ac933473d.json b/.sqlx/query-42f96f5890db3be1b4fafd30cfc3fff9163933840cc9c5cbb5dd2b4ac933473d.json new file mode 100644 index 0000000..52dee75 --- /dev/null +++ b/.sqlx/query-42f96f5890db3be1b4fafd30cfc3fff9163933840cc9c5cbb5dd2b4ac933473d.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO files_wup (\n file_key,\n task_id,\n boss_app_id,\n supported_countries,\n supported_languages,\n attributes,\n creator_user,\n name,\n type,\n hash,\n size,\n notify_on_new,\n notify_led,\n condition_played,\n auto_delete,\n created,\n updated\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,\n NOW() AT TIME ZONE 'UTC',\n NOW() AT TIME ZONE 'UTC'\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "TextArray", + "TextArray", + "Jsonb", + "Text", + "Text", + "Text", + "Text", + "Int8", + "TextArray", + "Bool", + "Int8", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "42f96f5890db3be1b4fafd30cfc3fff9163933840cc9c5cbb5dd2b4ac933473d" +} diff --git a/.sqlx/query-6c4c21b1d07887b668f00a24a00b743d4527f788aa48354c5b20db582b657f75.json b/.sqlx/query-6c4c21b1d07887b668f00a24a00b743d4527f788aa48354c5b20db582b657f75.json new file mode 100644 index 0000000..39d701f --- /dev/null +++ b/.sqlx/query-6c4c21b1d07887b668f00a24a00b743d4527f788aa48354c5b20db582b657f75.json @@ -0,0 +1,80 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM tasks WHERE deleted = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "in_game_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "boss_app_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "creator_user", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "interval", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "title_id", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "deleted", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "created", + "type_info": "Timestamp" + }, + { + "ordinal": 10, + "name": "updated", + "type_info": "Timestamp" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "6c4c21b1d07887b668f00a24a00b743d4527f788aa48354c5b20db582b657f75" +} diff --git a/.sqlx/query-6d1d798566ce48b0931a283d858f5e72dd49100ff5b6cc86b8bd71513f718c6a.json b/.sqlx/query-6d1d798566ce48b0931a283d858f5e72dd49100ff5b6cc86b8bd71513f718c6a.json new file mode 100644 index 0000000..fb60b95 --- /dev/null +++ b/.sqlx/query-6d1d798566ce48b0931a283d858f5e72dd49100ff5b6cc86b8bd71513f718c6a.json @@ -0,0 +1,83 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM tasks WHERE deleted = false AND id = $1 AND boss_app_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "in_game_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "boss_app_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "creator_user", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "interval", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "title_id", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "deleted", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "created", + "type_info": "Timestamp" + }, + { + "ordinal": 10, + "name": "updated", + "type_info": "Timestamp" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "6d1d798566ce48b0931a283d858f5e72dd49100ff5b6cc86b8bd71513f718c6a" +} diff --git a/.sqlx/query-84520448fbf405540d5ea250c97ff57963a3164b200c42955f1d787de8f62161.json b/.sqlx/query-84520448fbf405540d5ea250c97ff57963a3164b200c42955f1d787de8f62161.json new file mode 100644 index 0000000..3ab78f5 --- /dev/null +++ b/.sqlx/query-84520448fbf405540d5ea250c97ff57963a3164b200c42955f1d787de8f62161.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE files_wup\n SET deleted = TRUE,\n updated = NOW() AT TIME ZONE 'UTC'\n WHERE boss_app_id = $1 AND task_id = $2 AND name = $3;\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "84520448fbf405540d5ea250c97ff57963a3164b200c42955f1d787de8f62161" +} diff --git a/.sqlx/query-962610002ca8baab7b5d5e3e722dba23d4822aff9c9c3e528985631027b19c55.json b/.sqlx/query-962610002ca8baab7b5d5e3e722dba23d4822aff9c9c3e528985631027b19c55.json new file mode 100644 index 0000000..53e70bf --- /dev/null +++ b/.sqlx/query-962610002ca8baab7b5d5e3e722dba23d4822aff9c9c3e528985631027b19c55.json @@ -0,0 +1,80 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM tasks", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "in_game_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "boss_app_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "creator_user", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "interval", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "title_id", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "deleted", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "created", + "type_info": "Timestamp" + }, + { + "ordinal": 10, + "name": "updated", + "type_info": "Timestamp" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "962610002ca8baab7b5d5e3e722dba23d4822aff9c9c3e528985631027b19c55" +} diff --git a/.sqlx/query-c059d43146d4dc58ca32bdebd55b7734a3f4a3c3cac5a1525a84e8121b6a9c9c.json b/.sqlx/query-c059d43146d4dc58ca32bdebd55b7734a3f4a3c3cac5a1525a84e8121b6a9c9c.json new file mode 100644 index 0000000..4a515d1 --- /dev/null +++ b/.sqlx/query-c059d43146d4dc58ca32bdebd55b7734a3f4a3c3cac5a1525a84e8121b6a9c9c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT data FROM files WHERE key = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "data", + "type_info": "Bytea" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c059d43146d4dc58ca32bdebd55b7734a3f4a3c3cac5a1525a84e8121b6a9c9c" +} diff --git a/.sqlx/query-c5e09123f159bc164e59e1aeb02165afce0ab7dafb9026ed320bc0964500c89d.json b/.sqlx/query-c5e09123f159bc164e59e1aeb02165afce0ab7dafb9026ed320bc0964500c89d.json new file mode 100644 index 0000000..29ef436 --- /dev/null +++ b/.sqlx/query-c5e09123f159bc164e59e1aeb02165afce0ab7dafb9026ed320bc0964500c89d.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO tasks\n (id, in_game_id, boss_app_id, creator_user, status, interval, title_id, description, created, updated)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW() AT TIME ZONE 'UTC', NOW() AT TIME ZONE 'UTC')\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Text", + "Int4", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c5e09123f159bc164e59e1aeb02165afce0ab7dafb9026ed320bc0964500c89d" +} diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1a48e3a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "rboss" +version = "0.1.0" +edition = "2024" + +[dependencies] +rocket = { version = "0.5.1", features = ["json"] } +rocket_cors = "0.6.0" +dotenvy = "0.15.7" +sqlx = { version = "0.8.3", features = [ "postgres", "runtime-tokio", "macros", "chrono" ] } +serde = { version = "1.0", features = [ "derive" ] } +serde_json = "1" +quick-xml = { version = "0.31", features = [ "serialize" ] } +chrono = "0.4" +sha1 = "0.10" +sha2 = "0.10" +hex = "0.4" +hex-literal = "0.4" +aes = "0.8" +ctr = "0.9" +hmac = "0.12" +md-5 = "0.10" +rand = "0.8" +anyhow = "1.0" +base64 = "0.21" +reqwest = { version = "0.11", features = ["json", "rustls-tls"] } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..94eb8c2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1 + +FROM rust:alpine AS builder + +RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static protobuf-dev lld + +WORKDIR /app + +COPY Cargo.toml Cargo.lock ./ +RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo fetch + +COPY . . + +ENV SQLX_OFFLINE=true + +RUN OPENSSL_LIB_DIR=/usr/lib OPENSSL_INCLUDE_DIR=/usr/include/openssl OPENSSL_STATIC=1 RUSTFLAGS="-C target-feature=+aes,+sse -C relocation-model=static -C linker=ld.lld" cargo build --release --target x86_64-unknown-linux-musl + +FROM scratch AS final + +WORKDIR / +COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/rboss /rboss + +ENTRYPOINT ["/rboss"] diff --git a/db-setup.sql b/db-setup.sql new file mode 100644 index 0000000..6721fe5 --- /dev/null +++ b/db-setup.sql @@ -0,0 +1,57 @@ +CREATE TABLE files ( + key TEXT PRIMARY KEY, + data BYTEA NOT NULL +); + +CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + in_game_id TEXT NOT NULL, + boss_app_id TEXT NOT NULL, + creator_user TEXT NOT NULL, + status TEXT NOT NULL, + interval INTEGER NOT NULL, + title_id TEXT NOT NULL, + description TEXT NOT NULL, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + created TIMESTAMP NOT NULL, + updated TIMESTAMP NOT NULL +); + +CREATE SEQUENCE files_wup_data_id_seq START WITH 50000 INCREMENT BY 1; + +CREATE TABLE files_wup ( + data_id BIGINT PRIMARY KEY DEFAULT nextval('files_wup_data_id_seq'), + file_key TEXT NOT NULL, + task_id TEXT NOT NULL, + boss_app_id TEXT NOT NULL, + supported_countries TEXT[] NOT NULL, + supported_languages TEXT[] NOT NULL, + attributes JSONB NOT NULL, + creator_user TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + hash TEXT NOT NULL, + size BIGINT NOT NULL, + notify_on_new TEXT[] NOT NULL, + notify_led BOOLEAN NOT NULL, + condition_played BIGINT NOT NULL, + auto_delete BOOLEAN NOT NULL, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + created TIMESTAMP NOT NULL, + updated TIMESTAMP NOT NULL +); + +CREATE OR REPLACE FUNCTION files_wup_set_name_from_data_id() +RETURNS trigger AS $$ +BEGIN + IF NEW.name IS NULL OR NEW.name = '' THEN + NEW.name := NEW.data_id::text; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER files_wup_set_name +BEFORE INSERT ON files_wup +FOR EACH ROW +EXECUTE FUNCTION files_wup_set_name_from_data_id(); diff --git a/src/admin_auth.rs b/src/admin_auth.rs new file mode 100644 index 0000000..aba6732 --- /dev/null +++ b/src/admin_auth.rs @@ -0,0 +1,71 @@ +use std::env; +use rocket::{Request, http::Status, request::{FromRequest, Outcome}}; +use reqwest::Client; + +pub struct AdminAuth(pub String); + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for AdminAuth { + type Error = (); + + async fn from_request(req: &'r Request<'_>) -> Outcome { + let use_admin_server = env::var("USE_ADMIN_AUTH") + .map(|v| v == "true") + .unwrap_or(false); + + if use_admin_server { + let auth = match req.headers().get_one("Authorization") { + Some(auth) => auth, + None => return Outcome::Error((Status::BadRequest, ())), + }; + + let auth_url = match env::var("ADMIN_AUTH_URL") { + Ok(url) => url, + Err(_) => return Outcome::Error((Status::InternalServerError, ())), + }; + let auth_token = match env::var("ADMIN_AUTH_TOKEN") { + Ok(url) => url, + Err(_) => return Outcome::Error((Status::InternalServerError, ())), + }; + + let http = Client::new(); + let response = http.get(format!("{}/api/v1/validate_token/boss", auth_url)) + .header("Authorization", format!("InterServer {}", auth_token)) + .header("Token", auth) + .send() + .await; + + match response { + Ok(response) => { + match response.error_for_status() { + Ok(response) => match response.text().await { + Ok(text) => return Outcome::Success(AdminAuth(text)), + Err(_) => return Outcome::Error((Status::InternalServerError, ())) + }, + Err(_) => { + return Outcome::Error((Status::Unauthorized, ())); + } + } + }, + Err(_) => return Outcome::Error((Status::InternalServerError, ())), + } + } else { + let local_token = match env::var("ADMIN_AUTH_TOKEN") { + Ok(token) => token, + Err(_) => return Outcome::Error((Status::InternalServerError, ())) + }; + + let token = match req.headers().get_one("Authorization") { + Some(auth) => match auth.strip_prefix("Bearer ") { + Some(token) => token, + None => return Outcome::Error((Status::BadRequest, ())) + }, + None => return Outcome::Error((Status::BadRequest, ())), + }; + + if token != local_token.as_str() { return Outcome::Error((Status::Unauthorized, ())) } + + Outcome::Success(AdminAuth("admin".to_string())) + } + } +} \ No newline at end of file diff --git a/src/api/add_task.rs b/src/api/add_task.rs new file mode 100644 index 0000000..fc67e6f --- /dev/null +++ b/src/api/add_task.rs @@ -0,0 +1,51 @@ +use serde::Deserialize; +use rocket::{State, http::Status}; +use rocket::serde::json::Json; +use crate::Pool; +use crate::admin_auth::AdminAuth; +use crate::models::task::is_valid_task_status; + +#[derive(Deserialize)] +pub struct AddTaskData { + task_id: String, + boss_app_id: String, + title_id: String, + status: String, + interval: i32, + description: String, +} + +#[rocket::post("/api/v1/add_task", data = "")] +pub async fn add_task(pool: &State, input: Json, auth: AdminAuth) -> Result<(), Status> { + let pool = pool.inner(); + let data = input.into_inner(); + let admin_username = auth.0; + + if data.boss_app_id.len() != 16 { return Err(Status::BadRequest) }; + + if !is_valid_task_status(&data.status) { return Err(Status::BadRequest) }; + + sqlx::query!( + r#" + INSERT INTO tasks + (id, in_game_id, boss_app_id, creator_user, status, interval, title_id, description, created, updated) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW() AT TIME ZONE 'UTC', NOW() AT TIME ZONE 'UTC') + "#, + data.task_id[0..7].to_string(), + data.task_id, + data.boss_app_id, + admin_username, + data.status, + data.interval, + data.title_id, + data.description, + ) + .execute(pool) + .await + .map_err(|e| match e { + sqlx::Error::Database(_) => Status::Conflict, + _ => Status::InternalServerError, + })?; + + Ok(()) +} \ No newline at end of file diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..1edcd91 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,2 @@ +pub mod upload_file_wup; +pub mod add_task; \ No newline at end of file diff --git a/src/api/upload_file_wup.rs b/src/api/upload_file_wup.rs new file mode 100644 index 0000000..19339d8 --- /dev/null +++ b/src/api/upload_file_wup.rs @@ -0,0 +1,189 @@ +use std::env; +use serde::Deserialize; +use serde_json::to_value; +use rocket::{State, http::Status}; +use rocket::serde::json::Json; +use md5::{Md5, Digest}; +use crate::Pool; +use crate::admin_auth::AdminAuth; +use crate::models::file_wup::{is_valid_countries, is_valid_file_notify_conditions, is_valid_file_type, is_valid_languages}; +use crate::{crypto::wiiu::encrypt_wiiu, models::file_wup::FileWUPAttributes}; + +#[derive(Deserialize)] +pub struct UploadedWUP { + task_id: String, + boss_app_id: String, + supported_countries: Vec, + supported_languages: Vec, + name: Option, + r#type: String, + notify_on_new: Vec, + notify_led: bool, + data: String, + attributes: Option, + name_equals_data_id: bool, + condition_played: i64, + auto_delete: bool, +} + +#[rocket::post("/api/v1/upl_wup", data = "")] +pub async fn upload_file_wup(pool: &State, input: Json, auth: AdminAuth) -> Result<(), Status> { + let pool = pool.inner(); + let data = input.into_inner(); + let admin_username = auth.0; + + + + let aes_key = match env::var("AES_KEY") { + Ok(key) => key, + Err(_) => return Err(Status::InternalServerError), + }; + + let hmac_key = match env::var("HMAC_KEY") { + Ok(key) => key, + Err(_) => return Err(Status::InternalServerError), + }; + + let data_bytes = match base64::decode(data.data) { + Ok(data) => data, + Err(_) => return Err(Status::BadRequest) + }; + + + + if data.name == None && !data.name_equals_data_id { return Err(Status::BadRequest) }; + let name = data.name.clone().unwrap(); + + if data.boss_app_id.len() != 16 { return Err(Status::BadRequest) }; + + if !is_valid_countries(&data.supported_countries) { return Err(Status::BadRequest) }; + if !is_valid_languages(&data.supported_languages) { return Err(Status::BadRequest) }; + + if !is_valid_file_type(&data.r#type) { return Err(Status::BadRequest) }; + + if !is_valid_file_notify_conditions(&data.notify_on_new) { return Err(Status::BadRequest) }; + + if data_bytes.len() == 0 { return Err(Status::BadRequest) }; + + + + let attributes = match data.attributes { + Some(attr) => attr, + None => FileWUPAttributes{ + attribute1: "".to_string(), + attribute2: "".to_string(), + attribute3: "".to_string(), + description: "".to_string(), + } + }; + let attributes = match to_value(attributes) { + Ok(attr) => attr, + Err(_) => return Err(Status::InternalServerError), + }; + + let encrypted_data = match &data_bytes[0..4] == b"boss" { + true => data_bytes, + false => match encrypt_wiiu(&data_bytes, &aes_key.as_bytes(), &hmac_key.as_bytes()) { + Ok(data) => data, + Err(_) => return Err(Status::InternalServerError) + }, + }; + + let mut hasher = Md5::new(); + hasher.update(encrypted_data.clone()); + let hash_bytes = &hasher.finalize()[..]; + let hash = hex::encode(hash_bytes); + + let file_key = format!("{}/{}/{}", data.boss_app_id, data.task_id, hash); + + let _ = sqlx::query!( + r#" + INSERT INTO files (key, data) + VALUES ($1, $2) + ON CONFLICT (key) + DO UPDATE SET + key = EXCLUDED.key, + data = EXCLUDED.data; + "#, + file_key, + encrypted_data, + ) + .execute(pool) + .await; + + // Set old file to deleted if it previously existed + let _ = sqlx::query!( + r#" + UPDATE files_wup + SET deleted = TRUE, + updated = NOW() AT TIME ZONE 'UTC' + WHERE boss_app_id = $1 AND task_id = $2 AND name = $3; + "#, + &data.boss_app_id, + &data.task_id, + name + ) + .execute(pool) + .await; + + let file_name = match data.name.clone() { + Some(name) => { + if data.name_equals_data_id { + "".to_string() + } else { + name + } + }, + None => "".to_string(), + }; + + let result = sqlx::query!( + r#" + INSERT INTO files_wup ( + file_key, + task_id, + boss_app_id, + supported_countries, + supported_languages, + attributes, + creator_user, + name, + type, + hash, + size, + notify_on_new, + notify_led, + condition_played, + auto_delete, + created, + updated + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, + NOW() AT TIME ZONE 'UTC', + NOW() AT TIME ZONE 'UTC' + ) + "#, + file_key, + data.task_id, + data.boss_app_id, + &data.supported_countries, + &data.supported_languages, + attributes, + admin_username, + file_name, + data.r#type, + hash, + encrypted_data.len() as i64, + &data.notify_on_new, + data.notify_led, + data.condition_played, + data.auto_delete, + ) + .execute(pool) + .await; + + match result { + Ok(_) => Ok(()), + Err(_) => Err(Status::InternalServerError) + } +} diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs new file mode 100644 index 0000000..dfb2107 --- /dev/null +++ b/src/crypto/mod.rs @@ -0,0 +1 @@ +pub mod wiiu; \ No newline at end of file diff --git a/src/crypto/wiiu.rs b/src/crypto/wiiu.rs new file mode 100644 index 0000000..242ca6e --- /dev/null +++ b/src/crypto/wiiu.rs @@ -0,0 +1,105 @@ +use aes::Aes128; +use ctr::cipher::{KeyIvInit, StreamCipher}; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use md5::{Md5, Digest}; +use rand::RngCore; +use anyhow::{Result, bail}; + +type HmacSha256 = Hmac; +type Aes128Ctr = ctr::Ctr128BE; + +const BOSS_WUP_VER: u32 = 0x20001; + +const BOSS_AES_KEY_HASH: [u8; 16] = hex_literal::hex!("5202ce5099232c3d365e28379790a919"); +const BOSS_HMAC_KEY_HASH: [u8; 16] = hex_literal::hex!("b4482fef177b0100090ce0dbeb8ce977"); + +pub struct WupBossInfo { + pub hash_type: u16, + pub iv: Vec, + pub hmac: Vec, + pub content: Vec, +} + +fn verify_keys(aes_key: &[u8], hmac_key: &[u8]) -> Result<()> { + let mut hasher = Md5::new(); + hasher.update(aes_key); + let aes_md5 = hasher.finalize_reset(); + + if aes_md5[..] != BOSS_AES_KEY_HASH { + bail!("Invalid BOSS AES key"); + } + + hasher.update(hmac_key); + let hmac_md5 = hasher.finalize(); + + if hmac_md5[..] != BOSS_HMAC_KEY_HASH { + bail!("Invalid BOSS HMAC key"); + } + + Ok(()) +} + +pub fn decrypt_wiiu(data: &[u8], aes_key: &[u8], hmac_key: &[u8]) -> Result { + verify_keys(aes_key, hmac_key)?; + + let hash_type = u16::from_be_bytes([data[0xA], data[0xB]]); + if hash_type != 2 { + bail!("Unknown hash type"); + } + + let mut iv = Vec::with_capacity(16); + iv.extend_from_slice(&data[0xC..0x18]); + iv.extend_from_slice(&[0, 0, 0, 1]); + + let mut cipher = Aes128Ctr::new_from_slices(aes_key, &iv)?; + let mut decrypted = data[0x20..].to_vec(); + cipher.apply_keystream(&mut decrypted); + + let hmac = decrypted[..0x20].to_vec(); + let content = decrypted[0x20..].to_vec(); + + let mut mac = HmacSha256::new_from_slice(hmac_key)?; + mac.update(&content); + mac.verify_slice(&hmac) + .map_err(|_| anyhow::anyhow!("Content HMAC check failed"))?; + + Ok(WupBossInfo { + hash_type, + iv, + hmac, + content, + }) +} + +pub fn encrypt_wiiu(content: &[u8], aes_key: &[u8], hmac_key: &[u8]) -> Result> { + verify_keys(aes_key, hmac_key)?; + + let mut mac = HmacSha256::new_from_slice(hmac_key)?; + mac.update(content); + let hmac = mac.finalize().into_bytes(); + + let mut plaintext = Vec::new(); + plaintext.extend_from_slice(&hmac); + plaintext.extend_from_slice(content); + + let mut iv12 = [0u8; 12]; + rand::thread_rng().fill_bytes(&mut iv12); + + let mut iv = Vec::with_capacity(16); + iv.extend_from_slice(&iv12); + iv.extend_from_slice(&[0, 0, 0, 1]); + + let mut cipher = Aes128Ctr::new_from_slices(aes_key, &iv)?; + cipher.apply_keystream(&mut plaintext); + + let mut header = vec![0u8; 0x20]; + header[0..4].copy_from_slice(b"boss"); + header[0x4..0x8].copy_from_slice(&BOSS_WUP_VER.to_be_bytes()); + header[0x8..0xA].copy_from_slice(&1u16.to_be_bytes()); + header[0xA..0xC].copy_from_slice(&2u16.to_be_bytes()); + header[0xC..0x18].copy_from_slice(&iv12); + + header.extend_from_slice(&plaintext); + Ok(header) +} diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..810f5e8 --- /dev/null +++ b/src/database.rs @@ -0,0 +1,169 @@ +use crate::Pool; +use crate::models::task::Task; +use crate::models::file_wup::FileWUP; +use crate::models::file_wup::FileWUPAttributes; +use sqlx::QueryBuilder; +use sqlx::types::Json; + +pub async fn get_all_tasks(pool: &Pool, allow_deleted: bool) -> Vec { + if allow_deleted { + sqlx::query_as!( + Task, + "SELECT * FROM tasks", + ) + .fetch_all(pool) + .await + .expect("couldn't get all tasks") + } else { + sqlx::query_as!( + Task, + "SELECT * FROM tasks WHERE deleted = false", + ) + .fetch_all(pool) + .await + .expect("couldn't get all tasks") + } +} + +pub async fn get_task(pool: &Pool, boss_app_id: String, task_id: String) -> Option { + sqlx::query_as!( + Task, + "SELECT * FROM tasks WHERE deleted = false AND id = $1 AND boss_app_id = $2", + &task_id[0..7], + boss_app_id, + ) + .fetch_optional(pool) + .await + .expect("couldnt get task") +} + +pub async fn get_wup_task_files( + pool: &Pool, + allow_deleted: bool, + boss_app_id: String, + task_id: String, + country:Option, + language: Option, + any: Option +) -> Vec { + let any = any.unwrap_or(false); + + let mut qb = QueryBuilder::new( + "SELECT * FROM files_wup WHERE 1=1" + ); + + if !allow_deleted { qb.push(" AND deleted = false"); }; + + qb.push(" AND boss_app_id = "); + qb.push_bind(boss_app_id); + + qb.push(" AND task_id = "); + qb.push_bind(task_id); + + if let Some(country) = country { + qb.push(" AND (supported_countries = '{}' OR supported_countries @> ARRAY["); + qb.push_bind(country); + qb.push("])"); + } else if !any { + qb.push(" AND supported_countries = '{}'"); + } + + + if let Some(language) = language { + qb.push(" AND (supported_languages = '{}' OR supported_languages @> ARRAY["); + qb.push_bind(language); + qb.push("])"); + } else if !any { + qb.push(" AND supported_languages = '{}'"); + } + + + let response = qb.build_query_as::().fetch_all(pool).await; + + match response { + Ok(response) => return response, + Err(_) => return vec![], + } +} + +pub async fn get_wup_task_file( + pool: &Pool, + allow_deleted: bool, + boss_app_id: String, + task_id: String, + name: String, + country: Option, + language: Option, + any: Option +) -> Option { + let any = any.unwrap_or(false); + + let mut qb = QueryBuilder::new( + "SELECT * FROM files_wup WHERE name = " + ); + qb.push_bind(name); + + if !allow_deleted { qb.push(" AND deleted = false"); }; + + qb.push(" AND boss_app_id = "); + qb.push_bind(boss_app_id); + + qb.push(" AND task_id = "); + qb.push_bind(&task_id[..7]); + + if let Some(country) = country { + qb.push(" AND (supported_countries = '{}' OR supported_countries @> ARRAY["); + qb.push_bind(country); + qb.push("])"); + } else if !any { + qb.push(" AND supported_countries = '{}'"); + } + + + if let Some(language) = language { + qb.push(" AND (supported_languages = '{}' OR supported_languages @> ARRAY["); + qb.push_bind(language); + qb.push("])"); + } else if !any { + qb.push(" AND supported_languages = '{}'"); + } + + + let response = qb.build_query_as::().fetch_optional(pool).await; + + match response { + Ok(response) => return response, + Err(_) => return None, + } +} + +pub async fn get_wup_task_file_by_data_id(pool: &Pool, data_id: i64) -> Option { + sqlx::query_as!( + FileWUP, + r#"SELECT + deleted, + file_key, + data_id, + task_id, + boss_app_id, + supported_countries, + supported_languages, + attributes AS "attributes!: Json", + creator_user, + name, + type, + hash, + size, + notify_on_new, + notify_led, + condition_played, + auto_delete, + created, + updated + FROM files_wup WHERE data_id = $1 AND deleted = false;"#, + data_id + ) + .fetch_optional(pool) + .await + .expect("couldnt find wup task file by data id") +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a8b0b8a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,73 @@ +use std::env; +use dotenvy::dotenv; +use sqlx::postgres::PgPoolOptions; +use rocket::{routes, catchers, Request}; +use rocket::http::{Method, ContentType, Status}; +use rocket::response::content::RawXml; +use rocket_cors::{AllowedOrigins, CorsOptions}; + +mod models; +mod services; +mod database; +mod api; +mod crypto; +mod admin_auth; + +type Pool = sqlx::Pool; + +#[rocket::catch(404)] +fn not_found(_req: &Request) -> (Status, (ContentType, RawXml<&'static str>)) { + ( + Status::NotFound, + ( + ContentType::XML, + RawXml( + r#" + + + + 0008 + Not found + +"#, + ), + ), + ) +} + +#[rocket::launch] +async fn launch() -> _ { + dotenv().ok(); + + let database_url = env::var("DATABASE_URL").expect("No database URL set"); + + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&database_url).await + .expect("Unable to create database pool"); + + let cors = CorsOptions::default() + .allowed_origins(AllowedOrigins::All) + .allowed_methods( + vec![Method::Get] + .into_iter() + .map(From::from) + .collect(), + ) + .allow_credentials(true); + + rocket::build() + .attach(cors.to_cors().unwrap()) + .manage(pool) + .mount("/", routes![ + services::nppl::policylist, + services::nppl::policylist_consoletype, + services::npts::tasksheet, + services::npts::tasksheet_no_boss_app_id, + services::npts::tasksheet_file, + services::npdi::data, + api::upload_file_wup::upload_file_wup, + api::add_task::add_task, + ]) + .register("/", catchers![not_found]) +} \ No newline at end of file diff --git a/src/models/file_wup.rs b/src/models/file_wup.rs new file mode 100644 index 0000000..ad27f5d --- /dev/null +++ b/src/models/file_wup.rs @@ -0,0 +1,95 @@ +use chrono::NaiveDateTime; +use serde::{Serialize, Deserialize}; +use sqlx::{FromRow, Decode, Encode, types::Json}; + +const VALID_COUNTRIES: &[&str] = &[ + "GB", "US", "IT", "NL", "DE", "CA", "FR", "HU", "CR", + "AU", "BR", "RO", "CL", "MX", "RU", "ES", "JP", "CZ", + "PT", "MT", "AR", "SE", "PL", "IE", "BE", "HT", "NO", + "FI", "GR", "BO", "AT", "VE", "PA", "PE", "GF", "SA", + "CO", "LT", "NA", "CH", "CY", "RS", "KY", "GP", "DK", + "KR", "LU", "SV", "VA", "GT", "SK", "HR", "ZA", "DO", + "UY", "LV", "HN", "JM", "TR", "IN", "ER", "AW", "NZ", + "EC", "TW", "EE", "CN", "SI", "AI", "BG", "NI", "IS", + "MQ", "BZ", "BA", "MY", "AZ", "ZW", "AL", "IM", "VG", + "VI", "BM", "GY", "SR", "MS", "TC", "BB", "TT", +]; + +const VALID_LANGUAGES: &[&str] = &[ + "en", "it", "de", "fr", "es", "us", "pt", "ru", "ja", "nl", "ko", "zh", "tw", +]; + +const VALID_FILE_TYPES: &[&str] = &[ + "Message", + "AppData", +]; + +const VALID_FILE_NOTIFY_CONDITIONS: &[&str] = &[ + "app", + "account", +]; + +pub fn is_valid_countries(countries: &Vec) -> bool { + for country in countries { + match VALID_COUNTRIES.contains(&country.as_str()) { + true => (), + false => return false, + } + } + true +} + +pub fn is_valid_languages(languages: &Vec) -> bool { + for lang in languages { + match VALID_LANGUAGES.contains(&lang.as_str()) { + true => (), + false => return false, + } + }; + true +} + +pub fn is_valid_file_type(file_type: &str) -> bool { + VALID_FILE_TYPES.contains(&file_type) +} + +pub fn is_valid_file_notify_conditions(conditions: &Vec) -> bool { + for condition in conditions { + match VALID_FILE_NOTIFY_CONDITIONS.contains(&condition.as_str()) { + true => (), + false => return false, + } + }; + true +} + +#[derive(Serialize, Deserialize, Decode, Encode, Clone)] +pub struct FileWUPAttributes { + pub attribute1: String, + pub attribute2: String, + pub attribute3: String, + pub description: String, +} + +#[derive(FromRow, Clone)] +pub struct FileWUP { + pub deleted: bool, + pub file_key: String, + pub data_id: Option, + pub task_id: String, + pub boss_app_id: String, + pub supported_countries: Vec, + pub supported_languages: Vec, + pub attributes: Json, + pub creator_user: String, + pub name: String, + pub r#type: String, + pub hash: String, + pub size: i64, + pub notify_on_new: Vec, + pub notify_led: bool, + pub condition_played: i64, + pub auto_delete: bool, + pub created: NaiveDateTime, + pub updated: NaiveDateTime, +} \ No newline at end of file diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..1859d77 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,2 @@ +pub mod task; +pub mod file_wup; \ No newline at end of file diff --git a/src/models/task.rs b/src/models/task.rs new file mode 100644 index 0000000..13b7192 --- /dev/null +++ b/src/models/task.rs @@ -0,0 +1,27 @@ +use chrono::NaiveDateTime; +use serde::{Serialize, Deserialize}; +use sqlx::Type; + +const VALID_TASK_STATUSES: &[&str] = &[ + "open", + "close", +]; + +pub fn is_valid_task_status(status: &str) -> bool { + VALID_TASK_STATUSES.contains(&status) +} + +#[derive(Clone)] +pub struct Task { + pub deleted: bool, + pub id: String, + pub in_game_id: String, + pub boss_app_id: String, + pub creator_user: String, + pub status: String, + pub interval: i32, + pub title_id: String, + pub description: String, + pub created: NaiveDateTime, + pub updated: NaiveDateTime, +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..111511d --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1,3 @@ +pub mod nppl; +pub mod npts; +pub mod npdi; \ No newline at end of file diff --git a/src/services/npdi.rs b/src/services/npdi.rs new file mode 100644 index 0000000..23db7d9 --- /dev/null +++ b/src/services/npdi.rs @@ -0,0 +1,56 @@ +use std::io::Cursor; +use rocket::http::{Header, Status}; +use rocket::{Data, State}; +use rocket::response::{Response, Responder}; +use crate::Pool; +use crate::database::get_wup_task_file_by_data_id; + +#[derive(Responder)] +pub struct DataResponder { + inner: T, + content_type_header: Header<'static>, + content_disposition_header: Header<'static>, + content_transfer_encoding_header: Header<'static>, + content_length_header: Header<'static>, +} + +impl<'r, 'o: 'r, T: Responder<'r, 'o>> DataResponder { + fn new(inner: T, content_length: String) -> Self { + DataResponder { + inner, + content_type_header: Header::new("Content-Type", "applicatoin/octet-stream"), // Intentional spelling mistake + content_disposition_header: Header::new("Content-Disposition", "attachment"), + content_transfer_encoding_header: Header::new("Content-Transfer-Encoding", "binary"), + content_length_header: Header::new("Content-Length", content_length), + } + } +} + +#[rocket::get("/p01/data/1///")] +pub async fn data(pool: &State, boss_app_id: String, data_id: i64, file_hash: String) -> Result>, Status> { + let pool = pool.inner(); + + let file_wup = get_wup_task_file_by_data_id(pool, data_id).await; + + let file_wup = match file_wup { + Some(file_wup) => file_wup, + None => return Err(Status::NotFound), + }; + + if file_wup.hash != file_hash || file_wup.boss_app_id != boss_app_id { return Err(Status::NotFound); } + + let file = sqlx::query_scalar!( + "SELECT data FROM files WHERE key = $1", + file_wup.file_key, + ) + .fetch_optional(pool) + .await; + + let file = match file { + Ok(Some(file)) => { file }, + Ok(None) => return Err(Status::NotFound), + Err(_) => return Err(Status::NotFound), + }; + + Ok(DataResponder::new(file, file_wup.size.to_string())) +} \ No newline at end of file diff --git a/src/services/nppl.rs b/src/services/nppl.rs new file mode 100644 index 0000000..04ea96e --- /dev/null +++ b/src/services/nppl.rs @@ -0,0 +1,241 @@ +use rocket::http::Status; +use serde::Serialize; +use chrono::Utc; + +/* Commented out until database support is added +use rocket::State; +use crate::Pool; +*/ + +// TODO: +// - Use database for policy lists for easier modification if needed + +#[derive(Serialize)] +enum TaskLevel { + STOPPED, + HIGH, + EXPEDITE, +} + +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +struct PolicyPriority { + title_id: String, + task_id: String, + level: TaskLevel, + persistent: Option, + revive: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +struct PolicyList { + major_version: i32, + minor_version: i32, + list_id: i32, + default_stop: bool, + force_version_up: bool, + update_time: String, + priority: Vec, +} + +async fn get_policy_list(console_type: String, major_version: String, country_code: String) -> Result { + + let policy_list: Option = match console_type.as_str() { + + "0" => { // 3DS + if major_version != "3" { None } + else { + Some(PolicyList{ + major_version: 3, + minor_version: 0, + list_id: 1891, + default_stop: false, + force_version_up: false, + update_time: Utc::now().format("%Y-%m-%dT%H:%M:%S+0000").to_string(), + priority: vec![ + PolicyPriority{ + title_id: "0004003000008f02".to_string(), + task_id: "basho0".to_string(), + level: TaskLevel::HIGH, + persistent: Some(true), + revive: Some(true), + }, + PolicyPriority{ + title_id: "000400300000bc00".to_string(), + task_id: "OlvNotf".to_string(), + level: TaskLevel::HIGH, + persistent: Some(true), + revive: Some(true), + }, + PolicyPriority{ + title_id: "000400300000bd00".to_string(), + task_id: "OlvNotf".to_string(), + level: TaskLevel::HIGH, + persistent: Some(true), + revive: Some(true), + }, + PolicyPriority{ + title_id: "000400300000be00".to_string(), + task_id: "OlvNotf".to_string(), + level: TaskLevel::HIGH, + persistent: Some(true), + revive: Some(true), + }, + PolicyPriority{ + title_id: "0004003000008f02".to_string(), + task_id: "pl".to_string(), + level: TaskLevel::HIGH, + persistent: Some(true), + revive: Some(true), + }, + PolicyPriority{ + title_id: "0004013000003400".to_string(), + task_id: "sprelay".to_string(), + level: TaskLevel::HIGH, + persistent: Some(true), + revive: Some(true), + }, + ], + }) + } + }, + + "1" => { // Wii U + if major_version != "1" { None } + else { + Some(PolicyList{ + major_version: 1, + minor_version: 0, + list_id: 1891, + default_stop: false, + force_version_up: false, + update_time: Utc::now().format("%Y-%m-%dT%H:%M:%S+0000").to_string(), + priority: vec![ + PolicyPriority{ + title_id: "0005003010016000".to_string(), + task_id: "olvinfo".to_string(), + level: TaskLevel::EXPEDITE, + persistent: None, + revive: None, + }, + PolicyPriority{ + title_id: "0005003010016100".to_string(), + task_id: "olvinfo".to_string(), + level: TaskLevel::EXPEDITE, + persistent: None, + revive: None, + }, + PolicyPriority{ + title_id: "0005003010016200".to_string(), + task_id: "olvinfo".to_string(), + level: TaskLevel::EXPEDITE, + persistent: None, + revive: None, + }, + PolicyPriority{ + title_id: "000500301001600a".to_string(), + task_id: "olv1".to_string(), + level: TaskLevel::EXPEDITE, + persistent: None, + revive: None, + }, + PolicyPriority{ + title_id: "000500301001610a".to_string(), + task_id: "olv1".to_string(), + level: TaskLevel::EXPEDITE, + persistent: None, + revive: None, + }, + PolicyPriority{ + title_id: "000500301001620a".to_string(), + task_id: "olv1".to_string(), + level: TaskLevel::EXPEDITE, + persistent: None, + revive: None, + }, + PolicyPriority{ + title_id: "0005001010040000".to_string(), + task_id: "olvtopic".to_string(), + level: TaskLevel::EXPEDITE, + persistent: None, + revive: None, + }, + PolicyPriority{ + title_id: "0005001010040100".to_string(), + task_id: "olvtopic".to_string(), + level: TaskLevel::EXPEDITE, + persistent: None, + revive: None, + }, + PolicyPriority{ + title_id: "0005001010040200".to_string(), + task_id: "olvtopic".to_string(), + level: TaskLevel::EXPEDITE, + persistent: None, + revive: None, + }, + PolicyPriority{ + title_id: "000500101005a000".to_string(), + task_id: "Chat".to_string(), + level: TaskLevel::EXPEDITE, + persistent: None, + revive: None, + }, + PolicyPriority{ + title_id: "000500101005a100".to_string(), + task_id: "Chat".to_string(), + level: TaskLevel::EXPEDITE, + persistent: None, + revive: None, + }, + PolicyPriority{ + title_id: "000500101005a200".to_string(), + task_id: "Chat".to_string(), + level: TaskLevel::EXPEDITE, + persistent: None, + revive: None, + }, + PolicyPriority{ + title_id: "000500101004c100".to_string(), + task_id: "plog".to_string(), + level: TaskLevel::EXPEDITE, + persistent: None, + revive: None, + }, + ], + }) + } + }, + + _ => return Err(Status::InternalServerError), + }; + + match policy_list { + + Some(policy_list) => { + + let xml = quick_xml::se::to_string(&policy_list); + match xml { + Ok(xml) => return Ok(xml), + Err(_) => return Err(Status::InternalServerError), + } + + } + + None => return Err(Status::NotFound) + } +} + + + +#[rocket::get("/p01/policylist//")] +pub async fn policylist(major_version: String, country_code: String) -> Result { + let console_type = "0".to_string(); + get_policy_list(console_type, major_version, country_code).await +} + +#[rocket::get("/p01/policylist///")] +pub async fn policylist_consoletype(console_type: String, major_version: String, country_code: String) -> Result { + get_policy_list(console_type, major_version, country_code).await +} \ No newline at end of file diff --git a/src/services/npts.rs b/src/services/npts.rs new file mode 100644 index 0000000..b8999f8 --- /dev/null +++ b/src/services/npts.rs @@ -0,0 +1,202 @@ +use std::env; +use rocket::State; +use rocket::form::FromForm; +use rocket::http::Status; +use rocket::response::content::RawXml; +use serde::Serialize; +use crate::Pool; +use crate::database::{get_task, get_wup_task_files, get_wup_task_file}; +use crate::models::task::Task; +use crate::models::file_wup::FileWUP; +use crate::models::file_wup::FileWUPAttributes; + +#[derive(FromForm)] +struct QueryParams { + c: Option, + l: Option, + mode: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +struct FileWUPNotify { + new: String, + led: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +struct FileWUPFormatted { + filename: String, + data_id: Option, + r#type: String, + url: Option, + size: i64, + attributes: Option, + notify: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +struct TaskSheetFiles { + file: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +struct TaskSheet { + title_id: String, + task_id: String, + service_status: String, + files: TaskSheetFiles, +} + +fn build_file(task: Task, file: &FileWUP, attributes_mode: bool, cdn_url: &String) -> FileWUPFormatted { + let file = file.clone(); + if attributes_mode { + FileWUPFormatted { + filename: file.name, + r#type: file.r#type, + size: file.size, + attributes: Some(FileWUPAttributes { + attribute1: file.attributes.attribute1.clone(), + attribute2: file.attributes.attribute2.clone(), + attribute3: file.attributes.attribute3.clone(), + description: file.attributes.description.clone(), + }), + data_id: None, + url: None, + notify: None, + } + } else { + let url = format!("{}/p01/data/1/{}/{}/{}", cdn_url, task.boss_app_id, file.data_id.unwrap(), file.hash); + FileWUPFormatted { + filename: file.name, + data_id: Some(file.data_id.unwrap()), + r#type: file.r#type, + url: Some(url), + size: file.size, + notify: Some(FileWUPNotify { + new: file.notify_on_new.join(","), + led: file.notify_led, + }), + attributes: None, + } + } +} + +#[rocket::get("/p01/tasksheet///?")] +pub async fn tasksheet(pool: &State, id: &str, boss_app_id: &str, task_id: &str, query: QueryParams) -> Result, Status> { + let pool = pool.inner(); + let cdn_url = match env::var("BOSS_CDN_URL") { + Ok(url) => url, + Err(_) => return Err(Status::InternalServerError), + }; + + let country = query.c; + let language = query.l; + let mode = query.mode; + + let task = match get_task(pool, boss_app_id.to_string(), task_id.to_string()).await { + Some(task) => task, + None => return Err(Status::NotFound) + }; + + let files = get_wup_task_files(pool, false, boss_app_id.to_string(), task_id.to_string(), country, language, None).await; + + let tasksheet = TaskSheet{ + title_id: task.title_id.clone(), + task_id: task.id.clone(), + service_status: task.status.clone(), + files: TaskSheetFiles{file: files.iter().map(|f| build_file(task.clone(), f, mode == Some("attr".to_string()), &cdn_url)).collect()}, + }; + + let xml = quick_xml::se::to_string(&tasksheet); + + match xml { + Ok(xml) => { + let xml = format!(r#"{}"#, xml); + Ok(RawXml(xml)) + }, + Err(_) => Err(Status::InternalServerError), + } +} + +#[rocket::get("/p01/tasksheet//?")] +pub async fn tasksheet_no_boss_app_id(pool: &State, id: &str, task_id: &str, query: QueryParams) -> Result, Status> { + let pool = pool.inner(); + let cdn_url = match env::var("BOSS_CDN_URL") { + Ok(url) => url, + Err(_) => return Err(Status::InternalServerError), + }; + + let country = query.c; + let language = query.l; + let mode = query.mode; + + let boss_app_id = ""; + + let task = match get_task(pool, boss_app_id.to_string(), task_id.to_string()).await { + Some(task) => task, + None => return Err(Status::NotFound) + }; + + let files = get_wup_task_files(pool, false, boss_app_id.to_string(), task_id.to_string(), country, language, None).await; + + let tasksheet = TaskSheet{ + title_id: task.title_id.clone(), + task_id: task.id.clone(), + service_status: task.status.clone(), + files: TaskSheetFiles{file: files.iter().map(|f| build_file(task.clone(), f, mode == Some("attr".to_string()), &cdn_url)).collect()}, + }; + + let xml = quick_xml::se::to_string(&tasksheet); + + match xml { + Ok(xml) => { + let xml = format!(r#"{}"#, xml); + Ok(RawXml(xml)) + }, + Err(_) => Err(Status::InternalServerError), + } +} + +#[rocket::get("/p01/tasksheet////?")] +pub async fn tasksheet_file(pool: &State, id: &str, boss_app_id: &str, task_id: &str, file_name: &str, query: QueryParams) -> Result, Status> { + let pool = pool.inner(); + let cdn_url = match env::var("BOSS_CDN_URL") { + Ok(url) => url, + Err(_) => return Err(Status::InternalServerError), + }; + + let country = query.c; + let language = query.l; + let mode = query.mode; + + let task = match get_task(pool, boss_app_id.to_string(), task_id.to_string()).await { + Some(task) => task, + None => return Err(Status::NotFound) + }; + + let file = match get_wup_task_file(pool, false, boss_app_id.to_string(), task_id.to_string(), file_name.to_string(), country, language, None).await { + Some(file) => file, + None => return Err(Status::NotFound), + }; + + let tasksheet = TaskSheet{ + title_id: task.title_id.clone(), + task_id: task.id.clone(), + service_status: task.status.clone(), + files: TaskSheetFiles{file: vec![build_file(task.clone(), &file, mode == Some("attr".to_string()), &cdn_url)]}, + }; + + let xml = quick_xml::se::to_string(&tasksheet); + + match xml { + Ok(xml) => { + let xml = format!(r#"{}"#, xml); + Ok(RawXml(xml)) + }, + Err(_) => Err(Status::InternalServerError), + } +} \ No newline at end of file