fix(security): medium/low — event-lock, atomic config, a11y, diashow, hygiene

- social: likes/comments rejected on a closed event (e2e proven).
- admin: patch_config validates-then-writes atomically; char-based length.
- hashtag/comment models: atomic tag writes in edit + comment paths.
- Modal: inert the background (modal-inert action) for screen readers.
- sse.ts: idempotent visibilitychange listener (no duplicate reconnects).
- diashow: videos advance on `ended` (transitions + page wiring).
- docs/hygiene: README clone-case fix; removed stale committed .env.test and
  gitignored it; docker-compose.dev.yml tidy.

Left documented as accepted-risk per plan: PIN-persist-after-logout,
UUID-reachable hidden uploads, DECISION-media-auth.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-01 21:25:58 +02:00
parent f08d858281
commit 91ff961850
15 changed files with 188 additions and 90 deletions

View File

@@ -12,6 +12,18 @@ use crate::models::hashtag::{self, Hashtag};
use crate::models::upload::Upload;
use crate::state::AppState;
/// Reject the request when the event's uploads (and, by extension, social
/// interaction) are locked. Mirrors the guard in the upload handler.
async fn require_event_open(state: &AppState) -> Result<(), AppError> {
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
if event.uploads_locked_at.is_some() {
return Err(AppError::Forbidden("Das Event ist geschlossen.".into()));
}
Ok(())
}
pub async fn toggle_like(
State(state): State<AppState>,
auth: AuthUser,
@@ -31,6 +43,9 @@ pub async fn toggle_like(
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
// A closed event freezes social interaction too, matching the upload handler.
require_event_open(&state).await?;
// Try to insert; if conflict, delete (toggle)
let result = sqlx::query(
"INSERT INTO \"like\" (upload_id, user_id) VALUES ($1, $2)
@@ -120,6 +135,9 @@ pub async fn add_comment(
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
// A closed event freezes social interaction too, matching the upload handler.
require_event_open(&state).await?;
let text = body.body.trim();
let text_chars = text.chars().count();
if text_chars == 0 || text_chars > 500 {
@@ -128,20 +146,22 @@ pub async fn add_comment(
));
}
let comment = Comment::create(&state.pool, upload_id, auth.user_id, text).await?;
// Process hashtags in comment body
// Insert the comment and link its hashtags atomically, so a crash mid-loop
// can't leave a committed comment with only some of its tags indexed.
let tags = hashtag::extract_hashtags(text);
let mut tx = state.pool.begin().await?;
let comment = Comment::create(&mut *tx, upload_id, auth.user_id, text).await?;
for tag in &tags {
let h = Hashtag::upsert(&state.pool, auth.event_id, tag).await?;
let h = Hashtag::upsert(&mut *tx, auth.event_id, tag).await?;
sqlx::query(
"INSERT INTO comment_hashtag (comment_id, hashtag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(comment.id)
.bind(h.id)
.execute(&state.pool)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
// Fresh count so feed clients can patch the single card in place instead of
// refetching page 1 (mirrors v_feed.comment_count = COUNT(DISTINCT c.id); COUNT(*)