~starkingdoms/starkingdoms

7223c99da99e68fea869c26b2f8378fb7c7ad9fd — core 2 years ago 9ea24f2
remove old API in prep for replacement with new Go API
29 files changed, 0 insertions(+), 1179 deletions(-)

D api/Cargo.toml
D api/config.toml
D api/src/config.rs
D api/src/error.rs
D api/src/main.rs
D api/src/routes/beamin.rs
D api/src/routes/beamout.rs
D api/src/routes/callback.rs
D api/src/routes/mod.rs
D api/src/routes/select_realm.rs
D api/src/routes/server_list.rs
D api/starkingdoms_api_entities/Cargo.toml
D api/starkingdoms_api_entities/src/entity/mod.rs
D api/starkingdoms_api_entities/src/entity/prelude.rs
D api/starkingdoms_api_entities/src/entity/user.rs
D api/starkingdoms_api_entities/src/entity/user_auth_realm.rs
D api/starkingdoms_api_entities/src/entity/user_savefile.rs
D api/starkingdoms_api_entities/src/lib.rs
D api/starkingdoms_api_migration/.env
D api/starkingdoms_api_migration/Cargo.toml
D api/starkingdoms_api_migration/README.md
D api/starkingdoms_api_migration/src/lib.rs
D api/starkingdoms_api_migration/src/m20230417_162824_create_table_users.rs
D api/starkingdoms_api_migration/src/m20230417_164240_create_table_user_auth_realms.rs
D api/starkingdoms_api_migration/src/m20230420_144333_create_table_user_data.rs
D api/starkingdoms_api_migration/src/main.rs
D api/static/all.css
D api/templates/base.tera
D api/templates/select_realm.tera
D api/Cargo.toml => api/Cargo.toml +0 -34
@@ 1,34 0,0 @@
[package]
name = "starkingdoms-api"
version = "0.1.0"
edition = "2021"

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

[dependencies]
actix-web = "4"                 # Web framework
actix-request-identifier = "4"  # Web framework
actix-files = "0.6"             # Web framework
actix-cors = "0.6.4"            # Web framework

serde = { version = "1", features = ["derive"] }    # Serialization and deserialization

once_cell = "1" # Config
toml = "0.7"    # Config / Serialization and deserialization

log = "0.4"         # Logging
simple_logger = "4" # Logging

sea-orm = { version = "^0", features = [ "sqlx-postgres", "runtime-actix-rustls", "macros" ]}   # Database
starkingdoms_api_migration = { version = "0.1.0", path = "starkingdoms_api_migration" }                     # Database
starkingdoms_api_entities = { version = "0.1.0", path = "starkingdoms_api_entities" }                       # Database

ulid = "1.0.0"  # Identifiers

tera = "1"  # Templates

jwt = { version = "0.16", features = ["openssl"] }  # Auth
openssl = "0.10"                                    # Auth
reqwest = "0.11"                                    # Auth
hmac = "0.12.1"                                     # Auth
sha2 = "0.10.6"                                     # Auth

D api/config.toml => api/config.toml +0 -22
@@ 1,22 0,0 @@
game = "localhost:5173"
internal_tokens = ["01GY803PVK7YJKXZYWFTK6DS1Y-01GY8040ZQY9SG29DXY4HZ4EPD"]
jwt_signing_secret = "544adbc8144d375d581a1622a4f0cbcf92f006a156ef8b9d4afac6410f51f73c"
base = "localhost:8080"
servers = ["http://localhost:3000"]

[server]
listen = "0.0.0.0:8080"

[realms.discord]
authorize_url = "https://api.e3t.cc/auth/discord/authorize.php"
public_key = "-----BEGIN PUBLIC KEY-----\nMIGeMA0GCSqGSIb3DQEBAQUAA4GMADCBiAKBgHBcZsCM6ebFDCp3dFc+3EOzLw8B\n+fR+9Tx6S/lXOTghk49s7yaxza/zVRPxWaMqyjegfRCEepgV++jbWzBib7bhy91M\n+zlRbeZ9rf++N30Nf4R/XAnUAmhAHt8TzDC08DNQNYAFz37+r4EZlY7APHyND4qU\nd8w3qB95v/wMVB6nAgMBAAE=\n-----END PUBLIC KEY-----"
issuer = "https://api.e3t.cc"

[database]
url = "postgres://postgres@localhost/starkingdoms"

[endpoints]
allowed_return_endpoints = [
    "127.0.0.1:5173",
    "starkingdoms.tk"
]

D api/src/config.rs => api/src/config.rs +0 -90
@@ 1,90 0,0 @@
use log::error;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};

