~starkingdoms/starkingdoms

fb32ad5db3289f5757f2c17960cf64eb2196c4ed — core 1 year, 8 months ago 7baddd1
functioning api
M .gitignore => .gitignore +2 -1
@@ 12,4 12,5 @@ assets/final
starkingdoms-client/node_modules
starkingdoms-client/dist
client/node_modules
starkingdoms-backplane/config.toml
\ No newline at end of file
starkingdoms-backplane/config.toml
starkingdoms-api/config.toml
\ No newline at end of file

M Cargo.lock => Cargo.lock +26 -0
@@ 3665,6 3665,32 @@ dependencies = [
]

[[package]]
name = "starkingdoms-api"
version = "0.1.0-alpha1"
dependencies = [
 "actix-cors",
 "actix-web",
 "argon2",
 "bb8",
 "diesel",
 "diesel-async",
 "diesel_migrations",
 "env_logger",
 "hex",
 "hmac",
 "jwt",
 "log",
 "password-hash",
 "rand",
 "rs-snowflake",
 "serde",
 "serde_json",
 "sha2",
 "starkingdoms-common",
 "toml 0.8.12",
]

[[package]]
name = "starkingdoms-backplane"
version = "0.1.0-alpha1"
dependencies = [

M Cargo.toml => Cargo.toml +2 -2
@@ 4,7 4,7 @@ members = [
    "starkingdoms-backplane",
    "starkingdoms-common",
    "savefile_decoder"
]
, "starkingdoms-api"]
resolver = "2"

[profile.dev.package."*"]


@@ 13,4 13,4 @@ opt-level = 3
[profile.release-ci]
inherits = "release"
codegen-units = 1
lto = "fat"
\ No newline at end of file
lto = "fat"

A postgres.docker-compose.yml => postgres.docker-compose.yml +15 -0
@@ 0,0 1,15 @@
version: "3"
services:
  hayyadb:
    image: postgres
    environment:
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_USER=postgres
      - POSTGRES_DB=stkapi
    ports:
      - 5432:5432
    volumes:
      - db:/var/lib/postgresql/data

volumes:
  db:

A starkingdoms-api/.env => starkingdoms-api/.env +1 -0
@@ 0,0 1,1 @@
DATABASE_URL=postgres://postgres:postgres@localhost/stkapi
\ No newline at end of file

A starkingdoms-api/Cargo.toml => starkingdoms-api/Cargo.toml +31 -0
@@ 0,0 1,31 @@
[package]
name = "starkingdoms-api"
version = "0.1.0-alpha1"
authors = ["core <core@e3t.cc>"]
edition = "2021"
description = "The save storage API keeping your ship designs safe"
license = "AGPL-3"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
actix-web = "4"
actix-cors = "0.6"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
log = "0.4"
env_logger = "0.10"
diesel = { version = "2", features = ["serde_json"] }
diesel-async = { version = "0.4", features = ["postgres", "bb8", "async-connection-wrapper"] }
diesel_migrations = "2"
bb8 = "0.8"
rand = "0.8"
argon2 = "0.5"
password-hash = "0.5"
rs-snowflake = "0.6"
jwt = "0.16"
sha2 = "0.10"
hmac = "0.12"
hex = "0.4"
starkingdoms-common = { version = "0.1", path = "../starkingdoms-common" }
\ No newline at end of file

A starkingdoms-api/Dockerfile => starkingdoms-api/Dockerfile +5 -0
@@ 0,0 1,5 @@
FROM debian:bookworm-slim

COPY target/release-ci/starkingdoms-backplane /bin/starkingdoms-backplane

ENTRYPOINT ["starkingdoms-backplane", "/etc/starkingdoms/backplane.toml"]
\ No newline at end of file

A starkingdoms-api/build.rs => starkingdoms-api/build.rs +18 -0
@@ 0,0 1,18 @@
// StarKingdoms.IO, a browser game about drifting through space
//     Copyright (C) 2023 ghostly_zsh, TerraMaster85, core
//
//     This program is free software: you can redistribute it and/or modify
//     it under the terms of the GNU Affero General Public License as published by
//     the Free Software Foundation, either version 3 of the License, or
//     (at your option) any later version.
//
//     This program is distributed in the hope that it will be useful,
//     but WITHOUT ANY WARRANTY; without even the implied warranty of
//     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//     GNU Affero General Public License for more details.
//
//     You should have received a copy of the GNU Affero General Public License
//     along with this program.  If not, see <https://www.gnu.org/licenses/>.
fn main() {
    println!("cargo:rerun-if-changed=migrations")
}

