Implement Rust-based emote database and REST API
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod emotes;
|
||||
Reference in New Issue
Block a user