pub static CONFIG: Lazy<StarkingdomsApiConfig> = Lazy::new(|| {
    let config_str = match fs::read_to_string("/etc/starkingdoms/config.toml") {
        Ok(str) => str,
        Err(e) => {
            error!("Unable to read config file: {}", e);
            std::process::exit(1);
        }
    };

    match toml::from_str(&config_str) {
        Ok(cfg) => cfg,
        Err(e) => {
            error!("Unable to parse config file: {}", e);
            std::process::exit(1);
        }
    }
});

#[derive(Serialize, Debug, Deserialize)]
pub struct StarkingdomsApiConfig {
    pub database: StarkingdomsApiConfigDatabase,
    pub server: StarkingdomsApiConfigServer,
    pub internal_tokens: Vec<String>,
    pub jwt_signing_secret: String,
    pub base: String,
    pub game: String,
    pub realms: HashMap<String, StarkingdomsApiConfigRealm>,
    pub servers: Vec<String>,
}

#[derive(Serialize, Deserialize, Debug)]
pub struct StarkingdomsApiConfigDatabase {
    pub url: String,
    #[serde(default = "max_connections_default")]
    pub max_connections: u32,
    #[serde(default = "min_connections_default")]
    pub min_connections: u32,
    #[serde(default = "time_defaults")]
    pub connect_timeout: u64,
    #[serde(default = "time_defaults")]
    pub acquire_timeout: u64,
    #[serde(default = "time_defaults")]
    pub idle_timeout: u64,
    #[serde(default = "time_defaults")]
    pub max_lifetime: u64,
    #[serde(default = "sqlx_logging_default")]
    pub sqlx_logging: bool,
}

#[derive(Serialize, Deserialize, Debug)]
pub struct StarkingdomsApiConfigServer {
    #[serde(default = "socketaddr_8080")]
    pub bind: SocketAddr,
}

/*
authorize-url = "https://api.e3t.cc/auth/discord/authorize.php"
public-key-url = "https://api.e3t.cc/auth/discord/public-key.txt"
issuer = "https://api.e3t.cc"
 */

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct StarkingdomsApiConfigRealm {
    pub authorize_url: String,
    pub public_key: String,
    pub issuer: String,
}

fn max_connections_default() -> u32 {
    100
}
fn min_connections_default() -> u32 {
    5
}
fn time_defaults() -> u64 {
    8
}
fn sqlx_logging_default() -> bool {
    true
}
fn socketaddr_8080() -> SocketAddr {
    SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::from([0, 0, 0, 0]), 8080))
}

