Initial Commit

This commit is contained in:
BloxerHD 2026-02-12 11:56:29 +00:00
commit c09b00ff35
30 changed files with 1930 additions and 0 deletions

16
.env.example Normal file
View file

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

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
Cargo.lock
.env

43
.gitlab-ci.yml Normal file
View file

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

View file

@ -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"
}

View file

@ -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<FileWUPAttributes>\",\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<FileWUPAttributes>",
"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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

26
Cargo.toml Normal file
View file

@ -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"] }

23
Dockerfile Normal file
View file

@ -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"]

57
db-setup.sql Normal file
View file

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

71
src/admin_auth.rs Normal file
View file

@ -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<Self, Self::Error> {
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()))
}
}
}

51
src/api/add_task.rs Normal file
View file

@ -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 = "<input>")]
pub async fn add_task(pool: &State<Pool>, input: Json<AddTaskData>, 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(())
}

2
src/api/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod upload_file_wup;
pub mod add_task;

189
src/api/upload_file_wup.rs Normal file
View file

@ -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<String>,
supported_languages: Vec<String>,
name: Option<String>,
r#type: String,
notify_on_new: Vec<String>,
notify_led: bool,
data: String,
attributes: Option<FileWUPAttributes>,
name_equals_data_id: bool,
condition_played: i64,
auto_delete: bool,
}
#[rocket::post("/api/v1/upl_wup", data = "<input>")]
pub async fn upload_file_wup(pool: &State<Pool>, input: Json<UploadedWUP>, 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)
}
}

1
src/crypto/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod wiiu;

105
src/crypto/wiiu.rs Normal file
View file

@ -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<Sha256>;
type Aes128Ctr = ctr::Ctr128BE<Aes128>;
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<u8>,
pub hmac: Vec<u8>,
pub content: Vec<u8>,
}
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<WupBossInfo> {
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<Vec<u8>> {
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)
}

169
src/database.rs Normal file
View file

@ -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<Task> {
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<Task> {
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<String>,
language: Option<String>,
any: Option<bool>
) -> Vec<FileWUP> {
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::<FileWUP>().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<String>,
language: Option<String>,
any: Option<bool>
) -> Option<FileWUP> {
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::<FileWUP>().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<FileWUP> {
sqlx::query_as!(
FileWUP,
r#"SELECT
deleted,
file_key,
data_id,
task_id,
boss_app_id,
supported_countries,
supported_languages,
attributes AS "attributes!: Json<FileWUPAttributes>",
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")
}

73
src/main.rs Normal file
View file

@ -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<sqlx::Postgres>;
#[rocket::catch(404)]
fn not_found(_req: &Request) -> (Status, (ContentType, RawXml<&'static str>)) {
(
Status::NotFound,
(
ContentType::XML,
RawXml(
r#"<?xml version="1.0"?>
<errors>
<error>
<cause/>
<code>0008</code>
<message>Not found</message>
</error>
</errors>"#,
),
),
)
}
#[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])
}

95
src/models/file_wup.rs Normal file
View file

@ -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<String>) -> bool {
for country in countries {
match VALID_COUNTRIES.contains(&country.as_str()) {
true => (),
false => return false,
}
}
true
}
pub fn is_valid_languages(languages: &Vec<String>) -> 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<String>) -> 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<i64>,
pub task_id: String,
pub boss_app_id: String,
pub supported_countries: Vec<String>,
pub supported_languages: Vec<String>,
pub attributes: Json<FileWUPAttributes>,
pub creator_user: String,
pub name: String,
pub r#type: String,
pub hash: String,
pub size: i64,
pub notify_on_new: Vec<String>,
pub notify_led: bool,
pub condition_played: i64,
pub auto_delete: bool,
pub created: NaiveDateTime,
pub updated: NaiveDateTime,
}

2
src/models/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod task;
pub mod file_wup;

27
src/models/task.rs Normal file
View file

@ -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,
}

3
src/services/mod.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod nppl;
pub mod npts;
pub mod npdi;

56
src/services/npdi.rs Normal file
View file

@ -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<T> {
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<T> {
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/<boss_app_id>/<data_id>/<file_hash>")]
pub async fn data(pool: &State<Pool>, boss_app_id: String, data_id: i64, file_hash: String) -> Result<DataResponder<Vec<u8>>, 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()))
}

241
src/services/nppl.rs Normal file
View file

@ -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<bool>,
revive: Option<bool>,
}
#[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<PolicyPriority>,
}
async fn get_policy_list(console_type: String, major_version: String, country_code: String) -> Result<String, Status> {
let policy_list: Option<PolicyList> = 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/<major_version>/<country_code>")]
pub async fn policylist(major_version: String, country_code: String) -> Result<String, Status> {
let console_type = "0".to_string();
get_policy_list(console_type, major_version, country_code).await
}
#[rocket::get("/p01/policylist/<console_type>/<major_version>/<country_code>")]
pub async fn policylist_consoletype(console_type: String, major_version: String, country_code: String) -> Result<String, Status> {
get_policy_list(console_type, major_version, country_code).await
}

202
src/services/npts.rs Normal file
View file

@ -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<String>,
l: Option<String>,
mode: Option<String>,
}
#[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<i64>,
r#type: String,
url: Option<String>,
size: i64,
attributes: Option<FileWUPAttributes>,
notify: Option<FileWUPNotify>,
}
#[derive(Serialize)]
#[serde(rename_all = "PascalCase")]
struct TaskSheetFiles {
file: Vec<FileWUPFormatted>,
}
#[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/<id>/<boss_app_id>/<task_id>?<query..>")]
pub async fn tasksheet(pool: &State<Pool>, id: &str, boss_app_id: &str, task_id: &str, query: QueryParams) -> Result<RawXml<String>, 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 version="1.0" encoding="UTF-8"?>{}"#, xml);
Ok(RawXml(xml))
},
Err(_) => Err(Status::InternalServerError),
}
}
#[rocket::get("/p01/tasksheet/<id>/<task_id>?<query..>")]
pub async fn tasksheet_no_boss_app_id(pool: &State<Pool>, id: &str, task_id: &str, query: QueryParams) -> Result<RawXml<String>, 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 version="1.0" encoding="UTF-8"?>{}"#, xml);
Ok(RawXml(xml))
},
Err(_) => Err(Status::InternalServerError),
}
}
#[rocket::get("/p01/tasksheet/<id>/<boss_app_id>/<task_id>/<file_name>?<query..>")]
pub async fn tasksheet_file(pool: &State<Pool>, id: &str, boss_app_id: &str, task_id: &str, file_name: &str, query: QueryParams) -> Result<RawXml<String>, 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 version="1.0" encoding="UTF-8"?>{}"#, xml);
Ok(RawXml(xml))
},
Err(_) => Err(Status::InternalServerError),
}
}