Files
mikebase/src/routes/emotes.rs
T

241 lines
7.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<AppState>) -> impl IntoResponse {
match state.db.list_emotes().await {
Ok(rows) => {
let emotes: Vec<EmoteResponse> = 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<AppState>,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut name: Option<String> = None;
let mut alias: Option<String> = None;
let mut file_bytes: Option<bytes::Bytes> = None;
let mut file_name: Option<String> = None;
let mut content_type: Option<String> = 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<AppState>,
Path(uuid): Path<String>,
Json(payload): Json<UpdateEmoteRequest>,
) -> impl IntoResponse {
let alias_update: Option<Option<&str>> = 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<AppState>,
Path(uuid): Path<String>,
) -> 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()
}
}
}