D api/src/error.rs => api/src/error.rs +0 -116
@@ 1,116 0,0 @@
use actix_web::error::{JsonPayloadError, PayloadError};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct APIErrorsResponse {
    pub errors: Vec<APIError>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct APIError {
    pub code: String,
    pub message: String,
    #[serde(skip_serializing_if = "is_none")]
    #[serde(default)]
    pub path: Option<String>,
}

fn is_none<T>(o: &Option<T>) -> bool {
    o.is_none()
}

impl From<&JsonPayloadError> for APIError {
    fn from(value: &JsonPayloadError) -> Self {
        match value {
            JsonPayloadError::OverflowKnownLength { length, limit } => {
                APIError {
                    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 } => {
                APIError {
                    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 => {
                APIError {
                    code: "ERR_NOT_JSON".to_string(),
                    message: "Content-Type header not set to expected application/json".to_string(),
                    path: None,
                }
            },
            JsonPayloadError::Deserialize(e) => {
                APIError {
                    code: "ERR_JSON_DESERIALIZE".to_string(),
                    message: format!("Error deserializing JSON: {}", e),
                    path: None,
                }
            },
            JsonPayloadError::Serialize(e) => {
                APIError {
                    code: "ERR_JSON_SERIALIZE".to_string(),
                    message: format!("Error serializing JSON: {}", e),
                    path: None,
                }
            },
            JsonPayloadError::Payload(e) => {
                e.into()
            },
            _ => {
                APIError {
                    code: "ERR_UNKNOWN_ERROR".to_string(),
                    message: "An unknown error has occured".to_string(),
                    path: None,
                }
            }
        }
    }
}

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

D api/src/main.rs => api/src/main.rs +0 -82
@@ 1,82 0,0 @@
use crate::config::CONFIG;
use crate::error::{APIError, APIErrorsResponse};
use actix_cors::Cors;
use actix_request_identifier::RequestIdentifier;
use actix_web::http::header::HeaderValue;
use actix_web::web::{Data, JsonConfig};
use actix_web::{App, HttpResponse, HttpServer};
use log::{info, Level};
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
use starkingdoms_api_migration::{Migrator, MigratorTrait};
use std::error::Error;
use std::time::Duration;
use tera::Tera;

pub mod config;
pub mod error;
pub mod routes;

pub struct AppState {
    pub conn: DatabaseConnection,
    pub templates: Tera,
}

#[actix_web::main]
async fn main() -> Result<(), Box<dyn Error>> {
    simple_logger::init_with_level(Level::Debug).unwrap();

    info!("Connecting to database at {}...", CONFIG.database.url);

    let mut opt = ConnectOptions::new(CONFIG.database.url.clone());
    opt.max_connections(CONFIG.database.max_connections)
        .min_connections(CONFIG.database.min_connections)
        .connect_timeout(Duration::from_secs(CONFIG.database.connect_timeout))
        .acquire_timeout(Duration::from_secs(CONFIG.database.acquire_timeout))
        .idle_timeout(Duration::from_secs(CONFIG.database.idle_timeout))
        .max_lifetime(Duration::from_secs(CONFIG.database.max_lifetime))
        .sqlx_logging(CONFIG.database.sqlx_logging)
        .sqlx_logging_level(log::LevelFilter::Info);

    let db = Database::connect(opt).await?;

    info!("Performing database migration...");
    Migrator::up(&db, None).await?;

    info!("Loading templates...");
    let tera = Tera::new("templates/**/*.tera")?;

    let data = Data::new(AppState {
        conn: db,
        templates: tera,
    });

    HttpServer::new(move || {
        App::new()
            .wrap(Cors::permissive())
            .app_data(data.clone())
            .app_data(JsonConfig::default().error_handler(|err, _req| {
                let api_error: APIError = (&err).into();
                actix_web::error::InternalError::from_response(
                    err,
                    HttpResponse::BadRequest().json(APIErrorsResponse {
                        errors: vec![api_error],
                    }),
                )
                .into()
            }))
            .wrap(RequestIdentifier::with_generator(|| {
                HeaderValue::from_str(&ulid::Ulid::new().to_string()).unwrap()
            }))
            .service(routes::select_realm::select_realm)
            .service(routes::callback::callback)
            .service(routes::beamin::beam_in)
            .service(routes::beamout::beam_out)
            .service(routes::server_list::server_list)
            .service(actix_files::Files::new("/static", "static"))
    })
    .bind(CONFIG.server.bind)?
    .run()
    .await?;

    Ok(())
}

D api/src/routes/beamin.rs => api/src/routes/beamin.rs +0 -107
@@ 1,107 0,0 @@
use crate::config::CONFIG;
use crate::error::{APIError, APIErrorsResponse};
use crate::AppState;
use actix_web::web::{Data, Json};
use actix_web::{post, HttpResponse};
use hmac::digest::KeyInit;
use hmac::Hmac;
use jwt::VerifyWithKey;
use log::error;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use starkingdoms_protocol::api::APISavedPlayerData;
use std::collections::BTreeMap;

#[derive(Serialize, Deserialize)]
pub struct BeaminRequest {
    pub api_token: String,
    pub user_auth_realm_id: String,
    pub user_auth_token: String,
}

#[derive(Serialize, Deserialize)]
pub struct BeaminResponse {
    pub save_id: String,
    pub save: APISavedPlayerData,
}

#[post("/beamin")]
pub async fn beam_in(data: Json<BeaminRequest>, state: Data<AppState>) -> HttpResponse {
    if !CONFIG.internal_tokens.contains(&data.api_token) {
        return HttpResponse::Unauthorized().json(APIErrorsResponse {
            errors: vec![APIError {
                code: "ERR_BAD_TOKEN".to_string(),
                message: "Missing or invalid api token".to_string(),
                path: None,
            }],
        });
    }

    let key: Hmac<Sha256> = Hmac::new_from_slice(CONFIG.jwt_signing_secret.as_bytes()).unwrap();
    let token: BTreeMap<String, String> = match data.user_auth_token.verify_with_key(&key) {
        Ok(t) => t,
        Err(e) => {
            error!("verifying error: {}", e);
            return HttpResponse::Unauthorized().json(APIErrorsResponse {
                errors: vec![APIError {
                    code: "ERR_BAD_TOKEN".to_string(),
                    message: "Missing or invalid user token".to_string(),
                    path: None,
                }],
            });
        }
    };

    if !token.contains_key("user") || !token.contains_key("nonce") {
        return HttpResponse::Unauthorized().json(APIErrorsResponse {
            errors: vec![APIError {
                code: "ERR_BAD_TOKEN".to_string(),
                message: "Missing or invalid user token (missing scopes)".to_string(),
                path: None,
            }],
        });
    }

    let user_id = token.get("user").unwrap();

    let user_savefile: Vec<starkingdoms_api_entities::entity::user_savefile::Model> =
        match starkingdoms_api_entities::entity::user_savefile::Entity::find()
            .filter(starkingdoms_api_entities::entity::user_savefile::Column::User.eq(user_id))
            .order_by_desc(starkingdoms_api_entities::entity::user_savefile::Column::Timestamp)
            .all(&state.conn)
            .await
        {
            Ok(sf) => sf,
            Err(e) => {
                error!("database error: {}", e);
                return HttpResponse::InternalServerError().json(APIErrorsResponse {
                    errors: vec![APIError {
                        code: "ERR_DB_ERROR".to_string(),
                        message: "Unable to fetch user savefiles".to_string(),
                        path: None,
                    }],
                });
            }
        };
    if user_savefile.is_empty() {
        return HttpResponse::NoContent().json(APIErrorsResponse {
            errors: vec![APIError {
                code: "ERR_NO_SAVES".to_string(),
                message: "This user has no savefiles".to_string(),
                path: None,
            }],
        });
    }

    let save = &user_savefile[0];
    let save_id = &save.id;
    let save_data_str = &save.data;
    let save_data: APISavedPlayerData =
        toml::from_str(save_data_str).expect("database contained corrupted player save data");

    HttpResponse::Ok().json(BeaminResponse {
        save_id: save_id.clone(),
        save: save_data,
    })
}

D api/src/routes/beamout.rs => api/src/routes/beamout.rs +0 -92
@@ 1,92 0,0 @@
use crate::config::CONFIG;
use crate::error::{APIError, APIErrorsResponse};
use crate::AppState;
use actix_web::web::{Data, Json};
use actix_web::{post, HttpResponse};
use hmac::digest::KeyInit;
use hmac::Hmac;
use jwt::VerifyWithKey;
use log::error;
use sea_orm::{ActiveModelTrait, IntoActiveModel};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use starkingdoms_protocol::api::APISavedPlayerData;
use std::collections::BTreeMap;
use std::time::{SystemTime, UNIX_EPOCH};
use ulid::Ulid;

#[derive(Serialize, Deserialize)]
pub struct BeamoutRequest {
    pub api_token: String,
    pub user_auth_realm_id: String,
    pub user_auth_token: String,
    pub data: APISavedPlayerData,
}

#[derive(Serialize, Deserialize)]
pub struct BeamoutResponse {}

#[post("/beamout")]
pub async fn beam_out(data: Json<BeamoutRequest>, state: Data<AppState>) -> HttpResponse {
    if !CONFIG.internal_tokens.contains(&data.api_token) {
        return HttpResponse::Unauthorized().json(APIErrorsResponse {
            errors: vec![APIError {
                code: "ERR_BAD_TOKEN".to_string(),
                message: "Missing or invalid api token".to_string(),
                path: None,
            }],
        });
    }

    let key: Hmac<Sha256> = Hmac::new_from_slice(CONFIG.jwt_signing_secret.as_bytes()).unwrap();
    let token: BTreeMap<String, String> = match data.user_auth_token.verify_with_key(&key) {
        Ok(t) => t,
        Err(e) => {
            error!("verifying error: {}", e);
            return HttpResponse::Unauthorized().json(APIErrorsResponse {
                errors: vec![APIError {
                    code: "ERR_BAD_TOKEN".to_string(),
                    message: "Missing or invalid user token".to_string(),
                    path: None,
                }],
            });
        }
    };

    if !token.contains_key("user") || !token.contains_key("nonce") {
        return HttpResponse::Unauthorized().json(APIErrorsResponse {
            errors: vec![APIError {
                code: "ERR_BAD_TOKEN".to_string(),
                message: "Missing or invalid user token (missing scopes)".to_string(),
                path: None,
            }],
        });
    }

    let saved_data = toml::to_string(&data.data).unwrap();
    let savefile_model = starkingdoms_api_entities::entity::user_savefile::Model {
        id: format!("save-{}", Ulid::new().to_string()),
        user: token.get("user").unwrap().clone(),
        data: saved_data,
        timestamp: SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs() as i64,
    };
    let savefile_active_model = savefile_model.into_active_model();
    match savefile_active_model.insert(&state.conn).await {
        Ok(_) => (),
        Err(e) => {
            error!("database error: {}", e);
            return HttpResponse::InternalServerError().json(APIErrorsResponse {
                errors: vec![APIError {
                    code: "ERR_DB_ERROR".to_string(),
                    message: "database failure".to_string(),
                    path: None,
                }],
            });
        }
    }

    HttpResponse::Ok().json(BeamoutResponse {})
}

D api/src/routes/callback.rs => api/src/routes/callback.rs +0 -181
@@ 1,181 0,0 @@
use crate::config::CONFIG;
use crate::error::{APIError, APIErrorsResponse};
use crate::AppState;
use actix_web::web::{Data, Query};
use actix_web::{get, HttpResponse};
use hmac::digest::KeyInit;
use hmac::Hmac;
use jwt::{PKeyWithDigest, SignWithKey, VerifyWithKey};
use log::{debug, error};
use openssl::hash::MessageDigest;
use openssl::pkey::PKey;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter};
use serde::Deserialize;
use sha2::Sha256;
use starkingdoms_api_entities::entity;
use std::collections::BTreeMap;
use std::time::{SystemTime, UNIX_EPOCH};
use ulid::Ulid;

#[derive(Deserialize)]
pub struct CallbackQueryParams {
    pub token: String,
    pub realm: String,
}

#[get("/callback")]
pub async fn callback(query: Query<CallbackQueryParams>, state: Data<AppState>) -> HttpResponse {
    // verify token from auth provider API

    // 1. load the public key
    let realm = match CONFIG.realms.get(&query.realm) {
        Some(r) => r,
        None => {
            error!("realm not found");
            return HttpResponse::Unauthorized().json(APIErrorsResponse {
                errors: vec![APIError {
                    code: "ERR_UNKNOWN_REALM".to_string(),
                    message: "Unknown realm".to_string(),
                    path: None,
                }],
            });
        }
    };

    let rs256_pub_key = PKeyWithDigest {
        digest: MessageDigest::sha256(),
        key: PKey::public_key_from_pem(realm.public_key.as_bytes()).unwrap(),
    };

    let token: BTreeMap<String, String> = match query.token.verify_with_key(&rs256_pub_key) {
        Ok(tkn) => tkn,
        Err(e) => {
            error!("[callback] token verify error: {}", e);
            return generic_unauthorized();
        }
    };

    let realm = match token.get("realm").ok_or(generic_unauthorized()) {
        Ok(r) => r,
        Err(e) => return e,
    };
    let realm_local_id = match token.get("realm_native_id").ok_or(generic_unauthorized()) {
        Ok(r) => r,
        Err(e) => return e,
    };

    debug!(
        "got authenticated realm native authorization: authenticated as {}:{}",
        realm, realm_local_id
    );

    // see if a user on this realm already exists
    let maybe_user = match entity::user_auth_realm::Entity::find()
        .filter(entity::user_auth_realm::Column::RealmNativeId.eq(realm_local_id.clone()))
        .filter(entity::user_auth_realm::Column::Realm.eq(realm.clone()))
        .one(&state.conn)
        .await
    {
        Ok(r) => r,
        Err(e) => {
            error!("database error: {}", e);
            return HttpResponse::InternalServerError().json(APIErrorsResponse {
                errors: vec![APIError {
                    code: "ERR_DB_ERROR".to_string(),
                    message: "Database error".to_string(),
                    path: None,
                }],
            });
        }
    };

    if let Some(user) = maybe_user {
        let key: Hmac<Sha256> = Hmac::new_from_slice(CONFIG.jwt_signing_secret.as_bytes()).unwrap();
        let mut claims = BTreeMap::new();
        claims.insert("user", user.id.clone());
        claims.insert("nonce", Ulid::new().to_string());
        let token_str = claims.sign_with_key(&key).unwrap();
        let auth_url = format!("{}/?token={}&user={}", CONFIG.game, token_str, user.id);
        return HttpResponse::Found()
            .append_header(("Location", auth_url))
            .finish();
    }

    // create the user
    let new_user = entity::user::Model {
        id: format!("user-{}", Ulid::new().to_string()),
        username: Ulid::new().to_string(),
        created_on: SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs() as i64,
    };
    let new_user_realm = entity::user_auth_realm::Model {
        id: format!(
            "{}-{}:{}",
            new_user.id.clone(),
            realm.clone(),
            realm_local_id.clone()
        ),
        realm: realm.clone(),
        realm_native_id: realm_local_id.clone(),
        user: new_user.id.clone(),
    };

    let key: Hmac<Sha256> = Hmac::new_from_slice(CONFIG.jwt_signing_secret.as_bytes()).unwrap();
    let mut claims = BTreeMap::new();
    claims.insert("user", new_user.id.clone());
    claims.insert("nonce", Ulid::new().to_string());
    let token_str = claims.sign_with_key(&key).unwrap();
    let auth_url = format!(
        "{}/?token={}&user={}",
        CONFIG.game,
        token_str,
        new_user.id.clone()
    );

    let new_user_active_model = new_user.into_active_model();
    let new_user_realm_active_model = new_user_realm.into_active_model();

    match new_user_active_model.insert(&state.conn).await {
        Ok(_) => (),
        Err(e) => {
            error!("database error: {}", e);
            return HttpResponse::InternalServerError().json(APIErrorsResponse {
                errors: vec![APIError {
                    code: "ERR_DB_ERROR".to_string(),
                    message: "Database error".to_string(),
                    path: None,
                }],
            });
        }
    }

    match new_user_realm_active_model.insert(&state.conn).await {
        Ok(_) => (),
        Err(e) => {
            error!("database error: {}", e);
            return HttpResponse::InternalServerError().json(APIErrorsResponse {
                errors: vec![APIError {
                    code: "ERR_DB_ERROR".to_string(),
                    message: "Database error".to_string(),
                    path: None,
                }],
            });
        }
    }

    HttpResponse::Found()
        .append_header(("Location", auth_url))
        .finish()
}

fn generic_unauthorized() -> HttpResponse {
    HttpResponse::Unauthorized().json(APIErrorsResponse {
        errors: vec![APIError {
            code: "ERR_INVALID_STATE".to_string(),
            message: "Unknown/invalid login state".to_string(),
            path: None,
        }],
    })
}

D api/src/routes/mod.rs => api/src/routes/mod.rs +0 -5
@@ 1,5 0,0 @@
pub mod beamin;
pub mod beamout;
pub mod callback;
pub mod select_realm;
pub mod server_list;

D api/src/routes/select_realm.rs => api/src/routes/select_realm.rs +0 -50
@@ 1,50 0,0 @@
use crate::config::{StarkingdomsApiConfigRealm, CONFIG};
use crate::error::{APIError, APIErrorsResponse};
use crate::AppState;
use actix_web::web::Data;
use actix_web::{get, HttpResponse};
use log::error;
use serde::Serialize;
use std::collections::HashMap;
use tera::Context;

#[derive(Serialize)]
pub struct RealmsListTemplateContext {
    pub realms: HashMap<String, StarkingdomsApiConfigRealm>,
    pub back_to: String,
}

#[get("/select-realm")]
pub async fn select_realm(state: Data<AppState>) -> HttpResponse {
    let context = match Context::from_serialize(RealmsListTemplateContext {
        back_to: format!("{}/callback", CONFIG.base),
        realms: CONFIG.realms.clone(),
    }) {
        Ok(r) => r,
        Err(e) => {
            error!("[context] error creating render context: {}", e);
            return HttpResponse::InternalServerError().json(APIErrorsResponse {
                errors: vec![APIError {
                    code: "ERR_INTERNAL_SERVER_ERROR".to_string(),
                    message: "There was an error processing your request. Please try again later."
                        .to_string(),
                    path: None,
                }],
            });
        }
    };
    match state.templates.render("select_realm.tera", &context) {
        Ok(r) => HttpResponse::Ok().content_type("text/html").body(r),
        Err(e) => {
            error!("[context] error creating render context: {}", e);
            HttpResponse::InternalServerError().json(APIErrorsResponse {
                errors: vec![APIError {
                    code: "ERR_INTERNAL_SERVER_ERROR".to_string(),
                    message: "There was an error processing your request. Please try again later."
                        .to_string(),
                    path: None,
                }],
            })
        }
    }
}

D api/src/routes/server_list.rs => api/src/routes/server_list.rs +0 -16
@@ 1,16 0,0 @@
use crate::config::CONFIG;
use actix_web::get;
use actix_web::web::Json;
use serde::Serialize;

#[derive(Serialize)]
pub struct ServerListResponse {
    pub servers: Vec<String>,
}

#[get("server-list")]
pub async fn server_list() -> Json<ServerListResponse> {
    Json(ServerListResponse {
        servers: CONFIG.servers.clone(),
    })
}

D api/starkingdoms_api_entities/Cargo.toml => api/starkingdoms_api_entities/Cargo.toml +0 -9
@@ 1,9 0,0 @@
[package]
name = "starkingdoms_api_entities"
version = "0.1.0"
edition = "2021"

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

[dependencies]
sea-orm = { version = "^0" }

D api/starkingdoms_api_entities/src/entity/mod.rs => api/starkingdoms_api_entities/src/entity/mod.rs +0 -7
@@ 1,7 0,0 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.2

pub mod prelude;

pub mod user;
pub mod user_auth_realm;
pub mod user_savefile;

D api/starkingdoms_api_entities/src/entity/prelude.rs => api/starkingdoms_api_entities/src/entity/prelude.rs +0 -5
@@ 1,5 0,0 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.2

pub use super::user::Entity as User;
pub use super::user_auth_realm::Entity as UserAuthRealm;
pub use super::user_savefile::Entity as UserSavefile;

D api/starkingdoms_api_entities/src/entity/user.rs => api/starkingdoms_api_entities/src/entity/user.rs +0 -27
@@ 1,27 0,0 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.2

use sea_orm::entity::prelude::*;

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "user")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    pub id: String,
    #[sea_orm(unique)]
    pub username: String,
    pub created_on: i64,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
    #[sea_orm(has_many = "super::user_auth_realm::Entity")]
    UserAuthRealm,
}

impl Related<super::user_auth_realm::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::UserAuthRealm.def()
    }
}

