Fix S3 collision, alias clearing, upload limit, TOCTOU; add tests

Bug fixes:
- S3 key is now emoji/{uuid}.{ext} instead of emoji/{filename},
  preventing silent overwrites when two emotes share a filename
- UpdateEmoteRequest.alias uses Option<Option<String>> with a custom
  deserializer so a JSON null clears the alias rather than being ignored;
  the manage UI now sends null when the alias field is emptied
- POST /emotes is limited to 8 MiB via DefaultBodyLimit
- update_emote replaced the fetch-then-update pair with a single
  UPDATE … RETURNING using COALESCE/CASE WHEN, eliminating the TOCTOU
  race between concurrent edits

Refactoring:
- Extracted src/lib.rs so domain logic is a library crate; src/main.rs
  is now a thin startup entry point
- auth::check decoupled from AppState — takes Option<&AuthConfig> directly
- Removed unused config field from Database struct

Tests (40 total):
- auth: 10 unit tests covering all check() branches
- models: 6 unit tests for timestamp parsing and alias deserialization
- db: 9 unit tests against in-memory SQLite covering full CRUD
- routes: 15 integration tests in tests/routes.rs covering auth
  middleware, input validation, and all mutating endpoints
This commit is contained in:
Ganonmaster
2026-04-28 03:40:25 +02:00
parent 08fd6cea70
commit 2c219f5565
10 changed files with 736 additions and 95 deletions
+3 -45
View File
@@ -1,31 +1,9 @@
mod auth;
mod config;
mod db;
mod models;
mod routes;
mod storage;
use std::sync::Arc;
use axum::{
middleware,
routing::{delete, get, post, put},
Router,
};
use mikebase::{config::AppConfig, db::Database, storage::S3Storage, AppState, build_router};
use sqlx::any::install_default_drivers;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::{config::AppConfig, db::Database, storage::S3Storage};
/// Shared application state injected into every handler.
#[derive(Clone)]
pub struct AppState {
pub db: Database,
pub storage: S3Storage,
pub cfg: Arc<AppConfig>,
}
#[tokio::main]
async fn main() {
// Initialise structured logging.
@@ -45,7 +23,7 @@ async fn main() {
install_default_drivers();
// Connect to the database and run migrations.
let db = Database::connect(cfg.clone())
let db = Database::connect(&cfg.database.url)
.await
.expect("Failed to connect to database");
db.migrate().await.expect("Failed to run migrations");
@@ -55,32 +33,12 @@ async fn main() {
let state = AppState { db, storage, cfg: cfg.clone() };
let protected = Router::new()
.route("/manage", get(routes::manage::manage_root))
.route("/manage/emotes", get(routes::manage::list_admin_emotes))
.route("/emotes", post(routes::emotes::create_emote))
.route("/emotes/{uuid}", put(routes::emotes::update_emote))
.route("/emotes/{uuid}", delete(routes::emotes::delete_emote))
.layer(middleware::from_fn_with_state(
state.clone(),
auth::require_basic_auth,
));
let app = Router::new()
.route("/", get(routes::emotes::root))
.route("/health", get(routes::health::health))
.route("/version", get(routes::version::version))
.route("/json", get(routes::emotes::list_emotes))
.merge(protected)
.layer(TraceLayer::new_for_http())
.with_state(state);
let addr = format!("{}:{}", cfg.server.host, cfg.server.port);
tracing::info!("Listening on {addr}");
let listener = tokio::net::TcpListener::bind(&addr)
.await
.expect("Failed to bind address");
axum::serve(listener, app)
axum::serve(listener, build_router(state))
.await
.expect("Server error");
}