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