~starkingdoms/starkingdoms

97966ecd2b2f0a6c9d76b30e070d899dc951d5a0 — c0repwn3r 2 years ago 6acb9c5
beamin and beamout
M Cargo.lock => Cargo.lock +2 -0
@@ 3307,6 3307,7 @@ dependencies = [
 "serde",
 "sha2",
 "simple_logger",
 "starkingdoms-protocol",
 "starkingdoms_api_entities",
 "starkingdoms_api_migration",
 "tera",


@@ 3337,6 3338,7 @@ dependencies = [
 "nalgebra",
 "rand",
 "rapier2d-f64",
 "reqwest",
 "serde",
 "serde_json",
 "simple_logger",

M api/Cargo.toml => api/Cargo.toml +3 -1
@@ 30,4 30,6 @@ 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
\ No newline at end of file
sha2 = "0.10.6"                                     # Auth

starkingdoms-protocol = { version = "0.1.0", path = "../protocol" }
\ No newline at end of file

M api/src/main.rs => api/src/main.rs +2 -0
@@ 66,6 66,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
            .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(actix_files::Files::new("/static", "static"))
    }).bind(CONFIG.server.bind)?.run().await?;


D api/src/routes/auth.rs => api/src/routes/auth.rs +0 -0
A api/src/routes/beamin.rs => api/src/routes/beamin.rs +111 -0
@@ 0,0 1,111 @@
use std::collections::BTreeMap;
use actix_web::{HttpResponse, post};
use actix_web::web::{Data, Json};
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 crate::AppState;
use crate::config::CONFIG;
use crate::error::{APIError, APIErrorsResponse};

#[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) => {
            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
    })
}
\ No newline at end of file

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

#[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 {})
}
\ No newline at end of file

M api/src/routes/callback.rs => api/src/routes/callback.rs +2 -0
@@ 95,6 95,7 @@ pub async fn callback(query: Query<CallbackQueryParams>, state: Data<AppState>) 
        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();


@@ 116,6 117,7 @@ pub async fn callback(query: Query<CallbackQueryParams>, state: Data<AppState>) 
    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());


M api/src/routes/mod.rs => api/src/routes/mod.rs +3 -2
@@ 1,3 1,4 @@
pub mod auth;
pub mod beamin;
pub mod select_realm;
pub mod callback;
\ No newline at end of file
pub mod callback;
pub mod beamout;
\ No newline at end of file

M api/starkingdoms_api_entities/src/entity/mod.rs => api/starkingdoms_api_entities/src/entity/mod.rs +1 -0
@@ 4,3 4,4 @@ pub mod prelude;

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

M api/starkingdoms_api_entities/src/entity/prelude.rs => api/starkingdoms_api_entities/src/entity/prelude.rs +1 -0
@@ 2,3 2,4 @@

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

M api/starkingdoms_api_entities/src/entity/user_auth_realm.rs => api/starkingdoms_api_entities/src/entity/user_auth_realm.rs +8 -0
@@ 22,6 22,8 @@ pub enum Relation {
        on_delete = "NoAction"
    )]
    User,
    #[sea_orm(has_many = "super::user_savefile::Entity")]
    UserSavefile,
}

impl Related<super::user::Entity> for Entity {


@@ 30,4 32,10 @@ impl Related<super::user::Entity> for Entity {
    }
}

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

impl ActiveModelBehavior for ActiveModel {}

A api/starkingdoms_api_entities/src/entity/user_savefile.rs => api/starkingdoms_api_entities/src/entity/user_savefile.rs +34 -0
@@ 0,0 1,34 @@
//! `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 {}

M api/starkingdoms_api_migration/src/lib.rs => api/starkingdoms_api_migration/src/lib.rs +4 -2
@@ 1,7 1,8 @@
pub use sea_orm_migration::prelude::*;

mod m20230417_162824_create_table_users;
mod m20230417_164240_create_table_user_auth_realms;
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;



@@ 11,6 12,7 @@ impl MigratorTrait for Migrator {
        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),
        ]
    }
}

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

#[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
}

M client/index.html => client/index.html +16 -1
@@ 28,11 28,15 @@
        <input class="m-5px" type="text" name="username" id="username" required />
        <br>
        <button class="m-5px w-full">Launch!</button>
        <br>
        <p id="loginstatus">You are not logged in.</p>
        <button style="display: none;" id="logout">Log out</button>
        <a href="http://localhost:8080/select-realm" id="login">Click here to log in or change accounts.</a>
    </form>
</fieldset>