A starkingdoms-api/diesel.toml => starkingdoms-api/diesel.toml +9 -0
@@ 0,0 1,9 @@
# For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli

[print_schema]
file = "src/schema.rs"
custom_type_derives = ["diesel::query_builder::QueryId"]

[migrations_directory]
dir = "migrations"

A starkingdoms-api/migrations/.keep => starkingdoms-api/migrations/.keep +0 -0
A starkingdoms-api/migrations/00000000000000_diesel_initial_setup/down.sql => starkingdoms-api/migrations/00000000000000_diesel_initial_setup/down.sql +6 -0
@@ 0,0 1,6 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.

DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at();

A starkingdoms-api/migrations/00000000000000_diesel_initial_setup/up.sql => starkingdoms-api/migrations/00000000000000_diesel_initial_setup/up.sql +36 -0
@@ 0,0 1,36 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.




-- Sets up a trigger for the given table to automatically set a column called
-- `updated_at` whenever the row is modified (unless `updated_at` was included
-- in the modified columns)
--
-- # Example
--
-- ```sql
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
--
-- SELECT diesel_manage_updated_at('users');
-- ```
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
BEGIN
    EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
                    FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
END;
$$ LANGUAGE plpgsql;

CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
BEGIN
    IF (
        NEW IS DISTINCT FROM OLD AND
        NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
    ) THEN
        NEW.updated_at := current_timestamp;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

A starkingdoms-api/migrations/2024-04-08-152705_create_table_saves/down.sql => starkingdoms-api/migrations/2024-04-08-152705_create_table_saves/down.sql +1 -0
@@ 0,0 1,1 @@
-- This file should undo anything in `up.sql`

A starkingdoms-api/migrations/2024-04-08-152705_create_table_saves/up.sql => starkingdoms-api/migrations/2024-04-08-152705_create_table_saves/up.sql +8 -0
@@ 0,0 1,8 @@
CREATE TABLE saves
(
    id BIGINT NOT NULL PRIMARY KEY,
    user_id BIGINT NOT NULL,
    pack VARCHAR NOT NULL
);

CREATE INDEX idx_saves_user_id ON saves (user_id);
\ No newline at end of file

A starkingdoms-api/src/auth.rs => starkingdoms-api/src/auth.rs +38 -0
@@ 0,0 1,38 @@
// StarKingdoms.IO, a browser game about drifting through space
//     Copyright (C) 2023 ghostly_zsh, TerraMaster85, core
//
//     This program is free software: you can redistribute it and/or modify
//     it under the terms of the GNU Affero General Public License as published by
//     the Free Software Foundation, either version 3 of the License, or
//     (at your option) any later version.
//
//     This program is distributed in the hope that it will be useful,
//     but WITHOUT ANY WARRANTY; without even the implied warranty of
//     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//     GNU Affero General Public License for more details.
//
//     You should have received a copy of the GNU Affero General Public License
//     along with this program.  If not, see <https://www.gnu.org/licenses/>.

#[macro_export]
macro_rules! auth {
    ($i:expr,$c:expr) => {{
        let authorization_hdr_value = match $i.headers().get("Authorization") {
            Some(hdr) => hdr,
            None => $crate::err!(
                actix_web::http::StatusCode::UNAUTHORIZED,
                $crate::make_err!("ERR_UNAUTHORIZED", "unauthorized")
            ),
        };
        let hdr_value_split = $crate::handle_error!(authorization_hdr_value.to_str())
            .split(' ')
            .collect::<Vec<_>>();
        if hdr_value_split.len() < 2 {
            $crate::err!(
                actix_web::http::StatusCode::UNAUTHORIZED,
                $crate::make_err!("ERR_UNAUTHORIZED", "unauthorized")
            )
        }
        let tokens = hdr_value_split[1..].to_vec();
    }};
}

