Replaces the DB-blind `/media` ServeDir with a signed, DB-aware gateway at
`GET /media/{kind}/{id}`. Every media byte now flows through an HMAC-SHA256
signature check (minted into feed/upload DTOs for authenticated members; <img>
can't carry a Bearer header) plus a DB lookup:
- C1: export ZIP/HTML have no upload row, so they are unreachable by path —
download stays behind the authenticated /export endpoints.
- C2 (sink): responses carry X-Content-Type-Options: nosniff and a locked-down
CSP (default-src 'none'; sandbox), neutralizing any active content.
- C3 / H2: find_by_id filters deleted_at and the handler rejects ban-hidden
uploaders, so deleted and moderated artifacts 404 — and the unauthenticated
get_original alias (the H2 hole) is removed entirely.
- H10: delete paths (owner + host) now unlink original/preview/thumbnail after
commit; soft_delete returns the paths; an hourly reaper reclaims disk for
rows soft-deleted past a 1-day grace and hard-deletes them (FKs cascade).
Signed URLs are bucketed to a 1h window so they stay stable across feed polls
(browser cache hits) while expiring within 24h. media_token sign/verify has a
unit test (roundtrip + tamper + expiry).
Frontend: FeedUpload/pickMediaUrl now use the backend-provided signed
original_url; no client constructs a media path anymore.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
119 lines
4.1 KiB
Rust
119 lines
4.1 KiB
Rust
use axum::extract::FromRequestParts;
|
|
use axum::http::request::Parts;
|
|
use uuid::Uuid;
|
|
|
|
use crate::auth::jwt;
|
|
use crate::error::AppError;
|
|
use crate::models::session::Session;
|
|
use crate::models::user::UserRole;
|
|
use crate::state::AppState;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct AuthUser {
|
|
pub user_id: Uuid,
|
|
pub event_id: Uuid,
|
|
pub role: UserRole,
|
|
pub token_hash: String,
|
|
}
|
|
|
|
impl FromRequestParts<AppState> for AuthUser {
|
|
type Rejection = AppError;
|
|
|
|
async fn from_request_parts(
|
|
parts: &mut Parts,
|
|
state: &AppState,
|
|
) -> Result<Self, Self::Rejection> {
|
|
let header = parts
|
|
.headers
|
|
.get("authorization")
|
|
.and_then(|v| v.to_str().ok())
|
|
.ok_or_else(|| AppError::Unauthorized("Token fehlt.".into()))?;
|
|
|
|
let token = header
|
|
.strip_prefix("Bearer ")
|
|
.ok_or_else(|| AppError::Unauthorized("Ungültiges Token-Format.".into()))?;
|
|
|
|
let claims = jwt::verify_token(token, &state.config.jwt_secret)
|
|
.map_err(|_| AppError::Unauthorized("Token ungültig oder abgelaufen.".into()))?;
|
|
|
|
let token_hash = jwt::hash_token(token);
|
|
|
|
// Reconcile the session against the live `user` row. We do NOT trust the
|
|
// JWT claims for role/ban/event — a token can outlive a ban or demotion
|
|
// (default lifetime 30 days). The single JOIN query is the same number
|
|
// of round-trips as the old existence check.
|
|
let ctx = Session::find_auth_context(&state.pool, &token_hash)
|
|
.await
|
|
.map_err(|e| AppError::Internal(e.into()))?
|
|
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into()))?;
|
|
|
|
// Ban takes effect immediately on the next request, regardless of the
|
|
// token's remaining lifetime.
|
|
if ctx.is_banned {
|
|
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
|
}
|
|
|
|
// Defend against a JWT that was issued for a different user/event than
|
|
// the session's DB row points at (e.g. a swapped or replayed token).
|
|
if claims.sub != ctx.user_id || claims.event_id != ctx.event_id {
|
|
return Err(AppError::Unauthorized("Token passt nicht zur Sitzung.".into()));
|
|
}
|
|
|
|
// Update last_seen_at in the background (fire-and-forget). Failures are
|
|
// non-fatal but worth surfacing — silent swallowing hides DB connection
|
|
// pressure that would otherwise be the first symptom of a real problem.
|
|
let pool = state.pool.clone();
|
|
let session_id = ctx.session_id;
|
|
tokio::spawn(async move {
|
|
if let Err(e) = Session::touch(&pool, session_id).await {
|
|
tracing::warn!(error = ?e, session_id = %session_id, "session touch failed");
|
|
}
|
|
});
|
|
|
|
Ok(Self {
|
|
user_id: ctx.user_id,
|
|
event_id: ctx.event_id,
|
|
// Live role from the DB, not the claim — demotion/promotion takes
|
|
// effect on the next request.
|
|
role: ctx.role,
|
|
token_hash,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Extractor that requires at least Host role.
|
|
pub struct RequireHost(pub AuthUser);
|
|
|
|
impl FromRequestParts<AppState> for RequireHost {
|
|
type Rejection = AppError;
|
|
|
|
async fn from_request_parts(
|
|
parts: &mut Parts,
|
|
state: &AppState,
|
|
) -> Result<Self, Self::Rejection> {
|
|
let auth = AuthUser::from_request_parts(parts, state).await?;
|
|
match auth.role {
|
|
UserRole::Host | UserRole::Admin => Ok(Self(auth)),
|
|
_ => Err(AppError::Forbidden("Nur für Hosts und Admins.".into())),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Extractor that requires Admin role.
|
|
pub struct RequireAdmin(pub AuthUser);
|
|
|
|
impl FromRequestParts<AppState> for RequireAdmin {
|
|
type Rejection = AppError;
|
|
|
|
async fn from_request_parts(
|
|
parts: &mut Parts,
|
|
state: &AppState,
|
|
) -> Result<Self, Self::Rejection> {
|
|
let auth = AuthUser::from_request_parts(parts, state).await?;
|
|
match auth.role {
|
|
UserRole::Admin => Ok(Self(auth)),
|
|
_ => Err(AppError::Forbidden("Nur für Admins.".into())),
|
|
}
|
|
}
|
|
}
|