Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/lib/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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];
Expand Down
19 changes: 18 additions & 1 deletion src/lib/server/hits.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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');
Expand Down
15 changes: 13 additions & 2 deletions src/lib/server/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
12 changes: 11 additions & 1 deletion src/lib/ui/Button.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,17 @@
{/snippet}

{#if href}
<a {href} class="{base} {VARIANT[variant]} {SIZE[size]} {extra}" {...rest}>
<!-- An anchor has no `disabled`, so a busy or disabled link drops its href
and stops taking focus or clicks rather than navigating mid-flight. -->
{@const inert = disabled || busy}
<a
href={inert ? undefined : href}
class="{base} {VARIANT[variant]} {SIZE[size]} {inert ? 'pointer-events-none opacity-45' : ''} {extra}"
aria-disabled={inert || undefined}
aria-busy={busy || undefined}
tabindex={inert ? -1 : undefined}
{...rest}
>
{@render label()}
</a>
{:else}
Expand Down
7 changes: 4 additions & 3 deletions src/routes/admin/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@

<!--
A persistent sidebar at `md` and up, a bottom bar below it. Not a
hamburger in either case: three destinations do not need to be hidden
behind a menu, and on a phone the thumb is at the bottom of the screen.
hamburger in either case: this handful of destinations does not need to be
hidden behind a menu, and on a phone the thumb is at the bottom of the
screen.
-->
<nav class="sidebar" aria-label="Admin">
<a
Expand Down Expand Up @@ -141,7 +142,7 @@
gap: 0.5rem;
/* 44px minimum, made of padding rather than a bigger icon. */
min-height: 3.25rem;
/* Seven destinations with labels cannot clear 320px, so below `md`
/* This many destinations with labels cannot clear 320px, so below `md`
the bar is icons only (labels stay for screen readers and return in
the sidebar) and every stop is visible at once. */
padding-inline: 0.25rem;
Expand Down
12 changes: 11 additions & 1 deletion src/routes/admin/LinkForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@

const deleting = pending({ onSuccess: () => onDone?.() });

// Delete is destructive and one click away, so it asks first — the same
// confirm the asset list uses, so the whole admin behaves one way.
const confirmDelete: import('@sveltejs/kit').SubmitFunction = (input) => {
if (!confirm(`Delete ij5.dev/${row?.slug}? Analytics history is kept.`)) {
input.cancel();
return;
}
return deleting.submit(input);
};

const iso = (ms: number | null | undefined) =>
ms ? new Date(ms).toISOString().slice(0, 10) : '';

Expand Down Expand Up @@ -157,7 +167,7 @@
method="POST"
action="?/delete"
class="mt-6 border-t border-border-subtle pt-5"
use:enhance={deleting.submit}
use:enhance={confirmDelete}
>
<input type="hidden" name="slug" value={row.slug} />
<div class="flex items-center justify-between gap-4">
Expand Down
8 changes: 6 additions & 2 deletions src/routes/admin/analytics/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
}
};

// `mobile` / `desktop` / `bot` are stored lower-case; title-case them so the
// Devices list reads like the Countries, OS and Browser lists beside it.
const cap = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);

let hasData = $derived(data.traffic.length > 0 || data.totals.hits > 0 || data.totals.home > 0);

const rangeHref = (key: string) =>
Expand Down Expand Up @@ -102,7 +106,7 @@

<BarList
title="Devices"
rows={data.devices.map((d) => ({ label: d.device, value: d.hits }))}
rows={data.devices.map((d) => ({ label: cap(d.device), value: d.hits }))}
empty="No devices recorded yet."
/>

Expand Down Expand Up @@ -165,7 +169,7 @@
<BarList
level={3}
title="Devices"
rows={detail.devices.map((d) => ({ label: d.device, value: d.hits }))}
rows={detail.devices.map((d) => ({ label: cap(d.device), value: d.hits }))}
empty="No devices for this link."
/>
<BarList
Expand Down
12 changes: 11 additions & 1 deletion src/routes/admin/files/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@