A starkingdoms-api/src/config.rs => starkingdoms-api/src/config.rs +43 -0
@@ 0,0 1,43 @@
// StarKingdoms.IO, a browser game about drifting through space
//     Copyright (C) 2023 ghostly_zsh, TerraMaster85, core
//
//     This program is free software: you can redistribute it and/or modify
//     it under the terms of the GNU Affero General Public License as published by
//     the Free Software Foundation, either version 3 of the License, or
//     (at your option) any later version.
//
//     This program is distributed in the hope that it will be useful,
//     but WITHOUT ANY WARRANTY; without even the implied warranty of
//     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//     GNU Affero General Public License for more details.
//
//     You should have received a copy of the GNU Affero General Public License
//     along with this program.  If not, see <https://www.gnu.org/licenses/>.
use serde::Deserialize;
use std::net::IpAddr;

#[derive(Deserialize, Clone)]
pub struct Config {
    pub server: ConfigServer,
    pub database: ConfigDatabase,
}

#[derive(Deserialize, Clone)]
pub struct ConfigServer {
    pub bind: ConfigServerBind,
    pub workers: Option<usize>,
    pub machine_id: i32,
    pub node_id: i32,
    pub application_key: String,
}

#[derive(Deserialize, Clone)]
pub struct ConfigServerBind {
    pub ip: IpAddr,
    pub port: u16,
}

#[derive(Deserialize, Clone)]
pub struct ConfigDatabase {
    pub url: String,
}

A starkingdoms-api/src/error.rs => starkingdoms-api/src/error.rs +133 -0
@@ 0,0 1,133 @@
// StarKingdoms.IO, a browser game about drifting through space
//     Copyright (C) 2023 ghostly_zsh, TerraMaster85, core
//
//     This program is free software: you can redistribute it and/or modify
//     it under the terms of the GNU Affero General Public License as published by
//     the Free Software Foundation, either version 3 of the License, or
//     (at your option) any later version.
//
//     This program is distributed in the hope that it will be useful,
//     but WITHOUT ANY WARRANTY; without even the implied warranty of
//     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//     GNU Affero General Public License for more details.
//
//     You should have received a copy of the GNU Affero General Public License
//     along with this program.  If not, see <https://www.gnu.org/licenses/>.
use actix_web::error::{JsonPayloadError, PayloadError};
use serde::Serialize;
use std::fmt::{Display, Formatter};

#[derive(Serialize, Debug)]
pub struct APIErrorsResponse {
    pub errors: Vec<APIErrorResponse>,
}

#[derive(Serialize, Debug)]
pub struct APIErrorResponse {
    pub code: String,
    pub message: String,
    pub path: Option<String>,
}

impl Display for APIErrorResponse {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        write!(f, "{:?}", self)
    }
}

impl From<&JsonPayloadError> for APIErrorResponse {
    fn from(value: &JsonPayloadError) -> Self {
        match value {
            JsonPayloadError::OverflowKnownLength { length, limit } => {
                APIErrorResponse {
                    code: "ERR_PAYLOAD_OVERFLOW_KNOWN_LENGTH".to_string(),
                    message: format!("Payload size is bigger than allowed & content length header set. (length: {}, limit: {})", length, limit),
                    path: None,
                }
            },
            JsonPayloadError::Overflow { limit } => {
                APIErrorResponse {
                    code: "ERR_PAYLOAD_OVERFLOW".to_string(),
                    message: format!("Payload size is bigger than allowed but no content-length header is set. (limit: {})", limit),
                    path: None,
                }
            },
            JsonPayloadError::ContentType => {
                APIErrorResponse {
                    code: "ERR_NOT_JSON".to_string(),
                    message: "Content-Type header not set to expected application/json".to_string(),
                    path: None,
                }
            },
            JsonPayloadError::Deserialize(e) => {
                APIErrorResponse {
                    code: "ERR_JSON_DESERIALIZE".to_string(),
                    message: format!("Error deserializing JSON: {}", e),
                    path: None,
                }
            },
            JsonPayloadError::Serialize(e) => {
                APIErrorResponse {
                    code: "ERR_JSON_SERIALIZE".to_string(),
                    message: format!("Error serializing JSON: {}", e),
                    path: None,
                }
            },
            JsonPayloadError::Payload(e) => {
                e.into()
            },
            _ => {
                APIErrorResponse {
                    code: "ERR_UNKNOWN_ERROR".to_string(),
                    message: "An unknown error has occured".to_string(),
                    path: None,
                }
            }
        }
    }
}

