fix(review): CSP nonce for FOUC script + feed_delta truncation signal
Two items surfaced by the branch re-review: CSP regression (blocking): script-src 'self' blocked the template-authored anti-FOUC theme script in app.html — SvelteKit's mode:'auto' only hashes scripts it injects. Added nonce="%sveltekit.nonce%" so it's substituted per request and included in the CSP (survives future edits, unlike a pinned hash). Verified: emitted CSP nonce matches the script tag; no violation, no flash. feed_delta silent truncation: the LIMIT 200 (added earlier to kill the stale-`since` DoS) returned only the newest slice with no signal, so a client that missed 200+ uploads during a reconnect could not tell it should full- refresh — the older missed uploads were dropped and unrecoverable (the next delta advances `since` past them). DeltaResponse now carries `truncated`; the feed's feed-delta handler calls loadFeed(true) to resync from page 1 instead of merging a partial slice. Verified: cargo check clean, svelte-check 0 errors, production build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -172,6 +172,11 @@ pub struct DeltaQuery {
|
|||||||
pub struct DeltaResponse {
|
pub struct DeltaResponse {
|
||||||
pub uploads: Vec<FeedUpload>,
|
pub uploads: Vec<FeedUpload>,
|
||||||
pub deleted_ids: Vec<Uuid>,
|
pub deleted_ids: Vec<Uuid>,
|
||||||
|
/// True when the upload query hit `DELTA_LIMIT`: the response carries only the
|
||||||
|
/// newest slice of the gap, so the client must fall back to a full feed refresh
|
||||||
|
/// rather than merging (the older missed uploads are absent and unrecoverable
|
||||||
|
/// via a later delta, which advances `since` past them).
|
||||||
|
pub truncated: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn feed_delta(
|
pub async fn feed_delta(
|
||||||
@@ -197,6 +202,10 @@ pub async fn feed_delta(
|
|||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Hit the cap => this is only the newest slice of a larger gap. Signal the
|
||||||
|
// client to full-refresh instead of merging a partial delta.
|
||||||
|
let truncated = rows.len() as i64 >= DELTA_LIMIT;
|
||||||
|
|
||||||
let deleted_ids: Vec<(Uuid,)> = sqlx::query_as(
|
let deleted_ids: Vec<(Uuid,)> = sqlx::query_as(
|
||||||
"SELECT id FROM upload
|
"SELECT id FROM upload
|
||||||
WHERE event_id = $1 AND deleted_at IS NOT NULL AND deleted_at > $2",
|
WHERE event_id = $1 AND deleted_at IS NOT NULL AND deleted_at > $2",
|
||||||
@@ -229,6 +238,7 @@ pub async fn feed_delta(
|
|||||||
Ok(Json(DeltaResponse {
|
Ok(Json(DeltaResponse {
|
||||||
uploads,
|
uploads,
|
||||||
deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(),
|
deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(),
|
||||||
|
truncated,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,10 @@
|
|||||||
theme=dark don't flash a white screen. Mirrors the logic in
|
theme=dark don't flash a white screen. Mirrors the logic in
|
||||||
`src/lib/theme-store.ts`; kept in sync by hand (it's 6 lines).
|
`src/lib/theme-store.ts`; kept in sync by hand (it's 6 lines).
|
||||||
-->
|
-->
|
||||||
<script>
|
<!-- nonce is required: our CSP sets script-src 'self', and SvelteKit's
|
||||||
|
mode:'auto' only hashes scripts *it* injects, not this template-authored
|
||||||
|
one. %sveltekit.nonce% is substituted per request and added to the CSP. -->
|
||||||
|
<script nonce="%sveltekit.nonce%">
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
var pref = localStorage.getItem('eventsnap_theme') || 'system';
|
var pref = localStorage.getItem('eventsnap_theme') || 'system';
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export interface FeedResponse {
|
|||||||
export interface DeltaResponse {
|
export interface DeltaResponse {
|
||||||
uploads: FeedUpload[];
|
uploads: FeedUpload[];
|
||||||
deleted_ids: string[];
|
deleted_ids: string[];
|
||||||
|
// True when the delta hit the backend cap and is only the newest slice of the
|
||||||
|
// gap — the client must full-refresh rather than merge (see feed-delta handler).
|
||||||
|
truncated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// mirrors backend/src/handlers/feed.rs::HashtagCount
|
// mirrors backend/src/handlers/feed.rs::HashtagCount
|
||||||
|
|||||||
@@ -206,6 +206,13 @@
|
|||||||
onSseEvent('feed-delta', (data) => {
|
onSseEvent('feed-delta', (data) => {
|
||||||
try {
|
try {
|
||||||
const delta = JSON.parse(data) as DeltaResponse;
|
const delta = JSON.parse(data) as DeltaResponse;
|
||||||
|
if (delta.truncated) {
|
||||||
|
// Missed more than the backend delta cap while backgrounded — the
|
||||||
|
// delta is only the newest slice, so merging would leave a silent gap
|
||||||
|
// of older-but-still-new uploads. Resync from page 1 instead.
|
||||||
|
void loadFeed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (delta.uploads.length) {
|
if (delta.uploads.length) {
|
||||||
const seen = new Set(uploads.map((u) => u.id));
|
const seen = new Set(uploads.map((u) => u.id));
|
||||||
const fresh = delta.uploads.filter((u) => !seen.has(u.id));
|
const fresh = delta.uploads.filter((u) => !seen.has(u.id));
|
||||||
|
|||||||
Reference in New Issue
Block a user