From 2c9bf64244a8493efc8e6c832b2e9313f651fda0 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Sun, 9 Aug 2026 01:42:52 +0700 Subject: [PATCH 1/6] change player subscription handling a bit --- src/api/websocket/endpoint.rs | 141 ++++++++++++++++++++++++---------- src/api/websocket/structs.rs | 53 +++++++++++-- 2 files changed, 150 insertions(+), 44 deletions(-) diff --git a/src/api/websocket/endpoint.rs b/src/api/websocket/endpoint.rs index 4be527c..17faf7f 100644 --- a/src/api/websocket/endpoint.rs +++ b/src/api/websocket/endpoint.rs @@ -33,8 +33,8 @@ use crate::api::{ /// Max UUIDs in a single `SubscribePlayers` or `GetActiveCosmetics` message. const MAX_PLAYERS_PER_REQUEST: usize = 64; -/// Max distinct players a connection may subscribe to at once (render distance). -const MAX_PLAYER_SUBSCRIPTIONS: usize = 128; +const MAX_PLAYER_SUBSCRIPTIONS: usize = 512; +const REAL_PLAYER_UUID_VERSION: usize = 4; fn enforce_max_players_per_request(players: &[Uuid]) -> Result<(), WebsocketError> { if players.len() > MAX_PLAYERS_PER_REQUEST { @@ -45,6 +45,21 @@ fn enforce_max_players_per_request(players: &[Uuid]) -> Result<(), WebsocketErro Ok(()) } +fn is_fake_player(player: &Uuid) -> bool { + player.get_version_num() != REAL_PLAYER_UUID_VERSION +} + +fn empty_snapshot(rejected: Vec, request_id: Option) -> ClientBoundPacket { + ClientBoundPacket::SubscriptionSnapshot { + equipped: HashMap::new(), + active_emotes: HashMap::new(), + particle_colors: HashMap::new(), + users: Vec::new(), + rejected, + request_id, + } +} + pub(super) fn router() -> ApiRouter { ApiRouter::new() .route("/websocket", get(self::endpoint)) @@ -377,42 +392,49 @@ async fn subscribe( state: &ApiState, connection_id: ConnectionId, players: Vec, + request_id: Option, ) -> Result { - let requested = players.into_iter().collect::>(); - let newly_subscribed = { + let mut seen = HashSet::new(); + let requested = players + .into_iter() + .filter(|player| seen.insert(*player)) + .collect::>(); + + let (newly_subscribed, rejected) = { let mut connections = state.realtime.connections.write().await; let Some(connection) = connections.get_mut(&connection_id) else { - return Ok(ClientBoundPacket::SubscriptionSnapshot { - equipped: HashMap::new(), - active_emotes: HashMap::new(), - particle_colors: HashMap::new(), - users: Vec::new(), - }); + return Ok(empty_snapshot(Vec::new(), request_id)); }; - let pending = requested - .iter() - .filter(|player| !connection.subscriptions.contains(player)) - .count(); - if connection.subscriptions.len() + pending > MAX_PLAYER_SUBSCRIPTIONS { - return Err(WebsocketError::SubscriptionLimitExceeded { - limit: MAX_PLAYER_SUBSCRIPTIONS, - }); + let mut newly_subscribed = Vec::new(); + let mut rejected = Vec::new(); + for player in requested { + if is_fake_player(&player) { + rejected.push(player); + continue; + } + if connection.subscriptions.contains(&player) { + continue; + } + if connection.subscriptions.len() >= MAX_PLAYER_SUBSCRIPTIONS { + rejected.push(player); + continue; + } + connection.subscriptions.insert(player); + newly_subscribed.push(player); } - - requested - .into_iter() - .filter(|player| connection.subscriptions.insert(*player)) - .collect::>() + (newly_subscribed, rejected) }; + if !rejected.is_empty() { + warn!( + "Connection {connection_id} rejected {} subscription(s) (cap {MAX_PLAYER_SUBSCRIPTIONS})", + rejected.len() + ); + } + if newly_subscribed.is_empty() { - return Ok(ClientBoundPacket::SubscriptionSnapshot { - equipped: HashMap::new(), - active_emotes: HashMap::new(), - particle_colors: HashMap::new(), - users: Vec::new(), - }); + return Ok(empty_snapshot(rejected, request_id)); } { @@ -480,6 +502,8 @@ async fn subscribe( active_emotes, particle_colors, users, + rejected, + request_id, }) } @@ -530,14 +554,28 @@ async fn broadcast_to_watchers( } } +struct RequestError { + error: WebsocketError, + request_id: Option, +} + +impl From for RequestError { + fn from(error: WebsocketError) -> Self { + Self { + error, + request_id: None, + } + } +} + async fn handle_msg( socket: &mut WebSocket, state: &ApiState, player: &entities::user::Model, connection_id: ConnectionId, msg: Result, -) -> Result<(), WebsocketError> { - let msg = msg?; +) -> Result<(), RequestError> { + let msg = msg.map_err(WebsocketError::from)?; // Ignore control/keepalive frames. Ping/Pong carry an opaque payload (Ktor // sends a Ping every pingInterval) that is not a serializable request, and @@ -548,7 +586,20 @@ async fn handle_msg( let parsed = serde_json::from_slice::(&msg.into_data()) .map_err(WebsocketError::Deserialization)?; + let request_id = parsed.request_id(); + handle_packet(socket, state, player, connection_id, parsed) + .await + .map_err(|error| RequestError { error, request_id }) +} + +async fn handle_packet( + socket: &mut WebSocket, + state: &ApiState, + player: &entities::user::Model, + connection_id: ConnectionId, + parsed: ServerBoundPacket, +) -> Result<(), WebsocketError> { match parsed { ServerBoundPacket::GetActiveCosmetics { players } => { enforce_max_players_per_request(&players)?; @@ -560,9 +611,12 @@ async fn handle_msg( ) .await?; } - ServerBoundPacket::SubscribePlayers { players } => { + ServerBoundPacket::SubscribePlayers { + players, + request_id, + } => { enforce_max_players_per_request(&players)?; - let snapshot = subscribe(state, connection_id, players).await?; + let snapshot = subscribe(state, connection_id, players, request_id).await?; send_packet(socket, snapshot).await?; } ServerBoundPacket::UnsubscribePlayers { players } => { @@ -670,8 +724,14 @@ async fn endpoint( let equipped = match load_equipped(&state, player.id).await { Ok(equipped) => equipped, Err(error) => { - let _ = - send_packet(&mut socket, ClientBoundPacket::Error { error }).await; + let _ = send_packet( + &mut socket, + ClientBoundPacket::Error { + error, + request_id: None, + }, + ) + .await; return; } }; @@ -697,15 +757,18 @@ async fn endpoint( let Some(packet) = packet else { break; }; - send_packet(&mut socket, packet).await + send_packet(&mut socket, packet).await.map_err(RequestError::from) } }; match result { Ok(_) => continue, - Err(WebsocketError::Fatal(_)) => break, - Err(e) => { - let e = ClientBoundPacket::Error { error: e }; + Err(RequestError { + error: WebsocketError::Fatal(_), + .. + }) => break, + Err(RequestError { error, request_id }) => { + let e = ClientBoundPacket::Error { error, request_id }; if send_packet(&mut socket, e).await.is_err() { break; }; diff --git a/src/api/websocket/structs.rs b/src/api/websocket/structs.rs index b51ddb4..6d6b8a1 100644 --- a/src/api/websocket/structs.rs +++ b/src/api/websocket/structs.rs @@ -23,8 +23,6 @@ pub enum WebsocketError { UnownedEmote(i32), #[error("Too many players in one request (max {limit})")] TooManyPlayersInRequest { limit: usize }, - #[error("Too many player subscriptions (max {limit})")] - SubscriptionLimitExceeded { limit: usize }, } impl WebsocketError { @@ -37,8 +35,7 @@ impl WebsocketError { Self::DatabaseQuery(_) | Self::Serialization(_) => Self::ERROR_CODES[1], Self::Deserialization(_) | Self::InvalidSlot { .. } - | Self::TooManyPlayersInRequest { .. } - | Self::SubscriptionLimitExceeded { .. } => Self::ERROR_CODES[2], + | Self::TooManyPlayersInRequest { .. } => Self::ERROR_CODES[2], Self::UnownedCosmetic(_) | Self::UnownedEmote(_) => Self::ERROR_CODES[3], } } @@ -92,6 +89,8 @@ pub enum ServerBoundPacket { SubscribePlayers { /// Player UUIDs to watch. Capped per request and per connection total. players: Vec, + #[serde(default)] + request_id: Option, }, UnsubscribePlayers { players: Vec, @@ -133,6 +132,8 @@ pub enum ClientBoundPacket { /// The subset of subscribed players that currently have a live PolyPlus /// session connected. Used to render a "uses PolyPlus" indicator. users: Vec, + rejected: Vec, + request_id: Option, }, /// A subscribed player's PolyPlus session came online or went offline. PlayerPresence { @@ -166,9 +167,19 @@ pub enum ClientBoundPacket { Error { #[serde(flatten)] error: WebsocketError, + request_id: Option, }, } +impl ServerBoundPacket { + pub fn request_id(&self) -> Option { + match self { + Self::SubscribePlayers { request_id, .. } => *request_id, + _ => None, + } + } +} + #[cfg(test)] mod tests { use std::collections::HashMap; @@ -203,13 +214,28 @@ mod tests { .expect("packet should parse"); match packet { - ServerBoundPacket::SubscribePlayers { players } => { + ServerBoundPacket::SubscribePlayers { + players, + request_id, + } => { assert_eq!(players, vec![player]); + assert_eq!(request_id, None); } _ => panic!("unexpected packet variant"), } } + #[test] + fn parses_player_subscription_with_request_id() { + let player = Uuid::nil(); + let packet: ServerBoundPacket = serde_json::from_str(&format!( + r#"{{"type":"SubscribePlayers","players":["{player}"],"request_id":7}}"# + )) + .expect("packet should parse"); + + assert_eq!(packet.request_id(), Some(7)); + } + #[test] fn serializes_subscription_snapshot_packet() { let player = Uuid::nil(); @@ -218,12 +244,29 @@ mod tests { active_emotes: HashMap::from([(player, 6)]), particle_colors: HashMap::from([(player, 0xFF_0000)]), users: vec![player], + rejected: Vec::new(), + request_id: Some(3), }; let serialized = serde_json::to_value(packet).expect("packet should serialize"); assert_eq!(serialized["type"], "SubscriptionSnapshot"); assert_eq!(serialized["equipped"][player.to_string()]["cape"], 1); assert_eq!(serialized["users"][0], player.to_string()); + assert_eq!(serialized["rejected"].as_array().map(Vec::len), Some(0)); + assert_eq!(serialized["request_id"], 3); + } + + #[test] + fn serializes_error_packet_with_request_id() { + let packet = ClientBoundPacket::Error { + error: super::WebsocketError::TooManyPlayersInRequest { limit: 64 }, + request_id: Some(9), + }; + + let serialized = serde_json::to_value(packet).expect("packet should serialize"); + assert_eq!(serialized["type"], "Error"); + assert_eq!(serialized["error_code"], "bad_request"); + assert_eq!(serialized["request_id"], 9); } #[test] From 8f3df6a9ef262c5e44d88f7b602c84093b260a10 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:31:29 +0200 Subject: [PATCH 2/6] refactor: version the API --- src/api/docs.rs | 100 ++++++ src/api/mod.rs | 119 +++---- src/api/state.rs | 336 ------------------ src/api/state/mod.rs | 182 ++++++++++ src/api/state/persistence.rs | 174 +++++++++ src/api/state/realtime.rs | 49 +++ src/api/{ => v0}/account/login.rs | 2 +- src/api/{ => v0}/account/mod.rs | 8 +- src/api/{ => v0}/analytics.rs | 11 +- src/api/{ => v0}/assets.rs | 0 src/api/{ => v0}/bundles/manage/create.rs | 27 +- src/api/{ => v0}/bundles/manage/delete.rs | 0 src/api/{ => v0}/bundles/manage/mod.rs | 0 src/api/{ => v0}/bundles/manage/update.rs | 9 +- src/api/{ => v0}/bundles/mod.rs | 18 +- src/api/{ => v0}/bundles/search.rs | 22 +- src/api/{ => v0}/bundles/view.rs | 2 +- src/api/{ => v0}/category/list.rs | 0 src/api/{ => v0}/category/mod.rs | 0 src/api/{ => v0}/collections/create.rs | 2 +- src/api/{ => v0}/collections/delete.rs | 0 src/api/{ => v0}/collections/edit.rs | 2 +- src/api/{ => v0}/collections/list.rs | 0 src/api/{ => v0}/collections/mod.rs | 10 +- src/api/{ => v0}/collections/view.rs | 0 src/api/{ => v0}/cosmetics/cover.rs | 0 src/api/{ => v0}/cosmetics/get_player.rs | 8 +- src/api/{ => v0}/cosmetics/grant.rs | 5 +- src/api/{ => v0}/cosmetics/list.rs | 2 +- src/api/{ => v0}/cosmetics/list_capes.rs | 2 +- src/api/{ => v0}/cosmetics/manage/create.rs | 39 +- src/api/{ => v0}/cosmetics/manage/delete.rs | 0 src/api/{ => v0}/cosmetics/manage/mod.rs | 0 .../{ => v0}/cosmetics/manage/render_cover.rs | 4 +- src/api/{ => v0}/cosmetics/manage/update.rs | 9 +- src/api/{ => v0}/cosmetics/mod.rs | 38 +- src/api/{ => v0}/cosmetics/put_player.rs | 3 +- src/api/{ => v0}/cosmetics/search.rs | 78 +--- src/api/{ => v0}/cosmetics/view.rs | 2 +- src/api/{ => v0}/links.rs | 140 ++------ src/api/v0/mod.rs | 37 ++ src/api/{ => v0}/players.rs | 2 +- src/api/{ => v0}/stripe/create.rs | 2 +- src/api/{ => v0}/stripe/mod.rs | 0 src/api/{ => v0}/stripe/pricing.rs | 0 src/api/{ => v0}/stripe/products.rs | 5 - src/api/{ => v0}/stripe/webhook.rs | 6 +- src/api/{ => v0}/tags/apply.rs | 0 src/api/{ => v0}/tags/create.rs | 0 src/api/{ => v0}/tags/list.rs | 0 src/api/{ => v0}/tags/mod.rs | 0 src/api/{ => v0}/tags/remove.rs | 0 src/api/{ => v0}/transactions/mod.rs | 0 src/api/{ => v0}/transactions/player.rs | 2 +- src/api/{ => v0}/websocket/endpoint.rs | 6 +- src/api/{ => v0}/websocket/mod.rs | 0 src/api/{ => v0}/websocket/structs.rs | 0 src/api/v1/hello.rs | 34 ++ src/api/v1/mod.rs | 9 + src/database.rs | 11 +- src/main.rs | 2 + src/utils/hash.rs | 56 +++ src/utils/mod.rs | 8 + src/utils/money.rs | 17 + src/utils/pagination.rs | 12 + src/utils/serde.rs | 28 ++ src/utils/time.rs | 18 + src/utils/user_agent.rs | 65 ++++ src/utils/validation.rs | 55 +++ src/utils/zip.rs | 64 ++++ 70 files changed, 1098 insertions(+), 744 deletions(-) create mode 100644 src/api/docs.rs delete mode 100644 src/api/state.rs create mode 100644 src/api/state/mod.rs create mode 100644 src/api/state/persistence.rs create mode 100644 src/api/state/realtime.rs rename src/api/{ => v0}/account/login.rs (98%) rename src/api/{ => v0}/account/mod.rs (100%) rename src/api/{ => v0}/analytics.rs (98%) rename src/api/{ => v0}/assets.rs (100%) rename src/api/{ => v0}/bundles/manage/create.rs (95%) rename src/api/{ => v0}/bundles/manage/delete.rs (100%) rename src/api/{ => v0}/bundles/manage/mod.rs (100%) rename src/api/{ => v0}/bundles/manage/update.rs (97%) rename src/api/{ => v0}/bundles/mod.rs (100%) rename src/api/{ => v0}/bundles/search.rs (92%) rename src/api/{ => v0}/bundles/view.rs (97%) rename src/api/{ => v0}/category/list.rs (100%) rename src/api/{ => v0}/category/mod.rs (100%) rename src/api/{ => v0}/collections/create.rs (99%) rename src/api/{ => v0}/collections/delete.rs (100%) rename src/api/{ => v0}/collections/edit.rs (99%) rename src/api/{ => v0}/collections/list.rs (100%) rename src/api/{ => v0}/collections/mod.rs (88%) rename src/api/{ => v0}/collections/view.rs (100%) rename src/api/{ => v0}/cosmetics/cover.rs (100%) rename src/api/{ => v0}/cosmetics/get_player.rs (97%) rename src/api/{ => v0}/cosmetics/grant.rs (97%) rename src/api/{ => v0}/cosmetics/list.rs (97%) rename src/api/{ => v0}/cosmetics/list_capes.rs (97%) rename src/api/{ => v0}/cosmetics/manage/create.rs (96%) rename src/api/{ => v0}/cosmetics/manage/delete.rs (100%) rename src/api/{ => v0}/cosmetics/manage/mod.rs (100%) rename src/api/{ => v0}/cosmetics/manage/render_cover.rs (97%) rename src/api/{ => v0}/cosmetics/manage/update.rs (98%) rename src/api/{ => v0}/cosmetics/mod.rs (90%) rename src/api/{ => v0}/cosmetics/put_player.rs (97%) rename src/api/{ => v0}/cosmetics/search.rs (88%) rename src/api/{ => v0}/cosmetics/view.rs (99%) rename src/api/{ => v0}/links.rs (73%) create mode 100644 src/api/v0/mod.rs rename src/api/{ => v0}/players.rs (97%) rename src/api/{ => v0}/stripe/create.rs (98%) rename src/api/{ => v0}/stripe/mod.rs (100%) rename src/api/{ => v0}/stripe/pricing.rs (100%) rename src/api/{ => v0}/stripe/products.rs (88%) rename src/api/{ => v0}/stripe/webhook.rs (99%) rename src/api/{ => v0}/tags/apply.rs (100%) rename src/api/{ => v0}/tags/create.rs (100%) rename src/api/{ => v0}/tags/list.rs (100%) rename src/api/{ => v0}/tags/mod.rs (100%) rename src/api/{ => v0}/tags/remove.rs (100%) rename src/api/{ => v0}/transactions/mod.rs (100%) rename src/api/{ => v0}/transactions/player.rs (98%) rename src/api/{ => v0}/websocket/endpoint.rs (99%) rename src/api/{ => v0}/websocket/mod.rs (100%) rename src/api/{ => v0}/websocket/structs.rs (100%) create mode 100644 src/api/v1/hello.rs create mode 100644 src/api/v1/mod.rs create mode 100644 src/utils/hash.rs create mode 100644 src/utils/mod.rs create mode 100644 src/utils/money.rs create mode 100644 src/utils/pagination.rs create mode 100644 src/utils/serde.rs create mode 100644 src/utils/time.rs create mode 100644 src/utils/user_agent.rs create mode 100644 src/utils/validation.rs create mode 100644 src/utils/zip.rs diff --git a/src/api/docs.rs b/src/api/docs.rs new file mode 100644 index 0000000..ff0e41e --- /dev/null +++ b/src/api/docs.rs @@ -0,0 +1,100 @@ +use aide::{openapi::OpenApi, redoc::Redoc, scalar::Scalar, swagger::Swagger}; +use axum::{ + http::header, + response::Html, + routing::{MethodRouter, get}, +}; + +/// An API version with its own OpenAPI document and documentation pages. +#[derive(Debug, Clone, Copy)] +pub(super) struct DocVersion { + number: u8, + base: &'static str, +} + +pub(super) const V0: DocVersion = DocVersion { + number: 0, + base: "", +}; + +pub(super) const V1: DocVersion = DocVersion { + number: 1, + base: "/v1", +}; + +/// A documentation UI that every version is rendered into. +#[derive(Debug, Clone, Copy)] +pub(super) enum DocPage { + Scalar, + Swagger, + Redoc, +} + +impl DocVersion { + /// How the version is referred to in prose, e.g. `v0`. + pub(super) fn label(self) -> String { + format!("v{}", self.number) + } + + pub(super) fn document_version(self) -> String { + self.number.to_string() + } + + pub(super) fn title(self) -> String { + format!("Poly+ API ({})", self.label()) + } + + /// Path this version's OpenAPI document is served from. + pub(super) fn spec_url(self) -> String { + format!("{}/openapi.json", self.base) + } + + /// Path one of this version's documentation pages is served from. + pub(super) fn page_url(self, page: DocPage) -> String { + format!("{}/{}", self.base, page.name()) + } +} + +impl DocPage { + /// Every page, so that each version can be mounted into all of them. + pub(super) const ALL: &'static [Self] = &[Self::Scalar, Self::Swagger, Self::Redoc]; + + fn name(self) -> &'static str { + match self { + Self::Scalar => "scalar", + Self::Swagger => "swagger", + Self::Redoc => "redoc", + } + } +} + +/// Serves an already rendered OpenAPI document as JSON. +pub(super) fn spec_route(spec: &OpenApi) -> MethodRouter +where + S: Clone + Send + Sync + 'static, +{ + let json = serde_json::to_string(spec) + .expect("Unable to render OpenAPI documentation as JSON") + .into_boxed_str(); + + get(move || async move { ([(header::CONTENT_TYPE, "application/json")], json) }) +} + +/// Serves the given documentation page for the given version. +pub(super) fn page_route(version: DocVersion, page: DocPage) -> MethodRouter +where + S: Clone + Send + Sync + 'static, +{ + let spec_url = version.spec_url(); + let title = version.title(); + + let html = match page { + DocPage::Scalar => Scalar::new(spec_url).with_title(&title).html(), + DocPage::Swagger => Swagger::new(spec_url).with_title(&title).html(), + DocPage::Redoc => Redoc::new(spec_url).with_title(&title).html(), + }; + + let html: &'static str = Box::leak(html.into_boxed_str()); + + get(move || async move { Html(html) }) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 289a5eb..a959c53 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,32 +1,19 @@ -mod account; pub(crate) mod admin_auth; -mod analytics; -mod assets; -mod bundles; -mod category; -mod collections; -mod cosmetics; -mod links; -mod players; +mod docs; mod state; -mod stripe; -mod tags; -mod transactions; -mod websocket; +mod v0; +mod v1; + use aide::{ axum::ApiRouter, openapi::{ ApiKeyLocation, Contact, License, OpenApi, SchemaObject, SecurityScheme, Server, }, - redoc::Redoc, - scalar::Scalar, - swagger::Swagger, transform::TransformOpenApi, }; use axum::{ Extension, http::{Method, header}, - routing::get as axum_get, }; use schemars::{JsonSchema, schema_for}; use tokio::net::TcpListener; @@ -34,21 +21,27 @@ use tower_http::{cors::CorsLayer, trace::TraceLayer}; use crate::{ api::{ + docs::{DocPage, DocVersion}, state::ApiState, - websocket::structs::{ClientBoundPacket, ServerBoundPacket}, + v0::websocket::structs::{ClientBoundPacket, ServerBoundPacket}, }, commands::ServeArgs, }; -fn init_openapi_spec(spec: TransformOpenApi<'_>) -> TransformOpenApi<'_> { - spec.version("1.0.0") - .title("Poly+ API") +fn init_openapi_spec<'a>( + spec: TransformOpenApi<'a>, + version: DocVersion, +) -> TransformOpenApi<'a> { + spec.version(&version.document_version()) + .title(&version.title()) .summary("An API used as the backend of the Poly+ mod") - .description( + .description(&format!( "This API provides all the backend services necessary for enabling the \ functionalities of the Poly+ mod, such as storing and serving cosmetic \ - information.", - ) + information.\n\nThis document only covers the {} endpoints; the other \ + versions are documented separately.", + version.label() + )) .license(License { name: "PolyForm Shield License 1.0.0".to_string(), url: Some("https://polyformproject.org/licenses/shield/1.0.0/".to_string()), @@ -57,7 +50,7 @@ fn init_openapi_spec(spec: TransformOpenApi<'_>) -> TransformOpenApi<'_> { .tos("https://polyfrost.org/legal/terms/") .contact(Contact { url: Some("https://polyfrost.org/contact/".to_string()), - email: Some("ty@polyfrost.org".to_string()), + email: Some("contact@atmofrost.org".to_string()), ..Default::default() }) .server(Server { @@ -76,7 +69,7 @@ fn init_openapi_spec(spec: TransformOpenApi<'_>) -> TransformOpenApi<'_> { ..Default::default() }) .security_scheme( - account::OPENAPI_SECURITY_NAME, + v0::account::OPENAPI_SECURITY_NAME, SecurityScheme::Http { scheme: "bearer".to_string(), bearer_format: Some("paseto".to_string()), @@ -95,32 +88,17 @@ fn init_openapi_spec(spec: TransformOpenApi<'_>) -> TransformOpenApi<'_> { ) } -#[derive(Clone, Copy)] -struct OpenApiSpec(&'static str); - pub(crate) async fn start(args: ServeArgs) { let state = ApiState::new(&args).await; - let app = ApiRouter::new() - .nest("/stripe", stripe::setup_router().await) - .nest("/account", account::setup_router().await) - .nest("/transactions", transactions::setup_router().await) - .merge(assets::setup_router().await) - .merge(bundles::setup_router().await) - .merge(collections::setup_router().await) - .merge(links::setup_router().await) - .merge(analytics::setup_router().await) - .merge(players::setup_router().await) - .merge(cosmetics::setup_router().await) - .merge(tags::setup_router().await) - .merge(category::setup_router().await) - .merge(websocket::setup_router().await) - .with_state(state); + let mut openapi_v0 = OpenApi::default(); + let v0 = v0::setup_router() + .await + .with_state(state.clone()) + .finish_api_with(&mut openapi_v0, |spec| init_openapi_spec(spec, docs::V0)); - // Convert OpenAPI router to normal actix router, and render the doc as JSON - let mut openapi = OpenApi::default(); - let app = app.finish_api_with(&mut openapi, init_openapi_spec); - if let Some(components) = openapi.components.as_mut() { + // Manually add documentation for websockets because aide doesn't detect them + if let Some(components) = openapi_v0.components.as_mut() { components.schemas.insert( ClientBoundPacket::schema_name().into_owned(), SchemaObject { @@ -138,27 +116,27 @@ pub(crate) async fn start(args: ServeArgs) { }, ); } - let openapi_rendered = Box::leak( - serde_json::to_string(&openapi) - .expect("Unable to render OpenAPI documentation as JSON") - .into_boxed_str(), - ); + + let mut openapi_v1 = OpenApi::default(); + let v1 = ApiRouter::new() + .nest("/v1", v1::setup_router().await) + .with_state(state) + .finish_api_with(&mut openapi_v1, |spec| init_openapi_spec(spec, docs::V1)); + + // Final router object + let mut app = v0.merge(v1); + + // Add visual doc pages + for (version, openapi) in [(docs::V0, &openapi_v0), (docs::V1, &openapi_v1)] { + app = app.route(&version.spec_url(), docs::spec_route(openapi)); + + for &page in DocPage::ALL { + app = app.route(&version.page_url(page), docs::page_route(version, page)); + } + } + + // Add middleware let app = app - .route("/scalar", Scalar::new("/openapi.json").axum_route().into()) - .route( - "/swagger", - Swagger::new("/openapi.json").axum_route().into(), - ) - .route("/redoc", Redoc::new("/openapi.json").axum_route().into()) - .route( - "/openapi.json", - axum_get( - async |Extension(OpenApiSpec(spec)): Extension| { - ([(header::CONTENT_TYPE, "application/json")], spec) - }, - ), - ) - .layer(Extension(OpenApiSpec(openapi_rendered))) .layer(Extension(args.client_ip_source)) .layer(TraceLayer::new_for_http()) .layer( @@ -171,7 +149,10 @@ pub(crate) async fn start(args: ServeArgs) { let mut listeners = Vec::new(); for addr in &args.bind_addr { match TcpListener::bind(addr).await { - Ok(listener) => listeners.push(listener), + Ok(listener) => { + listeners.push(listener); + tracing::info!(%addr, "binded to address"); + } Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => { tracing::warn!(%addr, %err, "skipping bind address, already in use"); } diff --git a/src/api/state.rs b/src/api/state.rs deleted file mode 100644 index 2353be6..0000000 --- a/src/api/state.rs +++ /dev/null @@ -1,336 +0,0 @@ -use std::{ - collections::{HashMap, HashSet}, - sync::Arc, - time::Duration, -}; - -use chrono::{DateTime, Utc}; -use entities::prelude::*; -use entities::sea_orm_active_enums::BodySlot; -use migrations::{Migrator, MigratorTrait}; -use moka::future::Cache; -use pasetors::{ - keys::{Generate, SymmetricKey}, - version4::V4, -}; -use reqwest::{Client, ClientBuilder}; -use s3::{Bucket, creds::Credentials}; -use sea_orm::{ConnectOptions, Database, DatabaseConnection, EntityTrait}; -use stripe_client::Client as StripeClient; -use tracing::{info, warn}; -use uuid::Uuid; - -use crate::{api::cosmetics::CachedAssetInfo, commands::ServeArgs}; - -impl ApiState { - #[tracing::instrument(skip_all, name = "initialize_state", level = "debug")] - pub(super) async fn new(args: &ServeArgs) -> Self { - // Setup database - info!("Attempting to create database connection"); - let database = Database::connect({ - let mut opts = ConnectOptions::new(&args.database_url); - - opts.acquire_timeout(Duration::new(3, 0)); // Shorten connection timeout - opts.sqlx_logging(false); // SeaORM has its own logging, disable SQLx's - - opts - }) - .await - .expect("Unable to connect to database"); - - info!("Database connected, applying migrations"); - Migrator::up(&database, None) - .await - .expect("Failure migrating database"); - info!("Database successfully initialized"); - - // Setup s3 bucket - let s3_bucket: Arc = Bucket::new( - &args.s3_bucket_name, - s3::Region::Custom { - region: args.s3_bucket_region.clone(), - endpoint: args.s3_bucket_endpoint.clone(), - }, - Credentials::default().expect( - "Unable to read s3 credentials (https://lib.rs/crates/aws-creds)", - ), - ) - .expect("Unable to connect to s3 bucket") - .with_path_style() - .into(); - - // Initialize asset cache with initial values - let asset_cache = Cache::builder() - .time_to_live(Duration::from_hours(2)) - .build(); - - let assets = Asset::find() - .all(&database) - .await - .expect("Unable to fetch assets from db"); - for asset in assets { - let Ok(info) = - CachedAssetInfo::from_db_model(&asset, s3_bucket.clone()).await - else { - warn!( - "Unable to fetch cached asset info for asset id {}", - asset.id - ); - continue; - }; - asset_cache.insert(asset.id, info).await; - } - - let (equipment_persist_tx, equipment_persist_rx) = - tokio::sync::mpsc::channel(256); - tokio::spawn(persist_equipment_queue( - database.clone(), - equipment_persist_rx, - )); - - let (particle_color_persist_tx, particle_color_persist_rx) = - tokio::sync::mpsc::channel(256); - tokio::spawn(persist_particle_color_queue( - database.clone(), - particle_color_persist_rx, - )); - - let realtime = RealtimeState::default(); - tokio::spawn(flush_playtime_loop( - database.clone(), - realtime.playtime.clone(), - )); - - // Return final state - ApiState { - stripe: StripeApiState { - client: StripeClient::new(args.stripe_secret.clone()), - webhook_secret: args.stripe_webhook_secret.clone(), - success_url: args.stripe_success_url.clone(), - cancel_url: args.stripe_cancel_url.clone(), - }, - database, - client: ClientBuilder::new() - .https_only(true) - .user_agent("PolyPlus Backend") - .build() - .expect("Unable to build reqwest HTTPS client"), - render_client: ClientBuilder::new() - .user_agent("PolyPlus Backend") - .build() - .expect("Unable to build reqwest render client"), - paseto_key: SymmetricKey::generate() - .expect("Unable to generate paseto signing key"), - s3_bucket, - asset_cache, - realtime, - equipment_persist_tx, - particle_color_persist_tx, - admin_password: args.admin_password.clone(), - render_service_url: args.render_service_url.clone(), - } - } -} - -#[derive(Debug, Clone)] -pub(super) struct ApiState { - pub(super) stripe: StripeApiState, - pub(super) database: DatabaseConnection, - pub(super) client: Client, - pub(super) render_client: Client, - pub(super) paseto_key: SymmetricKey, - pub(super) s3_bucket: Arc, - pub(super) asset_cache: Cache, - pub(super) realtime: RealtimeState, - pub(super) equipment_persist_tx: tokio::sync::mpsc::Sender, - pub(super) particle_color_persist_tx: - tokio::sync::mpsc::Sender, - pub(super) admin_password: String, - pub(super) render_service_url: String, -} - -#[derive(Clone)] -pub(super) struct StripeApiState { - pub(super) client: StripeClient, - pub(super) webhook_secret: String, - pub(super) success_url: String, - pub(super) cancel_url: String, -} - -// i love leaking secrets -impl std::fmt::Debug for StripeApiState { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("StripeApiState").finish_non_exhaustive() - } -} - -#[derive(Debug, Clone, Default)] -pub(super) struct RealtimeState { - pub(super) connections: - Arc>>, - pub(super) connections_by_owner: - Arc>>>, - pub(super) player_runtime: - Arc>>, - pub(super) watchers: Arc>>>, - pub(super) playtime: Arc>>, -} - -pub(super) type ConnectionId = Uuid; - -#[derive(Debug, Clone)] -pub(super) struct RealtimeConnection { - pub(super) owner: Uuid, - pub(super) tx: tokio::sync::mpsc::UnboundedSender< - crate::api::websocket::structs::ClientBoundPacket, - >, - pub(super) subscriptions: HashSet, -} - -#[derive(Debug, Clone)] -pub(super) struct PlaytimeSession { - pub(super) player_id: i32, - /// Timestamp up to which this session's time has already been committed. - pub(super) last_accounted_at: DateTime, -} - -#[derive(Debug, Clone, Default)] -pub(super) struct PlayerRuntimeState { - pub(super) equipped: HashMap, - pub(super) active_emote: Option, - pub(super) particle_color: Option, -} - -#[derive(Debug, Clone)] -pub(super) struct EquipmentPersistence { - pub(super) player: Uuid, - pub(super) slot: BodySlot, - pub(super) cosmetic_id: Option, -} - -#[derive(Debug, Clone)] -pub(super) struct ParticleColorPersistence { - pub(super) player: Uuid, - pub(super) color: Option, -} - -async fn persist_equipment_queue( - database: DatabaseConnection, - mut rx: tokio::sync::mpsc::Receiver, -) { - use entities::{player_equipped_cosmetic, prelude::*, user}; - use sea_orm::{ - ActiveValue, ColumnTrait, EntityTrait, QueryFilter, Set, sea_query::OnConflict, - }; - - while let Some(update) = rx.recv().await { - let result = async { - let Some(player) = User::find() - .filter(user::Column::MinecraftUuid.eq(update.player)) - .one(&database) - .await? - else { - return Ok::<(), sea_orm::DbErr>(()); - }; - - if let Some(cosmetic_id) = update.cosmetic_id { - PlayerEquippedCosmetic::insert(player_equipped_cosmetic::ActiveModel { - player_id: Set(player.id), - slot: Set(update.slot), - cosmetic_id: Set(cosmetic_id), - updated_at: ActiveValue::NotSet, - }) - .on_conflict( - OnConflict::columns([ - player_equipped_cosmetic::Column::PlayerId, - player_equipped_cosmetic::Column::Slot, - ]) - .update_column(player_equipped_cosmetic::Column::CosmeticId) - .to_owned(), - ) - .exec(&database) - .await?; - } else { - PlayerEquippedCosmetic::delete_many() - .filter(player_equipped_cosmetic::Column::PlayerId.eq(player.id)) - .filter(player_equipped_cosmetic::Column::Slot.eq(update.slot)) - .exec(&database) - .await?; - } - - Ok(()) - } - .await; - - if let Err(error) = result { - warn!("Unable to persist websocket equipment update: {error}"); - } - } -} - -async fn persist_particle_color_queue( - database: DatabaseConnection, - mut rx: tokio::sync::mpsc::Receiver, -) { - use entities::{prelude::*, user}; - use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; - - while let Some(update) = rx.recv().await { - let result = async { - let Some(player) = User::find() - .filter(user::Column::MinecraftUuid.eq(update.player)) - .one(&database) - .await? - else { - return Ok::<(), sea_orm::DbErr>(()); - }; - - let mut player: user::ActiveModel = player.into(); - player.particle_color = Set(update.color); - player.update(&database).await?; - - Ok(()) - } - .await; - - if let Err(error) = result { - warn!("Unable to persist websocket particle color update: {error}"); - } - } -} - -const PLAYTIME_FLUSH_INTERVAL: Duration = Duration::from_secs(60); - -async fn flush_playtime_loop( - database: DatabaseConnection, - playtime: Arc>>, -) { - let mut interval = tokio::time::interval(PLAYTIME_FLUSH_INTERVAL); - interval.tick().await; - - loop { - interval.tick().await; - let now = Utc::now(); - - let pending: Vec<(i32, DateTime)> = { - let mut guard = playtime.write().await; - guard - .values_mut() - .map(|session| { - let from = session.last_accounted_at; - session.last_accounted_at = now; - (session.player_id, from) - }) - .collect() - }; - - for (player_id, from) in pending { - if let Err(error) = - crate::database::accrue_playtime(&database, player_id, from, now, false) - .await - { - warn!("Unable to flush playtime for player {player_id}: {error}"); - } - } - } -} diff --git a/src/api/state/mod.rs b/src/api/state/mod.rs new file mode 100644 index 0000000..d1373a4 --- /dev/null +++ b/src/api/state/mod.rs @@ -0,0 +1,182 @@ +//! Shared application state handed to every request handler. + +mod persistence; +mod realtime; + +use std::{sync::Arc, time::Duration}; + +use entities::prelude::*; +use migrations::{Migrator, MigratorTrait}; +use moka::future::Cache; +use pasetors::{ + keys::{Generate, SymmetricKey}, + version4::V4, +}; +use reqwest::{Client, ClientBuilder}; +use s3::{Bucket, creds::Credentials}; +use sea_orm::{ConnectOptions, Database, DatabaseConnection, EntityTrait}; +use stripe_client::Client as StripeClient; +use tokio::sync::mpsc; +use tracing::{info, warn}; + +pub(in crate::api) use self::{ + persistence::{EquipmentPersistence, ParticleColorPersistence}, + realtime::{ + ConnectionId, PlayerRuntimeState, PlaytimeSession, RealtimeConnection, + RealtimeState, + }, +}; +use crate::{api::v0::cosmetics::CachedAssetInfo, commands::ServeArgs}; + +/// How long a rendered asset stays in the in-memory cache. +const ASSET_CACHE_TTL: Duration = Duration::from_hours(2); +/// How long to wait for a free database connection before giving up. +const DATABASE_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(3); + +const USER_AGENT: &str = "PolyPlus Backend"; + +#[derive(Debug, Clone)] +pub(super) struct ApiState { + pub(super) stripe: StripeApiState, + pub(super) database: DatabaseConnection, + pub(super) client: Client, + pub(super) render_client: Client, + pub(super) paseto_key: SymmetricKey, + pub(super) s3_bucket: Arc, + pub(super) asset_cache: Cache, + pub(super) realtime: RealtimeState, + pub(super) equipment_persist_tx: mpsc::Sender, + pub(super) particle_color_persist_tx: mpsc::Sender, + pub(super) admin_password: String, + pub(super) render_service_url: String, +} + +#[derive(Clone)] +pub(super) struct StripeApiState { + pub(super) client: StripeClient, + pub(super) webhook_secret: String, + pub(super) success_url: String, + pub(super) cancel_url: String, +} + +// i love leaking secrets +impl std::fmt::Debug for StripeApiState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StripeApiState").finish_non_exhaustive() + } +} + +impl ApiState { + #[tracing::instrument(skip_all, name = "initialize_state", level = "debug")] + pub(super) async fn new(args: &ServeArgs) -> Self { + let database = connect_database(&args.database_url).await; + let s3_bucket = connect_s3_bucket(args); + let asset_cache = build_asset_cache(&database, &s3_bucket).await; + + let realtime = RealtimeState::default(); + persistence::spawn_playtime_flush(database.clone(), realtime.playtime.clone()); + + ApiState { + stripe: StripeApiState::new(args), + client: build_http_client(true), + render_client: build_http_client(false), + paseto_key: SymmetricKey::generate() + .expect("Unable to generate paseto signing key"), + asset_cache, + realtime, + equipment_persist_tx: persistence::spawn_equipment_queue(database.clone()), + particle_color_persist_tx: persistence::spawn_particle_color_queue( + database.clone(), + ), + admin_password: args.admin_password.clone(), + render_service_url: args.render_service_url.clone(), + s3_bucket, + database, + } + } +} + +impl StripeApiState { + fn new(args: &ServeArgs) -> Self { + StripeApiState { + client: StripeClient::new(args.stripe_secret.clone()), + webhook_secret: args.stripe_webhook_secret.clone(), + success_url: args.stripe_success_url.clone(), + cancel_url: args.stripe_cancel_url.clone(), + } + } +} + +/// Connects to the database and brings it up to the latest migration. +async fn connect_database(database_url: &str) -> DatabaseConnection { + info!("Attempting to create database connection"); + let database = Database::connect({ + let mut opts = ConnectOptions::new(database_url); + + opts.acquire_timeout(DATABASE_ACQUIRE_TIMEOUT); + opts.sqlx_logging(false); + + opts + }) + .await + .expect("Unable to connect to database"); + + info!("Database connected, applying migrations"); + Migrator::up(&database, None) + .await + .expect("Failure migrating database"); + info!("Database successfully initialized"); + + database +} + +fn connect_s3_bucket(args: &ServeArgs) -> Arc { + Bucket::new( + &args.s3_bucket_name, + s3::Region::Custom { + region: args.s3_bucket_region.clone(), + endpoint: args.s3_bucket_endpoint.clone(), + }, + Credentials::default() + .expect("Unable to read s3 credentials (https://lib.rs/crates/aws-creds)"), + ) + .expect("Unable to connect to s3 bucket") + .with_path_style() + .into() +} + +fn build_http_client(https_only: bool) -> Client { + ClientBuilder::new() + .https_only(https_only) + .user_agent(USER_AGENT) + .build() + .expect("Unable to build reqwest client") +} + +/// Builds the asset cache, warming it with every asset currently in the database. +async fn build_asset_cache( + database: &DatabaseConnection, + s3_bucket: &Arc, +) -> Cache { + let asset_cache = Cache::builder().time_to_live(ASSET_CACHE_TTL).build(); + + let assets = Asset::find() + .all(database) + .await + .expect("Unable to fetch assets from db"); + + for asset in assets { + let Ok(info) = CachedAssetInfo::from_db_model(&asset, s3_bucket.clone()).await + else { + warn!( + "Unable to fetch cached asset info for asset id {}", + asset.id + ); + continue; + }; + + asset_cache.insert(asset.id, info).await; + } + + asset_cache +} diff --git a/src/api/state/persistence.rs b/src/api/state/persistence.rs new file mode 100644 index 0000000..5c3e589 --- /dev/null +++ b/src/api/state/persistence.rs @@ -0,0 +1,174 @@ +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use entities::sea_orm_active_enums::BodySlot; +use sea_orm::DatabaseConnection; +use tokio::sync::mpsc; +use tracing::warn; +use uuid::Uuid; + +use crate::api::state::realtime::PlaytimeSessions; + +/// Capacity of the persistence channels before senders start to wait. +const PERSIST_QUEUE_SIZE: usize = 256; +/// How often accrued playtime is committed to the database. +const PLAYTIME_FLUSH_INTERVAL: Duration = Duration::from_secs(60); + +/// A pending change to a player's equipped cosmetic in a single body slot. +#[derive(Debug, Clone)] +pub struct EquipmentPersistence { + pub player: Uuid, + pub slot: BodySlot, + pub cosmetic_id: Option, +} + +/// A pending change to a player's particle color. +#[derive(Debug, Clone)] +pub struct ParticleColorPersistence { + pub player: Uuid, + pub color: Option, +} + +/// Spawns the equipment writer and returns the channel feeding it. +pub(super) fn spawn_equipment_queue( + database: DatabaseConnection, +) -> mpsc::Sender { + let (tx, rx) = mpsc::channel(PERSIST_QUEUE_SIZE); + tokio::spawn(persist_equipment_queue(database, rx)); + tx +} + +/// Spawns the particle color writer and returns the channel feeding it. +pub(super) fn spawn_particle_color_queue( + database: DatabaseConnection, +) -> mpsc::Sender { + let (tx, rx) = mpsc::channel(PERSIST_QUEUE_SIZE); + tokio::spawn(persist_particle_color_queue(database, rx)); + tx +} + +/// Spawns the periodic playtime flusher for the given session map. +pub(super) fn spawn_playtime_flush( + database: DatabaseConnection, + playtime: PlaytimeSessions, +) { + tokio::spawn(flush_playtime_loop(database, playtime)); +} + +async fn persist_equipment_queue( + database: DatabaseConnection, + mut rx: mpsc::Receiver, +) { + use entities::{player_equipped_cosmetic, prelude::*, user}; + use sea_orm::{ + ActiveValue, ColumnTrait, EntityTrait, QueryFilter, Set, sea_query::OnConflict, + }; + + while let Some(update) = rx.recv().await { + let result = async { + let Some(player) = User::find() + .filter(user::Column::MinecraftUuid.eq(update.player)) + .one(&database) + .await? + else { + return Ok::<(), sea_orm::DbErr>(()); + }; + + if let Some(cosmetic_id) = update.cosmetic_id { + PlayerEquippedCosmetic::insert(player_equipped_cosmetic::ActiveModel { + player_id: Set(player.id), + slot: Set(update.slot), + cosmetic_id: Set(cosmetic_id), + updated_at: ActiveValue::NotSet, + }) + .on_conflict( + OnConflict::columns([ + player_equipped_cosmetic::Column::PlayerId, + player_equipped_cosmetic::Column::Slot, + ]) + .update_column(player_equipped_cosmetic::Column::CosmeticId) + .to_owned(), + ) + .exec(&database) + .await?; + } else { + PlayerEquippedCosmetic::delete_many() + .filter(player_equipped_cosmetic::Column::PlayerId.eq(player.id)) + .filter(player_equipped_cosmetic::Column::Slot.eq(update.slot)) + .exec(&database) + .await?; + } + + Ok(()) + } + .await; + + if let Err(error) = result { + warn!("Unable to persist websocket equipment update: {error}"); + } + } +} + +async fn persist_particle_color_queue( + database: DatabaseConnection, + mut rx: mpsc::Receiver, +) { + use entities::{prelude::*, user}; + use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; + + while let Some(update) = rx.recv().await { + let result = async { + let Some(player) = User::find() + .filter(user::Column::MinecraftUuid.eq(update.player)) + .one(&database) + .await? + else { + return Ok::<(), sea_orm::DbErr>(()); + }; + + let mut player: user::ActiveModel = player.into(); + player.particle_color = Set(update.color); + player.update(&database).await?; + + Ok(()) + } + .await; + + if let Err(error) = result { + warn!("Unable to persist websocket particle color update: {error}"); + } + } +} + +async fn flush_playtime_loop(database: DatabaseConnection, playtime: PlaytimeSessions) { + let mut interval = tokio::time::interval(PLAYTIME_FLUSH_INTERVAL); + interval.tick().await; + + loop { + interval.tick().await; + let now = Utc::now(); + + // Claim every session's outstanding window up front so the lock is + // released before any database work happens. + let pending: Vec<(i32, DateTime)> = { + let mut guard = playtime.write().await; + guard + .values_mut() + .map(|session| { + let from = session.last_accounted_at; + session.last_accounted_at = now; + (session.player_id, from) + }) + .collect() + }; + + for (player_id, from) in pending { + if let Err(error) = + crate::database::accrue_playtime(&database, player_id, from, now, false) + .await + { + warn!("Unable to flush playtime for player {player_id}: {error}"); + } + } + } +} diff --git a/src/api/state/realtime.rs b/src/api/state/realtime.rs new file mode 100644 index 0000000..37d3b91 --- /dev/null +++ b/src/api/state/realtime.rs @@ -0,0 +1,49 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; + +use chrono::{DateTime, Utc}; +use entities::sea_orm_active_enums::BodySlot; +use tokio::sync::{RwLock, mpsc}; +use uuid::Uuid; + +use crate::api::v0::websocket::structs::ClientBoundPacket; + +pub type ConnectionId = Uuid; + +/// Everything the websocket layer keeps in memory, shared across connections. +#[derive(Debug, Clone, Default)] +pub struct RealtimeState { + pub connections: + Arc>>, + pub connections_by_owner: + Arc>>>, + pub player_runtime: Arc>>, + pub watchers: Arc>>>, + pub playtime: PlaytimeSessions, +} + +/// A single live websocket connection. +#[derive(Debug, Clone)] +pub struct RealtimeConnection { + pub owner: Uuid, + pub tx: mpsc::UnboundedSender, + pub subscriptions: HashSet, +} + +/// Cosmetic state a player is currently broadcasting to watchers. +#[derive(Debug, Clone, Default)] +pub struct PlayerRuntimeState { + pub equipped: HashMap, + pub active_emote: Option, + pub particle_color: Option, +} + +pub type PlaytimeSessions = Arc>>; + +#[derive(Debug, Clone)] +pub struct PlaytimeSession { + pub player_id: i32, + pub last_accounted_at: DateTime, +} diff --git a/src/api/account/login.rs b/src/api/v0/account/login.rs similarity index 98% rename from src/api/account/login.rs rename to src/api/v0/account/login.rs index 431d368..6d6b246 100644 --- a/src/api/account/login.rs +++ b/src/api/v0/account/login.rs @@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ - api::{ApiState, account::PASETO_IMPLICIT_ASSERT}, + api::{ApiState, v0::account::PASETO_IMPLICIT_ASSERT}, database::{DatabaseUserExt, record_monthly_active_login}, }; diff --git a/src/api/account/mod.rs b/src/api/v0/account/mod.rs similarity index 100% rename from src/api/account/mod.rs rename to src/api/v0/account/mod.rs index 2eceb91..cd9ba47 100644 --- a/src/api/account/mod.rs +++ b/src/api/v0/account/mod.rs @@ -21,6 +21,10 @@ use entities::{ pub const OPENAPI_SECURITY_NAME: &str = "Bearer Token"; pub const PASETO_IMPLICIT_ASSERT: Option<&[u8]> = Some(b"plus-backend"); +pub(super) async fn setup_router() -> ApiRouter { + ApiRouter::new().merge(login::router()) +} + #[derive(Debug)] pub struct AuthenticationExtractor(pub Uuid); #[derive(Debug)] @@ -221,7 +225,3 @@ mod tests { assert!(!role_at_least(&PlayerRole::Player, &PlayerRole::Moderator)); } } - -pub(super) async fn setup_router() -> ApiRouter { - ApiRouter::new().merge(login::router()) -} diff --git a/src/api/analytics.rs b/src/api/v0/analytics.rs similarity index 98% rename from src/api/analytics.rs rename to src/api/v0/analytics.rs index 096e06d..c593a55 100644 --- a/src/api/analytics.rs +++ b/src/api/v0/analytics.rs @@ -27,9 +27,9 @@ use serde::{Deserialize, Serialize}; use crate::{ api::{ ApiState, - account::{AuthenticatedPlayer, OPENAPI_SECURITY_NAME, role_at_least}, + v0::account::{AuthenticatedPlayer, OPENAPI_SECURITY_NAME, role_at_least}, }, - database::current_utc_month, + utils::time::{current_utc_month, start_of_day}, }; #[derive(Debug)] @@ -159,13 +159,6 @@ impl AnalyticsPeriod { } } -fn start_of_day(day: NaiveDate) -> DateTimeWithTimeZone { - day.and_hms_opt(0, 0, 0) - .expect("midnight is a valid time of day") - .and_utc() - .fixed_offset() -} - fn filter_timestamp_period( mut select: Select, column: E::Column, diff --git a/src/api/assets.rs b/src/api/v0/assets.rs similarity index 100% rename from src/api/assets.rs rename to src/api/v0/assets.rs diff --git a/src/api/bundles/manage/create.rs b/src/api/v0/bundles/manage/create.rs similarity index 95% rename from src/api/bundles/manage/create.rs rename to src/api/v0/bundles/manage/create.rs index 9d8be9e..573d43d 100644 --- a/src/api/bundles/manage/create.rs +++ b/src/api/v0/bundles/manage/create.rs @@ -13,12 +13,15 @@ use axum::{ use entities::sea_orm_active_enums::AssetKind; use schemars::JsonSchema; use sea_orm::{ActiveModelTrait, EntityTrait, Set}; -use sha2::{Digest, Sha256}; use uuid::Uuid; -use crate::api::{ - ApiState, admin_auth::AdminAuthenticationExtractor, bundles::BundleInfo, - stripe::products, +use crate::{ + api::{ + ApiState, + admin_auth::AdminAuthenticationExtractor, + v0::{bundles::BundleInfo, stripe::products}, + }, + utils::{hash::sha256_hex, money::to_cents}, }; #[derive(thiserror::Error, Debug, OperationIo)] @@ -145,13 +148,6 @@ impl OperationInput for FileUpload { } } -fn sha256_hex(data: &[u8]) -> String { - Sha256::digest(data) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() -} - async fn endpoint( State(state): State, _auth: AdminAuthenticationExtractor, @@ -249,12 +245,9 @@ async fn endpoint( let product_id = products::create_product(&state.stripe.client, &name, description.as_deref()) .await?; - let price_id = products::create_price( - &state.stripe.client, - &product_id, - products::to_cents(base_price), - ) - .await?; + let price_id = + products::create_price(&state.stripe.client, &product_id, to_cents(base_price)) + .await?; products::set_default_price(&state.stripe.client, &product_id, &price_id).await?; use entities::{bundles, bundles_cosmetics, prelude::*}; diff --git a/src/api/bundles/manage/delete.rs b/src/api/v0/bundles/manage/delete.rs similarity index 100% rename from src/api/bundles/manage/delete.rs rename to src/api/v0/bundles/manage/delete.rs diff --git a/src/api/bundles/manage/mod.rs b/src/api/v0/bundles/manage/mod.rs similarity index 100% rename from src/api/bundles/manage/mod.rs rename to src/api/v0/bundles/manage/mod.rs diff --git a/src/api/bundles/manage/update.rs b/src/api/v0/bundles/manage/update.rs similarity index 97% rename from src/api/bundles/manage/update.rs rename to src/api/v0/bundles/manage/update.rs index a5c0f14..fe78da1 100644 --- a/src/api/bundles/manage/update.rs +++ b/src/api/v0/bundles/manage/update.rs @@ -10,7 +10,10 @@ use sea_orm::{ }; use serde::Deserialize; -use crate::api::{ApiState, admin_auth::AdminAuthenticationExtractor, stripe::products}; +use crate::{ + api::{ApiState, admin_auth::AdminAuthenticationExtractor, v0::stripe::products}, + utils::money::to_cents, +}; #[derive(thiserror::Error, Debug, OperationIo)] pub enum UpdateError { @@ -144,7 +147,7 @@ async fn endpoint( let price_id = products::create_price( &state.stripe.client, product_id, - products::to_cents(discounted), + to_cents(discounted), ) .await?; @@ -159,7 +162,7 @@ async fn endpoint( let price_id = products::create_price( &state.stripe.client, product_id, - products::to_cents(new_price), + to_cents(new_price), ) .await?; products::set_default_price(&state.stripe.client, product_id, &price_id) diff --git a/src/api/bundles/mod.rs b/src/api/v0/bundles/mod.rs similarity index 100% rename from src/api/bundles/mod.rs rename to src/api/v0/bundles/mod.rs index 3103aae..8f94117 100644 --- a/src/api/bundles/mod.rs +++ b/src/api/v0/bundles/mod.rs @@ -9,6 +9,15 @@ use serde::Serialize; use crate::api::ApiState; +pub(super) async fn setup_router() -> ApiRouter { + ApiRouter::new().nest( + "/bundles", + search::router() + .merge(view::router()) + .merge(manage::router()), + ) +} + /// A single enabled bundle's public information. #[derive(Debug, Serialize, JsonSchema)] struct BundleInfo { @@ -37,12 +46,3 @@ impl From for BundleInfo { } } } - -pub(super) async fn setup_router() -> ApiRouter { - ApiRouter::new().nest( - "/bundles", - search::router() - .merge(view::router()) - .merge(manage::router()), - ) -} diff --git a/src/api/bundles/search.rs b/src/api/v0/bundles/search.rs similarity index 92% rename from src/api/bundles/search.rs rename to src/api/v0/bundles/search.rs index a8fc7cf..14219da 100644 --- a/src/api/bundles/search.rs +++ b/src/api/v0/bundles/search.rs @@ -15,7 +15,10 @@ use sea_orm::{ }; use serde::{Deserialize, Serialize}; -use crate::api::{ApiState, bundles::BundleInfo}; +use crate::{ + api::{ApiState, v0::bundles::BundleInfo}, + utils::pagination::MAX_PAGE_SIZE, +}; #[derive(thiserror::Error, Debug, OperationIo)] pub enum SearchError { @@ -50,24 +53,13 @@ pub enum Sort { Descending, } -/// The maximum number of bundles allowed per page. -const MAX_NB: u64 = 100; - -fn default_nb() -> u64 { - 50 -} - -fn default_page() -> u64 { - 1 -} - #[derive(Debug, Deserialize, JsonSchema)] pub struct SearchQuery { /// The number of bundles per page, capped at 100. - #[serde(default = "default_nb")] + #[serde(default = "crate::utils::pagination::default_page_size")] nb: u64, /// The 1-indexed page to return. - #[serde(default = "default_page")] + #[serde(default = "crate::utils::pagination::default_page")] page: u64, /// The order bundles are returned in. #[serde(default)] @@ -117,7 +109,7 @@ async fn endpoint( use entities::{bundles, prelude::*}; // [nb * (page - 1); nb * page). - let nb = query.nb.min(MAX_NB); + let nb = query.nb.min(MAX_PAGE_SIZE); let offset = nb.saturating_mul(query.page.saturating_sub(1)); let (column, order) = match query.sort { diff --git a/src/api/bundles/view.rs b/src/api/v0/bundles/view.rs similarity index 97% rename from src/api/bundles/view.rs rename to src/api/v0/bundles/view.rs index 377853a..a7178af 100644 --- a/src/api/bundles/view.rs +++ b/src/api/v0/bundles/view.rs @@ -13,7 +13,7 @@ use schemars::JsonSchema; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use serde::Serialize; -use crate::api::{ApiState, bundles::BundleInfo}; +use crate::api::{ApiState, v0::bundles::BundleInfo}; #[derive(thiserror::Error, Debug, OperationIo)] pub enum ViewError { diff --git a/src/api/category/list.rs b/src/api/v0/category/list.rs similarity index 100% rename from src/api/category/list.rs rename to src/api/v0/category/list.rs diff --git a/src/api/category/mod.rs b/src/api/v0/category/mod.rs similarity index 100% rename from src/api/category/mod.rs rename to src/api/v0/category/mod.rs diff --git a/src/api/collections/create.rs b/src/api/v0/collections/create.rs similarity index 99% rename from src/api/collections/create.rs rename to src/api/v0/collections/create.rs index 0756e7f..2c57117 100644 --- a/src/api/collections/create.rs +++ b/src/api/v0/collections/create.rs @@ -17,7 +17,7 @@ use serde::Serialize; use crate::api::{ ApiState, admin_auth::AdminAuthenticationExtractor, - collections::{StoreAssetError, store_asset}, + v0::collections::{StoreAssetError, store_asset}, }; #[derive(thiserror::Error, Debug, OperationIo)] diff --git a/src/api/collections/delete.rs b/src/api/v0/collections/delete.rs similarity index 100% rename from src/api/collections/delete.rs rename to src/api/v0/collections/delete.rs diff --git a/src/api/collections/edit.rs b/src/api/v0/collections/edit.rs similarity index 99% rename from src/api/collections/edit.rs rename to src/api/v0/collections/edit.rs index 242733e..b8065d8 100644 --- a/src/api/collections/edit.rs +++ b/src/api/v0/collections/edit.rs @@ -19,7 +19,7 @@ use serde::Serialize; use crate::api::{ ApiState, admin_auth::AdminAuthenticationExtractor, - collections::{StoreAssetError, store_asset}, + v0::collections::{StoreAssetError, store_asset}, }; #[derive(thiserror::Error, Debug, OperationIo)] diff --git a/src/api/collections/list.rs b/src/api/v0/collections/list.rs similarity index 100% rename from src/api/collections/list.rs rename to src/api/v0/collections/list.rs diff --git a/src/api/collections/mod.rs b/src/api/v0/collections/mod.rs similarity index 88% rename from src/api/collections/mod.rs rename to src/api/v0/collections/mod.rs index 48d3ee4..4d3b79e 100644 --- a/src/api/collections/mod.rs +++ b/src/api/v0/collections/mod.rs @@ -7,10 +7,9 @@ mod view; use aide::axum::ApiRouter; use entities::sea_orm_active_enums::AssetKind; use sea_orm::{ActiveModelTrait, Set}; -use sha2::{Digest, Sha256}; use uuid::Uuid; -use crate::api::ApiState; +use crate::{api::ApiState, utils::hash::sha256_hex}; #[derive(thiserror::Error, Debug)] pub(crate) enum StoreAssetError { @@ -20,13 +19,6 @@ pub(crate) enum StoreAssetError { S3(#[from] s3::error::S3Error), } -fn sha256_hex(data: &[u8]) -> String { - Sha256::digest(data) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() -} - pub(crate) async fn store_asset( state: &ApiState, data: &[u8], diff --git a/src/api/collections/view.rs b/src/api/v0/collections/view.rs similarity index 100% rename from src/api/collections/view.rs rename to src/api/v0/collections/view.rs diff --git a/src/api/cosmetics/cover.rs b/src/api/v0/cosmetics/cover.rs similarity index 100% rename from src/api/cosmetics/cover.rs rename to src/api/v0/cosmetics/cover.rs diff --git a/src/api/cosmetics/get_player.rs b/src/api/v0/cosmetics/get_player.rs similarity index 97% rename from src/api/cosmetics/get_player.rs rename to src/api/v0/cosmetics/get_player.rs index 44867f3..f361dc7 100644 --- a/src/api/cosmetics/get_player.rs +++ b/src/api/v0/cosmetics/get_player.rs @@ -17,9 +17,11 @@ use uuid::Uuid; use crate::api::{ ApiState, - account::OptionalAuthenticationExtractor, - cosmetics::{ - CosmeticInfo, EmoteInfo, EquippedCosmetics, group_cosmetics, load_groups, + v0::{ + account::OptionalAuthenticationExtractor, + cosmetics::{ + CosmeticInfo, EmoteInfo, EquippedCosmetics, group_cosmetics, load_groups, + }, }, }; diff --git a/src/api/cosmetics/grant.rs b/src/api/v0/cosmetics/grant.rs similarity index 97% rename from src/api/cosmetics/grant.rs rename to src/api/v0/cosmetics/grant.rs index 36d4a61..a95d9c3 100644 --- a/src/api/cosmetics/grant.rs +++ b/src/api/v0/cosmetics/grant.rs @@ -14,7 +14,10 @@ use serde::Deserialize; use uuid::Uuid; use crate::{ - api::{ApiState, account::AdminPlayer, websocket::structs::ClientBoundPacket}, + api::{ + ApiState, + v0::{account::AdminPlayer, websocket::structs::ClientBoundPacket}, + }, database::DatabaseUserExt, }; diff --git a/src/api/cosmetics/list.rs b/src/api/v0/cosmetics/list.rs similarity index 97% rename from src/api/cosmetics/list.rs rename to src/api/v0/cosmetics/list.rs index 0b5eb73..2ca6e3c 100644 --- a/src/api/cosmetics/list.rs +++ b/src/api/v0/cosmetics/list.rs @@ -10,7 +10,7 @@ use serde::Serialize; use crate::api::{ ApiState, - cosmetics::{CosmeticInfo, group_cosmetics, load_groups}, + v0::cosmetics::{CosmeticInfo, group_cosmetics, load_groups}, }; #[derive(thiserror::Error, Debug, OperationIo)] diff --git a/src/api/cosmetics/list_capes.rs b/src/api/v0/cosmetics/list_capes.rs similarity index 97% rename from src/api/cosmetics/list_capes.rs rename to src/api/v0/cosmetics/list_capes.rs index 816188a..bdc06a5 100644 --- a/src/api/cosmetics/list_capes.rs +++ b/src/api/v0/cosmetics/list_capes.rs @@ -11,7 +11,7 @@ use serde::Serialize; use crate::api::{ ApiState, - cosmetics::{CosmeticInfo, group_cosmetics, load_groups}, + v0::cosmetics::{CosmeticInfo, group_cosmetics, load_groups}, }; #[derive(thiserror::Error, Debug, OperationIo)] diff --git a/src/api/cosmetics/manage/create.rs b/src/api/v0/cosmetics/manage/create.rs similarity index 96% rename from src/api/cosmetics/manage/create.rs rename to src/api/v0/cosmetics/manage/create.rs index f8dfdc9..90cd629 100644 --- a/src/api/cosmetics/manage/create.rs +++ b/src/api/v0/cosmetics/manage/create.rs @@ -14,14 +14,22 @@ use std::collections::HashMap; use entities::sea_orm_active_enums::{AssetKind, BodySlot, CosmeticType}; use schemars::JsonSchema; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; -use sha2::{Digest, Sha256}; use uuid::Uuid; -use crate::api::{ - ApiState, - admin_auth::AdminAuthenticationExtractor, - cosmetics::{CosmeticInfo, group_cosmetics}, - stripe::products, +use crate::{ + api::{ + ApiState, + admin_auth::AdminAuthenticationExtractor, + v0::{ + cosmetics::{CosmeticInfo, group_cosmetics}, + stripe::products, + }, + }, + utils::{ + hash::sha256_hex, + money::to_cents, + zip::{is_zip, strip_macos_junk}, + }, }; #[derive(thiserror::Error, Debug, OperationIo)] @@ -240,13 +248,6 @@ impl OperationInput for FileUpload { } } -fn sha256_hex(data: &[u8]) -> String { - Sha256::digest(data) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() -} - async fn endpoint( State(state): State, _auth: AdminAuthenticationExtractor, @@ -355,9 +356,9 @@ async fn endpoint( return Err(UploadError::MissingSlots); } - let is_bundle = crate::api::cosmetics::is_zip(&data); + let is_bundle = is_zip(&data); let data: Vec = if is_bundle { - crate::api::cosmetics::strip_macos_junk(&data)? + strip_macos_junk(&data)? } else { data.to_vec() }; @@ -411,7 +412,7 @@ async fn endpoint( let cover_asset_id = if state.render_service_url.is_empty() { None } else { - match crate::api::cosmetics::cover::render_cover( + match crate::api::v0::cosmetics::cover::render_cover( &state.render_client, &state.render_service_url, &cosmetic_type, @@ -422,7 +423,7 @@ async fn endpoint( ) .await { - Ok(png) => match crate::api::collections::store_asset( + Ok(png) => match crate::api::v0::collections::store_asset( &state, &png, Some("image/png".to_string()), @@ -518,7 +519,7 @@ async fn endpoint( let price_id = products::create_price( &state.stripe.client, &product_id, - products::to_cents(base_price), + to_cents(base_price), ) .await?; products::set_default_price(&state.stripe.client, &product_id, &price_id) @@ -559,7 +560,7 @@ async fn endpoint( .await?; } - let info = crate::api::cosmetics::CachedAssetInfo::from_db_model( + let info = crate::api::v0::cosmetics::CachedAssetInfo::from_db_model( &asset, state.s3_bucket.clone(), ) diff --git a/src/api/cosmetics/manage/delete.rs b/src/api/v0/cosmetics/manage/delete.rs similarity index 100% rename from src/api/cosmetics/manage/delete.rs rename to src/api/v0/cosmetics/manage/delete.rs diff --git a/src/api/cosmetics/manage/mod.rs b/src/api/v0/cosmetics/manage/mod.rs similarity index 100% rename from src/api/cosmetics/manage/mod.rs rename to src/api/v0/cosmetics/manage/mod.rs diff --git a/src/api/cosmetics/manage/render_cover.rs b/src/api/v0/cosmetics/manage/render_cover.rs similarity index 97% rename from src/api/cosmetics/manage/render_cover.rs rename to src/api/v0/cosmetics/manage/render_cover.rs index ad7642a..b2ed74a 100644 --- a/src/api/cosmetics/manage/render_cover.rs +++ b/src/api/v0/cosmetics/manage/render_cover.rs @@ -147,7 +147,7 @@ async fn endpoint( let is_bundle = asset.asset_kind == AssetKind::Bundle; - let png = crate::api::cosmetics::cover::render_cover( + let png = crate::api::v0::cosmetics::cover::render_cover( &state.render_client, &state.render_service_url, &cosmetic.r#type, @@ -159,7 +159,7 @@ async fn endpoint( .await .map_err(|error| RenderCoverError::Render(error.to_string()))?; - let cover_asset_id = crate::api::collections::store_asset( + let cover_asset_id = crate::api::v0::collections::store_asset( &state, &png, Some("image/png".to_string()), diff --git a/src/api/cosmetics/manage/update.rs b/src/api/v0/cosmetics/manage/update.rs similarity index 98% rename from src/api/cosmetics/manage/update.rs rename to src/api/v0/cosmetics/manage/update.rs index 4bc847a..b33e568 100644 --- a/src/api/cosmetics/manage/update.rs +++ b/src/api/v0/cosmetics/manage/update.rs @@ -10,7 +10,10 @@ use sea_orm::{ }; use serde::Deserialize; -use crate::api::{ApiState, admin_auth::AdminAuthenticationExtractor, stripe::products}; +use crate::{ + api::{ApiState, admin_auth::AdminAuthenticationExtractor, v0::stripe::products}, + utils::money::to_cents, +}; #[derive(thiserror::Error, Debug, OperationIo)] pub enum UpdateError { @@ -156,7 +159,7 @@ async fn endpoint( let price_id = products::create_price( &state.stripe.client, &product_id, - products::to_cents(discounted), + to_cents(discounted), ) .await?; @@ -203,7 +206,7 @@ async fn endpoint( let price_id = products::create_price( &state.stripe.client, &product_id, - products::to_cents(new_price), + to_cents(new_price), ) .await?; products::set_default_price(&state.stripe.client, &product_id, &price_id) diff --git a/src/api/cosmetics/mod.rs b/src/api/v0/cosmetics/mod.rs similarity index 90% rename from src/api/cosmetics/mod.rs rename to src/api/v0/cosmetics/mod.rs index 827819e..3145f16 100644 --- a/src/api/cosmetics/mod.rs +++ b/src/api/v0/cosmetics/mod.rs @@ -25,43 +25,6 @@ use serde::{Deserialize, Serialize}; use crate::api::ApiState; -pub(super) fn is_zip(data: &[u8]) -> bool { - data.len() >= 4 && &data[0..4] == b"PK\x03\x04" -} - -fn is_macos_junk(name: &str) -> bool { - name.split('/') - .next_back() - .is_some_and(|base| base == ".DS_Store") - || name.starts_with("__MACOSX/") - || name.contains("/__MACOSX/") -} - -pub(super) fn strip_macos_junk(data: &[u8]) -> Result, zip::result::ZipError> { - use std::io::{Cursor, Read, Write}; - - let mut archive = zip::ZipArchive::new(Cursor::new(data))?; - let mut out = Cursor::new(Vec::new()); - { - let mut writer = zip::ZipWriter::new(&mut out); - for i in 0..archive.len() { - let mut entry = archive.by_index(i)?; - let name = entry.name().to_string(); - if entry.is_dir() || is_macos_junk(&name) { - continue; - } - let options = zip::write::SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Deflated); - writer.start_file(name, options)?; - let mut buf = Vec::with_capacity(entry.size() as usize); - entry.read_to_end(&mut buf)?; - writer.write_all(&buf)?; - } - writer.finish()?; - } - Ok(out.into_inner()) -} - /// A buyable cosmetic. What the player owns once and chooses variants within. /// /// When a cosmetic has multiple variants (e.g. every pride cape, or every cat @@ -198,6 +161,7 @@ pub(super) fn is_redundant_variant(variant_name: Option<&str>) -> bool { let Some(name) = variant_name else { return false; }; + let lower = name.to_ascii_lowercase(); lower.contains("reversed") || lower.contains("fold") } diff --git a/src/api/cosmetics/put_player.rs b/src/api/v0/cosmetics/put_player.rs similarity index 97% rename from src/api/cosmetics/put_player.rs rename to src/api/v0/cosmetics/put_player.rs index 358c852..048015a 100644 --- a/src/api/cosmetics/put_player.rs +++ b/src/api/v0/cosmetics/put_player.rs @@ -16,7 +16,8 @@ use sea_orm::{ use serde::Deserialize; use crate::api::{ - ApiState, account::AuthenticatedPlayer, cosmetics::PartialEquippedCosmetics, + ApiState, + v0::{account::AuthenticatedPlayer, cosmetics::PartialEquippedCosmetics}, }; #[derive(thiserror::Error, Debug, OperationIo)] diff --git a/src/api/cosmetics/search.rs b/src/api/v0/cosmetics/search.rs similarity index 88% rename from src/api/cosmetics/search.rs rename to src/api/v0/cosmetics/search.rs index 88b47af..7fd2005 100644 --- a/src/api/cosmetics/search.rs +++ b/src/api/v0/cosmetics/search.rs @@ -19,10 +19,15 @@ use sea_orm::{ }; use serde::{Deserialize, Serialize}; -use crate::api::{ - ApiState, - cosmetics::view::VariantView, - tags::{CosmeticTags, tags_for_cosmetics}, +use crate::{ + api::{ + ApiState, + v0::{ + cosmetics::view::VariantView, + tags::{CosmeticTags, tags_for_cosmetics}, + }, + }, + utils::{pagination::MAX_PAGE_SIZE, serde::deserialize_comma_list}, }; #[derive(thiserror::Error, Debug, OperationIo)] @@ -60,29 +65,18 @@ pub enum Sort { Popularity, } -/// The maximum number of results allowed per page. -const MAX_NB: u64 = 100; - /// Minimum trigram `word_similarity` a name must reach against the search text /// to be considered a fuzzy match. Higher = stricter. 0.3 tolerates typos and /// partial words without drowning results in noise. const SIMILARITY_THRESHOLD: f64 = 0.3; -fn default_nb() -> u64 { - 50 -} - -fn default_page() -> u64 { - 1 -} - #[derive(Debug, Deserialize, JsonSchema)] pub struct SearchQuery { /// The number of results per page, capped at 100. - #[serde(default = "default_nb")] + #[serde(default = "crate::utils::pagination::default_page_size")] nb: u64, /// The 1-indexed page to return. - #[serde(default = "default_page")] + #[serde(default = "crate::utils::pagination::default_page")] page: u64, /// The order results are returned in. #[serde(default)] @@ -91,60 +85,16 @@ pub struct SearchQuery { text: Option, /// Restrict results to one or more cosmetic types (including `emote`), /// comma-separated (e.g. `cape,emote`). Omit to return every type. - #[serde(default, deserialize_with = "deserialize_types")] + #[serde(default, deserialize_with = "deserialize_comma_list")] types: Option>, /// Restrict results to cosmetics carrying at least one of these tag names, /// comma-separated (e.g. `red,limited`). Omit to ignore tags. - #[serde(default, deserialize_with = "deserialize_tags")] + #[serde(default, deserialize_with = "deserialize_comma_list")] tags: Option>, /// the collection id to search for collection: Option, } -/// Parses a comma-separated list of tag names. Empty segments are ignored, and -/// an empty list is treated as no filter. -fn deserialize_tags<'de, D>(de: D) -> Result>, D::Error> -where - D: serde::Deserializer<'de>, -{ - let Some(raw) = Option::::deserialize(de)? else { - return Ok(None); - }; - - let tags: Vec = raw - .split(',') - .map(str::trim) - .filter(|part| !part.is_empty()) - .map(str::to_owned) - .collect(); - - Ok((!tags.is_empty()).then_some(tags)) -} - -/// Parses a comma-separated list of cosmetic types (e.g. `cape,emote`), -/// deferring to each type's own deserialization. Empty segments are ignored, -/// and an empty list is treated as no filter. -fn deserialize_types<'de, D>(de: D) -> Result>, D::Error> -where - D: serde::Deserializer<'de>, -{ - use serde::de::{Error, IntoDeserializer}; - - let Some(raw) = Option::::deserialize(de)? else { - return Ok(None); - }; - - let types = raw - .split(',') - .map(str::trim) - .filter(|part| !part.is_empty()) - .map(|part| CosmeticType::deserialize(part.into_deserializer())) - .collect::, serde::de::value::Error>>() - .map_err(Error::custom)?; - - Ok((!types.is_empty()).then_some(types)) -} - /// A single store entry, doesn't contain price id. /// /// A grouped cosmetic collapses into one entry: the fields describe its @@ -348,7 +298,7 @@ async fn endpoint( use entities::{cosmetic, cosmetic_group, prelude::*}; - let nb = query.nb.min(MAX_NB); + let nb = query.nb.min(MAX_PAGE_SIZE); let offset = nb.saturating_mul(query.page.saturating_sub(1)); let (sort_key, order) = match query.sort { diff --git a/src/api/cosmetics/view.rs b/src/api/v0/cosmetics/view.rs similarity index 99% rename from src/api/cosmetics/view.rs rename to src/api/v0/cosmetics/view.rs index 299d825..3553951 100644 --- a/src/api/cosmetics/view.rs +++ b/src/api/v0/cosmetics/view.rs @@ -17,7 +17,7 @@ use serde::Serialize; use crate::api::{ ApiState, - tags::{CosmeticTags, tags_for_cosmetics}, + v0::tags::{CosmeticTags, tags_for_cosmetics}, }; #[derive(thiserror::Error, Debug, OperationIo)] diff --git a/src/api/links.rs b/src/api/v0/links.rs similarity index 73% rename from src/api/links.rs rename to src/api/v0/links.rs index 7cc7f4a..b85aa21 100644 --- a/src/api/links.rs +++ b/src/api/v0/links.rs @@ -20,9 +20,15 @@ use sea_orm::{ TransactionTrait, TryInsertResult, sea_query::Expr, }; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use crate::api::{ApiState, admin_auth::AdminAuthenticationExtractor}; +use crate::{ + api::{ApiState, admin_auth::AdminAuthenticationExtractor}, + utils::{ + hash::sha256_hex_parts, + user_agent::is_bot, + validation::{valid_http_url, valid_slug}, + }, +}; #[derive(thiserror::Error, Debug, OperationIo)] pub enum LinksError { @@ -76,31 +82,29 @@ impl FromRequestParts for VisitorId { let ClientIp(ip) = ClientIp::from_request_parts(parts, state) .await .map_err(IntoResponse::into_response)?; + let user_agent = parts .headers .get(header::USER_AGENT) .and_then(|value| value.to_str().ok()) .unwrap_or_default() .to_owned(); + Ok(Self { ip, user_agent }) } } impl VisitorId { + /// A stable, salted id for this visitor on one slug. The salt keeps the + /// digest from being reversible into an ip, and including the slug stops + /// visits to different links being correlated. fn hash(&self, salt: &str, slug: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(salt.as_bytes()); - hasher.update([0]); - hasher.update(self.ip.to_string().as_bytes()); - hasher.update([0]); - hasher.update(self.user_agent.as_bytes()); - hasher.update([0]); - hasher.update(slug.as_bytes()); - hasher - .finalize() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() + sha256_hex_parts(&[ + salt.as_bytes(), + self.ip.to_string().as_bytes(), + self.user_agent.as_bytes(), + slug.as_bytes(), + ]) } } @@ -134,52 +138,6 @@ pub struct ListResponse { links: Vec, } -fn valid_slug(slug: &str) -> bool { - !slug.is_empty() - && slug.len() <= 128 - && slug - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') -} - -fn valid_target(url: &str) -> bool { - (url.starts_with("https://") || url.starts_with("http://")) && url.len() <= 2048 -} - -const BOT_UA_MARKERS: &[&str] = &[ - "bot", - "crawler", - "spider", - "slurp", - "preview", - "unfurl", - "embed", - "facebookexternalhit", - "discord", - "twitter", - "telegram", - "whatsapp", - "slack", - "skype", - "linkedin", - "pinterest", - "redditbot", - "mastodon", - "curl", - "wget", - "python-requests", - "go-http-client", - "headless", -]; - -fn is_bot(user_agent: &str) -> bool { - if user_agent.trim().is_empty() { - return true; - } - let ua = user_agent.to_ascii_lowercase(); - BOT_UA_MARKERS.iter().any(|marker| ua.contains(marker)) -} - fn redirect_doc(op: TransformOperation) -> TransformOperation { op.id("followTrackedLink") .summary("Follow a tracked link") @@ -282,7 +240,7 @@ async fn create( if !valid_slug(&body.slug) { return Err(LinksError::InvalidSlug); } - if !valid_target(&body.target_url) { + if !valid_http_url(&body.target_url) { return Err(LinksError::InvalidUrl); } @@ -347,67 +305,17 @@ async fn delete( mod tests { use std::net::IpAddr; - use super::{VisitorId, is_bot, valid_slug, valid_target}; + use super::VisitorId; fn visitor(ip: &str, user_agent: &str) -> VisitorId { VisitorId { - ip: ip.parse::().unwrap(), + ip: ip + .parse::() + .expect("'ip' is not a valid ip address"), user_agent: user_agent.to_owned(), } } - #[test] - fn accepts_reasonable_slugs() { - assert!(valid_slug("oneclient")); - assert!(valid_slug("oneclient-twitter")); - assert!(valid_slug("promo_2026")); - } - - #[test] - fn rejects_bad_slugs() { - assert!(!valid_slug("")); - assert!(!valid_slug("Has Space")); - assert!(!valid_slug("UPPER")); - assert!(!valid_slug("emoji-\u{1f600}")); - assert!(!valid_slug(&"x".repeat(129))); - } - - #[test] - fn only_absolute_http_targets_allowed() { - assert!(valid_target("https://polyfrost.org/projects/oneclient")); - assert!(valid_target("http://example.com")); - assert!(!valid_target("/projects/oneclient")); - assert!(!valid_target("javascript:alert(1)")); - assert!(!valid_target("data:text/html,x")); - assert!(!valid_target(&format!( - "https://x.com/{}", - "a".repeat(2048) - ))); - } - - #[test] - fn browser_user_agents_are_not_bots() { - let chrome = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \ - AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"; - let firefox = - "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0"; - assert!(!is_bot(chrome)); - assert!(!is_bot(firefox)); - } - - #[test] - fn unfurl_and_scripted_agents_are_bots() { - assert!(is_bot("")); // empty ua - assert!(is_bot(" ")); - assert!(is_bot("Discordbot/2.0 (+https://discordapp.com)")); - assert!(is_bot("Twitterbot/1.0")); - assert!(is_bot("facebookexternalhit/1.1")); - assert!(is_bot("TelegramBot (like TwitterBot)")); - assert!(is_bot("Slackbot-LinkExpanding 1.0")); - assert!(is_bot("curl/8.4.0")); - assert!(is_bot("python-requests/2.31.0")); - } - #[test] fn hash_is_stable_for_same_visitor_and_slug() { let v = visitor("203.0.113.7", "Chrome"); diff --git a/src/api/v0/mod.rs b/src/api/v0/mod.rs new file mode 100644 index 0000000..5378f70 --- /dev/null +++ b/src/api/v0/mod.rs @@ -0,0 +1,37 @@ +pub(super) mod account; +mod analytics; +mod assets; +mod bundles; +mod category; +mod collections; +pub(super) mod cosmetics; +mod links; +mod players; +mod stripe; +mod tags; +mod transactions; +pub(super) mod websocket; + +use aide::axum::ApiRouter; + +use crate::api::ApiState; + +/// The original, unversioned API. It is served from the root of the server +/// rather than from a `/v0` prefix, so that clients written before the +/// versioning scheme existed keep working unchanged. +pub(super) async fn setup_router() -> ApiRouter { + ApiRouter::new() + .nest("/stripe", stripe::setup_router().await) + .nest("/account", account::setup_router().await) + .nest("/transactions", transactions::setup_router().await) + .merge(assets::setup_router().await) + .merge(bundles::setup_router().await) + .merge(collections::setup_router().await) + .merge(links::setup_router().await) + .merge(analytics::setup_router().await) + .merge(players::setup_router().await) + .merge(cosmetics::setup_router().await) + .merge(tags::setup_router().await) + .merge(category::setup_router().await) + .merge(websocket::setup_router().await) +} diff --git a/src/api/players.rs b/src/api/v0/players.rs similarity index 97% rename from src/api/players.rs rename to src/api/v0/players.rs index f096f9d..cc68805 100644 --- a/src/api/players.rs +++ b/src/api/v0/players.rs @@ -10,7 +10,7 @@ use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; use serde::Deserialize; use uuid::Uuid; -use crate::api::{ApiState, account::AdminPlayer}; +use crate::api::{ApiState, v0::account::AdminPlayer}; #[derive(thiserror::Error, Debug, OperationIo)] pub enum RoleError { diff --git a/src/api/stripe/create.rs b/src/api/v0/stripe/create.rs similarity index 98% rename from src/api/stripe/create.rs rename to src/api/v0/stripe/create.rs index aeadd30..5629a29 100644 --- a/src/api/stripe/create.rs +++ b/src/api/v0/stripe/create.rs @@ -14,7 +14,7 @@ use uuid::Uuid; use crate::api::{ ApiState, - stripe::pricing::{cosmetics_for_price, display_name}, + v0::stripe::pricing::{cosmetics_for_price, display_name}, }; #[derive(Debug, thiserror::Error, OperationIo)] diff --git a/src/api/stripe/mod.rs b/src/api/v0/stripe/mod.rs similarity index 100% rename from src/api/stripe/mod.rs rename to src/api/v0/stripe/mod.rs diff --git a/src/api/stripe/pricing.rs b/src/api/v0/stripe/pricing.rs similarity index 100% rename from src/api/stripe/pricing.rs rename to src/api/v0/stripe/pricing.rs diff --git a/src/api/stripe/products.rs b/src/api/v0/stripe/products.rs similarity index 88% rename from src/api/stripe/products.rs rename to src/api/v0/stripe/products.rs index a1e0d5d..9248b69 100644 --- a/src/api/stripe/products.rs +++ b/src/api/v0/stripe/products.rs @@ -45,8 +45,3 @@ pub(in crate::api) async fn set_default_price( Ok(()) } - -/// Converts a USD major-unit price (e.g. 4.99) to integer cents. -pub(in crate::api) fn to_cents(base_price: f32) -> i64 { - (base_price * 100.0).round() as i64 -} diff --git a/src/api/stripe/webhook.rs b/src/api/v0/stripe/webhook.rs similarity index 99% rename from src/api/stripe/webhook.rs rename to src/api/v0/stripe/webhook.rs index b0ff394..4c552ba 100644 --- a/src/api/stripe/webhook.rs +++ b/src/api/v0/stripe/webhook.rs @@ -21,8 +21,10 @@ use uuid::Uuid; use crate::{ api::{ - ApiState, stripe::pricing::cosmetics_for_price, - websocket::structs::ClientBoundPacket, + ApiState, + v0::{ + stripe::pricing::cosmetics_for_price, websocket::structs::ClientBoundPacket, + }, }, database::{DatabaseTransactionExt, DatabaseUserExt}, }; diff --git a/src/api/tags/apply.rs b/src/api/v0/tags/apply.rs similarity index 100% rename from src/api/tags/apply.rs rename to src/api/v0/tags/apply.rs diff --git a/src/api/tags/create.rs b/src/api/v0/tags/create.rs similarity index 100% rename from src/api/tags/create.rs rename to src/api/v0/tags/create.rs diff --git a/src/api/tags/list.rs b/src/api/v0/tags/list.rs similarity index 100% rename from src/api/tags/list.rs rename to src/api/v0/tags/list.rs diff --git a/src/api/tags/mod.rs b/src/api/v0/tags/mod.rs similarity index 100% rename from src/api/tags/mod.rs rename to src/api/v0/tags/mod.rs diff --git a/src/api/tags/remove.rs b/src/api/v0/tags/remove.rs similarity index 100% rename from src/api/tags/remove.rs rename to src/api/v0/tags/remove.rs diff --git a/src/api/transactions/mod.rs b/src/api/v0/transactions/mod.rs similarity index 100% rename from src/api/transactions/mod.rs rename to src/api/v0/transactions/mod.rs diff --git a/src/api/transactions/player.rs b/src/api/v0/transactions/player.rs similarity index 98% rename from src/api/transactions/player.rs rename to src/api/v0/transactions/player.rs index 0770e8e..50ee43b 100644 --- a/src/api/transactions/player.rs +++ b/src/api/v0/transactions/player.rs @@ -13,7 +13,7 @@ use uuid::Uuid; use crate::api::{ ApiState, - account::{AuthenticatedPlayer, role_at_least}, + v0::account::{AuthenticatedPlayer, role_at_least}, }; #[derive(thiserror::Error, Debug, OperationIo)] diff --git a/src/api/websocket/endpoint.rs b/src/api/v0/websocket/endpoint.rs similarity index 99% rename from src/api/websocket/endpoint.rs rename to src/api/v0/websocket/endpoint.rs index 17faf7f..43bfa07 100644 --- a/src/api/websocket/endpoint.rs +++ b/src/api/v0/websocket/endpoint.rs @@ -23,12 +23,14 @@ use uuid::Uuid; use crate::api::{ ApiState, - account::AuthenticatedPlayer, state::{ ConnectionId, EquipmentPersistence, ParticleColorPersistence, PlayerRuntimeState, PlaytimeSession, RealtimeConnection, }, - websocket::structs::{ClientBoundPacket, ServerBoundPacket, WebsocketError}, + v0::{ + account::AuthenticatedPlayer, + websocket::structs::{ClientBoundPacket, ServerBoundPacket, WebsocketError}, + }, }; /// Max UUIDs in a single `SubscribePlayers` or `GetActiveCosmetics` message. diff --git a/src/api/websocket/mod.rs b/src/api/v0/websocket/mod.rs similarity index 100% rename from src/api/websocket/mod.rs rename to src/api/v0/websocket/mod.rs diff --git a/src/api/websocket/structs.rs b/src/api/v0/websocket/structs.rs similarity index 100% rename from src/api/websocket/structs.rs rename to src/api/v0/websocket/structs.rs diff --git a/src/api/v1/hello.rs b/src/api/v1/hello.rs new file mode 100644 index 0000000..b2088a2 --- /dev/null +++ b/src/api/v1/hello.rs @@ -0,0 +1,34 @@ +use aide::{ + axum::{ApiRouter, routing::get_with}, + transform::TransformOperation, +}; +use axum::Json; +use schemars::JsonSchema; +use serde::Serialize; + +use crate::api::ApiState; + +pub(super) fn router() -> ApiRouter { + ApiRouter::new().api_route("/hello", get_with(self::endpoint, self::endpoint_doc)) +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct HelloResponse { + message: String, +} + +fn endpoint_doc(op: TransformOperation) -> TransformOperation { + op.id("helloV1") + .summary("Hello world") + .description( + "A placeholder endpoint that confirms the v1 API is mounted and reachable.", + ) + .tag("hello") +} + +#[tracing::instrument(level = "debug")] +async fn endpoint() -> Json { + Json(HelloResponse { + message: "Hello, world!".to_string(), + }) +} diff --git a/src/api/v1/mod.rs b/src/api/v1/mod.rs new file mode 100644 index 0000000..c485590 --- /dev/null +++ b/src/api/v1/mod.rs @@ -0,0 +1,9 @@ +mod hello; + +use aide::axum::ApiRouter; + +use crate::api::ApiState; + +pub(super) async fn setup_router() -> ApiRouter { + ApiRouter::new().merge(hello::router()) +} diff --git a/src/database.rs b/src/database.rs index 33f29c1..2465bf5 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,4 +1,4 @@ -use chrono::{DateTime, Datelike, Days, NaiveDate, Utc}; +use chrono::{DateTime, Days, NaiveDate, Utc}; use entities::{ daily_playtime, monthly_active_login, prelude::*, @@ -12,6 +12,8 @@ use sea_orm::{ }; use uuid::Uuid; +use crate::utils::time::current_utc_month; + pub(crate) trait DatabaseUserExt { /// Gets a [user::Model] given a specific Minecraft UUID, or else inserts a /// new user into the database. @@ -86,13 +88,6 @@ impl DatabaseTransactionExt for Transaction { } } -pub(crate) fn current_utc_month() -> Date { - Utc::now() - .date_naive() - .with_day(1) - .expect("every month has a first day") -} - pub(crate) async fn record_monthly_active_login( db: &impl ConnectionTrait, player_id: i32, diff --git a/src/main.rs b/src/main.rs index 25f0aa9..771634c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ use crate::commands::backend_args; mod api; mod commands; mod database; +mod utils; #[tokio::main] async fn main() { @@ -16,6 +17,7 @@ async fn main() { .with(fmt::layer()) .with(EnvFilter::from_default_env()) .init(); + // Setup TLS rustls::crypto::ring::default_provider() .install_default() diff --git a/src/utils/hash.rs b/src/utils/hash.rs new file mode 100644 index 0000000..9a5e705 --- /dev/null +++ b/src/utils/hash.rs @@ -0,0 +1,56 @@ +use sha2::{Digest, Sha256}; + +/// Hashes `data` and returns the digest as a lowercase hex string. +pub(crate) fn sha256_hex(data: &[u8]) -> String { + hex(Sha256::digest(data)) +} + +/// Hashes `parts` as a single digest, separated by a null byte so that +/// different groupings of the same bytes cannot collide. +pub(crate) fn sha256_hex_parts(parts: &[&[u8]]) -> String { + let mut hasher = Sha256::new(); + for (index, part) in parts.iter().enumerate() { + if index > 0 { + hasher.update([0]); + } + hasher.update(part); + } + hex(hasher.finalize()) +} + +fn hex(digest: impl AsRef<[u8]>) -> String { + digest + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{sha256_hex, sha256_hex_parts}; + + #[test] + fn digest_null() { + assert_eq!( + sha256_hex(b"null"), + "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b" + ) + } + + #[test] + fn digest_is_lowercase_hex_sha256() { + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn parts_are_separated_so_regroupings_differ() { + assert_ne!( + sha256_hex_parts(&[b"ab", b"c"]), + sha256_hex_parts(&[b"a", b"bc"]) + ); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..39f6038 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,8 @@ +pub(crate) mod hash; +pub(crate) mod money; +pub(crate) mod pagination; +pub(crate) mod serde; +pub(crate) mod time; +pub(crate) mod user_agent; +pub(crate) mod validation; +pub(crate) mod zip; diff --git a/src/utils/money.rs b/src/utils/money.rs new file mode 100644 index 0000000..0dd594c --- /dev/null +++ b/src/utils/money.rs @@ -0,0 +1,17 @@ +/// Converts a USD major-unit price (e.g. `4.99`) to integer cents. +pub(crate) fn to_cents(base_price: f32) -> i64 { + (base_price * 100.0).round() as i64 +} + +#[cfg(test)] +mod tests { + use super::to_cents; + + #[test] + fn rounds_to_the_nearest_cent() { + assert_eq!(to_cents(4.99), 499); + assert_eq!(to_cents(0.0), 0); + assert_eq!(to_cents(9.995), 1000); + assert_eq!(to_cents(3.01), 301); + } +} diff --git a/src/utils/pagination.rs b/src/utils/pagination.rs new file mode 100644 index 0000000..4fdb267 --- /dev/null +++ b/src/utils/pagination.rs @@ -0,0 +1,12 @@ +/// The largest page a client may ask for, whatever `nb` it sends. +pub(crate) const MAX_PAGE_SIZE: u64 = 100; + +/// Serde default for the page size (`nb`). +pub(crate) fn default_page_size() -> u64 { + 50 +} + +/// Serde default for the 1-indexed page number. +pub(crate) fn default_page() -> u64 { + 1 +} diff --git a/src/utils/serde.rs b/src/utils/serde.rs new file mode 100644 index 0000000..0f62d4d --- /dev/null +++ b/src/utils/serde.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Deserializer, de::DeserializeOwned, de::IntoDeserializer}; + +/// Parses a comma-separated query parameter into a list, deferring to `T`'s own +/// deserialization for each segment (e.g. `cape,emote` or `red,limited`). +/// +/// Empty segments are ignored and an empty list deserializes to `None`, so an +/// absent and a blank parameter both mean "no filter". +pub(crate) fn deserialize_comma_list<'de, D, T>(de: D) -> Result>, D::Error> +where + D: Deserializer<'de>, + T: DeserializeOwned, +{ + use serde::de::Error; + + let Some(raw) = Option::::deserialize(de)? else { + return Ok(None); + }; + + let values = raw + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(|part| T::deserialize(part.into_deserializer())) + .collect::, serde::de::value::Error>>() + .map_err(Error::custom)?; + + Ok((!values.is_empty()).then_some(values)) +} diff --git a/src/utils/time.rs b/src/utils/time.rs new file mode 100644 index 0000000..614ae75 --- /dev/null +++ b/src/utils/time.rs @@ -0,0 +1,18 @@ +use chrono::{Datelike, NaiveDate, Utc}; +use sea_orm::prelude::{Date, DateTimeWithTimeZone}; + +/// The first day of the current UTC month, the key monthly counters bucket by. +pub(crate) fn current_utc_month() -> Date { + Utc::now() + .date_naive() + .with_day(1) + .expect("every month has a first day") +} + +/// Midnight UTC at the start of `day`. +pub(crate) fn start_of_day(day: NaiveDate) -> DateTimeWithTimeZone { + day.and_hms_opt(0, 0, 0) + .expect("midnight is a valid time of day") + .and_utc() + .fixed_offset() +} diff --git a/src/utils/user_agent.rs b/src/utils/user_agent.rs new file mode 100644 index 0000000..e0cce15 --- /dev/null +++ b/src/utils/user_agent.rs @@ -0,0 +1,65 @@ +/// Matched case-insensitively, so entries must be lowercase. +const BOT_UA_MARKERS: &[&str] = &[ + "bot", + "crawler", + "spider", + "slurp", + "preview", + "unfurl", + "embed", + "facebookexternalhit", + "discord", + "twitter", + "telegram", + "whatsapp", + "slack", + "skype", + "linkedin", + "pinterest", + "redditbot", + "mastodon", + "curl", + "wget", + "python-requests", + "go-http-client", + "headless", +]; + +/// Whether `user_agent` looks like a crawler, unfurler or scripted client. +/// A missing or blank user-agent counts as a bot. +pub(crate) fn is_bot(user_agent: &str) -> bool { + if user_agent.trim().is_empty() { + return true; + } + + let ua = user_agent.to_ascii_lowercase(); + BOT_UA_MARKERS.iter().any(|marker| ua.contains(marker)) +} + +#[cfg(test)] +mod tests { + use super::is_bot; + + #[test] + fn browser_user_agents_are_not_bots() { + let chrome = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \ + AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"; + let firefox = + "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0"; + assert!(!is_bot(chrome)); + assert!(!is_bot(firefox)); + } + + #[test] + fn unfurl_and_scripted_agents_are_bots() { + assert!(is_bot("")); // empty ua + assert!(is_bot(" ")); + assert!(is_bot("Discordbot/2.0 (+https://discordapp.com)")); + assert!(is_bot("Twitterbot/1.0")); + assert!(is_bot("facebookexternalhit/1.1")); + assert!(is_bot("TelegramBot (like TwitterBot)")); + assert!(is_bot("Slackbot-LinkExpanding 1.0")); + assert!(is_bot("curl/8.4.0")); + assert!(is_bot("python-requests/2.31.0")); + } +} diff --git a/src/utils/validation.rs b/src/utils/validation.rs new file mode 100644 index 0000000..e41e958 --- /dev/null +++ b/src/utils/validation.rs @@ -0,0 +1,55 @@ +/// The longest slug accepted in a url path segment. +const MAX_SLUG_LEN: usize = 128; +/// The longest url accepted as a redirect target. +const MAX_URL_LEN: usize = 2048; + +/// Whether `slug` is a safe url path segment: non-empty, lowercase ascii +/// alphanumerics plus `-` and `_`. +pub(crate) fn valid_slug(slug: &str) -> bool { + !slug.is_empty() + && slug.len() <= MAX_SLUG_LEN + && slug + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') +} + +/// Whether `url` is an absolute http(s) url of a sane length. This rejects +/// relative paths and scheme-based payloads such as `javascript:` or `data:`. +pub(crate) fn valid_http_url(url: &str) -> bool { + (url.starts_with("https://") || url.starts_with("http://")) + && url.len() <= MAX_URL_LEN +} + +#[cfg(test)] +mod tests { + use super::{valid_http_url, valid_slug}; + + #[test] + fn accepts_reasonable_slugs() { + assert!(valid_slug("oneclient")); + assert!(valid_slug("oneclient-twitter")); + assert!(valid_slug("promo_2026")); + } + + #[test] + fn rejects_bad_slugs() { + assert!(!valid_slug("")); + assert!(!valid_slug("Has Space")); + assert!(!valid_slug("UPPER")); + assert!(!valid_slug("emoji-\u{1f600}")); + assert!(!valid_slug(&"x".repeat(129))); + } + + #[test] + fn only_absolute_http_urls_allowed() { + assert!(valid_http_url("https://polyfrost.org/projects/oneclient")); + assert!(valid_http_url("http://example.com")); + assert!(!valid_http_url("/projects/oneclient")); + assert!(!valid_http_url("javascript:alert(1)")); + assert!(!valid_http_url("data:text/html,x")); + assert!(!valid_http_url(&format!( + "https://x.com/{}", + "a".repeat(2048) + ))); + } +} diff --git a/src/utils/zip.rs b/src/utils/zip.rs new file mode 100644 index 0000000..b2f68b1 --- /dev/null +++ b/src/utils/zip.rs @@ -0,0 +1,64 @@ +use std::io::{Cursor, Read, Write}; + +use zip::result::ZipError; + +/// Whether `data` starts with the zip local file header magic. +pub(crate) fn is_zip(data: &[u8]) -> bool { + data.len() >= 4 && &data[0..4] == b"PK\x03\x04" +} + +/// Entries macOS adds when compressing from Finder, which are meaningless to +/// every other platform. +fn is_macos_junk(name: &str) -> bool { + name.split('/') + .next_back() + .is_some_and(|base| base == ".DS_Store") + || name.starts_with("__MACOSX/") + || name.contains("/__MACOSX/") +} + +/// Rewrites `data` as a new archive without directory entries or macOS junk. +pub(crate) fn strip_macos_junk(data: &[u8]) -> Result, ZipError> { + let mut archive = zip::ZipArchive::new(Cursor::new(data))?; + let mut out = Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut out); + for i in 0..archive.len() { + let mut entry = archive.by_index(i)?; + let name = entry.name().to_string(); + if entry.is_dir() || is_macos_junk(&name) { + continue; + } + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + writer.start_file(name, options)?; + let mut buf = Vec::with_capacity(entry.size() as usize); + entry.read_to_end(&mut buf)?; + writer.write_all(&buf)?; + } + writer.finish()?; + } + + Ok(out.into_inner()) +} + +#[cfg(test)] +mod tests { + use super::{is_macos_junk, is_zip}; + + #[test] + fn detects_zip_magic() { + assert!(is_zip(b"PK\x03\x04rest")); + assert!(!is_zip(b"\x89PNG")); + assert!(!is_zip(b"PK")); + } + + #[test] + fn detects_macos_junk() { + assert!(is_macos_junk("__MACOSX/cape.png")); + assert!(is_macos_junk("bundle/__MACOSX/cape.png")); + assert!(is_macos_junk(".DS_Store")); + assert!(is_macos_junk("bundle/.DS_Store")); + assert!(!is_macos_junk("bundle/cape.png")); + } +} From 309210c1b1793b01078f31a55cacb518a11b0f2c Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:39:04 +0200 Subject: [PATCH 3/6] refactor: move setup_router functions to the top of the file --- src/api/v0/analytics.rs | 14 +++++++------- src/api/v0/assets.rs | 24 ++++++++++++------------ src/api/v0/category/mod.rs | 8 ++++---- src/api/v0/collections/mod.rs | 24 ++++++++++++------------ src/api/v0/cosmetics/mod.rs | 32 ++++++++++++++++---------------- src/api/v0/links.rs | 16 ++++++++-------- src/api/v0/players.rs | 14 +++++++------- src/api/v0/tags/mod.rs | 22 +++++++++++----------- 8 files changed, 77 insertions(+), 77 deletions(-) diff --git a/src/api/v0/analytics.rs b/src/api/v0/analytics.rs index c593a55..70c0f04 100644 --- a/src/api/v0/analytics.rs +++ b/src/api/v0/analytics.rs @@ -32,6 +32,13 @@ use crate::{ utils::time::{current_utc_month, start_of_day}, }; +pub(super) async fn setup_router() -> ApiRouter { + ApiRouter::new().api_route( + "/analytics/overview", + get_with(self::endpoint, self::endpoint_doc), + ) +} + #[derive(Debug)] pub struct PrivateAnalyticsAuth; @@ -307,13 +314,6 @@ impl OwnedItemsCounts { } } -pub(super) async fn setup_router() -> ApiRouter { - ApiRouter::new().api_route( - "/analytics/overview", - get_with(self::endpoint, self::endpoint_doc), - ) -} - #[tracing::instrument(level = "debug", skip(state))] async fn endpoint( State(state): State, diff --git a/src/api/v0/assets.rs b/src/api/v0/assets.rs index 3753ffc..66dd55b 100644 --- a/src/api/v0/assets.rs +++ b/src/api/v0/assets.rs @@ -15,6 +15,18 @@ use serde::Serialize; use crate::api::ApiState; +pub(super) async fn setup_router() -> ApiRouter { + ApiRouter::new() + .api_route( + "/asset/{id}", + get_with(self::redirect_endpoint, self::redirect_doc), + ) + .api_route( + "/asset/{id}/url", + get_with(self::url_endpoint, self::url_doc), + ) +} + #[derive(thiserror::Error, Debug, OperationIo)] pub enum AssetError { #[error("No asset with that id has a resolvable url")] @@ -85,18 +97,6 @@ fn url_doc(op: TransformOperation) -> TransformOperation { .tag("assets") } -pub(super) async fn setup_router() -> ApiRouter { - ApiRouter::new() - .api_route( - "/asset/{id}", - get_with(self::redirect_endpoint, self::redirect_doc), - ) - .api_route( - "/asset/{id}/url", - get_with(self::url_endpoint, self::url_doc), - ) -} - #[tracing::instrument(level = "debug", skip(state))] async fn redirect_endpoint( State(state): State, diff --git a/src/api/v0/category/mod.rs b/src/api/v0/category/mod.rs index 2f4b854..77ef574 100644 --- a/src/api/v0/category/mod.rs +++ b/src/api/v0/category/mod.rs @@ -4,6 +4,10 @@ use aide::axum::ApiRouter; use crate::api::ApiState; +pub(super) async fn setup_router() -> ApiRouter { + ApiRouter::new().nest("/category", ApiRouter::new().merge(list::router())) +} + // Fetches the category for each of the given cosmetic ids, grouped by type. // Cosmetics with no category are absent from the map; callers should treat a @@ -33,7 +37,3 @@ use crate::api::ApiState; // Ok(rows) // } - -pub(super) async fn setup_router() -> ApiRouter { - ApiRouter::new().nest("/category", ApiRouter::new().merge(list::router())) -} diff --git a/src/api/v0/collections/mod.rs b/src/api/v0/collections/mod.rs index 4d3b79e..9efb85f 100644 --- a/src/api/v0/collections/mod.rs +++ b/src/api/v0/collections/mod.rs @@ -11,6 +11,18 @@ use uuid::Uuid; use crate::{api::ApiState, utils::hash::sha256_hex}; +pub(super) async fn setup_router() -> ApiRouter { + ApiRouter::new().nest( + "/collections", + ApiRouter::new() + .merge(list::router()) + .merge(create::router()) + .merge(edit::router()) + .merge(delete::router()) + .merge(view::router()), + ) +} + #[derive(thiserror::Error, Debug)] pub(crate) enum StoreAssetError { #[error("Database error: {0}")] @@ -51,15 +63,3 @@ pub(crate) async fn store_asset( Ok(asset.id) } - -pub(super) async fn setup_router() -> ApiRouter { - ApiRouter::new().nest( - "/collections", - ApiRouter::new() - .merge(list::router()) - .merge(create::router()) - .merge(edit::router()) - .merge(delete::router()) - .merge(view::router()), - ) -} diff --git a/src/api/v0/cosmetics/mod.rs b/src/api/v0/cosmetics/mod.rs index 3145f16..170c8ab 100644 --- a/src/api/v0/cosmetics/mod.rs +++ b/src/api/v0/cosmetics/mod.rs @@ -25,6 +25,22 @@ use serde::{Deserialize, Serialize}; use crate::api::ApiState; +pub(super) async fn setup_router() -> ApiRouter { + ApiRouter::new() + .nest( + "/cosmetics", + ApiRouter::new() + .merge(get_player::router()) + .merge(put_player::router()) + .merge(manage::router()) + .merge(grant::router()) + .merge(list_capes::router()), + ) + .merge(list::router()) + .merge(search::router()) + .merge(view::router()) +} + /// A buyable cosmetic. What the player owns once and chooses variants within. /// /// When a cosmetic has multiple variants (e.g. every pride cape, or every cat @@ -347,19 +363,3 @@ pub(super) type EquippedCosmetics = HashMap; pub(super) struct PartialEquippedCosmetics { pub equipped: HashMap>, } - -pub(super) async fn setup_router() -> ApiRouter { - ApiRouter::new() - .nest( - "/cosmetics", - ApiRouter::new() - .merge(get_player::router()) - .merge(put_player::router()) - .merge(manage::router()) - .merge(grant::router()) - .merge(list_capes::router()), - ) - .merge(list::router()) - .merge(search::router()) - .merge(view::router()) -} diff --git a/src/api/v0/links.rs b/src/api/v0/links.rs index b85aa21..c9ca92b 100644 --- a/src/api/v0/links.rs +++ b/src/api/v0/links.rs @@ -30,6 +30,14 @@ use crate::{ }, }; +pub(super) async fn setup_router() -> ApiRouter { + ApiRouter::new() + .api_route("/go/{slug}", get_with(self::follow, self::redirect_doc)) + .api_route("/links", post_with(self::create, self::create_doc)) + .api_route("/links", get_with(self::list, self::list_doc)) + .api_route("/links/{slug}", delete_with(self::delete, self::delete_doc)) +} + #[derive(thiserror::Error, Debug, OperationIo)] pub enum LinksError { #[error("No tracked link with that slug exists")] @@ -178,14 +186,6 @@ fn delete_doc(op: TransformOperation) -> TransformOperation { .tag("links") } -pub(super) async fn setup_router() -> ApiRouter { - ApiRouter::new() - .api_route("/go/{slug}", get_with(self::follow, self::redirect_doc)) - .api_route("/links", post_with(self::create, self::create_doc)) - .api_route("/links", get_with(self::list, self::list_doc)) - .api_route("/links/{slug}", delete_with(self::delete, self::delete_doc)) -} - #[tracing::instrument(level = "debug", skip(state, visitor))] async fn follow( State(state): State, diff --git a/src/api/v0/players.rs b/src/api/v0/players.rs index cc68805..db831ee 100644 --- a/src/api/v0/players.rs +++ b/src/api/v0/players.rs @@ -12,6 +12,13 @@ use uuid::Uuid; use crate::api::{ApiState, v0::account::AdminPlayer}; +pub(super) async fn setup_router() -> ApiRouter { + ApiRouter::new().api_route( + "/players/role", + put_with(self::endpoint, self::endpoint_doc), + ) +} + #[derive(thiserror::Error, Debug, OperationIo)] pub enum RoleError { #[error("The requested player does not exist")] @@ -46,13 +53,6 @@ struct RoleRequest { role: PlayerRole, } -pub(super) async fn setup_router() -> ApiRouter { - ApiRouter::new().api_route( - "/players/role", - put_with(self::endpoint, self::endpoint_doc), - ) -} - #[tracing::instrument(level = "debug", skip(state))] async fn endpoint( State(state): State, diff --git a/src/api/v0/tags/mod.rs b/src/api/v0/tags/mod.rs index d0cdddc..2ba76d3 100644 --- a/src/api/v0/tags/mod.rs +++ b/src/api/v0/tags/mod.rs @@ -15,6 +15,17 @@ use serde::Serialize; use crate::api::ApiState; +pub(super) async fn setup_router() -> ApiRouter { + ApiRouter::new().nest( + "/tags", + ApiRouter::new() + .merge(list::router()) + .merge(create::router()) + .merge(apply::router()) + .merge(remove::router()), + ) +} + /// The tags applied to a cosmetic, grouped by their type. #[derive(Clone, Debug, Default, Serialize, JsonSchema)] pub(crate) struct CosmeticTags { @@ -96,14 +107,3 @@ async fn expand_groups( Ok(Some(expanded.into_iter().collect())) } - -pub(super) async fn setup_router() -> ApiRouter { - ApiRouter::new().nest( - "/tags", - ApiRouter::new() - .merge(list::router()) - .merge(create::router()) - .merge(apply::router()) - .merge(remove::router()), - ) -} From 2e64a75bda3b4d1251c8f5606293c5a5b525e79f Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:44:27 +0200 Subject: [PATCH 4/6] refactor: move cosmetic routes --- src/api/v0/cosmetics/mod.rs | 6 +++--- src/api/v0/cosmetics/search.rs | 2 +- src/api/v0/cosmetics/view.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/api/v0/cosmetics/mod.rs b/src/api/v0/cosmetics/mod.rs index 170c8ab..4154eda 100644 --- a/src/api/v0/cosmetics/mod.rs +++ b/src/api/v0/cosmetics/mod.rs @@ -34,11 +34,11 @@ pub(super) async fn setup_router() -> ApiRouter { .merge(put_player::router()) .merge(manage::router()) .merge(grant::router()) - .merge(list_capes::router()), + .merge(list_capes::router()) + .merge(search::router()) + .merge(view::router()) ) .merge(list::router()) - .merge(search::router()) - .merge(view::router()) } /// A buyable cosmetic. What the player owns once and chooses variants within. diff --git a/src/api/v0/cosmetics/search.rs b/src/api/v0/cosmetics/search.rs index 7fd2005..95b6b23 100644 --- a/src/api/v0/cosmetics/search.rs +++ b/src/api/v0/cosmetics/search.rs @@ -284,7 +284,7 @@ fn filtered(query: &SearchQuery) -> Select { pub(super) fn router() -> ApiRouter { ApiRouter::new().api_route( - "/cosmetics/search", + "/search", get_with(self::endpoint, self::endpoint_doc), ) } diff --git a/src/api/v0/cosmetics/view.rs b/src/api/v0/cosmetics/view.rs index 3553951..c6e2c15 100644 --- a/src/api/v0/cosmetics/view.rs +++ b/src/api/v0/cosmetics/view.rs @@ -102,7 +102,7 @@ fn endpoint_doc(op: TransformOperation) -> TransformOperation { pub(super) fn router() -> ApiRouter { ApiRouter::new().api_route( - "/cosmetics/view/{id}", + "/view/{id}", get_with(self::endpoint, self::endpoint_doc), ) } From 90c42fae1f45e525534ece6fbec2b5c2060637e3 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:13:27 +0200 Subject: [PATCH 5/6] chore: add filter to cosmetic list endpoint and remove list capes endpoint --- src/api/v0/cosmetics/list.rs | 16 ++++- src/api/v0/cosmetics/list_capes.rs | 101 ----------------------------- src/api/v0/cosmetics/mod.rs | 2 - 3 files changed, 13 insertions(+), 106 deletions(-) delete mode 100644 src/api/v0/cosmetics/list_capes.rs diff --git a/src/api/v0/cosmetics/list.rs b/src/api/v0/cosmetics/list.rs index 2ca6e3c..9488102 100644 --- a/src/api/v0/cosmetics/list.rs +++ b/src/api/v0/cosmetics/list.rs @@ -3,10 +3,11 @@ use aide::{ axum::{ApiRouter, routing::get_with}, transform::TransformOperation, }; -use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; +use axum::{Json, extract::{Query, State}, http::StatusCode, response::IntoResponse}; +use entities::sea_orm_active_enums::CosmeticType; use schemars::JsonSchema; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; -use serde::Serialize; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryTrait}; +use serde::{Deserialize, Serialize}; use crate::api::{ ApiState, @@ -54,6 +55,11 @@ pub struct Response { cosmetics: Vec, } +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct ListFilters { + r#type: Option +} + pub(super) fn router() -> ApiRouter { ApiRouter::new().api_route("/cosmetics", get_with(self::endpoint, self::endpoint_doc)) } @@ -61,6 +67,7 @@ pub(super) fn router() -> ApiRouter { #[tracing::instrument(level = "debug", skip(state))] async fn endpoint( State(state): State, + Query(filters): Query, ) -> Result, ResponseError> { let mut response = Response::default(); @@ -69,6 +76,9 @@ async fn endpoint( let cosmetics = Cosmetic::find() .filter(cosmetic::Column::Enabled.eq(true)) + .apply_if(filters.r#type, |query, v| { + query.filter(cosmetic::Column::Type.eq(v)) + }) .find_with_related(CosmeticAllowedSlot) .all(&state.database) .await?; diff --git a/src/api/v0/cosmetics/list_capes.rs b/src/api/v0/cosmetics/list_capes.rs deleted file mode 100644 index bdc06a5..0000000 --- a/src/api/v0/cosmetics/list_capes.rs +++ /dev/null @@ -1,101 +0,0 @@ -use aide::{ - OperationIo, - axum::{ApiRouter, routing::get_with}, - transform::TransformOperation, -}; -use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; -use entities::sea_orm_active_enums::{BodySlot, CosmeticType}; -use schemars::JsonSchema; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; -use serde::Serialize; - -use crate::api::{ - ApiState, - v0::cosmetics::{CosmeticInfo, group_cosmetics, load_groups}, -}; - -#[derive(thiserror::Error, Debug, OperationIo)] -pub enum ResponseError { - #[error("Unable to fetch user data from database: {0}")] - DatabaseFetch(#[from] sea_orm::error::DbErr), - #[error("Unable to presign S3 URLs: {0}")] - S3Presign(#[from] s3::error::S3Error), -} - -fn endpoint_doc(op: TransformOperation) -> TransformOperation { - op.id("listCapes") - .summary("List all capes") - .description("Lists all capes, including their URLs and unique IDs") - .tag("cosmetics") - .response_with::<{ StatusCode::INTERNAL_SERVER_ERROR.as_u16() }, String, _>( - |res| { - res.description( - "An internal server error occurred while trying to fetch capes", - ) - }, - ) -} - -impl IntoResponse for ResponseError { - fn into_response(self) -> axum::response::Response { - ( - match self { - ResponseError::S3Presign(_) => StatusCode::INTERNAL_SERVER_ERROR, - ResponseError::DatabaseFetch(_) => StatusCode::INTERNAL_SERVER_ERROR, - }, - self.to_string(), - ) - .into_response() - } -} - -/// Information about the player's cosmetics -#[derive(Debug, Default, Serialize, JsonSchema)] -pub struct Response { - capes: Vec, -} - -pub(super) fn router() -> ApiRouter { - ApiRouter::new().api_route("/capes", get_with(self::endpoint, self::endpoint_doc)) -} - -#[tracing::instrument(level = "debug", skip(state))] -async fn endpoint( - State(state): State, -) -> Result, ResponseError> { - let mut response = Response::default(); - - { - use entities::{cosmetic, prelude::*}; - - let cosmetics = Cosmetic::find() - .filter(cosmetic::Column::Type.eq(CosmeticType::Cape)) - .filter(cosmetic::Column::Enabled.eq(true)) - .find_also_related(Asset) - .all(&state.database) - .await?; - - let mut rows = Vec::with_capacity(cosmetics.len()); - for (cosmetic, asset) in cosmetics { - let cover_asset = match cosmetic.cover_asset_id { - Some(asset_id) => { - Asset::find_by_id(asset_id).one(&state.database).await? - } - None => None, - }; - rows.push((cosmetic, asset, cover_asset, vec![BodySlot::Cape])); - } - - let groups = load_groups(&state.database).await?; - response.capes = group_cosmetics( - rows, - groups, - state.asset_cache.clone(), - state.s3_bucket.clone(), - true, - ) - .await?; - }; - - Ok(Json(response)) -} diff --git a/src/api/v0/cosmetics/mod.rs b/src/api/v0/cosmetics/mod.rs index 4154eda..a85d36e 100644 --- a/src/api/v0/cosmetics/mod.rs +++ b/src/api/v0/cosmetics/mod.rs @@ -2,7 +2,6 @@ mod cover; mod get_player; mod grant; mod list; -mod list_capes; mod manage; mod put_player; mod search; @@ -34,7 +33,6 @@ pub(super) async fn setup_router() -> ApiRouter { .merge(put_player::router()) .merge(manage::router()) .merge(grant::router()) - .merge(list_capes::router()) .merge(search::router()) .merge(view::router()) ) From 37ca4d665ed876a00271cff770a7872d0676182a Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:25:03 +0200 Subject: [PATCH 6/6] chore: update flake.lock --- flake.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/flake.lock b/flake.lock index c788091..4c70b23 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1768873933, - "narHash": "sha256-CfyzdaeLNGkyAHp3kT5vjvXhA1pVVK7nyDziYxCPsNk=", + "lastModified": 1785782307, + "narHash": "sha256-MPaRdVkf6zZP5fCPxYCi8Dr4pZzgmXzg8T9nVEbp3Mw=", "owner": "ipetkov", "repo": "crane", - "rev": "0bda7e7d005ccb5522a76d11ccfbf562b71953ca", + "rev": "2c71e194474d13de031d729b729c968ddbe3507f", "type": "github" }, "original": { @@ -35,11 +35,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1769018530, - "narHash": "sha256-MJ27Cy2NtBEV5tsK+YraYr2g851f3Fl1LpNHDzDX15c=", + "lastModified": 1786106723, + "narHash": "sha256-zDSUbpoeo/9ZmD2+wXnzxoo1+uhL8vxc0b8yuYMKYq0=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "88d3861acdd3d2f0e361767018218e51810df8a1", + "rev": "f13ff45afd1bb73e640eaa08a7066dbed07e3238", "type": "github" }, "original": { @@ -65,11 +65,11 @@ ] }, "locked": { - "lastModified": 1769222645, - "narHash": "sha256-gu6oZ86zLudBZMq8LL1qdtYt/S69GV5keQVXdvBrVSU=", + "lastModified": 1786334499, + "narHash": "sha256-g63bW8kYMVhJSncbPuqc1ugbd5ZL3YU3ieEVeaBq/0k=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "22da29e7f3d8cff75009cbbcf992c7cb66920cfd", + "rev": "2cb7cad89f0326df860ca399e209352ba6a19ef9", "type": "github" }, "original": { @@ -100,11 +100,11 @@ ] }, "locked": { - "lastModified": 1768158989, - "narHash": "sha256-67vyT1+xClLldnumAzCTBvU0jLZ1YBcf4vANRWP3+Ak=", + "lastModified": 1785945821, + "narHash": "sha256-NLSyTCW4K4ofhNBllt3omPasm6QpralXH1DBZOc91Dw=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "e96d59dff5c0d7fddb9d113ba108f03c3ef99eca", + "rev": "ae7910970dddc408fe6ab1c8e4b277bb21d72dc0", "type": "github" }, "original": {