impl From<&PayloadError> for APIErrorResponse {
    fn from(value: &PayloadError) -> Self {
        match value {
            PayloadError::Incomplete(e) => APIErrorResponse {
                code: "ERR_UNEXPECTED_EOF".to_string(),
                message: match e {
                    None => "Payload reached EOF but was incomplete".to_string(),
                    Some(e) => format!("Payload reached EOF but was incomplete: {}", e),
                },
                path: None,
            },
            PayloadError::EncodingCorrupted => APIErrorResponse {
                code: "ERR_CORRUPTED_PAYLOAD".to_string(),
                message: "Payload content encoding corrupted".to_string(),
                path: None,
            },
            PayloadError::Overflow => APIErrorResponse {
                code: "ERR_PAYLOAD_OVERFLOW".to_string(),
                message: "Payload reached size limit".to_string(),
                path: None,
            },
            PayloadError::UnknownLength => APIErrorResponse {
                code: "ERR_PAYLOAD_UNKNOWN_LENGTH".to_string(),
                message: "Unable to determine payload length".to_string(),
                path: None,
            },
            PayloadError::Http2Payload(e) => APIErrorResponse {
                code: "ERR_HTTP2_ERROR".to_string(),
                message: format!("HTTP/2 error: {}", e),
                path: None,
            },
            PayloadError::Io(e) => APIErrorResponse {
                code: "ERR_IO_ERROR".to_string(),
                message: format!("I/O error: {}", e),
                path: None,
            },
            _ => APIErrorResponse {
                code: "ERR_UNKNOWN_ERROR".to_string(),
                message: "An unknown error has occured".to_string(),
                path: None,
            },
        }
    }
}

A starkingdoms-api/src/main.rs => starkingdoms-api/src/main.rs +185 -0
@@ 0,0 1,185 @@
// StarKingdoms.IO, a browser game about drifting through space
//     Copyright (C) 2023 ghostly_zsh, TerraMaster85, core
//
//     This program is free software: you can redistribute it and/or modify
//     it under the terms of the GNU Affero General Public License as published by
//     the Free Software Foundation, either version 3 of the License, or
//     (at your option) any later version.
//
//     This program is distributed in the hope that it will be useful,
//     but WITHOUT ANY WARRANTY; without even the implied warranty of
//     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//     GNU Affero General Public License for more details.
//
//     You should have received a copy of the GNU Affero General Public License
//     along with this program.  If not, see <https://www.gnu.org/licenses/>.
use crate::config::Config;
use crate::error::APIErrorResponse;
use actix_web::middleware::Logger;
use actix_web::web::{Data, JsonConfig};
use actix_web::{App, Error, HttpResponse, HttpServer};
use diesel::Connection;
use diesel_async::async_connection_wrapper::AsyncConnectionWrapper;
use diesel_async::pooled_connection::bb8::Pool;
use diesel_async::pooled_connection::AsyncDieselConnectionManager;
use diesel_async::AsyncPgConnection;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use hmac::digest::KeyInit;
use hmac::Hmac;
use log::{error, info};
use sha2::Sha256;
use snowflake::SnowflakeIdGenerator;
use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, UNIX_EPOCH};

pub mod error;
#[macro_use]
pub mod response;
pub mod config;
pub mod models;
pub mod routes;
pub mod schema;

pub mod auth;
pub mod tokens;

#[derive(Clone)]
pub struct AppState {
    pub config: Config,
    pub pool: bb8::Pool<AsyncDieselConnectionManager<AsyncPgConnection>>,
    pub idgen: Arc<Mutex<SnowflakeIdGenerator>>,
    pub key: Hmac<Sha256>,
    pub key_raw: Vec<u8>
}

pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");