impl ActiveModelBehavior for ActiveModel {}

D api/starkingdoms_api_entities/src/entity/user_auth_realm.rs => api/starkingdoms_api_entities/src/entity/user_auth_realm.rs +0 -41
@@ 1,41 0,0 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.2

use sea_orm::entity::prelude::*;

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "user_auth_realm")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    pub id: String,
    pub realm: String,
    pub realm_native_id: String,
    pub user: String,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
    #[sea_orm(
        belongs_to = "super::user::Entity",
        from = "Column::User",
        to = "super::user::Column::Id",
        on_update = "NoAction",
        on_delete = "NoAction"
    )]
    User,
    #[sea_orm(has_many = "super::user_savefile::Entity")]
    UserSavefile,
}

impl Related<super::user::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::User.def()
    }
}

impl Related<super::user_savefile::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::UserSavefile.def()
    }
}

impl ActiveModelBehavior for ActiveModel {}

D api/starkingdoms_api_entities/src/entity/user_savefile.rs => api/starkingdoms_api_entities/src/entity/user_savefile.rs +0 -34
@@ 1,34 0,0 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.2

use sea_orm::entity::prelude::*;

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "user_savefile")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    pub id: String,
    pub user: String,
    pub data: String,
    #[sea_orm(unique)]
    pub timestamp: i64,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
    #[sea_orm(
        belongs_to = "super::user_auth_realm::Entity",
        from = "Column::User",
        to = "super::user_auth_realm::Column::Id",
        on_update = "NoAction",
        on_delete = "NoAction"
    )]
    UserAuthRealm,
}

