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
5 changes: 4 additions & 1 deletion app/(dashboard)/routes/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ function shortDate(dateStr: string): string {

function displayViewer(viewer: string, verifiedEmails: Set<string>): string {
if (verifiedEmails.has(viewer)) return viewer;
const prefix = viewer.replace(/[^a-z0-9]/gi, "").slice(0, 8) || viewer.slice(0, 8);
// Anonymous viewers are keyed "anon" (legacy) or "anon:<per-browser id>". Strip the prefix
// before shortening so distinct browsers read as distinct anonymous viewers (anon-<id>).
const id = viewer.startsWith("anon:") ? viewer.slice(5) : viewer;
const prefix = id.replace(/[^a-z0-9]/gi, "").slice(0, 8) || "viewer";
return `anon-${prefix}`;
}

Expand Down
6 changes: 5 additions & 1 deletion app/v/[slug]/page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,11 @@ describe("viewer page", () => {
const token = html.match(/var t="([^"]+)"/)![1];
const claim = verifyTrackToken(token)!;
expect(claim.linkId).toBe(link.id);
expect(claim.viewer).toBe("anon"); // no access cookie in this render
// No access cookie in this render, so the viewer is anonymous. It is keyed by a per-browser
// id ("anon:<id>") rather than the bare "anon" so distinct viewers notify independently.
expect(claim.viewer.startsWith("anon")).toBe(true);
// The script also persists that per-browser id as a cookie so a refresh dedupes.
expect(html).toContain("sentou_vid=");
});

