backend(features): quota enforcement, PIN reset, /me, original download, toggles
- handlers/me.rs (new): GET /api/v1/me/context (profile + role + privacy_note
+ quota toggle state, fetched once on app bootstrap) and GET /api/v1/me/quota
(live used / limit / active uploaders / free disk).
- handlers/upload.rs:
- quota enforcement via the dynamic formula
floor((free_disk * tolerance) / max(active_uploaders, 1)),
gated by quota_enabled + storage_quota_enabled toggles
- new GET /api/v1/upload/{id}/original — unauthed by design
(matches /media/previews/* — URL is the secret) so it works as
<img src> / <video src> / window.open
- rate-limit toggle wiring (rate_limits_enabled + upload_rate_enabled)
- handlers/host.rs:
- POST /api/v1/host/users/{id}/pin-reset — Host may reset guest PINs,
Admin may reset guest + host PINs (never another admin or self).
Returns the freshly-generated plaintext PIN once; emits a global
pin-reset SSE so the affected user's device can clear its localStorage.
- set_role guard expanded so hosts can demote other hosts (not self,
never admins) — backend match for the doc'd permission model.
- handlers/admin.rs: ALLOWED_KEYS split into NUMERIC_KEYS / BOOL_KEYS /
TEXT_KEYS with per-kind validation; saving privacy_note broadcasts an
event-updated SSE so other clients refresh live.
- handlers/feed.rs, handlers/admin.rs (export), auth/handlers.rs:
rate-limit toggle wiring at every limiter call site.
- auth/handlers.rs: when an expired PIN lockout is detected on /recover,
reset failed_pin_attempts to zero before the bcrypt check — without
this every wrong PIN re-locked the user after the cooldown.
- main.rs: wire startup_recovery + spawn_periodic_tasks, register the
new /me/context, /me/quota, /upload/{id}/original, and
/host/users/{id}/pin-reset routes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,8 @@ use crate::error::AppError;
|
||||
use crate::models::comment::Comment;
|
||||
use crate::models::event::Event;
|
||||
use crate::models::upload::Upload;
|
||||
use crate::state::AppState;
|
||||
use crate::models::user::UserRole;
|
||||
use crate::state::{AppState, SseEvent};
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -141,13 +142,39 @@ pub async fn set_role(
|
||||
Json(body): Json<SetRoleRequest>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
if user_id == auth.user_id {
|
||||
return Err(AppError::BadRequest("Du kannst deine eigene Rolle nicht ändern.".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"Du kannst deine eigene Rolle nicht ändern.".into(),
|
||||
));
|
||||
}
|
||||
let new_role = match body.role.as_str() {
|
||||
"guest" => "guest",
|
||||
"host" => "host",
|
||||
_ => return Err(AppError::BadRequest("Ungültige Rolle. Erlaubt: guest, host.".into())),
|
||||
_ => {
|
||||
return Err(AppError::BadRequest(
|
||||
"Ungültige Rolle. Erlaubt: guest, host.".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Look up the current role so we can apply the host-vs-admin guard. Hosts may
|
||||
// promote guests and demote *other* hosts (the user explicitly requested this
|
||||
// expansion). Hosts may not touch admins. Admins may do anything (except change
|
||||
// themselves, blocked above).
|
||||
let target = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(auth.event_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||
|
||||
if target.0 == "admin" {
|
||||
return Err(AppError::Forbidden(
|
||||
"Admins können nicht geändert werden.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE \"user\" SET role = $2::user_role WHERE id = $1 AND event_id = $3")
|
||||
.bind(user_id)
|
||||
.bind(new_role)
|
||||
@@ -157,6 +184,76 @@ pub async fn set_role(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PinResetResponse {
|
||||
/// Plaintext PIN — shown to the operator **once**. Never persisted client-side.
|
||||
pub pin: String,
|
||||
}
|
||||
|
||||
/// Generate a fresh PIN for another user, returning the plaintext exactly once.
|
||||
///
|
||||
/// Authorisation:
|
||||
/// - Host caller → may reset **guest** PINs only.
|
||||
/// - Admin caller → may reset **guest** and **host** PINs (never another admin).
|
||||
/// - Target ≠ caller.
|
||||
pub async fn reset_user_pin(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(auth): RequireHost,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<Json<PinResetResponse>, AppError> {
|
||||
use rand::Rng;
|
||||
|
||||
if user_id == auth.user_id {
|
||||
return Err(AppError::BadRequest(
|
||||
"Du kannst deine eigene PIN nicht über diese Funktion zurücksetzen.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let target = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(auth.event_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||
|
||||
match (auth.role.clone(), target.0.as_str()) {
|
||||
(UserRole::Admin, "guest" | "host") => {}
|
||||
(UserRole::Host, "guest") => {}
|
||||
_ => {
|
||||
return Err(AppError::Forbidden(
|
||||
"Du darfst die PIN dieses Benutzers nicht zurücksetzen.".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
||||
let pin_hash =
|
||||
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE \"user\"
|
||||
SET recovery_pin_hash = $1,
|
||||
pin_failed_attempts = 0,
|
||||
pin_locked_until = NULL
|
||||
WHERE id = $2",
|
||||
)
|
||||
.bind(&pin_hash)
|
||||
.bind(user_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Notify the *recipient* device(s) if they happen to be online so they can clear
|
||||
// their cached local PIN. They'll save the new one on the next /recover.
|
||||
let _ = state.sse_tx.send(SseEvent::new(
|
||||
"pin-reset",
|
||||
serde_json::json!({ "user_id": user_id }).to_string(),
|
||||
));
|
||||
|
||||
Ok(Json(PinResetResponse { pin }))
|
||||
}
|
||||
|
||||
pub async fn host_delete_upload(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(_auth): RequireHost,
|
||||
@@ -168,10 +265,10 @@ pub async fn host_delete_upload(
|
||||
|
||||
Upload::soft_delete(&state.pool, upload_id).await?;
|
||||
|
||||
let _ = state.sse_tx.send(crate::state::SseEvent {
|
||||
event_type: "upload-deleted".to_string(),
|
||||
data: serde_json::json!({ "upload_id": upload.id }).to_string(),
|
||||
});
|
||||
let _ = state.sse_tx.send(SseEvent::new(
|
||||
"upload-deleted",
|
||||
serde_json::json!({ "upload_id": upload.id }).to_string(),
|
||||
));
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -200,10 +297,7 @@ pub async fn close_event(
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
let _ = state.sse_tx.send(crate::state::SseEvent {
|
||||
event_type: "event-closed".to_string(),
|
||||
data: "{}".to_string(),
|
||||
});
|
||||
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -219,10 +313,7 @@ pub async fn open_event(
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
let _ = state.sse_tx.send(crate::state::SseEvent {
|
||||
event_type: "event-opened".to_string(),
|
||||
data: "{}".to_string(),
|
||||
});
|
||||
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user