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)
+}