#[actix_web::main]
async fn main() {
    env_logger::init();
    info!(
        "StarKingdoms API v{} starting up",
        env!("CARGO_PKG_VERSION")
    );

    let mut args = std::env::args();
    let config_path = match args.nth(1) {
        Some(path) => path,
        None => {
            eprintln!("usage: starkingdoms-api <config_path>");
            std::process::exit(1);
        }
    };

    let config_pathbuf = PathBuf::from(config_path);
    info!("Loading config from {}", config_pathbuf.display());

    let config_str = match fs::read_to_string(&config_pathbuf) {
        Ok(c_str) => c_str,
        Err(e) => {
            error!(
                "Error loading configuration from {}: {}",
                config_pathbuf.display(),
                e
            );
            std::process::exit(1);
        }
    };

    let config: Config = match toml::from_str(&config_str) {
        Ok(config) => config,
        Err(e) => {
            error!(
                "Error parsing configuration in {}: {}",
                config_pathbuf.display(),
                e
            );
            std::process::exit(1);
        }
    };

    info!("Connecting to the database...");

    let pool_config = AsyncDieselConnectionManager::<AsyncPgConnection>::new(&config.database.url);
    let pool = match Pool::builder().build(pool_config).await {
        Ok(pool) => pool,
        Err(e) => {
            error!("Error while creating database pool: {}", e);
            std::process::exit(1);
        }
    };

    info!("Running pending migrations...");

    let local_config = config.clone();
    let db_url = config.database.url.clone();

    match actix_web::rt::task::spawn_blocking(move || {
        // Lock block
        let mut conn = match AsyncConnectionWrapper::<AsyncPgConnection>::establish(&db_url) {
            Ok(conn) => conn,
            Err(e) => {
                error!("Error acquiring connection from pool: {}", e);
                std::process::exit(1);
            }
        };

        match conn.run_pending_migrations(MIGRATIONS) {
            Ok(_) => (),
            Err(e) => {
                error!("Failed to run pending migrations: {}", e);
                std::process::exit(1);
            }
        }
    })
    .await
    {
        Ok(_) => (),
        Err(e) => {
            error!("Error waiting for migrations: {}", e);
            std::process::exit(1);
        }
    }

    let key = Hmac::new_from_slice(config.server.application_key.clone().as_bytes()).unwrap();

    let stk_epoch = UNIX_EPOCH + Duration::from_secs(1616260136);
    let id_generator = SnowflakeIdGenerator::with_epoch(
        config.server.machine_id,
        config.server.node_id,
        stk_epoch,
    );

    let app_state = Data::new(AppState {
        pool,
        idgen: Arc::new(Mutex::new(id_generator)),
        key,
        key_raw: config.server.application_key.as_bytes().to_vec(),
        config: config.clone(),
    });

    let server = HttpServer::new(move || {
        App::new()
            .app_data(JsonConfig::default().error_handler(|err, _rq| {
                Error::from({
                    let err2: APIErrorResponse = (&err).into();
                    actix_web::error::InternalError::from_response(
                        err,
                        HttpResponse::BadRequest().json(err2),
                    )
                })
            }))
            .service(routes::sign_save::sign_save_req)
            .wrap(Logger::default())
            .wrap(actix_cors::Cors::permissive())
            .app_data(app_state.clone())
    })
    .bind((local_config.server.bind.ip, local_config.server.bind.port))
    .unwrap();

    server.run().await.unwrap();

    info!("Goodbye!");
}

A starkingdoms-api/src/models.rs => starkingdoms-api/src/models.rs +26 -0
@@ 0,0 1,26 @@
// StarKingdoms.IO, a browser game about drifting through space
//     Copyright (C) 2023 ghostly_zsh, TerraMaster85, core
//
//     This program is free software: you can redistribute it and/or modify
//     it under the terms of the GNU Affero General Public License as published by
//     the Free Software Foundation, either version 3 of the License, or
//     (at your option) any later version.
//
//     This program is distributed in the hope that it will be useful,
//     but WITHOUT ANY WARRANTY; without even the implied warranty of
//     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//     GNU Affero General Public License for more details.
//
//     You should have received a copy of the GNU Affero General Public License
//     along with this program.  If not, see <https://www.gnu.org/licenses/>.

use diesel::{Identifiable, Insertable, Queryable, Selectable};

#[derive(Queryable, Selectable, Insertable, Identifiable, Debug, PartialEq, Clone)]
#[diesel(table_name = crate::schema::saves)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct Save {
    pub id: i64,
    pub user_id: i64,
    pub pack: String
}

