From 0d6ff7be88d5850cad14e0de29a99b05e2665074 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 10:31:02 +0000 Subject: [PATCH] Fix audit findings: upload button, links hint, paste counter, analytics dedupe, and polish Functional bugs: - FileForm: add missing `bind:this` on the file input. Without it the "Choose a file" button was a no-op (only the field label and drag-drop worked) and the remove button couldn't clear the native input, so a "removed" file was still uploaded on submit. - IdentityForm: the quick-links hint described the wrong field order (icon | label | url) and the wrong destination (:::links buttons). The field parses label | href | icon and feeds the share card and JSON-LD, not the on-page link buttons. Corrected the hint. - PasteForm: the character counter was bound to a derived prop, so it froze at the initial length while typing. Bind the textarea to local state (re-seeded on selection change via untrack) so it counts live. - analytics/beacon: the visitor-identity migration (UPDATE onto the visitors PK) could collide when the page's deferred track() write landed after the beacon, aborting the batch and dropping the fingerprint cookie. Switch to UPDATE OR IGNORE plus a purge of leftover rows, and stop the batch failure from failing the beacon. Adds a regression test. Polish: - Analytics: title-case device labels (Mobile/Desktop/Bot) to match the lists beside them. - Download page: stop printing the file size twice. - w.js: the contribution-graph wheel handler no longer traps page scroll at the pan's edges (releases the wheel when the pan can't move). - Consistent delete confirmation on link, paste, and file deletes. - Button: an `` variant now drops its href and stops taking focus/clicks when busy or disabled. - imageKeysIn: match the `[\w.-]` key characters the renderer accepts so a manually-written key isn't GC'd out from under a live document. - Refresh two stale nav comments. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DwxzS8U3YVf6NyDqSaHsAE --- src/lib/markdown.ts | 7 ++++-- src/lib/server/hits.test.ts | 19 +++++++++++++- src/lib/server/track.ts | 15 ++++++++++-- src/lib/ui/Button.svelte | 12 ++++++++- src/routes/admin/+layout.svelte | 7 +++--- src/routes/admin/LinkForm.svelte | 12 ++++++++- src/routes/admin/analytics/+page.svelte | 8 ++++-- src/routes/admin/files/+page.svelte | 12 ++++++++- src/routes/admin/files/FileForm.svelte | 1 + src/routes/admin/home/IdentityForm.svelte | 2 +- src/routes/admin/pastes/PasteForm.svelte | 30 ++++++++++++++++++++--- src/routes/analytics/beacon/+server.ts | 17 +++++++++---- src/routes/d/[slug]/+page.svelte | 7 ++++-- static/w.js | 12 ++++++--- 14 files changed, 133 insertions(+), 28 deletions(-) diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 4ea0349..f8f6850 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -356,10 +356,13 @@ export function findLiveRequests( return [...out.values()]; } -/** Every `img/…` asset key referenced by the document (for dims + GC). */ +/** Every `img/…` asset key referenced by the document (for dims + GC). The base + * name matches the `[\w.-]` the image renderer accepts, not just the hex of a + * freshly minted key, so a manually-written key is never GC'd out from under a + * document that still draws it. */ export function imageKeysIn(src: string | null | undefined): string[] { const keys = new Set(); - const re = /img\/[A-Za-z0-9]+\.(?:webp|png|jpe?g|avif)/g; + const re = /img\/[\w-]+\.(?:webp|png|jpe?g|avif)/g; let m: RegExpExecArray | null; while ((m = re.exec(src ?? ''))) keys.add(m[0]); return [...keys]; diff --git a/src/lib/server/hits.test.ts b/src/lib/server/hits.test.ts index e8f5fbc..24db10b 100644 --- a/src/lib/server/hits.test.ts +++ b/src/lib/server/hits.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { beforeEach, describe, expect, it } from 'vitest'; -import { DEVICE_UPSERT, HITS_UPSERT, MIGRATE_VISITOR, VISITORS_INSERT } from './track'; +import { DEVICE_UPSERT, HITS_UPSERT, MIGRATE_VISITOR, PURGE_VISITOR, VISITORS_INSERT } from './track'; /** * §13, test 3 — the `hits` upsert increments rather than duplicating. @@ -101,6 +101,23 @@ withSqlite('hits', () => { ]); }); + it('folds without aborting when the fingerprint row already exists', () => { + // The page's own deferred write can land after the beacon, leaving a row + // under the fingerprint identity on a slug the cookie-less identity also + // hit. A plain UPDATE onto that primary key would throw and abort the + // batch; `OR IGNORE` skips it and the purge clears the leftover, so the + // slug ends up counted exactly once. + db.prepare(VISITORS_INSERT).run('2026-01-01', 'gh', 'old'); + db.prepare(VISITORS_INSERT).run('2026-01-01', 'gh', 'new'); // already present + + expect(() => db.prepare(MIGRATE_VISITOR).run('2026-01-01', 'new', 'old')).not.toThrow(); + db.prepare(PURGE_VISITOR).run('2026-01-01', 'old'); + + expect(rows<{ vh: string; slug: string }>(`SELECT vh, slug FROM visitors`)).toEqual([ + { vh: 'new', slug: 'gh' } + ]); + }); + it('leaves the next day untouched when it migrates an identity', () => { db.prepare(VISITORS_INSERT).run('2026-01-01', 'gh', 'old'); db.prepare(VISITORS_INSERT).run('2026-01-02', 'gh', 'old'); diff --git a/src/lib/server/track.ts b/src/lib/server/track.ts index e87ff31..d825283 100644 --- a/src/lib/server/track.ts +++ b/src/lib/server/track.ts @@ -115,8 +115,19 @@ export const DEVICE_UPSERT = `INSERT INTO hits_device (day, slug, kind, os, brow export const VISITORS_INSERT = `INSERT OR IGNORE INTO visitors (day, slug, vh) VALUES (?, ?, ?)`; -/** Fold a cookie-less identity into the fingerprint identity for the whole day. */ -export const MIGRATE_VISITOR = `UPDATE visitors SET vh = ?2 WHERE day = ?1 AND vh = ?3`; +/** + * Fold a cookie-less identity into the fingerprint identity for the whole day. + * `OR IGNORE` because a row for the fingerprint identity may already exist on + * the same (day, slug) — the page's own deferred `track()` can land after the + * beacon — and a plain UPDATE onto that existing primary key would abort the + * whole batch. Rows that cannot move are cleared by `PURGE_VISITOR` below, so + * the old identity never lingers as a duplicate. + */ +export const MIGRATE_VISITOR = `UPDATE OR IGNORE visitors SET vh = ?2 WHERE day = ?1 AND vh = ?3`; + +/** Remove any cookie-less rows the migration could not move (their fingerprint + * row already existed), so the two identities never double count. */ +export const PURGE_VISITOR = `DELETE FROM visitors WHERE day = ?1 AND vh = ?2`; /** First-party fingerprint cookie. HttpOnly: the page never needs to read it. */ export const FP_COOKIE = 'f'; diff --git a/src/lib/ui/Button.svelte b/src/lib/ui/Button.svelte index 5dd3353..cccf32a 100644 --- a/src/lib/ui/Button.svelte +++ b/src/lib/ui/Button.svelte @@ -71,7 +71,17 @@ {/snippet} {#if href} - + + {@const inert = disabled || busy} + {@render label()} {:else} diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index ef398f7..487f3d2 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -35,8 +35,9 @@