<script>

    let api_server = "http://localhost:8080";
    let servers = ["localhost:3000"];

    function server_url_to_ping_url(server) { return "http://" + server + "/ping" }


@@ 60,6 64,17 @@
        window.localStorage.setItem("token", query.get("token"));
        window.localStorage.setItem("user", query.get("user"));
    }

    if (window.localStorage.getItem("token") !== null && window.localStorage.getItem("user") !== null) {
        document.getElementById("logout").style.setProperty("display", "block");
        document.getElementById("logout").addEventListener("click", () => {
            window.localStorage.clear();
            window.location.reload();
        })
        document.getElementById("loginstatus").innerText = `Logged in! (you are ${window.localStorage.getItem("user")})`;
    }

    document.getElementById("login").href = `${api_server}/select-realm`;
</script>
</body>
</html>

M protocol/src/api.rs => protocol/src/api.rs +2 -1
@@ 1,6 1,7 @@
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
// ALL FIELDS **MUST** BE WRAPPED IN Option<>
#[derive(Serialize, Deserialize, Default, Clone, Debug)]
pub struct APISavedPlayerData {

}
\ No newline at end of file

M server/Cargo.toml => server/Cargo.toml +1 -0
@@ 26,6 26,7 @@ lazy_static = "1.4.0"
rapier2d-f64 = { version = "0.17.2", features = [ "simd-stable" ] }
nalgebra = "0.32.2"
rand = "0.8.5"
reqwest = "0.11.16"

[build-dependencies]
cargo_metadata = "0.15"

M server/src/api.rs => server/src/api.rs +62 -3
@@ 1,12 1,71 @@
use std::error::Error;
use log::error;
use reqwest::StatusCode;
use serde::{Serialize, Deserialize};
use starkingdoms_protocol::api::APISavedPlayerData;

#[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
}

pub async fn load_player_data_from_api(token: &str, user_id: &str, internal_token: &str) -> Result<APISavedPlayerData, Box<dyn Error>> {
    // TODO
    Ok(APISavedPlayerData {})
    let client = reqwest::Client::new();

    let req_body = BeaminRequest {
        api_token: internal_token.to_owned(),
        user_auth_realm_id: user_id.to_owned(),
        user_auth_token: token.to_owned()
    };

    let res = client.post(format!("{}/beamin", std::env::var("STK_API_URL").unwrap())).header("Content-Type", "application/json").body(serde_json::to_string(&req_body)?).send().await?;

    if res.status() == StatusCode::NO_CONTENT {
        return Ok(APISavedPlayerData::default())
    }

    if res.status() != StatusCode::OK {
        error!("error with API call (status: {}, body: {})", res.status(), res.text().await?);
        return Err("Error with API call".into())
    }

    let resp: BeaminResponse = serde_json::from_str(&res.text().await?)?;

    Ok(resp.save)
}

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

pub async fn save_player_data_to_api(data: &APISavedPlayerData, token: &str, user_id: &str, internal_token: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
    // TODO
    let client = reqwest::Client::new();

    let req_body = BeamoutRequest {
        api_token: internal_token.to_owned(),
        user_auth_realm_id: user_id.to_owned(),
        user_auth_token: token.to_owned(),
        data: data.to_owned()
    };

    let res = client.post(format!("{}/beamout", std::env::var("STK_API_URL").unwrap())).header("Content-Type", "application/json").body(serde_json::to_string(&req_body)?).send().await?;

    if res.status() != StatusCode::OK {
        error!("error with API call (status: {}, body: {})", res.status(), res.text().await?);
        return Err("Error with API call".into())
    }

    Ok(())
}
\ No newline at end of file

M server/src/handler.rs => server/src/handler.rs +2 -0
@@ 185,6 185,8 @@ pub async fn handle_client(mgr: ClientManager, entities: Arc<RwLock<EntityHandle
                                        }
                                    };

                                    info!("[{}] Beamin: loaded player data! {:?}", remote_addr, player_data);

                                    player.load_api_data(&player_data);
                                }


M server/src/main.rs => server/src/main.rs +4 -0
@@ 167,6 167,10 @@ async fn main() {
        error!("Unable to read the API key from STK_API_KEY. Ensure it is set, and has a valid value.");
        std::process::exit(1);
    }
    if std::env::var("STK_API_URL").is_err() {
        error!("Unable to read the API server URL from STK_API_URL. Ensure it is set, and has a valid value.");
        std::process::exit(1);
    }

    let addr = SocketAddr::from(([0, 0, 0, 0], 3000));