A starkingdoms-api/src/response.rs => starkingdoms-api/src/response.rs +122 -0
@@ 0,0 1,122 @@
// StarKingdoms.IO, a browser game about drifting through space
//     Copyright (C) 2023 ghostly_zsh, TerraMaster85, core
//
//     This program is free software: you can redistribute it and/or modify
//     it under the terms of the GNU Affero General Public License as published by
//     the Free Software Foundation, either version 3 of the License, or
//     (at your option) any later version.
//
//     This program is distributed in the hope that it will be useful,
//     but WITHOUT ANY WARRANTY; without even the implied warranty of
//     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//     GNU Affero General Public License for more details.
//
//     You should have received a copy of the GNU Affero General Public License
//     along with this program.  If not, see <https://www.gnu.org/licenses/>.
use crate::error::APIErrorsResponse;
use actix_web::body::BoxBody;
use actix_web::error::JsonPayloadError;
use actix_web::http::StatusCode;
use actix_web::{HttpRequest, HttpResponse, Responder};
use serde::Serialize;
use std::fmt::{Debug, Display, Formatter};

#[derive(Debug)]
pub enum JsonAPIResponse<T: Serialize + Debug> {
    Error(StatusCode, APIErrorsResponse),
    Success(StatusCode, T),
}

impl<T: Serialize + Debug> Display for JsonAPIResponse<T> {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        write!(f, "{:?}", self)
    }
}

impl<T: Serialize + Debug> Responder for JsonAPIResponse<T> {
    type Body = BoxBody;

    fn respond_to(self, _: &HttpRequest) -> HttpResponse<Self::Body> {
        match self {
            JsonAPIResponse::Error(c, r) => match serde_json::to_string(&r) {
                Ok(body) => HttpResponse::build(c).body(body),
                Err(err) => HttpResponse::from_error(JsonPayloadError::Serialize(err)),
            },
            JsonAPIResponse::Success(c, b) => match serde_json::to_string(&b) {
                Ok(body) => HttpResponse::build(c).body(body),
                Err(err) => HttpResponse::from_error(JsonPayloadError::Serialize(err)),
            },
        }
    }
}

#[macro_export]
macro_rules! err {
    ($c:expr,$e:expr) => {
        return $crate::response::JsonAPIResponse::Error($c, $e)
    };
}

#[macro_export]
macro_rules! ok {
    ($c:expr,$e:expr) => {
        return $crate::response::JsonAPIResponse::Success($c, $e)
    };
    ($e:expr) => {
        return $crate::response::JsonAPIResponse::Success(actix_web::http::StatusCode::OK, $e)
    };
}

#[macro_export]
macro_rules! internal_error {
    ($e:expr) => {{
        log::error!("internal error: {}", $e);
        $crate::err!(
            actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
            $crate::make_err!("ERR_INTERNAL_ERROR", $e)
        );
    }};
}

#[macro_export]
macro_rules! handle_error {
    ($e:expr,$c:expr,$r:expr) => {
        match $e {
            Ok(r) => r,
            Err(e) => {
                log::error!("error: {}", e);
                $crate::err!($c, $r)
            }
        }
    };
    ($e:expr) => {
        match $e {
            Ok(r) => r,
            Err(e) => {
                $crate::internal_error!(e)
            }
        }
    };
}

#[macro_export]
macro_rules! make_err {
    ($c:expr,$m:expr,$p:expr) => {
        $crate::error::APIErrorsResponse {
            errors: vec![$crate::error::APIErrorResponse {
                code: $c.to_string(),
                message: $m.to_string(),
                path: Some($p.to_string()),
            }],
        }
    };
    ($c:expr,$m:expr) => {
        $crate::error::APIErrorsResponse {
            errors: vec![$crate::error::APIErrorResponse {
                code: $c.to_string(),
                message: $m.to_string(),
                path: None,
            }],
        }
    };
}

