diff --git a/src/api/v0/cosmetics/manage/create.rs b/src/api/v0/cosmetics/manage/create.rs index 8ff3187..ea3f142 100644 --- a/src/api/v0/cosmetics/manage/create.rs +++ b/src/api/v0/cosmetics/manage/create.rs @@ -356,93 +356,20 @@ async fn endpoint( return Err(UploadError::MissingSlots); } - let is_bundle = is_zip(&data); - let data: Vec = if is_bundle { - strip_macos_junk(&data)? - } else { - data.to_vec() - }; - if is_bundle { - extension = "zip".to_string(); - content_type = Some("application/zip".to_string()); - } - let asset_kind = if is_emote || is_bundle { - AssetKind::Bundle - } else { - AssetKind::Image - }; - let default_content_type = if is_emote { - "application/zip" - } else { - "image/png" - }; - - let path = format!( - "{}/{}.{}", - storage_prefix(&cosmetic_type), - Uuid::now_v7(), - extension - ); - - state - .s3_bucket - .put_object_with_content_type( - &path, - &data, - content_type.as_deref().unwrap_or(default_content_type), - ) - .await?; - - use entities::{ - asset, cosmetic, cosmetic_allowed_slot, cosmetic_group, - cosmetic_group_allowed_slot, prelude::*, - }; - - let asset = asset::ActiveModel { - storage_path: Set(Some(path)), - url: Set(None), - asset_kind: Set(asset_kind), - content_type: Set(content_type.or_else(|| Some(default_content_type.to_string()))), - hash: Set(Some(sha256_hex(&data))), - ..Default::default() - } - .insert(&state.database) + let (asset, cover_asset_id) = store_cosmetic_file( + &state, + &cosmetic_type, + &slots, + model_variant.as_deref(), + &data, + extension, + content_type, + ) .await?; - let cover_asset_id = if state.render_service_url.is_empty() { - None - } else { - match crate::api::v0::cosmetics::cover::render_cover( - &state.render_client, - &state.render_service_url, - &cosmetic_type, - &slots, - model_variant.as_deref(), - is_bundle, - &data, - ) - .await - { - Ok(png) => match crate::api::v0::collections::store_asset( - &state, - &png, - Some("image/png".to_string()), - "png", - "covers", - ) - .await - { - Ok(id) => Some(id), - Err(error) => { - tracing::warn!("Unable to store cosmetic cover asset: {error}"); - None - } - }, - Err(error) => { - tracing::warn!("Unable to render cosmetic cover: {error}"); - None - } - } + use entities::{ + cosmetic, cosmetic_allowed_slot, cosmetic_group, cosmetic_group_allowed_slot, + prelude::*, }; let group = match group_name { @@ -562,13 +489,6 @@ async fn endpoint( .await?; } - let info = crate::api::v0::cosmetics::CachedAssetInfo::from_db_model( - &asset, - state.s3_bucket.clone(), - ) - .await?; - state.asset_cache.insert(asset.id, info).await; - let groups = match &group { Some(group) => { let group_slots = CosmeticGroupAllowedSlot::find() @@ -604,3 +524,111 @@ async fn endpoint( Ok(Json(infos.remove(0))) } + +/// Uploads a cosmetic's source file, renders its cover when the render service +/// is configured, and returns the new asset with the cover's id. +pub(super) async fn store_cosmetic_file( + state: &ApiState, + cosmetic_type: &CosmeticType, + slots: &[BodySlot], + model_variant: Option<&str>, + data: &[u8], + mut extension: String, + mut content_type: Option, +) -> Result<(entities::asset::Model, Option), UploadError> { + use entities::asset; + + let is_emote = matches!(cosmetic_type, CosmeticType::Emote); + let is_bundle = is_zip(data); + let data: Vec = if is_bundle { + strip_macos_junk(data)? + } else { + data.to_vec() + }; + if is_bundle { + extension = "zip".to_string(); + content_type = Some("application/zip".to_string()); + } + let asset_kind = if is_emote || is_bundle { + AssetKind::Bundle + } else { + AssetKind::Image + }; + let default_content_type = if is_emote { + "application/zip" + } else { + "image/png" + }; + + let path = format!( + "{}/{}.{}", + storage_prefix(cosmetic_type), + Uuid::now_v7(), + extension + ); + + state + .s3_bucket + .put_object_with_content_type( + &path, + &data, + content_type.as_deref().unwrap_or(default_content_type), + ) + .await?; + + let asset = asset::ActiveModel { + storage_path: Set(Some(path)), + url: Set(None), + asset_kind: Set(asset_kind), + content_type: Set(content_type.or_else(|| Some(default_content_type.to_string()))), + hash: Set(Some(sha256_hex(&data))), + ..Default::default() + } + .insert(&state.database) + .await?; + + let cover_asset_id = if state.render_service_url.is_empty() { + None + } else { + match crate::api::v0::cosmetics::cover::render_cover( + &state.render_client, + &state.render_service_url, + cosmetic_type, + slots, + model_variant, + is_bundle, + &data, + ) + .await + { + Ok(png) => match crate::api::v0::collections::store_asset( + state, + &png, + Some("image/png".to_string()), + "png", + "covers", + ) + .await + { + Ok(id) => Some(id), + Err(error) => { + tracing::warn!("Unable to store cosmetic cover asset: {error}"); + None + } + }, + Err(error) => { + tracing::warn!("Unable to render cosmetic cover: {error}"); + None + } + } + }; + + let info = crate::api::v0::cosmetics::CachedAssetInfo::from_db_model( + &asset, + state.s3_bucket.clone(), + ) + .await?; + state.asset_cache.insert(asset.id, info).await; + + Ok((asset, cover_asset_id)) +} diff --git a/src/api/v0/cosmetics/manage/mod.rs b/src/api/v0/cosmetics/manage/mod.rs index b40bd56..88062c2 100644 --- a/src/api/v0/cosmetics/manage/mod.rs +++ b/src/api/v0/cosmetics/manage/mod.rs @@ -1,7 +1,9 @@ mod create; mod delete; mod render_cover; +mod replace_asset; mod update; +mod update_variant; use aide::axum::ApiRouter; @@ -13,6 +15,8 @@ pub(super) fn router() -> ApiRouter { ApiRouter::new() .merge(create::router()) .merge(update::router()) + .merge(update_variant::router()) + .merge(replace_asset::router()) .merge(delete::router()) .merge(render_cover::router()), ) diff --git a/src/api/v0/cosmetics/manage/render_cover.rs b/src/api/v0/cosmetics/manage/render_cover.rs index 19b4ab6..85fcd52 100644 --- a/src/api/v0/cosmetics/manage/render_cover.rs +++ b/src/api/v0/cosmetics/manage/render_cover.rs @@ -100,9 +100,7 @@ async fn endpoint( _auth: AdminAuthenticationExtractor, Json(body): Json, ) -> Result, RenderCoverError> { - use entities::{ - cosmetic, cosmetic_allowed_slot, cosmetic_group_allowed_slot, prelude::*, - }; + use entities::{cosmetic, prelude::*}; if state.render_service_url.is_empty() { return Err(RenderCoverError::RenderDisabled); @@ -130,24 +128,7 @@ async fn endpoint( }; let data = state.s3_bucket.get_object(storage_path).await?.to_vec(); - let mut slots: Vec = CosmeticAllowedSlot::find() - .filter(cosmetic_allowed_slot::Column::CosmeticId.eq(cosmetic.id)) - .all(&state.database) - .await? - .into_iter() - .map(|row| row.slot) - .collect(); - if let Some(group_id) = cosmetic.group_id - && slots.is_empty() - { - slots = CosmeticGroupAllowedSlot::find() - .filter(cosmetic_group_allowed_slot::Column::GroupId.eq(group_id)) - .all(&state.database) - .await? - .into_iter() - .map(|row| row.slot) - .collect(); - } + let slots = cosmetic_slots(&state.database, &cosmetic).await?; let is_bundle = asset.asset_kind == AssetKind::Bundle; @@ -182,3 +163,29 @@ async fn endpoint( cover_asset_id, })) } + +/// The slots a cosmetic renders in: its own, else its group's. +pub(super) async fn cosmetic_slots( + db: &C, + cosmetic: &entities::cosmetic::Model, +) -> Result, sea_orm::DbErr> { + use entities::{cosmetic_allowed_slot, cosmetic_group_allowed_slot, prelude::*}; + + let slots: Vec = CosmeticAllowedSlot::find() + .filter(cosmetic_allowed_slot::Column::CosmeticId.eq(cosmetic.id)) + .all(db) + .await? + .into_iter() + .map(|row| row.slot) + .collect(); + match cosmetic.group_id { + Some(group_id) if slots.is_empty() => Ok(CosmeticGroupAllowedSlot::find() + .filter(cosmetic_group_allowed_slot::Column::GroupId.eq(group_id)) + .all(db) + .await? + .into_iter() + .map(|row| row.slot) + .collect()), + _ => Ok(slots), + } +} diff --git a/src/api/v0/cosmetics/manage/replace_asset.rs b/src/api/v0/cosmetics/manage/replace_asset.rs new file mode 100644 index 0000000..9d79eb5 --- /dev/null +++ b/src/api/v0/cosmetics/manage/replace_asset.rs @@ -0,0 +1,204 @@ +use aide::{ + OperationInput, OperationIo, + axum::{ApiRouter, routing::post_with}, + transform::TransformOperation, +}; +use axum::{ + Json, + extract::{FromRequest, Multipart, Request, State}, + http::StatusCode, + response::IntoResponse, +}; +use schemars::JsonSchema; +use sea_orm::{ActiveModelTrait, EntityTrait, Set}; +use serde::Serialize; + +use super::create::{UploadError, store_cosmetic_file}; +use crate::api::{ApiState, admin_auth::AdminAuthenticationExtractor}; + +#[derive(thiserror::Error, Debug, OperationIo)] +pub enum ReplaceAssetError { + #[error("The requested cosmetic does not exist")] + MissingCosmetic, + #[error("Missing or invalid cosmetic_id")] + InvalidId, + #[error(transparent)] + Upload(#[from] UploadError), + #[error("Database error: {0}")] + Database(#[from] sea_orm::error::DbErr), + #[error("Multipart error: {0}")] + Multipart(#[from] axum::extract::multipart::MultipartError), +} + +impl IntoResponse for ReplaceAssetError { + fn into_response(self) -> axum::response::Response { + crate::api::admin_error_response( + match self { + Self::Upload(error) => return error.into_response(), + Self::MissingCosmetic => StatusCode::NOT_FOUND, + Self::InvalidId => StatusCode::BAD_REQUEST, + Self::Database(_) | Self::Multipart(_) => { + StatusCode::INTERNAL_SERVER_ERROR + } + }, + self, + ) + } +} + +#[derive(Debug, Serialize, JsonSchema)] +struct ReplaceAssetResponse { + cosmetic_id: i32, + asset_id: i32, + cover_asset_id: Option, +} + +fn endpoint_doc(op: TransformOperation) -> TransformOperation { + op.id("replaceCosmeticAsset") + .summary("Replace a cosmetic's file") + .description( + "Uploads a new model, texture, or emote bundle for an existing cosmetic \ + (or a single variant) and re-renders its cover, keeping its id, \ + ownership, and product. The previous asset is left in storage. Admin \ + password required.", + ) + .tag("cosmetics") + .response_with::<{ StatusCode::OK.as_u16() }, Json, _>( + |res| res.description("The file was replaced"), + ) + .response_with::<{ StatusCode::NOT_FOUND.as_u16() }, String, _>(|res| { + res.description("No cosmetic exists with the given id") + }) + .response_with::<{ StatusCode::UNAUTHORIZED.as_u16() }, String, _>(|res| { + res.description("Invalid or missing admin password") + }) +} + +pub(super) fn router() -> ApiRouter { + ApiRouter::new().api_route( + "/replace-asset", + post_with(self::endpoint, self::endpoint_doc), + ) +} + +struct FileUpload(Multipart); + +impl FromRequest for FileUpload +where + S: Send + Sync, +{ + type Rejection = UploadError; + + async fn from_request(req: Request, state: &S) -> Result { + Ok(Self( + Multipart::from_request(req, state) + .await + .map_err(UploadError::Rejection)?, + )) + } +} + +#[derive(JsonSchema)] +#[allow(dead_code)] +struct ReplaceAssetRequest { + /// The id of the cosmetic (variant) whose file to replace. + cosmetic_id: i32, + #[schemars(with = "String")] + file: String, +} + +impl OperationInput for FileUpload { + fn operation_input( + ctx: &mut aide::generate::GenContext, + operation: &mut aide::openapi::Operation, + ) { + operation.request_body = Some(aide::openapi::ReferenceOr::Item( + aide::openapi::RequestBody { + description: Some("Multipart cosmetic file replacement".into()), + content: [( + "multipart/form-data".into(), + aide::openapi::MediaType { + schema: Some(aide::openapi::SchemaObject { + json_schema: ctx + .schema + .subschema_for::(), + example: None, + external_docs: None, + }), + ..Default::default() + }, + )] + .into_iter() + .collect(), + required: true, + extensions: Default::default(), + }, + )); + } +} + +async fn endpoint( + State(state): State, + _auth: AdminAuthenticationExtractor, + FileUpload(mut multipart): FileUpload, +) -> Result, ReplaceAssetError> { + use entities::{cosmetic, prelude::*}; + + let mut cosmetic_id = None; + let mut file_data = None; + let mut content_type = None; + let mut extension = "png".to_string(); + + while let Some(field) = multipart.next_field().await? { + match field.name() { + Some("cosmetic_id") => { + cosmetic_id = field.text().await?.trim().parse::().ok(); + } + Some("file") => { + if let Some(file_name) = field.file_name() + && let Some(ext) = std::path::Path::new(file_name).extension() + { + extension = ext.to_string_lossy().to_string(); + } + content_type = field.content_type().map(|s| s.to_string()); + file_data = Some(field.bytes().await?); + } + _ => {} + } + } + + let cosmetic_id = cosmetic_id.ok_or(ReplaceAssetError::InvalidId)?; + let data = file_data.ok_or(UploadError::MissingFile)?; + + let Some(cosmetic) = Cosmetic::find_by_id(cosmetic_id) + .one(&state.database) + .await? + else { + return Err(ReplaceAssetError::MissingCosmetic); + }; + + let slots = super::render_cover::cosmetic_slots(&state.database, &cosmetic).await?; + let (asset, cover_asset_id) = store_cosmetic_file( + &state, + &cosmetic.r#type, + &slots, + cosmetic.model_variant.as_deref(), + &data, + extension, + content_type, + ) + .await?; + + // A failed render keeps the old cover rather than none. + let cover_asset_id = cover_asset_id.or(cosmetic.cover_asset_id); + let mut active: cosmetic::ActiveModel = cosmetic.into(); + active.asset_id = Set(Some(asset.id)); + active.cover_asset_id = Set(cover_asset_id); + let updated = active.update(&state.database).await?; + + Ok(Json(ReplaceAssetResponse { + cosmetic_id: updated.id, + asset_id: asset.id, + cover_asset_id, + })) +} diff --git a/src/api/v0/cosmetics/manage/update_variant.rs b/src/api/v0/cosmetics/manage/update_variant.rs new file mode 100644 index 0000000..9fdd117 --- /dev/null +++ b/src/api/v0/cosmetics/manage/update_variant.rs @@ -0,0 +1,166 @@ +use aide::{ + OperationIo, + axum::{ApiRouter, routing::post_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::{ + ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait, +}; +use serde::Deserialize; + +use crate::api::{ApiState, admin_auth::AdminAuthenticationExtractor}; + +#[derive(thiserror::Error, Debug, OperationIo)] +pub enum UpdateVariantError { + #[error("The requested cosmetic does not exist")] + MissingCosmetic, + #[error("At least one body slot must be provided")] + MissingSlots, + #[error("Database error: {0}")] + Database(#[from] sea_orm::error::DbErr), +} + +impl IntoResponse for UpdateVariantError { + fn into_response(self) -> axum::response::Response { + crate::api::admin_error_response( + match self { + Self::MissingCosmetic => StatusCode::NOT_FOUND, + Self::MissingSlots => StatusCode::BAD_REQUEST, + Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR, + }, + self, + ) + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct UpdateVariantRequest { + /// The id of the variant to update. + cosmetic_id: i32, + /// When present, sets (or clears with null) the variant label. + #[serde( + default, + deserialize_with = "serde_with::rust::double_option::deserialize" + )] + variant_name: Option>, + /// When present, sets (or clears with null) the skin model ("slim"/"wide"). + #[serde( + default, + deserialize_with = "serde_with::rust::double_option::deserialize" + )] + model_variant: Option>, + /// When set, the variant's position within its group. + variant_order: Option, + /// When set, replaces the body slots this variant may be equipped in. + /// Players equipping it in a removed slot are unequipped. + slots: Option>, +} + +fn endpoint_doc(op: TransformOperation) -> TransformOperation { + op.id("updateCosmeticVariant") + .summary("Update a cosmetic variant") + .description( + "Updates a single variant's label, skin model, order, and allowed body \ + slots without touching its siblings. Group-wide fields (name, price, \ + description) are set from `/update`. Re-render the cover from \ + `/render-cover` after changing the skin model. Admin password required.", + ) + .tag("cosmetics") + .response_with::<{ StatusCode::NO_CONTENT.as_u16() }, (), _>(|res| { + res.description("The variant was updated") + }) + .response_with::<{ StatusCode::NOT_FOUND.as_u16() }, String, _>(|res| { + res.description("No cosmetic exists with the given id") + }) + .response_with::<{ StatusCode::UNAUTHORIZED.as_u16() }, String, _>(|res| { + res.description("Invalid or missing admin password") + }) +} + +pub(super) fn router() -> ApiRouter { + ApiRouter::new().api_route( + "/update-variant", + post_with(self::endpoint, self::endpoint_doc), + ) +} + +#[tracing::instrument(level = "debug", skip(state, _auth))] +async fn endpoint( + State(state): State, + _auth: AdminAuthenticationExtractor, + Json(body): Json, +) -> Result { + use entities::{ + cosmetic, cosmetic_allowed_slot, cosmetic_group_allowed_slot, + player_equipped_cosmetic, prelude::*, + }; + + let txn = state.database.begin().await?; + + let Some(cosmetic) = Cosmetic::find_by_id(body.cosmetic_id).one(&txn).await? else { + return Err(UpdateVariantError::MissingCosmetic); + }; + + if let Some(slots) = &body.slots { + if slots.is_empty() && cosmetic.r#type != CosmeticType::Emote { + return Err(UpdateVariantError::MissingSlots); + } + + CosmeticAllowedSlot::delete_many() + .filter(cosmetic_allowed_slot::Column::CosmeticId.eq(cosmetic.id)) + .exec(&txn) + .await?; + if !slots.is_empty() { + CosmeticAllowedSlot::insert_many(slots.iter().map(|slot| { + cosmetic_allowed_slot::ActiveModel { + cosmetic_id: Set(cosmetic.id), + slot: Set(slot.clone()), + } + })) + .exec(&txn) + .await?; + } + + // Additive like `/create`: siblings may still use the group's other slots. + if let Some(group_id) = cosmetic.group_id + && !slots.is_empty() + { + CosmeticGroupAllowedSlot::insert_many(slots.iter().map(|slot| { + cosmetic_group_allowed_slot::ActiveModel { + group_id: Set(group_id), + slot: Set(slot.clone()), + } + })) + .on_conflict_do_nothing() + .exec(&txn) + .await?; + } + + PlayerEquippedCosmetic::delete_many() + .filter(player_equipped_cosmetic::Column::CosmeticId.eq(cosmetic.id)) + .filter(player_equipped_cosmetic::Column::Slot.is_not_in(slots.clone())) + .exec(&txn) + .await?; + } + + let mut active: cosmetic::ActiveModel = cosmetic.into(); + if let Some(variant_name) = body.variant_name { + active.variant_name = Set(variant_name); + } + if let Some(model_variant) = body.model_variant { + active.model_variant = Set(model_variant); + } + if let Some(variant_order) = body.variant_order { + active.variant_order = Set(variant_order); + } + if active.is_changed() { + active.update(&txn).await?; + } + + txn.commit().await?; + + Ok(StatusCode::NO_CONTENT) +}