const formGate = { requireEmail: true, allowedDomains: null, expiresAt: null, revoked: false };
Expand Down
13 changes: 12 additions & 1 deletion app/v/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { notFound } from "next/navigation";
import { cookies } from "next/headers";
import { getLinkBySlug } from "@/lib/links";
Expand Down Expand Up @@ -360,7 +361,15 @@ export default async function ViewerPage({
// ── Viewer: artifact in sandboxed iframe ───────────────────────────────────
// STRUCTURE: main's DIRECT children must be [maybe-notice-div, iframe, maybe-script].
// No extra wrappers here — tests verify direct siblings to prove sandbox isolation.
const tracking = trackingContext(link, claim);
//
// Per-browser id for anonymous open-notification dedup. A server component cannot set a
// cookie, so mint one when absent, use it for this render's tracking token, and persist it
// client-side in the tracking script below. It is only a dedup bucket (not an identity or a
// security token), so a client-set, non-httpOnly cookie is fine; tampering only affects that
// viewer's own notification dedup. Only meaningful when tracking is on.
const VISITOR_COOKIE = "sentou_vid";
const visitorId = cookieStore.get(VISITOR_COOKIE)?.value || randomUUID();
const tracking = trackingContext(link, claim, visitorId);
const iframe = (
<iframe
title="artifact"
Expand All @@ -386,6 +395,8 @@ export default async function ViewerPage({
<script
dangerouslySetInnerHTML={{
__html:
// Persist the per-browser id so a refresh reuses it and dedupes; ~1yr, lax.
`document.cookie=${JSON.stringify(`${VISITOR_COOKIE}=${visitorId}; path=/; max-age=31536000; samesite=lax`)};` +
`(function(){var t=${JSON.stringify(tracking.token)},s=Date.now();` +
`function send(type,extra){try{navigator.sendBeacon('/api/track',new Blob([JSON.stringify(Object.assign({token:t,type:type},extra||{}))],{type:'application/json'}))}catch(e){}}` +
`send('open');` +
Expand Down
20 changes: 19 additions & 1 deletion lib/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import path from "node:path";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { getDb } from "@/lib/db/client";
import { createLink } from "@/lib/links";
import { createLink, recordOpen } from "@/lib/links";
import { getStore } from "@/lib/server-store";
import * as schema from "@/lib/db/schema";
import { maybeNotifyOpen } from "@/lib/notifications";
Expand Down Expand Up @@ -279,3 +279,21 @@ describe("notification_prefs — DB read/write", () => {
expect(updated?.webhookUrl).toBeNull();
});
});

// recordOpen returns firstOpen (the flag /api/track uses to decide whether to notify). With
// per-browser anonymous ids, each distinct browser is a first open (one notification each) while
// a refresh by the same browser is not — the fix for anonymous notifications collapsing to one.
describe("recordOpen first-open gating for anonymous viewers", () => {
it("treats distinct per-browser anon viewers as separate first opens, and a repeat as not first", async () => {
const store = getStore();
const link = await createLink(store, "<h1>x</h1>", undefined, true);
const open = (viewer: string, eventId: string) =>
recordOpen(store, { eventId, linkId: link.id, viewer, version: 1, openedAt: new Date().toISOString(), dwellMs: 0 });

// Two different browsers → both are a first open → both would notify.
expect(await open("anon:browser-a", "e1")).toBe(true);
expect(await open("anon:browser-b", "e2")).toBe(true);
// Same browser opening again (a refresh: new page-load, new eventId) → not a first open.
expect(await open("anon:browser-a", "e3")).toBe(false);
});
});
14 changes: 14 additions & 0 deletions lib/tracking-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,18 @@ describe("trackingContext", () => {
const ctx = trackingContext(link, { linkId: "some-other-link-id", email: "a@x.com" }) as { track: true; token: string };
expect(verifyTrackToken(ctx.token)!.viewer).toBe("anon");
});
it("keys an anonymous viewer by the per-browser id so distinct browsers are distinct viewers", async () => {
const link = await createLink(createMemoryStore(), "<h1>x</h1>", undefined, true);
const a = trackingContext(link, null, "browser-a") as { track: true; token: string };
const b = trackingContext(link, null, "browser-b") as { track: true; token: string };
expect(verifyTrackToken(a.token)!.viewer).toBe("anon:browser-a");
expect(verifyTrackToken(b.token)!.viewer).toBe("anon:browser-b");
// Distinct browsers must map to distinct viewers, or the owner's open-notification collapses.
expect(verifyTrackToken(a.token)!.viewer).not.toBe(verifyTrackToken(b.token)!.viewer);
});
it("ignores the visitor id for a VERIFIED viewer (email still wins)", async () => {
const link = await createLink(createMemoryStore(), "<h1>x</h1>", undefined, true);
const ctx = trackingContext(link, { linkId: link.id, email: "a@x.com", verified: true }, "browser-a") as { track: true; token: string };
expect(verifyTrackToken(ctx.token)!.viewer).toBe("a@x.com");
});
});
13 changes: 10 additions & 3 deletions lib/tracking-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@ import { signTrackToken } from "@/lib/track-token";
export function trackingContext(
link: Link,
claim: { linkId: string; email: string; verified?: boolean } | null,
visitorId?: string,
): { track: false } | { track: true; token: string } {
if (!link.track) return { track: false };
// Attribute opens to an email only when it was actually verified; an unverified (record-only)
// viewer is "anon", so tracking never stores an address Sentou could not confirm either.
const viewer = claim && claim.linkId === link.id && claim.verified ? claim.email : "anon";
// Attribute opens to an email only when it was actually verified; an unverified viewer is
// anonymous, so tracking never stores an address Sentou could not confirm. Key an anonymous
// viewer by a stable per-browser id ("anon:<id>") rather than the bare constant "anon": with a
// single "anon" bucket, recordOpen's per-viewer first-open check treats every distinct person
// after the first as a repeat, so the owner's open-notification fires only once for the whole
// link. A per-browser id makes distinct viewers distinct (each notifies once) while a refresh
// by the same browser still dedupes. Falls back to "anon" when no id is supplied.
const verified = claim && claim.linkId === link.id && claim.verified;
const viewer = verified ? claim.email : visitorId ? `anon:${visitorId}` : "anon";
const version = currentVersion(link);
// 24h covers a long-open tab still firing its close beacon, while bounding indefinite replay.
const token = signTrackToken({ linkId: link.id, version, viewer, eventId: nanoid(), exp: Date.now() + 24 * 3600_000 });
Expand Down
Loading