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:
@@ -146,6 +146,8 @@ pub async fn patch_config(
|
||||
|
||||
let mut privacy_note_changed = false;
|
||||
|
||||
// Validate every key first so a bad value in the batch can't leave a partial
|
||||
// update behind — validation must fully precede any write.
|
||||
for (key, value) in &body {
|
||||
let key_str = key.as_str();
|
||||
if NUMERIC_KEYS.contains(&key_str) {
|
||||
@@ -164,7 +166,9 @@ pub async fn patch_config(
|
||||
}
|
||||
}
|
||||
} else if TEXT_KEYS.contains(&key_str) {
|
||||
if value.len() > PRIVACY_NOTE_MAX_LEN {
|
||||
// Count characters, not bytes — the message says "Zeichen" and a
|
||||
// multi-byte grapheme shouldn't count against the limit multiple times.
|
||||
if value.chars().count() > PRIVACY_NOTE_MAX_LEN {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Wert für {key} ist zu lang (max. {PRIVACY_NOTE_MAX_LEN} Zeichen)."
|
||||
)));
|
||||
@@ -177,16 +181,21 @@ pub async fn patch_config(
|
||||
"Unbekannter Konfigurationsschlüssel: {key}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Apply all writes in one transaction — the batch is all-or-nothing.
|
||||
let mut tx = state.pool.begin().await?;
|
||||
for (key, value) in &body {
|
||||
sqlx::query(
|
||||
"INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
|
||||
)
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.execute(&state.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
// Notify all clients that a publicly-readable config value changed so their stores
|
||||
// (e.g. the privacy note in My Account) refresh without a manual reload.
|
||||
|
||||
@@ -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(*)
|
||||
|
||||
@@ -24,19 +24,24 @@ pub struct CommentDto {
|
||||
}
|
||||
|
||||
impl Comment {
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
/// Takes any executor so the caller can insert the comment and link its
|
||||
/// hashtags inside a single transaction.
|
||||
pub async fn create<'e, E>(
|
||||
executor: E,
|
||||
upload_id: Uuid,
|
||||
user_id: Uuid,
|
||||
body: &str,
|
||||
) -> Result<Self, sqlx::Error> {
|
||||
) -> Result<Self, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
sqlx::query_as::<_, Self>(
|
||||
"INSERT INTO comment (upload_id, user_id, body) VALUES ($1, $2, $3) RETURNING *",
|
||||
)
|
||||
.bind(upload_id)
|
||||
.bind(user_id)
|
||||
.bind(body)
|
||||
.fetch_one(pool)
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user