impl Related<super::user_auth_realm::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::UserAuthRealm.def()
    }
}

impl ActiveModelBehavior for ActiveModel {}

D api/starkingdoms_api_entities/src/lib.rs => api/starkingdoms_api_entities/src/lib.rs +0 -1
@@ 1,1 0,0 @@
pub mod entity;

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

D api/starkingdoms_api_migration/Cargo.toml => api/starkingdoms_api_migration/Cargo.toml +0 -24
@@ 1,24 0,0 @@
[package]
name = "starkingdoms_api_migration"
version = "0.1.0"
edition = "2021"
publish = false

[lib]
name = "starkingdoms_api_migration"
path = "src/lib.rs"

[dependencies]
async-std = { version = "1", features = ["attributes", "tokio1"] }

[dependencies.sea-orm-migration]
version = "0.11.0"
features = [
  # Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
  # View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
  # e.g.
  # "runtime-tokio-rustls",  # `ASYNC_RUNTIME` feature
  # "sqlx-postgres",         # `DATABASE_DRIVER` feature
    "runtime-tokio-rustls",
    "sqlx-postgres"
]

D api/starkingdoms_api_migration/README.md => api/starkingdoms_api_migration/README.md +0 -41
@@ 1,41 0,0 @@
# Running Migrator CLI