const deleting = pending({ onSuccess: () => goto('/admin/files', { noScroll: true }) });

// Deleting a file removes the bytes from R2 for good, so it asks first — the
// same confirm the home asset list uses.
const confirmDelete: import('@sveltejs/kit').SubmitFunction = (input) => {
if (!confirm(`Delete ${data.selected?.name}? The bytes are removed from R2.`)) {
input.cancel();
return;
}
return deleting.submit(input);
};

let selection = $derived(page.url.searchParams.get('s'));
let creating = $derived(selection === 'new');
let detail = $derived(creating || Boolean(data.selected));
Expand Down Expand Up @@ -209,7 +219,7 @@
method="POST"
action="?/delete"
class="mt-6 border-t border-border-subtle pt-5"
use:enhance={deleting.submit}
use:enhance={confirmDelete}
>
<input type="hidden" name="slug" value={data.selected.slug} />
<div class="flex items-center justify-between gap-4">
Expand Down
1 change: 1 addition & 0 deletions src/routes/admin/files/FileForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
{#snippet children({ id, describedBy, invalid })}
<div class="flex flex-col gap-2">
<input
bind:this={fileEl}
{id}
name="file"
type="file"
Expand Down
2 changes: 1 addition & 1 deletion src/routes/admin/home/IdentityForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
id="p-links"
label="Quick links"
optional
hint="For the :::links buttons. One per line: icon | label | url. Icons: github, x, linkedin, instagram, youtube, mail, rss, globe."
hint="Shown on the share card and in structured data — not the :::links buttons (those live in the page body). One per line: label | url | icon. Icons: github, x, linkedin, instagram, youtube, mail, rss, globe."
>
{#snippet children({ id, describedBy, invalid })}
<textarea
Expand Down
30 changes: 26 additions & 4 deletions src/routes/admin/pastes/PasteForm.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import Button from '$lib/ui/Button.svelte';
import Field from '$lib/ui/Field.svelte';
Expand Down Expand Up @@ -26,6 +27,16 @@

const deleting = pending({ onSuccess: () => onDone?.() });

// Destructive and one click away, so it asks first — the same confirm the
// asset list uses, so the whole admin behaves one way.
const confirmDelete: import('@sveltejs/kit').SubmitFunction = (input) => {
if (!confirm(`Delete ij5.dev/p/${row?.slug}? Analytics history is kept.`)) {
input.cancel();
return;
}
return deleting.submit(input);
};

const iso = (ms: number | null | undefined) =>
ms ? new Date(ms).toISOString().slice(0, 10) : '';

Expand All @@ -35,12 +46,23 @@

let initial = $derived({
slug: values.slug ?? row?.slug ?? '',
body: values.body ?? row?.body ?? '',
note: values.note ?? row?.note ?? '',
expires: values.expires ?? iso(row?.expires_at)
});

let bodyLen = $derived(initial.body.length);
// The body is bound so the counter tracks typing. It re-seeds when the
// selected paste (or echoed-back values) change, but `untrack` keeps that
// reseed from firing on every keystroke and wiping what someone is typing.
// svelte-ignore state_referenced_locally
let body = $state(values.body ?? row?.body ?? '');
$effect(() => {
const seed = values.body ?? row?.body ?? '';
untrack(() => {
body = seed;
});
});

let bodyLen = $derived(body.length);
</script>

<form
Expand Down Expand Up @@ -92,7 +114,7 @@
{id}
name="body"
rows={14}
value={initial.body}
bind:value={body}
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
required
Expand Down Expand Up @@ -175,7 +197,7 @@
method="POST"
action="?/delete"
class="mt-6 border-t border-border-subtle pt-5"
use:enhance={deleting.submit}
use:enhance={confirmDelete}
>
<input type="hidden" name="slug" value={row.slug} />
<div class="flex items-center justify-between gap-4">
Expand Down
17 changes: 12 additions & 5 deletions src/routes/analytics/beacon/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
fpCookie,
MIGRATE_VISITOR,
parseBeacon,
PURGE_VISITOR,
today,
visitorHash,
VISITORS_INSERT
Expand Down Expand Up @@ -54,21 +55,27 @@ export const POST: RequestHandler = async ({ request, url, platform }) => {
const old = await visitorHash(salt, day, ip, ua);
const vh = await visitorHash(salt, day, fp);

// The UPDATE folds in every cookie-less row from earlier today (all slugs —
// a redirect clicked before the beacon is still the same person). The INSERT
// OR IGNORE guarantees the home row exists even if it landed late.
// The migration folds in every cookie-less row from earlier today (all slugs
// — a redirect clicked before the beacon is still the same person); the purge
// clears any it could not move; the INSERT OR IGNORE guarantees the home row
// exists even if it landed late. Analytics never fail a request (§13), so a
// storage hiccup here must not cost the visitor their fingerprint cookie.
const stmts = [
env.DB.prepare(MIGRATE_VISITOR).bind(day, vh, old),
env.DB.prepare(PURGE_VISITOR).bind(day, old),
env.DB.prepare(VISITORS_INSERT).bind(day, '', vh)
];
await env.DB.batch(stmts);
await env.DB.batch(stmts).catch(() => {});

// The page's own track() write is scheduled behind the response it rode in
// on; the beacon can land before that batch finishes. One delayed re-run of
// the migration folds any straggler in. Cheap insurance, not the happy path.
const retry = (async () => {
await new Promise((r) => setTimeout(r, 1500));
await env.DB.prepare(MIGRATE_VISITOR).bind(day, vh, old).run();
await env.DB.batch([
env.DB.prepare(MIGRATE_VISITOR).bind(day, vh, old),
env.DB.prepare(PURGE_VISITOR).bind(day, old)
]);
})().catch(() => {});
platform?.context?.waitUntil?.(retry);

Expand Down
7 changes: 5 additions & 2 deletions src/routes/d/[slug]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@
return dot > 0 ? data.file.name.slice(dot + 1).toLowerCase() : '';
});

// The size already sits in `.file-sub` above this line, so it is deliberately
// left out here rather than repeated.
let sub = $derived(
[
`${fmtBytes(data.file.bytes)}`,
`uploaded ${fmtDate(data.file.created_at)}`,
data.file.expires_at ? `expires ${fmtDate(data.file.expires_at)}` : null,
`${data.file.downloads} ${data.file.downloads === 1 ? 'download' : 'downloads'}`
].join(' · ')
]
.filter(Boolean)
.join(' · ')
);
</script>

Expand Down
12 changes: 9 additions & 3 deletions static/w.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@
grass.addEventListener('scroll', paintGrass, { passive: true });
window.addEventListener('resize', paintGrass, { passive: true });
// With no visible scrollbar the wheel is the graph's only affordance,
// so a vertical wheel pans it horizontally and is blocked from
// scrolling the page — but only while there is anything to pan.
// so a vertical wheel pans it horizontally while there is anything
// to pan in that direction.
grass.addEventListener(
'wheel',
function (event) {
Expand All @@ -75,8 +75,14 @@
else if (event.deltaMode === 2) d *= window.innerHeight;
if (event.deltaX) d += event.deltaX;
if (d === 0) return;
event.preventDefault();
// Only swallow the wheel while the pan actually moves. At the edge
// scrollLeft does not change, so the event is left to scroll the
// page instead of trapping it. Comparing before and after
// sidesteps the RTL scroll-offset disagreement between engines: it
// asks whether the pan moved, not which way the numbers run.
var before = grass.scrollLeft;
grass.scrollLeft += d;
if (grass.scrollLeft !== before) event.preventDefault();
},
{ passive: false }
);
Expand Down
Loading