From 24f8199ceebb555982731ff9c334ef2ab9be0396 Mon Sep 17 00:00:00 2001 From: James Kerr Date: Sat, 13 Dec 2025 11:18:38 +1300 Subject: [PATCH 01/57] refactor: Admin functionality Split up into actor/hexagonal arch --- src/actors.rs | 11 +++ src/application/mod.rs | 1 + src/db/mod.rs | 2 + src/domain/mod.rs | 1 + src/web/admin.rs | 219 ++++++++++++++++++++--------------------- 5 files changed, 123 insertions(+), 111 deletions(-) diff --git a/src/actors.rs b/src/actors.rs index 83e21bd..3451b1b 100644 --- a/src/actors.rs +++ b/src/actors.rs @@ -10,6 +10,7 @@ use crate::{ db::{ item_update_actor::{ItemUpdateActor, ItemUpdateArgs}, properties_actor::{PropertiesActor, PropertiesArgs}, + admin_actor::{AdminActor, AdminArgs}, }, processing::{ bb_actor::{BBActor, BBArgs}, @@ -60,6 +61,16 @@ pub async fn spawn(config: &Config, db: &Surreal) -> Result<(), Whatever> { .await .whatever_context("Spawning properties actor")?; + let (..) = Actor::spawn( + Some("/admin".to_string()), + AdminActor, + AdminArgs { + database: db.clone(), + }, + ) + .await + .whatever_context("Spawning admin actor")?; + let (ml_queue_actor, _) = Actor::spawn( Some("/ml_queue".to_string()), MLQueueActor, diff --git a/src/application/mod.rs b/src/application/mod.rs index ecf46a3..8478811 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -1 +1,2 @@ +pub mod admin_service; pub mod properties_service; diff --git a/src/db/mod.rs b/src/db/mod.rs index 1d9cc5b..9ba165c 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,3 +1,5 @@ +pub mod admin_actor; +pub mod admin_repository; pub mod item_update_actor; pub mod model; pub mod properties_actor; diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 09d2bb6..943419e 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1 +1,2 @@ +pub mod admin; pub mod properties; diff --git a/src/web/admin.rs b/src/web/admin.rs index 4a137a1..00305e4 100644 --- a/src/web/admin.rs +++ b/src/web/admin.rs @@ -1,134 +1,131 @@ -use reqwest::StatusCode; +use ractor::{ActorProcessingErr, RactorErr, call}; use salvo::{ - Depot, Response, Writer, handler, - oapi::{ToSchema, extract::JsonBody}, - prelude::{Json, endpoint}, + Depot, Writer, + oapi::extract::JsonBody, + prelude::{Json, StatusCode, StatusError, endpoint}, }; -use serde::{Deserialize, Serialize}; -use surrealdb::{Surreal, engine::local::Db}; -use tracing::error; +use snafu::{ErrorCompat, prelude::*}; -use crate::db::{ - ItemID, UserID, - model::{Property, Status, User, WorkshopItemProperties}, +use crate::{ + db::{ + UserID, + admin_actor::{ADMIN_ACTOR, AdminMsg}, + model::{Property, User, WorkshopItemProperties}, + }, + domain::admin::{AdminError, PatchRelationshipData, PatchUserData}, }; -#[endpoint] -pub async fn get_users(depot: &mut Depot, response: &mut Response) { - match depot - .obtain::>() - .expect("getting shared state") - .query("SELECT record::id(id) as id, * FROM users") - .await - .map(|mut q| q.take(0)) - { - Ok(Ok(results)) => { - response.render(Json::>>(results)); - } - Ok(Err(e)) | Err(e) => { - error!("{e:?}"); - response.status_code(StatusCode::INTERNAL_SERVER_ERROR); +pub type Result = std::result::Result; +pub type Error = StatusError; + +#[derive(Debug, Snafu)] +#[non_exhaustive] +#[snafu(visibility(pub(crate)))] +enum InnerError { + #[snafu(display("Bad request: {msg}"))] + BadRequest { + msg: String, + }, + Conflict, + InternalError, +} + +impl InnerError { + pub fn status_code(&self) -> StatusCode { + match self { + InnerError::BadRequest { .. } => StatusCode::BAD_REQUEST, + InnerError::Conflict => StatusCode::CONFLICT, + InnerError::InternalError => StatusCode::INTERNAL_SERVER_ERROR, } } } -#[endpoint] -pub async fn patch_user(data: JsonBody, depot: &mut Depot, response: &mut Response) { - let id = UserID::from(data.0.id); - if let Some(banned) = data.0.banned { - let res = depot - .obtain::>() - .expect("getting shared state") - .query("UPDATE $user SET banned=$banned") - .bind(("user", id.clone())) - .bind(("banned", banned)) - .await; - if let Err(e) = res { - error!("{e:?}"); - response.status_code(StatusCode::INTERNAL_SERVER_ERROR); - return; - } +impl From for StatusError { + fn from(value: InnerError) -> Self { + let mut error = StatusError::internal_server_error(); + error.code = value.status_code(); + error.name = value + .status_code() + .canonical_reason() + .unwrap_or_default() + .to_string(); + error.brief = value.to_string(); + error.detail = value.backtrace().map(ToString::to_string); + error } +} - if let Some(admin) = data.0.admin { - let res = depot - .obtain::>() - .expect("getting shared state") - .query("UPDATE $user SET $admin=admin") - .bind(("user", id)) - .bind(("admin", admin)) - .await; - if let Err(e) = res { - error!("{e:?}"); - response.status_code(StatusCode::INTERNAL_SERVER_ERROR); - return; +impl From for InnerError { + fn from(_: ActorProcessingErr) -> Self { + Self::InternalError + } +} +impl From> for InnerError { + fn from(_: RactorErr) -> Self { + Self::InternalError + } +} +impl From for InnerError { + fn from(value: AdminError) -> Self { + match value { + AdminError::BadRequest { msg } => Self::BadRequest { msg }, + AdminError::Conflict => Self::Conflict, + AdminError::Internal => Self::InternalError, } } - response.status_code(StatusCode::NO_CONTENT); } -#[derive(Serialize, Deserialize, Clone, Debug, ToSchema)] -pub struct PatchUser { - pub id: String, - pub banned: Option, - pub admin: Option, +#[endpoint] +pub async fn get_users(_: &mut Depot) -> Result>>> { + let actor = ADMIN_ACTOR + .get() + .cloned() + .ok_or(InnerError::InternalError)?; + let users: Vec> = call!(actor, |reply| AdminMsg::ListUsers(reply)) + .map_err(InnerError::from)? + .map_err(InnerError::from)?; + Ok(Json(users)) } #[endpoint] -pub async fn get_workshop_item_properties(depot: &mut Depot, response: &mut Response) { - match depot - .obtain::>() - .expect("getting shared state") - .query( - "SELECT record::id(in) as in, out.*.id.{class,value} as out, source.to_string(), \ - id.to_string(), * FROM workshop_item_properties", - ) - .await - .map(|mut q| q.take(0)) - { - Ok(Ok(results)) => { - response.render(Json::>>( - results, - )); - } - Ok(Err(e)) | Err(e) => { - error!("{e:?}"); - response.status_code(StatusCode::INTERNAL_SERVER_ERROR); - } - } +pub async fn patch_user(data: JsonBody, _: &mut Depot) -> Result<()> { + let actor = ADMIN_ACTOR + .get() + .cloned() + .ok_or(InnerError::InternalError)?; + call!(actor, |reply| AdminMsg::PatchUser(data.0, reply)) + .map_err(InnerError::from)? + .map_err(InnerError::from)?; + Ok(()) } -#[handler] -pub async fn patch_workshop_item_properties( - data: JsonBody, - depot: &mut Depot, - response: &mut Response, -) { - let res = depot - .obtain::>() - .expect("getting shared state") - .query("LET $link = properties:{class: $class, value: $value}") - .query( - "UPDATE ONLY workshop_item_properties SET status=$status WHERE in = $item AND out = \ - $link;", - ) - .bind(("class", data.0.property.class)) - .bind(("value", data.0.property.value)) - .bind(("item", ItemID::from(data.0.item).into_recordid())) - .bind(("status", data.0.status)) - .await; - if let Err(e) = res { - error!("{e:?}"); - response.status_code(StatusCode::INTERNAL_SERVER_ERROR); - return; - } - response.status_code(StatusCode::NO_CONTENT); +#[endpoint] +pub async fn get_workshop_item_properties( + _: &mut Depot, +) -> Result>>> { + let actor = ADMIN_ACTOR + .get() + .cloned() + .ok_or(InnerError::InternalError)?; + let list = call!(actor, |reply| AdminMsg::ListWorkshopItemProperties(reply)) + .map_err(InnerError::from)? + .map_err(InnerError::from)?; + Ok(Json(list)) } -#[derive(Serialize, Deserialize, Clone, Debug, ToSchema)] -pub struct PatchRelationship { - pub item: String, - #[serde(flatten)] - pub property: Property, - pub status: Status, +#[endpoint] +pub async fn patch_workshop_item_properties( + data: JsonBody, + _: &mut Depot, +) -> Result<()> { + let actor = ADMIN_ACTOR + .get() + .cloned() + .ok_or(InnerError::InternalError)?; + call!(actor, |reply| AdminMsg::PatchWorkshopItemProperty( + data.0, reply + )) + .map_err(InnerError::from)? + .map_err(InnerError::from)?; + Ok(()) } From 40cb51d98478b934faf6ba70ebb2868b9339b454 Mon Sep 17 00:00:00 2001 From: James Kerr Date: Sat, 13 Dec 2025 11:20:21 +1300 Subject: [PATCH 02/57] chore: stash --- src/app_config.rs | 2 +- src/application/admin_service.rs | 40 ++++++++++ src/application/properties_service.rs | 1 + src/apps.rs | 1 + src/db/admin_actor.rs | 79 +++++++++++++++++++ src/db/admin_repository.rs | 109 ++++++++++++++++++++++++++ src/domain/admin.rs | 44 +++++++++++ src/main.rs | 1 + src/web/properties.rs | 5 +- 9 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 src/application/admin_service.rs create mode 100644 src/apps.rs create mode 100644 src/db/admin_actor.rs create mode 100644 src/db/admin_repository.rs create mode 100644 src/domain/admin.rs diff --git a/src/app_config.rs b/src/app_config.rs index 3fc1d98..eedad04 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -13,6 +13,7 @@ pub struct Config { pub force_update: bool, pub base_url: Arc, pub biscuit: Arc, + pub admin_users: Vec, } #[derive(Deserialize, Redact)] pub struct Steam { @@ -31,7 +32,6 @@ pub struct Database { #[redact(all)] pub struct BiscuitConfig { pub private_key: PrivateKey, - // pub lifetime: Duration, } impl<'de> serde::Deserialize<'de> for BiscuitConfig { diff --git a/src/application/admin_service.rs b/src/application/admin_service.rs new file mode 100644 index 0000000..e00fa6f --- /dev/null +++ b/src/application/admin_service.rs @@ -0,0 +1,40 @@ +use crate::{ + db::model::{Property, User, WorkshopItemProperties}, + domain::admin::{AdminError, AdminPort, PatchRelationshipData, PatchUserData}, +}; + +pub struct AdminService { + repo: R, +} + +impl AdminService { + pub fn new(repo: R) -> Self { + Self { repo } + } + + pub async fn list_users(&self) -> Result>, AdminError> { + self.repo.list_users().await + } + + pub async fn patch_user(&self, patch: PatchUserData) -> Result<(), AdminError> { + if patch.admin.is_none() && patch.banned.is_none() { + return Err(AdminError::BadRequest { + msg: "Must set at least one of 'admin' or 'banned'".into(), + }); + } + self.repo.patch_user(patch).await + } + + pub async fn list_workshop_item_properties( + &self, + ) -> Result>, AdminError> { + self.repo.list_workshop_item_properties().await + } + + pub async fn patch_workshop_item_property( + &self, + patch: PatchRelationshipData, + ) -> Result<(), AdminError> { + self.repo.patch_workshop_item_property(patch).await + } +} diff --git a/src/application/properties_service.rs b/src/application/properties_service.rs index f34ec33..044b850 100644 --- a/src/application/properties_service.rs +++ b/src/application/properties_service.rs @@ -1,3 +1,4 @@ + use crate::{ db::model::{Source, Status}, domain::properties::{NewProperty, PropertiesError, PropertiesPort, VoteData}, diff --git a/src/apps.rs b/src/apps.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/apps.rs @@ -0,0 +1 @@ + diff --git a/src/db/admin_actor.rs b/src/db/admin_actor.rs new file mode 100644 index 0000000..1dc6fad --- /dev/null +++ b/src/db/admin_actor.rs @@ -0,0 +1,79 @@ +use std::sync::OnceLock; + +use ractor::{Actor, ActorProcessingErr, ActorRef, RpcReplyPort, async_trait}; +use surrealdb::{Surreal, engine::local::Db}; + +use crate::{ + application::admin_service::AdminService, + db::{ + admin_repository::AdminSilo, + model::{Property, User, WorkshopItemProperties}, + }, + domain::admin::{AdminError, PatchRelationshipData, PatchUserData}, +}; + +pub static ADMIN_ACTOR: OnceLock> = OnceLock::new(); + +pub struct AdminActor; + +pub struct AdminArgs { + pub database: Surreal, +} + +pub struct AdminState { + service: AdminService, +} + +pub enum AdminMsg { + ListUsers(RpcReplyPort>, AdminError>>), + PatchUser(PatchUserData, RpcReplyPort>), + ListWorkshopItemProperties( + RpcReplyPort>, AdminError>>, + ), + PatchWorkshopItemProperty(PatchRelationshipData, RpcReplyPort>), +} + +#[async_trait] +impl Actor for AdminActor { + type Arguments = AdminArgs; + type Msg = AdminMsg; + type State = AdminState; + + async fn pre_start( + &self, + myself: ActorRef, + args: Self::Arguments, + ) -> Result { + ADMIN_ACTOR.get_or_init(|| myself); + Ok(AdminState { + service: AdminService::new(AdminSilo::new(args.database)), + }) + } + + async fn handle( + &self, + _: ActorRef, + message: Self::Msg, + state: &mut Self::State, + ) -> Result<(), ActorProcessingErr> { + match message { + AdminMsg::ListUsers(reply) => { + let res = state.service.list_users().await; + let _ = reply.send(res); + } + AdminMsg::PatchUser(patch, reply) => { + let res = state.service.patch_user(patch).await; + let _ = reply.send(res); + } + AdminMsg::ListWorkshopItemProperties(reply) => { + let res = state.service.list_workshop_item_properties().await; + let _ = reply.send(res); + } + AdminMsg::PatchWorkshopItemProperty(patch, reply) => { + let res = state.service.patch_workshop_item_property(patch).await; + let _ = reply.send(res); + } + } + Ok(()) + } +} diff --git a/src/db/admin_repository.rs b/src/db/admin_repository.rs new file mode 100644 index 0000000..71b2394 --- /dev/null +++ b/src/db/admin_repository.rs @@ -0,0 +1,109 @@ +use surrealdb::{Surreal, engine::local::Db}; +use tracing::error; + +use crate::{ + db::{ + ItemID, UserID, + model::{Property, Status, User, WorkshopItemProperties}, + }, + domain::admin::{AdminError, AdminPort, PatchRelationshipData, PatchUserData}, +}; + +pub struct AdminSilo { + pub db: Surreal, +} + +impl AdminSilo { + pub fn new(db: Surreal) -> Self { + Self { db } + } +} + +impl AdminPort for AdminSilo { + async fn list_users(&self) -> Result>, AdminError> { + match self + .db + .query("SELECT id.to_string() as id, * FROM users") + .await + .map(|mut q| q.take(0)) + { + Ok(Ok(results)) => Ok(results), + Ok(Err(e)) | Err(e) => { + error!(?e, "failed to list users"); + Err(AdminError::Internal) + } + } + } + + async fn patch_user(&self, patch: PatchUserData) -> Result<(), AdminError> { + let id = UserID::from(patch.id); + if let Some(banned) = patch.banned { + if let Err(e) = self + .db + .query("UPDATE $user SET banned=$banned") + .bind(("user", id.clone())) + .bind(("banned", banned)) + .await + { + error!(?e, "failed to update banned flag"); + return Err(AdminError::Internal); + } + } + if let Some(admin) = patch.admin { + if let Err(e) = self + .db + .query("UPDATE $user SET admin=$admin") + .bind(("user", id)) + .bind(("admin", admin)) + .await + { + error!(?e, "failed to update admin flag"); + return Err(AdminError::Internal); + } + } + Ok(()) + } + + async fn list_workshop_item_properties( + &self, + ) -> Result>, AdminError> { + match self + .db + .query( + "SELECT record::id(in) as in, out.*.id.{class,value} as out, source.to_string(), \ + id.to_string(), * FROM workshop_item_properties", + ) + .await + .map(|mut q| q.take(0)) + { + Ok(Ok(results)) => Ok(results), + Ok(Err(e)) | Err(e) => { + error!(?e, "failed to list workshop item properties"); + Err(AdminError::Internal) + } + } + } + + async fn patch_workshop_item_property( + &self, + patch: PatchRelationshipData, + ) -> Result<(), AdminError> { + let res = self + .db + .query("LET $link = properties:{class: $class, value: $value}") + .query( + "UPDATE ONLY workshop_item_properties SET status=$status WHERE in = $item AND out \ + = $link;", + ) + .bind(("class", patch.property.class)) + .bind(("value", patch.property.value)) + .bind(("item", ItemID::from(patch.item).into_recordid())) + .bind(("status", patch.status)) + .await; + if let Err(e) = res { + error!(?e, "failed to patch workshop item property"); + return Err(AdminError::Internal); + } + Ok(()) + } +} diff --git a/src/domain/admin.rs b/src/domain/admin.rs new file mode 100644 index 0000000..d24bd8b --- /dev/null +++ b/src/domain/admin.rs @@ -0,0 +1,44 @@ +use salvo::oapi::ToSchema; +use serde::{Deserialize, Serialize}; +use snafu::prelude::*; + +use crate::db::model::{Property, Status, User, WorkshopItemProperties}; + +#[derive(Debug, Snafu, Clone)] +#[non_exhaustive] +pub enum AdminError { + #[snafu(display("Bad request: {msg}"))] + BadRequest { msg: String }, + #[snafu(display("Conflict"))] + Conflict, + #[snafu(display("Internal error"))] + Internal, +} + +#[derive(Serialize, Deserialize, Clone, Debug, ToSchema)] +pub struct PatchUserData { + pub id: String, + pub banned: Option, + pub admin: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug, ToSchema)] +pub struct PatchRelationshipData { + pub item: String, + #[serde(flatten)] + pub property: Property, + pub status: Status, +} + +/// Port for admin-related persistence operations. +pub trait AdminPort: Send + Sync + 'static { + async fn list_users(&self) -> Result>, AdminError>; + async fn patch_user(&self, patch: PatchUserData) -> Result<(), AdminError>; + async fn list_workshop_item_properties( + &self, + ) -> Result>, AdminError>; + async fn patch_workshop_item_property( + &self, + patch: PatchRelationshipData, + ) -> Result<(), AdminError>; +} diff --git a/src/main.rs b/src/main.rs index f1327ae..af0ccd2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ use tracing_subscriber::fmt::format::FmtSpan; mod actors; mod app_config; mod application; +mod apps; mod db; mod domain; mod processing; diff --git a/src/web/properties.rs b/src/web/properties.rs index 3be60a1..d40e5d8 100644 --- a/src/web/properties.rs +++ b/src/web/properties.rs @@ -10,7 +10,10 @@ use crate::{ db::{ model::{Source, Status}, properties_actor::{PROPERTIES_ACTOR, PropertiesMsg}, - }, + }, db::{ + model::Source, + properties_actor::{PROPERTIES_ACTOR, PropertiesMsg}, +}, domain::properties::{NewProperty, PropertiesError, VoteData}, web::auth, }; From 95ddc6f180575ef8883ef28fab861ca0f3d44b26 Mon Sep 17 00:00:00 2001 From: James Kerr Date: Sat, 13 Dec 2025 11:46:57 +1300 Subject: [PATCH 03/57] feat: Populate admin users from config --- src/db/admin_repository.rs | 4 ++-- src/main.rs | 19 ++++++++++++++++++- src/web/admin.rs | 1 - src/web/properties.rs | 5 +---- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/db/admin_repository.rs b/src/db/admin_repository.rs index 71b2394..d0ee583 100644 --- a/src/db/admin_repository.rs +++ b/src/db/admin_repository.rs @@ -4,7 +4,7 @@ use tracing::error; use crate::{ db::{ ItemID, UserID, - model::{Property, Status, User, WorkshopItemProperties}, + model::{Property, User, WorkshopItemProperties}, }, domain::admin::{AdminError, AdminPort, PatchRelationshipData, PatchUserData}, }; @@ -36,7 +36,7 @@ impl AdminPort for AdminSilo { } async fn patch_user(&self, patch: PatchUserData) -> Result<(), AdminError> { - let id = UserID::from(patch.id); + let id = UserID::from(patch.id).into_recordid(); if let Some(banned) = patch.banned { if let Err(e) = self .db diff --git a/src/main.rs b/src/main.rs index af0ccd2..b5921d4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,9 +4,11 @@ use salvo::__private::tracing::debug; use snafu::{Whatever, prelude::*}; use surrealdb::{Surreal, engine::local::RocksDb, opt::auth::Root}; use surrealdb_migrations::MigrationRunner; -use tracing::{Instrument, info_span}; +use tracing::{Instrument, error, info_span}; use tracing_subscriber::fmt::format::FmtSpan; +use crate::{application::admin_service::AdminService, db::admin_repository::AdminSilo}; + mod actors; mod app_config; mod application; @@ -65,6 +67,21 @@ async fn main() -> Result<()> { .await .whatever_context("Failed to apply migrations")?; debug!("migrations finished"); + { + let admin_service = AdminService::new(AdminSilo::new(db.clone())); + for user in &settings.admin_users { + debug!(%user, "Setting admin flag for user"); + let _ = admin_service + .patch_user(domain::admin::PatchUserData { + id: user.clone(), + banned: None, + admin: Some(true), + }) + .await + .inspect_err(|error| error!(?error, %user, "Failed to set admin flag for user")); + } + } + actors::spawn(&settings, &db) .instrument(info_span!(parent: &span, "spawn actors")) .await?; diff --git a/src/web/admin.rs b/src/web/admin.rs index 00305e4..2df2b37 100644 --- a/src/web/admin.rs +++ b/src/web/admin.rs @@ -8,7 +8,6 @@ use snafu::{ErrorCompat, prelude::*}; use crate::{ db::{ - UserID, admin_actor::{ADMIN_ACTOR, AdminMsg}, model::{Property, User, WorkshopItemProperties}, }, diff --git a/src/web/properties.rs b/src/web/properties.rs index d40e5d8..3be60a1 100644 --- a/src/web/properties.rs +++ b/src/web/properties.rs @@ -10,10 +10,7 @@ use crate::{ db::{ model::{Source, Status}, properties_actor::{PROPERTIES_ACTOR, PropertiesMsg}, - }, db::{ - model::Source, - properties_actor::{PROPERTIES_ACTOR, PropertiesMsg}, -}, + }, domain::properties::{NewProperty, PropertiesError, VoteData}, web::auth, }; From 127894eb2006dfc176b403f465361602c7bf42ea Mon Sep 17 00:00:00 2001 From: James Kerr Date: Sat, 13 Dec 2025 12:36:44 +1300 Subject: [PATCH 04/57] fix: Banning users Ban and admin used different methods, to the same endpoint --- ui/src/routes/admin/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/routes/admin/+page.svelte b/ui/src/routes/admin/+page.svelte index 9954e9b..a76b326 100644 --- a/ui/src/routes/admin/+page.svelte +++ b/ui/src/routes/admin/+page.svelte @@ -58,7 +58,7 @@ async function toggleUserBan(id: number, value: boolean) { users = users.map((u) => (u.id === id ? { ...u, banned: !u.banned } : u)); let res = await fetch('/api/admin/users', { - method: 'patch', + method: 'put', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: id, From 296c15f36aaf00779a211c551abf186a3d20743e Mon Sep 17 00:00:00 2001 From: James Kerr Date: Sat, 13 Dec 2025 12:51:42 +1300 Subject: [PATCH 05/57] fix(prompt): Update themes prompt to remove some of the technical details that have been appearing --- prompts/genres.txt | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/prompts/genres.txt b/prompts/genres.txt index f6a4336..7494a04 100644 --- a/prompts/genres.txt +++ b/prompts/genres.txt @@ -1,20 +1,24 @@ -You are an expert data analyst specializing in video game metadata. Your task is to analyze a list of game modifications (mods) and extract their genres and themes. +You are an expert data analyst specializing in video game metadata. Your task is to analyze a list of game modifications (mods) and extract their narrative, aesthetic, and setting-based genres and themes. ### INSTRUCTIONS: -1. **Identify Genres and Themes:** Carefully analyze the "title" and "description" for each game mod provided. - - **Genre** refers to the *category* or *style* of the game mod (e.g., Fantasy, Sci-Fi, Horror, Realism, Comedy). - - **Theme** refers to the *central idea*, *subject*, or *message* explored (e.g., Survival, Exploration, War, Steampunk, Cyberpunk). -2. **Output Format:** You MUST output a valid JSON object with the following structure: +1. **Identify Genres and Themes:** Carefully analyze the "title" and "description" for each game mod. + * **Genre:** Refers to the **narrative or aesthetic category** of the mod's content (e.g., Fantasy, Sci-Fi, Horror, Realism, Comedy, Historical, Adventure). It describes the *kind of world or story* the mod creates. + * **Theme:** Refers to the **core subject, setting, or narrative focus** of the mod (e.g., Survival, Exploration, War, Steampunk, Cyberpunk, Western, Mystery, Romance). +2. **Exclude Technical & Meta Details:** You must filter out any technical, quality-of-life, or modding framework details. + * **Genres must NOT include:** Simulation (unless referring to a life/narrative simulator), Framework, Library, Utility, Overhaul (if mechanical), etc. + * **Themes must NOT include:** Performance, Bug Fixes, Community, Modding, Upgrades, Compatibility, Graphics, Quality of Life, Gameplay Tweaks, etc. + * Only include genres and themes that describe the mod's fictional content, setting, or narrative purpose. +3. **Output Format:** You MUST output a valid JSON object with the following structure: { "genres": [ "genre1", "genre2", ... ], "themes": [ "theme1", "theme2", ... ] } - - The "genres" array should contain a deduplicated list of all identified genres. - - The "themes" array should contain a deduplicated list of all identified themes. -3. **Output Only JSON:** Do not include any additional text, explanations, or formatting outside the JSON object. Your entire response must be the JSON object only. + * Use clear, standardized terms. Deduplicate all entries within each array. + * If no relevant genres or themes are identified for a category, output an empty array `[]` for it. +4. **Output Only JSON:** Do not include any additional text, explanations, or formatting outside the JSON object. Your entire response must be the JSON object only. ### INPUT DATA: -Here is a game mod with its titles and description: +Here is a game mod with its title and description: TITLE: [TITLE] DESCRIPTION: [DESCRIPTION] \ No newline at end of file From d6989a4ba2e701f09bab55501d4e3e3a0eead7ba Mon Sep 17 00:00:00 2001 From: James Kerr Date: Sat, 13 Dec 2025 12:53:27 +1300 Subject: [PATCH 06/57] feat: Admin ui enhancements --- ui/src/routes/admin/+page.svelte | 237 ++++++++++++++++--------------- 1 file changed, 121 insertions(+), 116 deletions(-) diff --git a/ui/src/routes/admin/+page.svelte b/ui/src/routes/admin/+page.svelte index a76b326..824bd1d 100644 --- a/ui/src/routes/admin/+page.svelte +++ b/ui/src/routes/admin/+page.svelte @@ -84,131 +84,136 @@ {#snippet content()} -
- - -
- - - - - - - - - - - - - - {#each properties as property} - {@debug property} - - - - - - - + + {/each} + +
IDClassValueSubmitted ByStatusActions
{property.in}{property.out.class}{property.out.value}{property.source} - {#if property.status === -1} - Denied - {:else if property.status === 0} - Pending - {:else} - Approved - {/if} - - +
+{/snippet} - - - - - - - - - - - - - - {#each users as user} - {@debug user} - - - - + + + {/each} + +
IDAdminBannedActionsLast Logged In
{user.id} - toggleUserAdmin(user.id, e.target.checked)} - /> - - { +{#snippet usersPanel()} + + + + + + + + + + + + {#each users as user} + + + + + - - - - {/each} - -
IDNameAdminBannedLast Logged In
{user.id}{user.name ?? "unpopulated" } + toggleUserAdmin(user.id, e.target.checked)} + /> + + { toggleUserBan(user.id, e.target.checked); }} - /> - - {user.last_logged_in} - - -
- - {/snippet} - - + /> +
+ {user.last_logged_in} +
+{/snippet} \ No newline at end of file From 68f4bf60501c3b645427f451e496bc1cea293817 Mon Sep 17 00:00:00 2001 From: James Kerr Date: Mon, 22 Dec 2025 15:58:17 +1300 Subject: [PATCH 07/57] fix: fix user listing query in admin repository --- src/db/admin_repository.rs | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/db/admin_repository.rs b/src/db/admin_repository.rs index d0ee583..f3eb739 100644 --- a/src/db/admin_repository.rs +++ b/src/db/admin_repository.rs @@ -23,7 +23,7 @@ impl AdminPort for AdminSilo { async fn list_users(&self) -> Result>, AdminError> { match self .db - .query("SELECT id.to_string() as id, * FROM users") + .query("SELECT id.id().to_string() as id, * FROM users") .await .map(|mut q| q.take(0)) { @@ -37,29 +37,27 @@ impl AdminPort for AdminSilo { async fn patch_user(&self, patch: PatchUserData) -> Result<(), AdminError> { let id = UserID::from(patch.id).into_recordid(); - if let Some(banned) = patch.banned { - if let Err(e) = self + if let Some(banned) = patch.banned + && let Err(e) = self .db .query("UPDATE $user SET banned=$banned") .bind(("user", id.clone())) .bind(("banned", banned)) .await - { - error!(?e, "failed to update banned flag"); - return Err(AdminError::Internal); - } + { + error!(?e, "failed to update banned flag"); + return Err(AdminError::Internal); } - if let Some(admin) = patch.admin { - if let Err(e) = self + if let Some(admin) = patch.admin + && let Err(e) = self .db .query("UPDATE $user SET admin=$admin") .bind(("user", id)) .bind(("admin", admin)) .await - { - error!(?e, "failed to update admin flag"); - return Err(AdminError::Internal); - } + { + error!(?e, "failed to update admin flag"); + return Err(AdminError::Internal); } Ok(()) } From 2ab844b48065324c595ccdcdd933bba61ddfc7b8 Mon Sep 17 00:00:00 2001 From: James Kerr Date: Mon, 22 Dec 2025 15:58:20 +1300 Subject: [PATCH 08/57] refactor: simplify load function in item page --- ui/src/routes/item/[item]/+page.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/src/routes/item/[item]/+page.ts b/ui/src/routes/item/[item]/+page.ts index 4aa6cb0..b5a21bb 100644 --- a/ui/src/routes/item/[item]/+page.ts +++ b/ui/src/routes/item/[item]/+page.ts @@ -1,7 +1,8 @@ import type { PageLoad } from '../../../../.svelte-kit/types/src/routes'; + export const prerender = false; export const load: PageLoad = async ({ fetch, params }) => { const res = await fetch(`/api/item/${params.item}`); - const item = await res.json(); - return item; + + return await res.json(); }; From 0660f8a027cb5b6f7db4e828cf2096e317e59cde Mon Sep 17 00:00:00 2001 From: James Kerr Date: Mon, 22 Dec 2025 15:58:23 +1300 Subject: [PATCH 09/57] refactor: remove unused Depot parameter from admin endpoints --- src/web/admin.rs | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/web/admin.rs b/src/web/admin.rs index 2df2b37..b531c3c 100644 --- a/src/web/admin.rs +++ b/src/web/admin.rs @@ -1,6 +1,6 @@ use ractor::{ActorProcessingErr, RactorErr, call}; use salvo::{ - Depot, Writer, + Writer, oapi::extract::JsonBody, prelude::{Json, StatusCode, StatusError, endpoint}, }; @@ -75,19 +75,19 @@ impl From for InnerError { } #[endpoint] -pub async fn get_users(_: &mut Depot) -> Result>>> { +pub async fn get_users() -> Result>>> { let actor = ADMIN_ACTOR .get() .cloned() .ok_or(InnerError::InternalError)?; - let users: Vec> = call!(actor, |reply| AdminMsg::ListUsers(reply)) + let users: Vec> = call!(actor, AdminMsg::ListUsers) .map_err(InnerError::from)? .map_err(InnerError::from)?; Ok(Json(users)) } #[endpoint] -pub async fn patch_user(data: JsonBody, _: &mut Depot) -> Result<()> { +pub async fn patch_user(data: JsonBody) -> Result<()> { let actor = ADMIN_ACTOR .get() .cloned() @@ -99,24 +99,20 @@ pub async fn patch_user(data: JsonBody, _: &mut Depot) -> Result< } #[endpoint] -pub async fn get_workshop_item_properties( - _: &mut Depot, -) -> Result>>> { +pub async fn get_workshop_item_properties() +-> Result>>> { let actor = ADMIN_ACTOR .get() .cloned() .ok_or(InnerError::InternalError)?; - let list = call!(actor, |reply| AdminMsg::ListWorkshopItemProperties(reply)) + let list = call!(actor, AdminMsg::ListWorkshopItemProperties) .map_err(InnerError::from)? .map_err(InnerError::from)?; Ok(Json(list)) } #[endpoint] -pub async fn patch_workshop_item_properties( - data: JsonBody, - _: &mut Depot, -) -> Result<()> { +pub async fn patch_workshop_item_properties(data: JsonBody) -> Result<()> { let actor = ADMIN_ACTOR .get() .cloned() From cb29c663ba6e6c59feb9ab1654b8d3ba601ff7e2 Mon Sep 17 00:00:00 2001 From: James Kerr Date: Mon, 22 Dec 2025 15:58:57 +1300 Subject: [PATCH 10/57] feat: implement multi-app support across API and UI --- src/actors.rs | 65 ++++-- src/app_config.rs | 1 - src/application/apps_service.rs | 34 +++ src/application/mod.rs | 1 + src/application/properties_service.rs | 1 - src/db/apps_actor.rs | 89 ++++++++ src/db/apps_repository.rs | 93 ++++++++ src/db/mod.rs | 2 + src/db/model.rs | 3 +- src/domain/apps.rs | 25 +++ src/domain/mod.rs | 1 + src/steam/steam_download_actor.rs | 129 ++++++++--- src/web/apps.rs | 128 +++++++++++ src/web/mod.rs | 7 +- src/web/query.rs | 8 + ui/src/routes/+page.svelte | 22 +- ui/src/routes/+page.ts | 7 + ui/src/routes/admin/+page.svelte | 164 ++++++++++++++ ui/src/routes/admin/AdminApps.svelte | 303 ++++++++++++++++++++++++++ ui/src/routes/app/[id]/+page.ts | 2 + 20 files changed, 1016 insertions(+), 69 deletions(-) create mode 100644 src/application/apps_service.rs create mode 100644 src/db/apps_actor.rs create mode 100644 src/db/apps_repository.rs create mode 100644 src/domain/apps.rs create mode 100644 src/web/apps.rs create mode 100644 ui/src/routes/+page.ts create mode 100644 ui/src/routes/admin/AdminApps.svelte diff --git a/src/actors.rs b/src/actors.rs index 3451b1b..c94644c 100644 --- a/src/actors.rs +++ b/src/actors.rs @@ -8,9 +8,10 @@ use tracing::{Instrument, info_span, instrument}; use crate::{ app_config::Config, db::{ + admin_actor::{AdminActor, AdminArgs}, + apps_actor::{AppsActor, AppsArgs}, item_update_actor::{ItemUpdateActor, ItemUpdateArgs}, properties_actor::{PropertiesActor, PropertiesArgs}, - admin_actor::{AdminActor, AdminArgs}, }, processing::{ bb_actor::{BBActor, BBArgs}, @@ -61,16 +62,6 @@ pub async fn spawn(config: &Config, db: &Surreal) -> Result<(), Whatever> { .await .whatever_context("Spawning properties actor")?; - let (..) = Actor::spawn( - Some("/admin".to_string()), - AdminActor, - AdminArgs { - database: db.clone(), - }, - ) - .await - .whatever_context("Spawning admin actor")?; - let (ml_queue_actor, _) = Actor::spawn( Some("/ml_queue".to_string()), MLQueueActor, @@ -83,6 +74,7 @@ pub async fn spawn(config: &Config, db: &Surreal) -> Result<(), Whatever> { .instrument(info_span!("spawn::ml_queue")) .await .whatever_context("Spawning ML queue actor")?; + let (item_update_actor, _) = Actor::spawn( Some("/item_updater".to_string()), ItemUpdateActor {}, @@ -96,36 +88,61 @@ pub async fn spawn(config: &Config, db: &Surreal) -> Result<(), Whatever> { .instrument(info_span!("spawn::item_update")) .await .whatever_context("Spawning item_update actor")?; + let (..) = Actor::spawn( - Some("/auth".to_string()), - AuthActor {}, - AuthArgs { + Some("/admin".to_string()), + AdminActor, + AdminArgs { database: db.clone(), - client: reqwest_client.clone(), - base_url: config.base_url.clone(), - biscuit: config.biscuit.clone(), }, ) - .instrument(info_span!("spawn::auth")) .await - .whatever_context("Spawning auth actor")?; - if config.updater { - let (..) = Actor::spawn( + .whatever_context("Spawning admin actor")?; + + let steam_download_actor = if config.updater { + let (actor, _) = Actor::spawn( Some("/steam-download".to_string()), SteamDownloadActor {}, SteamDownloadArgs { steam_token: config.steam.api_token.clone(), item_processing_actor_ref: item_update_actor, database: db.clone(), - app_id: config.steam.appid, - client: reqwest_client, + client: reqwest_client.clone(), force: config.force_update, }, ) .instrument(info_span!("spawn::steam_download")) .await .whatever_context("Spawning steam download actor")?; - } + Some(actor) + } else { + None + }; + + let (..) = Actor::spawn( + Some("/apps".to_string()), + AppsActor, + AppsArgs { + database: db.clone(), + download_actor: steam_download_actor, + }, + ) + .await + .whatever_context("Spawning apps actor")?; + + let (..) = Actor::spawn( + Some("/auth".to_string()), + AuthActor {}, + AuthArgs { + database: db.clone(), + client: reqwest_client.clone(), + base_url: config.base_url.clone(), + biscuit: config.biscuit.clone(), + }, + ) + .instrument(info_span!("spawn::auth")) + .await + .whatever_context("Spawning auth actor")?; let (..) = Actor::spawn( Some("/item".to_string()), diff --git a/src/app_config.rs b/src/app_config.rs index eedad04..bcf7918 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -19,7 +19,6 @@ pub struct Config { pub struct Steam { #[redact] pub api_token: Arc, - pub appid: u32, } #[derive(Deserialize, Redact)] pub struct Database { diff --git a/src/application/apps_service.rs b/src/application/apps_service.rs new file mode 100644 index 0000000..b693d95 --- /dev/null +++ b/src/application/apps_service.rs @@ -0,0 +1,34 @@ +use crate::{ + db::model::App, + domain::apps::{AppError, AppsPort}, +}; + +pub struct AppsService { + repo: R, +} + +impl AppsService { + pub fn new(repo: R) -> Self { + Self { repo } + } + + pub async fn list_available(&self) -> Result, AppError> { + self.repo.list_available().await + } + + pub async fn upsert(&self, app: App) -> Result<(), AppError> { + self.repo.upsert(app).await + } + + pub async fn remove(&self, id: u32) -> Result<(), AppError> { + self.repo.remove(id).await + } + + pub async fn list(&self) -> Result, AppError> { + self.repo.list().await + } + + pub async fn get(&self, id: u32) -> Result { + self.repo.get(id).await + } +} diff --git a/src/application/mod.rs b/src/application/mod.rs index 8478811..f29f2fd 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -1,2 +1,3 @@ pub mod admin_service; +pub mod apps_service; pub mod properties_service; diff --git a/src/application/properties_service.rs b/src/application/properties_service.rs index 044b850..f34ec33 100644 --- a/src/application/properties_service.rs +++ b/src/application/properties_service.rs @@ -1,4 +1,3 @@ - use crate::{ db::model::{Source, Status}, domain::properties::{NewProperty, PropertiesError, PropertiesPort, VoteData}, diff --git a/src/db/apps_actor.rs b/src/db/apps_actor.rs new file mode 100644 index 0000000..2b7687a --- /dev/null +++ b/src/db/apps_actor.rs @@ -0,0 +1,89 @@ +use std::sync::OnceLock; + +use ractor::{Actor, ActorProcessingErr, ActorRef, RpcReplyPort, async_trait}; +use surrealdb::{Surreal, engine::local::Db}; + +use crate::{ + application::apps_service::AppsService, + db::{apps_repository::AppsSilo, model::App}, + domain::apps::AppError, + steam::steam_download_actor::SteamDownloadMsg, +}; + +pub static APPS_ACTOR: OnceLock> = OnceLock::new(); + +pub struct AppsActor; + +pub struct AppsArgs { + pub database: Surreal, + pub download_actor: Option>, +} + +pub struct AppsState { + service: AppsService, + download_actor: Option>, +} + +pub enum AppsMsg { + ListAvailable(RpcReplyPort, AppError>>), + Upsert(App, RpcReplyPort>), + Remove(u32, RpcReplyPort>), + List(RpcReplyPort, AppError>>), + Get(u32, RpcReplyPort>), +} + +#[async_trait] +impl Actor for AppsActor { + type Arguments = AppsArgs; + type Msg = AppsMsg; + type State = AppsState; + + async fn pre_start( + &self, + myself: ActorRef, + args: Self::Arguments, + ) -> Result { + APPS_ACTOR.get_or_init(|| myself); + Ok(AppsState { + service: AppsService::new(AppsSilo::new(args.database)), + download_actor: args.download_actor, + }) + } + + async fn handle( + &self, + _: ActorRef, + message: Self::Msg, + state: &mut Self::State, + ) -> Result<(), ActorProcessingErr> { + match message { + AppsMsg::ListAvailable(reply) => { + let _ = reply.send(state.service.list_available().await); + } + AppsMsg::Upsert(app, reply) => { + let app_id = app.id; + let res = state.service.upsert(app).await; + if let Some(download_actor) = &state.download_actor { + let _ = download_actor.send_message(SteamDownloadMsg::AddApp(app_id)); + } + let _ = reply.send(res); + } + AppsMsg::Remove(id, reply) => { + let res = state.service.remove(id).await; + if res.is_ok() + && let Some(download_actor) = &state.download_actor + { + let _ = download_actor.send_message(SteamDownloadMsg::RemoveApp(id)); + } + let _ = reply.send(res); + } + AppsMsg::List(reply) => { + let _ = reply.send(state.service.list().await); + } + AppsMsg::Get(id, reply) => { + let _ = reply.send(state.service.get(id).await); + } + } + Ok(()) + } +} diff --git a/src/db/apps_repository.rs b/src/db/apps_repository.rs new file mode 100644 index 0000000..7b64d2f --- /dev/null +++ b/src/db/apps_repository.rs @@ -0,0 +1,93 @@ +use surrealdb::{RecordId, Surreal, engine::local::Db}; +use tracing::error; + +use crate::{ + db::model::App, + domain::apps::{AppError, AppsPort}, +}; + +pub struct AppsSilo { + pub db: Surreal, +} + +impl AppsSilo { + pub fn new(db: Surreal) -> Self { + Self { db } + } +} + +impl AppsPort for AppsSilo { + async fn list_available(&self) -> Result, AppError> { + match self + .db + .query("SELECT *, id.id() FROM apps WHERE available = true") + .await + .map(|mut q| q.take(0)) + { + Ok(Ok(results)) => Ok(results), + Ok(Err(e)) | Err(e) => { + error!(?e, "failed to list available apps"); + Err(AppError::Internal) + } + } + } + + async fn upsert(&self, app: App) -> Result<(), AppError> { + if let Err(e) = self + .db + .query("UPSERT apps CONTENT $app") + .bind(("app", app.clone())) + // .bind(("id", app.id)) + .await + { + error!(?e, "failed to upsert app"); + return Err(AppError::Internal); + } + Ok(()) + } + + async fn remove(&self, id: u32) -> Result<(), AppError> { + if let Err(e) = self + .db + .query("DELETE $id") + .bind(("id", RecordId::from_table_key("apps", i64::from(id)))) + .await + { + error!(?e, "failed to remove app"); + return Err(AppError::Internal); + } + Ok(()) + } + + async fn list(&self) -> Result, AppError> { + match self + .db + .query("SELECT *, id.id() FROM apps") + .await + .map(|mut q| q.take(0)) + { + Ok(Ok(results)) => Ok(results), + Ok(Err(e)) | Err(e) => { + error!(?e, "failed to list apps"); + Err(AppError::Internal) + } + } + } + + async fn get(&self, id: u32) -> Result { + match self + .db + .query("SELECT *, id.id() FROM apps WHERE id = $id") + .bind(("id", id)) + .await + .map(|mut q| q.take(0)) + { + Ok(Ok(Some(app))) => Ok(app), + Ok(Ok(None)) => Err(AppError::NotFound), + Ok(Err(e)) | Err(e) => { + error!(?e, "failed to get app"); + Err(AppError::Internal) + } + } + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 9ba165c..85ac5b7 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,5 +1,7 @@ pub mod admin_actor; pub mod admin_repository; +pub mod apps_actor; +pub mod apps_repository; pub mod item_update_actor; pub mod model; pub mod properties_actor; diff --git a/src/db/model.rs b/src/db/model.rs index 2c65a1b..f027c1f 100644 --- a/src/db/model.rs +++ b/src/db/model.rs @@ -92,7 +92,6 @@ pub fn into_string(key: &RecordIdKey) -> String { } /// A steam workshop app -#[expect(unused, reason = "To be used soon")] #[derive(Serialize, Deserialize, Clone, Debug, ToSchema)] pub struct App { /// The steam ID for an app @@ -109,7 +108,7 @@ pub struct App { /// Whether the app is visible on the index pub available: bool, /// List of tags to select by default - pub default_tags: Vec<()>, + pub default_tags: Vec, } /// A workshop walker user diff --git a/src/domain/apps.rs b/src/domain/apps.rs new file mode 100644 index 0000000..cb48182 --- /dev/null +++ b/src/domain/apps.rs @@ -0,0 +1,25 @@ +use snafu::prelude::*; + +use crate::db::model::App; + +#[derive(Debug, Snafu, Clone)] +#[non_exhaustive] +pub enum AppError { + #[snafu(display("Bad request: {msg}"))] + BadRequest { msg: String }, + #[snafu(display("Conflict"))] + Conflict, + #[snafu(display("Internal error"))] + Internal, + #[snafu(display("Not found"))] + NotFound, +} + +/// Port for app-related persistence operations. +pub trait AppsPort: Send + Sync + 'static { + async fn list_available(&self) -> Result, AppError>; + async fn upsert(&self, app: App) -> Result<(), AppError>; + async fn remove(&self, id: u32) -> Result<(), AppError>; + async fn list(&self) -> Result, AppError>; + async fn get(&self, id: u32) -> Result; +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 943419e..b37e009 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1,2 +1,3 @@ pub mod admin; +pub mod apps; pub mod properties; diff --git a/src/steam/steam_download_actor.rs b/src/steam/steam_download_actor.rs index e998e5d..a11c118 100644 --- a/src/steam/steam_download_actor.rs +++ b/src/steam/steam_download_actor.rs @@ -1,13 +1,19 @@ use std::{ + collections::HashMap, ops::Add, - sync::Arc, + sync::{Arc, OnceLock}, time::{Duration, SystemTime, UNIX_EPOCH}, }; use ractor::{Actor, ActorProcessingErr, ActorRef, async_trait}; use reqwest::Client; use snafu::{ResultExt, Whatever}; -use surrealdb::{Surreal, engine::local::Db}; +use surrealdb::{ + Surreal, + engine::local::Db, + sql::{Cond, Expression, Field, Operator, Value, statements::SelectStatement}, +}; +use tokio::task::JoinHandle; use tracing::{debug, error, info}; use crate::{ @@ -15,13 +21,14 @@ use crate::{ steam::model::{EPublishedFileQueryType, GetPage, IPublishedResponse, SteamRoot}, }; +pub static DOWNLOAD_ACTOR: OnceLock> = OnceLock::new(); + pub struct SteamDownloadActor {} pub struct SteamDownloadArgs { pub steam_token: Arc, pub item_processing_actor_ref: ActorRef, pub database: Surreal, - pub app_id: u32, pub client: Client, pub force: bool, } @@ -29,10 +36,14 @@ pub struct SteamDownloadState { client: Client, steam_token: Arc, item_processing_actor_ref: ActorRef, + apps: HashMap>, + database: Surreal, } pub enum SteamDownloadMsg { Download { app_id: u32, first_page: GetPage }, + AddApp(u32), + RemoveApp(u32), } #[async_trait] impl Actor for SteamDownloadActor { @@ -45,48 +56,48 @@ impl Actor for SteamDownloadActor { myself: ActorRef, args: Self::Arguments, ) -> Result { - // ToDo: do this per app - let timestamp: Option = args - .database - .query("SELECT last_updated FROM workshop_items ORDER BY last_updated DESC LIMIT 1") - .await - .unwrap() - .take((0, "last_updated")) - .unwrap(); - let timestamp = timestamp.unwrap_or(0); - let time_since = SystemTime::now() - .duration_since(UNIX_EPOCH.add(Duration::from_secs(timestamp))) - .unwrap(); - let h12 = Duration::from_secs(60 * 60 * 12); - let message_builder = move || SteamDownloadMsg::Download { - app_id: args.app_id, - first_page: GetPage { - query_type: EPublishedFileQueryType::RankedByLastUpdatedDate, - ..Default::default() - }, + let apps: Vec = { + let mut stmt = SelectStatement::default(); + stmt.expr.0 = vec![Field::Single { + expr: "id".into(), + alias: None, + }]; + stmt.what = vec![Value::Table("apps".into())].into(); + let mut cond = Cond::default(); + cond.0 = Expression::new( + Value::Idiom("enabled".into()), + Operator::Equal, + Value::Bool(true), + ) + .into(); + stmt.cond = Some(cond); + args.database.query(stmt).await?.take(0)? }; - if time_since > h12 || args.force { - myself.send_message(message_builder())?; - info!(period = %humantime::Duration::from(time_since), "newest mod is at least 12 hours out of date; running update now"); - } - myself.send_interval(h12, message_builder); - Ok(Self::State { + let mut state = Self::State { client: args.client, steam_token: args.steam_token, item_processing_actor_ref: args.item_processing_actor_ref, - }) + apps: HashMap::new(), + database: args.database, + }; + for app_id in apps { + start_downloader(&myself, &mut state, app_id, args.force).await; + } + + DOWNLOAD_ACTOR.get_or_init(|| myself); + Ok(state) } async fn handle( &self, - _: ActorRef, + myself: ActorRef, message: Self::Msg, state: &mut Self::State, ) -> Result<(), ActorProcessingErr> { match message { SteamDownloadMsg::Download { app_id, first_page } => { - if let Err(e) = download( + if let Err(error) = download( state, app_id, first_page, @@ -94,7 +105,18 @@ impl Actor for SteamDownloadActor { ) .await { - error!("Downloading workshop items for {app_id} with err: {e:?}"); + error!(app_id, ?error, "Downloading workshop items"); + } + } + SteamDownloadMsg::AddApp(app_id) => { + if !state.apps.contains_key(&app_id) { + start_downloader(&myself, state, app_id, false).await; + } + } + SteamDownloadMsg::RemoveApp(app_id) => { + if let Some(handle) = state.apps.remove(&app_id) { + handle.abort(); + info!(app_id, "Stopped downloading workshop items"); } } } @@ -148,3 +170,46 @@ async fn download( } Ok(()) } + +async fn start_downloader( + myself: &ActorRef, + state: &mut SteamDownloadState, + app_id: u32, + force: bool, +) { + let timestamp: Option = state + .database + .query( + "SELECT last_updated FROM workshop_items WHERE appid = $appid ORDER BY last_updated \ + DESC LIMIT 1", + ) + .bind(("appid", app_id)) + .await + .unwrap() + .take((0, "last_updated")) + .unwrap(); + let timestamp = timestamp.unwrap_or(0); + let time_since = SystemTime::now() + .duration_since(UNIX_EPOCH.add(Duration::from_secs(timestamp))) + .unwrap(); + let h12 = Duration::from_hours(12); + let message_builder = move || SteamDownloadMsg::Download { + app_id, + first_page: GetPage { + query_type: EPublishedFileQueryType::RankedByLastUpdatedDate, + ..Default::default() + }, + }; + if time_since > h12 || force { + let _ = myself.send_message(message_builder()); + info!(period = %humantime::Duration::from(time_since),app = app_id, "newest mod is at least 12 hours out of date; running update now"); + } + + if let Some(old) = state + .apps + .insert(app_id, myself.send_interval(h12, message_builder)) + { + // Remember to abort the old timer + old.abort(); + } +} diff --git a/src/web/apps.rs b/src/web/apps.rs new file mode 100644 index 0000000..7ce546f --- /dev/null +++ b/src/web/apps.rs @@ -0,0 +1,128 @@ +use ractor::{ActorProcessingErr, RactorErr, call}; +use reqwest::StatusCode; +use salvo::{ + http::StatusError, + oapi::{ + endpoint, + extract::{JsonBody, QueryParam}, + }, + prelude::*, +}; +use snafu::{ErrorCompat, Snafu}; + +use crate::{ + db::{ + apps_actor::{APPS_ACTOR, AppsMsg}, + model::App, + }, + domain::apps::AppError, +}; + +pub type Result = std::result::Result; +pub type Error = StatusError; + +#[derive(Debug, Snafu)] +#[non_exhaustive] +#[snafu(visibility(pub(crate)))] +enum InnerError { + #[snafu(display("Bad request: {msg}"))] + BadRequest { + msg: String, + }, + Conflict, + InternalError, + Unavailable, + NotFound, +} + +impl InnerError { + pub fn status_code(&self) -> StatusCode { + match self { + InnerError::BadRequest { .. } => StatusCode::BAD_REQUEST, + InnerError::Conflict => StatusCode::CONFLICT, + InnerError::InternalError => StatusCode::INTERNAL_SERVER_ERROR, + InnerError::Unavailable => StatusCode::SERVICE_UNAVAILABLE, + InnerError::NotFound => StatusCode::NOT_FOUND, + } + } +} + +impl From for StatusError { + fn from(value: InnerError) -> Self { + let mut error = StatusError::internal_server_error(); + error.code = value.status_code(); + error.name = value + .status_code() + .canonical_reason() + .unwrap_or_default() + .to_string(); + error.brief = value.to_string(); + error.detail = value.backtrace().map(ToString::to_string); + error + } +} + +impl From for InnerError { + fn from(_: ActorProcessingErr) -> Self { + Self::InternalError + } +} +impl From> for InnerError { + fn from(_: RactorErr) -> Self { + Self::InternalError + } +} +impl From for InnerError { + fn from(value: AppError) -> Self { + match value { + AppError::BadRequest { msg } => Self::BadRequest { msg }, + AppError::Conflict => Self::Conflict, + AppError::Internal => Self::InternalError, + AppError::NotFound => Self::NotFound, + } + } +} + +#[endpoint] +pub async fn list_available() -> Result>> { + let actor = APPS_ACTOR.get().ok_or(InnerError::Unavailable)?; + let apps = call!(actor, AppsMsg::ListAvailable) + .map_err(InnerError::from)? + .map_err(InnerError::from)?; + Ok(Json(apps)) +} + +#[endpoint] +pub async fn upsert(app: JsonBody) -> Result<()> { + let actor = APPS_ACTOR.get().ok_or(InnerError::Unavailable)?; + call!(actor, |reply| AppsMsg::Upsert(app.0, reply)) + .map_err(InnerError::from)? + .map_err(InnerError::from)?; + Ok(()) +} +#[endpoint] +pub async fn remove(id: QueryParam) -> Result<()> { + let actor = APPS_ACTOR.get().ok_or(InnerError::Unavailable)?; + call!(actor, |reply| AppsMsg::Remove(*id, reply)) + .map_err(InnerError::from)? + .map_err(InnerError::from)?; + Ok(()) +} + +#[endpoint] +pub async fn list() -> Result>> { + let actor = APPS_ACTOR.get().ok_or(InnerError::Unavailable)?; + let apps = call!(actor, AppsMsg::List) + .map_err(InnerError::from)? + .map_err(InnerError::from)?; + Ok(Json(apps)) +} + +#[endpoint] +pub async fn get(id: QueryParam) -> Result> { + let actor = APPS_ACTOR.get().ok_or(InnerError::Unavailable)?; + let app = call!(actor, |reply| AppsMsg::Get(*id, reply)) + .map_err(InnerError::from)? + .map_err(InnerError::from)?; + Ok(Json(app)) +} diff --git a/src/web/mod.rs b/src/web/mod.rs index c8fbf56..2b37e9f 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -1,4 +1,5 @@ mod admin; +mod apps; pub mod auth; mod companions; pub mod item; @@ -59,8 +60,12 @@ pub async fn start(db: Surreal, config: Arc) { Router::with_path("users") .get(admin::get_users) .put(admin::patch_user), - ), + ) + .push(Router::with_path("apps").get(apps::list).post(apps::upsert)) + .push(Router::with_path("app").delete(apps::remove)), ) + .push(Router::with_path("app/{id}").get(apps::get)) + .push(Router::with_path("apps").get(apps::list_available)) .hoop(affix_state::inject(config)) .push(Router::with_path("login").get(auth::redirect_to_steam_auth)) .push(Router::with_path("verify").get(auth::verify_token_from_steam)) diff --git a/src/web/query.rs b/src/web/query.rs index f53abd2..728f82f 100644 --- a/src/web/query.rs +++ b/src/web/query.rs @@ -27,6 +27,7 @@ use crate::{ #[endpoint] pub async fn list( _: &mut Request, + app: QueryParam, page: QueryParam, limit: QueryParam, languages: QueryParam, @@ -40,6 +41,7 @@ pub async fn list( let db: &Surreal = DB_POOL.get().expect("Getting db connection"); #[instrument(skip_all)] async fn query( + app: u64, page: u64, limit: u64, languages: Option, @@ -169,6 +171,11 @@ pub async fn list( Value::Strand(title_query.into()), ) }), + Some(Expression::new( + Value::Idiom("appid".into()), + Operator::Equal, + Value::Number(app.into()), + )), ] .into_iter() .flatten() @@ -267,6 +274,7 @@ pub async fn list( .collect()) } let results = query( + app.into_inner(), page, limit, *languages, diff --git a/ui/src/routes/+page.svelte b/ui/src/routes/+page.svelte index 0fb800f..f856189 100644 --- a/ui/src/routes/+page.svelte +++ b/ui/src/routes/+page.svelte @@ -1,5 +1,8 @@ @@ -9,14 +12,17 @@ -
- +
+ {#each data.items as app} + + {/each} { + const res = await fetch(`/api/apps`); + const apps = await res.json(); + + return { items: apps }; +}; diff --git a/ui/src/routes/admin/+page.svelte b/ui/src/routes/admin/+page.svelte index 824bd1d..87e13cd 100644 --- a/ui/src/routes/admin/+page.svelte +++ b/ui/src/routes/admin/+page.svelte @@ -1,5 +1,6 @@
@@ -80,6 +142,7 @@ {#snippet list()} Properties Users + Apps {/snippet} {#snippet content()} @@ -91,6 +154,11 @@ {@render usersPanel()} + + + + {@render appsPanel()} + {/snippet}
@@ -216,4 +284,100 @@ {/each} +{/snippet} + +{#snippet appsPanel()} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/snippet} \ No newline at end of file diff --git a/ui/src/routes/admin/AdminApps.svelte b/ui/src/routes/admin/AdminApps.svelte new file mode 100644 index 0000000..ba3d41e --- /dev/null +++ b/ui/src/routes/admin/AdminApps.svelte @@ -0,0 +1,303 @@ + + +{#if loading} +

Loading…

+{:else if error} +

{error}

+{/if} + +
+ + + {#each apps as state (state.localKey)} +
+ + + + {#if !state.collapsed} +
save(state)} + > +
+ + + + + + +