- Generate a new migration file
    ```sh
    cargo run -- migrate generate MIGRATION_NAME
    ```
- Apply all pending migrations
    ```sh
    cargo run
    ```
    ```sh
    cargo run -- up
    ```
- Apply first 10 pending migrations
    ```sh
    cargo run -- up -n 10
    ```
- Rollback last applied migrations
    ```sh
    cargo run -- down
    ```
- Rollback last 10 applied migrations
    ```sh
    cargo run -- down -n 10
    ```
- Drop all tables from the database, then reapply all migrations
    ```sh
    cargo run -- fresh
    ```
- Rollback all applied migrations, then reapply all migrations
    ```sh
    cargo run -- refresh
    ```
- Rollback all applied migrations
    ```sh
    cargo run -- reset
    ```
- Check the status of all migrations
    ```sh
    cargo run -- status
    ```

D api/starkingdoms_api_migration/src/lib.rs => api/starkingdoms_api_migration/src/lib.rs +0 -18
@@ 1,18 0,0 @@
pub use sea_orm_migration::prelude::*;

pub mod m20230417_162824_create_table_users;
pub mod m20230417_164240_create_table_user_auth_realms;
pub mod m20230420_144333_create_table_user_data;

pub struct Migrator;

#[async_trait::async_trait]
impl MigratorTrait for Migrator {
    fn migrations() -> Vec<Box<dyn MigrationTrait>> {
        vec![
            Box::new(m20230417_162824_create_table_users::Migration),
            Box::new(m20230417_164240_create_table_user_auth_realms::Migration),
            Box::new(m20230420_144333_create_table_user_data::Migration),
        ]
    }
}

