diff --git a/Cargo.lock b/Cargo.lock index faecfd7..4428da1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2617,6 +2617,7 @@ dependencies = [ "env_logger", "git-version", "log", + "oj_rc_core", "tokio", "zip", ] @@ -2700,8 +2701,10 @@ dependencies = [ "polariton 0.3.0", "polariton_server", "rand 0.9.0", + "reqwest", "serde", "serde_json", + "sha2", "tokio", ] @@ -3499,6 +3502,7 @@ checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "futures-util", "http 1.2.0", diff --git a/cdn/Cargo.toml b/cdn/Cargo.toml index 9defe12..55caa8e 100644 --- a/cdn/Cargo.toml +++ b/cdn/Cargo.toml @@ -16,3 +16,5 @@ env_logger.workspace = true tokio = { version = "1.43", features = [ "rt-multi-thread" ] } zip = "4" git-version.workspace = true + +oj_rc_core = { version = "*", path = "../rc_core" } diff --git a/cdn/src/main.rs b/cdn/src/main.rs index 8f545fb..1927964 100644 --- a/cdn/src/main.rs +++ b/cdn/src/main.rs @@ -18,13 +18,16 @@ async fn index() -> impl Responder { async fn main() -> std::io::Result<()> { env_logger::init(); let cli_args = cli::CliArgs::get(); - let cli_args2 = cli_args.clone(); + let cli_args2 = actix_web::web::Data::new(cli_args.clone()); + let internal_auth = actix_web::web::Data::new(crate::robocraft::IntercomAuth::new(&cli_args.data_robocraft)?); HttpServer::new(move || { App::new() - .app_data(actix_web::web::Data::new(cli_args2.clone())) + .app_data(cli_args2.clone()) + .app_data(internal_auth.clone()) .service(index) .service(robocraft::live_data::live_data_json) .service(robocraft::user_avatar::get) + .service(robocraft::user_avatar::post) .service(robocraft::clan_avatar::get) .service(robocraft::brawl_data::get) .service(robocraft::campaign_data::get) diff --git a/cdn/src/robocraft/internal_auth.rs b/cdn/src/robocraft/internal_auth.rs new file mode 100644 index 0000000..f0e6518 --- /dev/null +++ b/cdn/src/robocraft/internal_auth.rs @@ -0,0 +1,82 @@ +pub struct IntercomAuth { + key: Vec, +} + +impl IntercomAuth { + pub fn new(data: impl AsRef) -> std::io::Result { + let key = std::fs::read(data.as_ref().join(oj_rc_core::persist::user::TOKEN_SECRET_FILENAME))?; + Ok(Self { + key, + }) + } + + fn validate_token(&self, received_token: &str, salt: &str) -> Result<(), IntercomOpError> { + let expected_token = oj_rc_core::persist::user::generate_intercom_token(salt.as_bytes(), &self.key); + if received_token.to_lowercase() == expected_token.to_lowercase() { + Ok(()) + } else { + Err(IntercomOpError::Unauthorized) + } + } + + pub fn validate(&self, req: &actix_web::HttpRequest, salt: &str) -> Result<(), IntercomOpError> { + if let Some(auth_header) = req.headers().get("Authorization") { + if let Ok(header_val) = auth_header.to_str() { + if let Some((bearer, token)) = header_val.split_once(" ") { + if bearer.to_lowercase() == "internal" || bearer.to_lowercase() == "bearer" { + self.validate_token(token, salt)?; + } else { + return Err(super::IntercomOpError::BadHeader); + } + } else { + return Err(super::IntercomOpError::BadHeader); + } + } else { + return Err(super::IntercomOpError::BadHeader); + } + } else { + return Err(super::IntercomOpError::Unauthorized); + } + Ok(()) + } +} + +#[derive(Debug)] +pub enum IntercomOpError { + BadHeader, + Unauthorized, + Io(std::io::Error), + #[allow(dead_code)] + Unknown, +} + +impl core::fmt::Display for IntercomOpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Intercom error variant {:?}", self) + } +} + +impl actix_web::error::ResponseError for IntercomOpError { + fn status_code(&self) -> actix_web::http::StatusCode { + match self { + Self::BadHeader => actix_web::http::StatusCode::BAD_REQUEST, + Self::Unauthorized => actix_web::http::StatusCode::FORBIDDEN, + Self::Io(_) => actix_web::http::StatusCode::INSUFFICIENT_STORAGE, + Self::Unknown => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> actix_web::HttpResponse { + match self { + Self::Io(io_e) => { + actix_web::HttpResponse::new(self.status_code()) + .set_body(format!("Intercom IO error: {}", io_e)) + .map_into_boxed_body() + }, + _ => { + actix_web::HttpResponse::new(self.status_code()).set_body(self.to_string()).map_into_boxed_body() + } + } + + } +} diff --git a/cdn/src/robocraft/mod.rs b/cdn/src/robocraft/mod.rs index 8cf0e97..2fc499a 100644 --- a/cdn/src/robocraft/mod.rs +++ b/cdn/src/robocraft/mod.rs @@ -5,5 +5,7 @@ pub mod brawl_data; pub mod campaign_data; pub mod factory; pub mod favicon; +mod internal_auth; +pub use internal_auth::{IntercomAuth, IntercomOpError}; pub(self) const DEFAULT_IMAGE: &str = "default.jpg"; diff --git a/cdn/src/robocraft/user_avatar.rs b/cdn/src/robocraft/user_avatar.rs index ac9f778..1c841a5 100644 --- a/cdn/src/robocraft/user_avatar.rs +++ b/cdn/src/robocraft/user_avatar.rs @@ -1,4 +1,4 @@ -use actix_web::{get, web::{Data, Path}, Responder}; +use actix_web::{get, post, web::{Data, Path, Bytes}, Responder}; #[get("/customavatar/Live/{name}")] pub async fn get(cli: Data, name: Path) -> impl Responder { @@ -11,3 +11,12 @@ pub async fn get(cli: Data, name: Path) -> impl Res actix_files::NamedFile::open_async(std::path::PathBuf::from(&cli.assets_robocraft).join(super::DEFAULT_IMAGE)).await } } + +#[post("/customavatar/Live/{name}")] +pub async fn post(cli: Data, auth: Data, name: Path, body: Bytes, req: actix_web::HttpRequest) -> Result { + auth.validate(&req, &name)?; + let path = std::path::PathBuf::from(&cli.data_robocraft).join("customavatars").join(format!("{}.jpg", *name)); + log::debug!("Saving customavatar for {} to {}: {}B", name, path.display(), body.len()); + std::fs::write(path, &body).map_err(super::IntercomOpError::Io)?; + Ok(actix_web::HttpResponse::NoContent().finish()) +} diff --git a/rc_core/Cargo.toml b/rc_core/Cargo.toml index b44ede7..148812b 100644 --- a/rc_core/Cargo.toml +++ b/rc_core/Cargo.toml @@ -25,5 +25,9 @@ libfj.workspace = true jsonwebtoken = "9" argon2 = { version = "0.5", features = [ "std" ] } +# intercom +sha2 = "0.10" +reqwest = { version = "0.12", default-features = false, features = [ "rustls-tls", "charset" ] } + oj_rc_database = { version = "*", path = "../rc_database" } oj_rc_factory = { version = "*", path = "../rc_factory" } diff --git a/rc_core/src/persist/config/cubes_json.rs b/rc_core/src/persist/config/cubes_json.rs index ad57ff5..42025f7 100644 --- a/rc_core/src/persist/config/cubes_json.rs +++ b/rc_core/src/persist/config/cubes_json.rs @@ -263,6 +263,7 @@ impl super::ConfigProvider for CubeConfig { database: self.settings.server.database.clone(), auto_signup: self.settings.server.auto_signup, queue_mode: super::QueueChangeMode::from_persist(self.settings.server.queue_mode.clone()), + cdn_url: self.settings.server.cdn_url.trim_end_matches('/').to_owned(), } } diff --git a/rc_core/src/persist/config/traits.rs b/rc_core/src/persist/config/traits.rs index 93440bd..11f64e0 100644 --- a/rc_core/src/persist/config/traits.rs +++ b/rc_core/src/persist/config/traits.rs @@ -106,6 +106,7 @@ pub struct ServerConfig { pub database: String, pub auto_signup: bool, pub queue_mode: QueueChangeMode, + pub cdn_url: String, } pub enum QueueChangeMode { diff --git a/rc_core/src/persist/settings.rs b/rc_core/src/persist/settings.rs index 01e5c54..dabe163 100644 --- a/rc_core/src/persist/settings.rs +++ b/rc_core/src/persist/settings.rs @@ -76,6 +76,8 @@ pub struct ServerSettings { pub auto_signup: bool, #[serde(default)] pub queue_mode: QueueMode, + #[serde(default = "default_cdn_root_url")] + pub cdn_url: String, } #[derive(Serialize, Deserialize, Clone, Debug, Default)] @@ -95,5 +97,10 @@ fn default_server_conf() -> ServerSettings { database: default_db_conn(), auto_signup: false, queue_mode: QueueMode::Notify, + cdn_url: default_cdn_root_url(), } } + +fn default_cdn_root_url() -> String { + "http://127.0.0.1:8010".to_owned() +} diff --git a/rc_core/src/persist/user/account_json.rs b/rc_core/src/persist/user/account_json.rs index 015f3de..edbe0aa 100644 --- a/rc_core/src/persist/user/account_json.rs +++ b/rc_core/src/persist/user/account_json.rs @@ -6,7 +6,8 @@ pub struct AccountProvider { cubes: std::sync::Arc>, garage_upgrades: std::sync::Arc, auto_signups: bool, - secret: Vec, + cdn: std::sync::Arc, + secret: std::sync::Arc>, db: std::sync::Arc, } @@ -22,7 +23,8 @@ impl AccountProvider { cubes: std::sync::Arc::new(>::ids(conf)), garage_upgrades: std::sync::Arc::new(>::garage_upgrades(conf)), auto_signups: server_settings.auto_signup, - secret: std::fs::read(&token_path)?, + cdn: std::sync::Arc::new(server_settings.cdn_url), + secret: std::sync::Arc::new(std::fs::read(&token_path)?), db: std::sync::Arc::new(db), }) } @@ -100,7 +102,9 @@ impl super::UserProvider for AccountProvider { perms: user_perms, cubes: self.cubes.clone(), garage_upgrades: self.garage_upgrades.clone(), + cdn: self.cdn.clone(), db: self.db.clone(), + secret: self.secret.clone(), })) //Err("Unable to authenticate".to_string()) } @@ -133,7 +137,9 @@ impl super::UserProvider for AccountProvider { perms: user_perms, cubes: self.cubes.clone(), garage_upgrades: self.garage_upgrades.clone(), + cdn: self.cdn.clone(), db: self.db.clone(), + secret: self.secret.clone(), })) } } @@ -287,13 +293,14 @@ impl super::UserAuthenticator for AccountProvider { } } -#[allow(dead_code)] -struct UserData { - account: oj_rc_database::schema::user::Model, - perms: oj_rc_database::schema::permissions::Model, - cubes: std::sync::Arc>, - garage_upgrades: std::sync::Arc, - db: std::sync::Arc, +pub(super) struct UserData { + pub(super) account: oj_rc_database::schema::user::Model, + pub(super) perms: oj_rc_database::schema::permissions::Model, + pub(super) cubes: std::sync::Arc>, + pub(super) garage_upgrades: std::sync::Arc, + pub(super) cdn: std::sync::Arc, + pub(super) db: std::sync::Arc, + pub(super) secret: std::sync::Arc>, } impl UserData { @@ -1365,7 +1372,6 @@ impl super::LobbyUser for UserData { #[async_trait::async_trait] impl super::MultiplayerUser for UserData { - // TODO fn user_id(&self) -> i32 { self.account.id } diff --git a/rc_core/src/persist/user/intercom.rs b/rc_core/src/persist/user/intercom.rs new file mode 100644 index 0000000..4dee647 --- /dev/null +++ b/rc_core/src/persist/user/intercom.rs @@ -0,0 +1,28 @@ +#[async_trait::async_trait] +impl super::IntercomUser for super::account_json::UserData { + async fn save_custom_avatar(&self, image: Vec) -> Result<(), polariton_server::operations::SimpleOpError> { + // seems to always be jpg + let token = generate_token(self.account.public_id.as_bytes(), &self.secret); + let auth_header_val = format!("Internal {}", token); + let url = format!("{}/customavatar/Live/{}", self.cdn, self.account.public_id); + if let Err(e) = reqwest::Client::new().post(url) + .header("Authorization", auth_header_val) + .body(image) + .send() + .await { + log::error!("Failed to update custom avatar for {} ({}): {}", self.account.public_id, self.account.id, e); + return Err((crate::data::error_codes::WebServicesError::UnexpectedError as i16).into()); + } + Ok(()) + } +} + +pub fn generate_token(salt: &[u8], key: &[u8]) -> String { + use sha2::{Digest, Sha512}; + let mut hasher = Sha512::new(); + hasher.update(salt); + hasher.update(key); + let token_bytes = hasher.finalize(); + hex::encode(&token_bytes[..]) +} + diff --git a/rc_core/src/persist/user/mod.rs b/rc_core/src/persist/user/mod.rs index c666ede..2537a61 100644 --- a/rc_core/src/persist/user/mod.rs +++ b/rc_core/src/persist/user/mod.rs @@ -11,7 +11,10 @@ mod inventory; pub use inventory::UnlockedParts; mod traits; -pub use traits::{UserProvider, User, UserToken, UserSlots, UserSlotData, VehicleData, UserInfo, UserLoginInfo, ExtraUserInfo, UserAuthenticator, NewSlotData, UserId, RegistrationInfo, VehicleUploadData, ChatUser, AvatarInfo, GetAvatarInfo, ControlData, ControlType, CustomisationData, GetCustomisationData, SetSanction, SanctionType, LobbyUser, GameDescriptor, PlayerLobbyDescriptor, MultiplayerUser, MultiplayerError, MultiplayerErrorCode, PlayerDescriptor, GameEventSetter, CurrentGameEvent, AuthError}; +pub use traits::{UserProvider, User, UserToken, UserSlots, UserSlotData, VehicleData, UserInfo, UserLoginInfo, ExtraUserInfo, UserAuthenticator, NewSlotData, UserId, RegistrationInfo, VehicleUploadData, ChatUser, AvatarInfo, GetAvatarInfo, ControlData, ControlType, CustomisationData, GetCustomisationData, SetSanction, SanctionType, LobbyUser, GameDescriptor, PlayerLobbyDescriptor, MultiplayerUser, MultiplayerError, MultiplayerErrorCode, PlayerDescriptor, GameEventSetter, CurrentGameEvent, AuthError, IntercomUser}; + +mod intercom; +pub use intercom::generate_token as generate_intercom_token; pub const TOKEN_SECRET_FILENAME: &str = "token_secret.key"; diff --git a/rc_core/src/persist/user/traits.rs b/rc_core/src/persist/user/traits.rs index 91b7e4f..6b14e1f 100644 --- a/rc_core/src/persist/user/traits.rs +++ b/rc_core/src/persist/user/traits.rs @@ -61,7 +61,7 @@ pub trait UserAuthenticator { } #[async_trait::async_trait] -pub trait User: ChatUser + LobbyUser + MultiplayerUser { +pub trait User: ChatUser + LobbyUser + MultiplayerUser + IntercomUser { fn public_id(&self) -> &'_ str; fn is_mod(&self) -> bool; fn is_admin(&self) -> bool; @@ -328,3 +328,8 @@ pub trait MultiplayerUser { async fn complete_game(&self, guid: &str) -> Result<(), MultiplayerError>; async fn game_info(&self, guid: &str) -> Result, MultiplayerError>; } + +#[async_trait::async_trait] +pub trait IntercomUser { + async fn save_custom_avatar(&self, image: Vec) -> Result<(), polariton_server::operations::SimpleOpError>; +} diff --git a/rc_services_room/src/operations/avatar_set_custom.rs b/rc_services_room/src/operations/avatar_set_custom.rs index 19c33b4..273b6f8 100644 --- a/rc_services_room/src/operations/avatar_set_custom.rs +++ b/rc_services_room/src/operations/avatar_set_custom.rs @@ -9,12 +9,16 @@ pub(super) fn custom_avatar_upload_handler() -> CustomAvatarHandler { CustomAvatarHandler } -async fn do_save(params: ParameterTable<()>, _user: &crate::UserTy) -> Result { +async fn do_save(params: ParameterTable<()>, user: &crate::UserTy) -> Result { let mut params = params.to_dict(); //let user_info = user.user()?; if let Some(Typed::Bytes(image)) = params.remove(&IMG_PARAM_KEY) { if let Some(Typed::Int(format)) = params.remove(&FORMAT_PARAM_KEY) { log::debug!("Got custom avatar ({}B) with format {}", image.vec.len(), format); + if format != 0 { + log::warn!("Got non-jpg format {} for custom avatar, this was assumed to be impossible", format); + } + user.user()?.save_custom_avatar(image.vec).await?; // TODO actually save image /*let info = oj_rc_core::persist::user::AvatarInfo { avatar_id: 0, diff --git a/rc_services_room/src/operations/premium_duration.rs b/rc_services_room/src/operations/premium_duration.rs index e4eac45..b44c58c 100644 --- a/rc_services_room/src/operations/premium_duration.rs +++ b/rc_services_room/src/operations/premium_duration.rs @@ -14,7 +14,7 @@ pub(super) fn premium_remaining_provider() -> SimpleFunc<15, crate::UserTy, impl params.insert(HOURS_PARAM_KEY, Typed::Int(0)); params.insert(MINUTES_PARAM_KEY, Typed::Int(0)); params.insert(SECONDS_PARAM_KEY, Typed::Int(0)); - params.insert(LIFETIME_PARAM_KEY, Typed::Bool(false)); + params.insert(LIFETIME_PARAM_KEY, Typed::Bool(true)); Ok(params.into()) }) }