A starkingdoms-api/src/routes/mod.rs => starkingdoms-api/src/routes/mod.rs +16 -0
@@ 0,0 1,16 @@
// StarKingdoms.IO, a browser game about drifting through space
//     Copyright (C) 2023 ghostly_zsh, TerraMaster85, core
//
//     This program is free software: you can redistribute it and/or modify
//     it under the terms of the GNU Affero General Public License as published by
//     the Free Software Foundation, either version 3 of the License, or
//     (at your option) any later version.
//
//     This program is distributed in the hope that it will be useful,
//     but WITHOUT ANY WARRANTY; without even the implied warranty of
//     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//     GNU Affero General Public License for more details.
//
//     You should have received a copy of the GNU Affero General Public License
//     along with this program.  If not, see <https://www.gnu.org/licenses/>.
pub mod sign_save;
\ No newline at end of file

A starkingdoms-api/src/routes/sign_save.rs => starkingdoms-api/src/routes/sign_save.rs +100 -0
@@ 0,0 1,100 @@
// StarKingdoms.IO, a browser game about drifting through space
//     Copyright (C) 2023 ghostly_zsh, TerraMaster85, core
//
//     This program is free software: you can redistribute it and/or modify
//     it under the terms of the GNU Affero General Public License as published by
//     the Free Software Foundation, either version 3 of the License, or
//     (at your option) any later version.
//
//     This program is distributed in the hope that it will be useful,
//     but WITHOUT ANY WARRANTY; without even the implied warranty of
//     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//     GNU Affero General Public License for more details.
//
//     You should have received a copy of the GNU Affero General Public License
//     along with this program.  If not, see <https://www.gnu.org/licenses/>.

use std::collections::HashMap;
use std::hash::Hash;
use crate::response::JsonAPIResponse;
use crate::AppState;
use actix_web::{
    http::StatusCode,
    post,
    web::{Data, Json},
};
use log::error;
use serde::{Deserialize, Serialize};
use starkingdoms_common::{__noverify__unpack_savefile, pack_savefile, PartType, SaveModule, unpack_savefile};

#[derive(Deserialize)]
pub struct SignSaveRequest {
    pub old_save: String,
    pub new_partial: String
}

#[derive(Serialize, Debug)]
pub struct SignSaveResponse {
    pub signed_save: String
}

fn inc<T: Hash + PartialEq + Clone + Eq>(a: &mut HashMap<T, u64>, t: &T, by: u64) {
    if let Some(r) = a.get_mut(t) {
        *r += by;
    } else {
        a.insert(t.clone(), by);
    }
}

fn recursive_inc(a: &mut HashMap<PartType, u64>, m: &Option<SaveModule>) {
    if let Some(m) = m {
        inc(a, &m.part_type, 1);
        for child in &m.children {
            recursive_inc(a, child);
        }
    }
}

#[post("/sign_save")]
pub async fn sign_save_req(
    req: Json<SignSaveRequest>,
    state: Data<AppState>,
) -> JsonAPIResponse<SignSaveResponse> {
    let old_save = handle_error!(unpack_savefile(&state.key_raw, req.old_save.clone()));
    let new_save = handle_error!(__noverify__unpack_savefile(req.new_partial.clone()));

    // ensure part counts are the same
    let mut part_counts_old: HashMap<PartType, u64> = HashMap::new();

    for unused_modules in &old_save.unused_modules {
        inc(&mut part_counts_old, &unused_modules.0, unused_modules.1 as u64);
    }
    for child in &old_save.children {
        recursive_inc(&mut part_counts_old, child);
    }

    let mut part_counts_new: HashMap<PartType, u64> = HashMap::new();

    for unused_modules in &new_save.unused_modules {
        inc(&mut part_counts_new, &unused_modules.0, unused_modules.1 as u64);
    }
    for child in &new_save.children {
        recursive_inc(&mut part_counts_new, child);
    }

    if part_counts_old != part_counts_new {
        error!("save validation failed: {:?} -> {:?}", part_counts_old, part_counts_new);
        error!("old file: {:?}", old_save);
        error!("new file: {:?}", new_save);
        err!(
            StatusCode::BAD_REQUEST,
            make_err!("ERR_SAVE_VALIDATION_FAILED", "save validation failed: part counts do not match")
        )
    }

    // resign the savefile and return it to the user
    
    ok!(SignSaveResponse {
        signed_save: pack_savefile(&state.key_raw, new_save)
    })
}

A starkingdoms-api/src/schema.rs => starkingdoms-api/src/schema.rs +9 -0
@@ 0,0 1,9 @@
// @generated automatically by Diesel CLI.