D api/starkingdoms_api_migration/src/m20230417_162824_create_table_users.rs => api/starkingdoms_api_migration/src/m20230417_162824_create_table_users.rs +0 -40
@@ 1,40 0,0 @@
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .create_table(
                Table::create()
                    .table(User::Table)
                    .col(ColumnDef::new(User::Id).string().primary_key().not_null())
                    .col(
                        ColumnDef::new(User::Username)
                            .string()
                            .unique_key()
                            .not_null(),
                    )
                    .col(ColumnDef::new(User::CreatedOn).big_unsigned().not_null())
                    .to_owned(),
            )
            .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .drop_table(Table::drop().table(User::Table).to_owned())
            .await
    }
}

/// Learn more at https://docs.rs/sea-query#iden
#[derive(Iden)]
pub enum User {
    Table,
    Id,
    Username,
    CreatedOn,
}

D api/starkingdoms_api_migration/src/m20230417_164240_create_table_user_auth_realms.rs => api/starkingdoms_api_migration/src/m20230417_164240_create_table_user_auth_realms.rs +0 -52
@@ 1,52 0,0 @@
use crate::m20230417_162824_create_table_users::User;
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .create_table(
                Table::create()
                    .table(UserAuthRealm::Table)
                    .col(
                        ColumnDef::new(UserAuthRealm::Id)
                            .string()
                            .not_null()
                            .primary_key(),
                    )
                    .col(ColumnDef::new(UserAuthRealm::Realm).string().not_null())
                    .col(
                        ColumnDef::new(UserAuthRealm::RealmNativeId)
                            .string()
                            .not_null(),
                    )
                    .col(ColumnDef::new(UserAuthRealm::User).string().not_null())
                    .foreign_key(
                        ForeignKey::create()
                            .from(UserAuthRealm::Table, UserAuthRealm::User)
                            .to(User::Table, User::Id),
                    )
                    .to_owned(),
            )
            .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .drop_table(Table::drop().table(UserAuthRealm::Table).to_owned())
            .await
    }
}

