use axum::{ extract::{Multipart, Path, State}, http::StatusCode, response::{Html, IntoResponse, Json}, }; use serde_json::json; use crate::{ models::{EmoteResponse, UpdateEmoteRequest}, AppState, }; /// GET / /// Serves an HTML page that dynamically loads and displays emotes from /json. pub async fn root() -> impl IntoResponse { Html(include_str!("../templates/index.html")) } /// GET /json /// Returns all emotes formatted as {"emotes": [...]}. pub async fn list_emotes(State(state): State) -> impl IntoResponse { match state.db.list_emotes().await { Ok(rows) => { let emotes: Vec = rows .into_iter() .map(|row| EmoteResponse { name: row.name.clone(), url: state.storage.public_url(&row.image_key), created: row.created_dt(), modified: row.modified_dt(), }) .collect(); (StatusCode::OK, Json(json!({"emotes": emotes}))).into_response() } Err(e) => { tracing::error!("Failed to list emotes: {e}"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Failed to list emotes"})), ) .into_response() } } } /// POST /emotes /// Create a new emote. Accepts multipart/form-data with: /// - `name` (text) /// - `alias` (text, optional) /// - `file` (binary – the emote image) pub async fn create_emote( State(state): State, mut multipart: Multipart, ) -> impl IntoResponse { let mut name: Option = None; let mut alias: Option = None; let mut file_bytes: Option = None; let mut file_name: Option = None; let mut content_type: Option = None; while let Ok(Some(field)) = multipart.next_field().await { match field.name() { Some("name") => { name = field.text().await.ok(); } Some("alias") => { alias = field.text().await.ok(); } Some("file") => { file_name = field.file_name().map(|s| s.to_string()); content_type = field.content_type().map(|s| s.to_string()); file_bytes = field.bytes().await.ok(); } _ => {} } } let name = match name { Some(n) if !n.is_empty() => n, _ => { return ( StatusCode::BAD_REQUEST, Json(json!({"error": "Missing required field: name"})), ) .into_response(); } }; let (bytes, fname) = match (file_bytes, file_name) { (Some(b), Some(n)) => (b, n), _ => { return ( StatusCode::BAD_REQUEST, Json(json!({"error": "Missing required field: file"})), ) .into_response(); } }; let ct = content_type .unwrap_or_else(|| mime_guess::from_path(&fname).first_or_octet_stream().to_string()); let key = format!("emoji/{fname}"); match state.storage.upload(&key, bytes, &ct).await { Ok(_) => {} Err(e) => { tracing::error!("S3 upload failed: {e}"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Failed to upload image"})), ) .into_response(); } } match state .db .create_emote(&name, alias.as_deref(), &key) .await { Ok(row) => { let url = state.storage.public_url(&row.image_key); let created = row.created_dt(); let modified = row.modified_dt(); let resp = EmoteResponse { name: row.name, url, created, modified, }; (StatusCode::CREATED, Json(json!(resp))).into_response() } Err(e) => { tracing::error!("DB insert failed: {e}"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Failed to create emote"})), ) .into_response() } } } /// PUT /emotes/:uuid /// Update an existing emote's metadata. /// Accepts JSON body with optional fields: `name`, `alias`, `image_key`. pub async fn update_emote( State(state): State, Path(uuid): Path, Json(payload): Json, ) -> impl IntoResponse { let alias_update: Option> = payload.alias.as_ref().map(|a| Some(a.as_str())); match state .db .update_emote( &uuid, payload.name.as_deref(), alias_update, payload.image_key.as_deref(), ) .await { Ok(Some(row)) => { let url = state.storage.public_url(&row.image_key); let created = row.created_dt(); let modified = row.modified_dt(); let resp = EmoteResponse { name: row.name, url, created, modified, }; (StatusCode::OK, Json(json!(resp))).into_response() } Ok(None) => ( StatusCode::NOT_FOUND, Json(json!({"error": "Emote not found"})), ) .into_response(), Err(e) => { tracing::error!("DB update failed: {e}"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Failed to update emote"})), ) .into_response() } } } /// DELETE /emotes/:uuid /// Delete an emote and its associated image from S3. pub async fn delete_emote( State(state): State, Path(uuid): Path, ) -> impl IntoResponse { // Fetch the row first so we can clean up S3. let row = match state.db.get_emote_by_id(&uuid).await { Ok(Some(r)) => r, Ok(None) => { return ( StatusCode::NOT_FOUND, Json(json!({"error": "Emote not found"})), ) .into_response(); } Err(e) => { tracing::error!("DB fetch failed: {e}"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Failed to fetch emote"})), ) .into_response(); } }; // Delete from S3 (best-effort – don't abort if this fails). if let Err(e) = state.storage.delete(&row.image_key).await { tracing::warn!("S3 delete failed for key {}: {e}", row.image_key); } match state.db.delete_emote(&uuid).await { Ok(true) => (StatusCode::NO_CONTENT).into_response(), Ok(false) => ( StatusCode::NOT_FOUND, Json(json!({"error": "Emote not found"})), ) .into_response(), Err(e) => { tracing::error!("DB delete failed: {e}"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Failed to delete emote"})), ) .into_response() } } }