diesel::table! {
    saves (id) {
        id -> Int8,
        user_id -> Int8,
        pack -> Varchar,
    }
}

A starkingdoms-api/src/tokens.rs => starkingdoms-api/src/tokens.rs +26 -0
@@ 0,0 1,26 @@
// StarKingdoms.IO, a browser game about drifting through space
//     Copyright (C) 2023 ghostly_zsh, TerraMaster85, core
//
//     This program is free software: you can redistribute it and/or modify
//     it under the terms of the GNU Affero General Public License as published by
//     the Free Software Foundation, either version 3 of the License, or
//     (at your option) any later version.
//
//     This program is distributed in the hope that it will be useful,
//     but WITHOUT ANY WARRANTY; without even the implied warranty of
//     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//     GNU Affero General Public License for more details.
//
//     You should have received a copy of the GNU Affero General Public License
//     along with this program.  If not, see <https://www.gnu.org/licenses/>.

use serde::{Deserialize, Serialize};
use std::time::SystemTime;

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct UserToken {
    pub id: i64,
    pub username: String,
    pub permission_level: i32,
    pub expires: SystemTime,
}

M starkingdoms-client/src/pages/ShipEditor.svelte => starkingdoms-client/src/pages/ShipEditor.svelte +24 -2
@@ 5,7 5,7 @@
  import "../css/style.scss";
  import HeartIcon from "../icons/HeartIcon.svelte";
  import Popup from "../components/ui/Popup.svelte";
  import { unpack_save } from "../save.ts";
  import {__pack_save_for_api, unpack_save} from "../save.ts";
  import { PartType } from "../protocol.ts";
  import { onMount } from "svelte";
  import { part_texture_url } from "../textures.ts";


@@ 347,7 347,7 @@
    return [part_type, children];
  }

  function save_btn() {
  async function save_btn() {
    if (confirm_save) {
      // todo: @ghostly you need to turn this back into a savefile and then call pack_partial on it
      let children = save_recursive(0, 0, 0).children;


@@ 360,6 360,28 @@
      }
      let save_data = [children, unused_modules];
      console.log(save_data);

      let packed_savefile = __pack_save_for_api(save_data);

      let r = await fetch(config.environments[3].apiBaseUrl + '/sign_save', {
        method: 'POST',
        headers: {
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          old_save: window.localStorage.getItem("save")!,
          new_partial: packed_savefile
        })
      });

      if (r.ok) {
        let body = await r.json();

        // @ts-ignore
        window.localStorage.setItem("save", body.signed_save);
        window.location.href = "/";
      }

      //window.location.href = "/";
    }
    confirm_save = !confirm_save;

M starkingdoms-client/src/save.ts => starkingdoms-client/src/save.ts +14 -2
@@ 12,10 12,22 @@ function bytesToBase64(bytes: any) {
}

export function unpack_save(data: string): any {
    console.log(decode(base64ToBytes(data)));
  // @ts-ignore
  return decode(decode(base64ToBytes(data))[0]);
}

export function pack_partial(data: any): string {
  return bytesToBase64(encode(data));
export function __pack_save_for_api(data: any): string {
    console.log([
        encode(data),
        []
    ]);
  return bytesToBase64(
      encode(
          [
              Array.from(encode(data)),
              []
          ],
      )
  );
}

M starkingdoms-common/src/lib.rs => starkingdoms-common/src/lib.rs +15 -0
@@ 92,3 92,18 @@ pub fn unpack_savefile(key: &[u8], file: String) -> Result<SaveData, Box<dyn Err

    Ok(save_data)
}
pub fn __noverify__unpack_savefile(file: String) -> Result<SaveData, Box<dyn Error>> {
    // << reverse! <<
    let savefile_bytes = base64::engine::general_purpose::STANDARD
        .decode(file)
        .map_err(|e| format!("error decoding b64: {e}"))?;

    let save_file: Savefile = rmp_serde::from_slice(&savefile_bytes)
        .map_err(|e| format!("error decoding savefile wrapper: {e}"))?;


    let save_data = rmp_serde::from_slice(&save_file.data_msgpack)
        .map_err(|e| format!("error decoding inner signature: {e}"))?;

    Ok(save_data)
}