Implement Rust-based emote database and REST API

Co-authored-by: Ganonmaster <168445+Ganonmaster@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-18 13:18:40 +00:00
parent fd843535e6
commit 4fb17abb71
12 changed files with 4761 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
use axum::{
extract::{Multipart, Path, State},
http::StatusCode,
response::{IntoResponse, Json},
};
use serde_json::json;
use crate::{
models::{EmoteResponse, UpdateEmoteRequest},
AppState,
};
/// GET /
/// Returns a simple health-check message.
pub async fn root() -> impl IntoResponse {
Json(json!({"status": "ok", "message": "mikebase server is running"}))
}
/// 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()
}
}
}