/// Learn more at https://docs.rs/sea-query#iden
#[derive(Iden)]
pub enum UserAuthRealm {
    Table,
    Id,
    Realm,
    RealmNativeId,
    User,
}

D api/starkingdoms_api_migration/src/m20230420_144333_create_table_user_data.rs => api/starkingdoms_api_migration/src/m20230420_144333_create_table_user_data.rs +0 -53
@@ 1,53 0,0 @@
use crate::m20230417_164240_create_table_user_auth_realms::UserAuthRealm;
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .create_table(
                Table::create()
                    .table(UserSavefile::Table)
                    .col(
                        ColumnDef::new(UserSavefile::Id)
                            .string()
                            .not_null()
                            .primary_key(),
                    )
                    .col(ColumnDef::new(UserSavefile::User).string().not_null())
                    .col(ColumnDef::new(UserSavefile::Data).string().not_null())
                    .col(
                        ColumnDef::new(UserSavefile::Timestamp)
                            .big_unsigned()
                            .not_null()
                            .unique_key(),
                    )
                    .foreign_key(
                        ForeignKey::create()
                            .from(UserSavefile::Table, UserSavefile::User)
                            .to(UserAuthRealm::Table, UserAuthRealm::Id),
                    )
                    .to_owned(),
            )
            .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .drop_table(Table::drop().table(UserSavefile::Table).to_owned())
            .await
    }
}

/// Learn more at https://docs.rs/sea-query#iden
#[derive(Iden)]
pub enum UserSavefile {
    Table,
    Id,
    User,
    Data,
    Timestamp,
}

D api/starkingdoms_api_migration/src/main.rs => api/starkingdoms_api_migration/src/main.rs +0 -6
@@ 1,6 0,0 @@
use sea_orm_migration::prelude::*;

#[async_std::main]
async fn main() {
    cli::run_cli(starkingdoms_api_migration::Migrator).await;
}

D api/static/all.css => api/static/all.css +0 -0
D api/templates/base.tera => api/templates/base.tera +0 -14
@@ 1,14 0,0 @@
<!DOCTYPE html>
<html lang="en">
    <head>
        {% block head %}
            <title>{% block title %}StarKingdoms API{% endblock title %}</title>
            <link rel="stylesheet" href="/static/all.css"/>
        {% endblock head %}
    </head>
    <body>
        {% block body %}
            <p>This page has no content.</p>
        {% endblock body %}
    </body>
</html>
\ No newline at end of file

D api/templates/select_realm.tera => api/templates/select_realm.tera +0 -11
@@ 1,11 0,0 @@
{% extends "base.tera" %}

{% block title %}Select Auth Provider{% endblock title %}

{% block body %}
    <h1>Select Authentication Provider</h1>
    {% for realm_id, realm in realms %}
        <a href="{{ realm.authorize_url }}?return={{ back_to }}">{{ realm_id }}</a>
    {% endfor %}
    <p>Selecting an authentication provider will redirect you to our parent <a href="https://e3t.cc">e3team</a>'s website for login. You will be returned to StarKingdoms when finished.</p>
{% endblock body %}
\ No newline at end of file