From b6fa4da77bd653497d4ed7596947481310c9e3d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 13:19:57 +0000 Subject: [PATCH] Add anonymous mode A "Continue anonymously" button on the AccessGate issues a Turnstile-gated anonymous session token. The worker tracks session mode in the bearer auth middleware and redacts displayName + avatarUrl in stats responses for anonymous sessions, so PII never reaches the client. In the web UI, names render as a pixelated redaction pill of deterministic hash-based width, and avatars render as a 5x5 symmetric identicon derived from the userId. A small bottom-right banner lets anonymous visitors exit back to the password gate. The analytics route's cache namespace varies by session mode so authed and anonymous responses can't share entries. https://claude.ai/code/session_01Dw3UaAP1QDLQU47ZJhMNvX --- packages/web/src/components/AccessGate.tsx | 72 ++++++++++++++++- .../web/src/components/AnonymousAvatar.tsx | 43 ++++++++++ .../web/src/components/AnonymousBanner.tsx | 25 ++++++ packages/web/src/components/AnonymousName.tsx | 29 +++++++ packages/web/src/components/Avatar.tsx | 16 ++-- packages/web/src/components/UserName.tsx | 14 ++++ .../web/src/components/layouts/BaseLayout.tsx | 8 +- .../components/parrots/ParrotProfileCard.tsx | 14 ++-- .../web/src/components/stats/DetailView.tsx | 2 +- .../src/components/stats/LeaderboardRow.tsx | 2 +- packages/web/src/components/stats/Trends.tsx | 10 ++- packages/web/src/lib/anonymous.ts | 38 +++++++++ packages/web/src/lib/api.ts | 6 ++ .../web/src/routes/stats/emojis/$emoji.tsx | 13 ++- .../web/src/routes/stats/users/$userId.tsx | 21 ++++- packages/web/src/routes/stats/users/index.tsx | 13 ++- packages/worker/src/api.ts | 16 +++- packages/worker/src/lib/auth.ts | 64 ++++++++++++++- packages/worker/src/lib/types.ts | 12 +++ packages/worker/src/routes/analytics.ts | 31 +++++--- packages/worker/src/routes/auth.ts | 79 ++++++++++++++----- packages/worker/src/routes/emojis.ts | 24 ++++-- packages/worker/src/routes/rankings.ts | 14 +++- packages/worker/src/routes/users.ts | 19 ++++- 24 files changed, 514 insertions(+), 71 deletions(-) create mode 100644 packages/web/src/components/AnonymousAvatar.tsx create mode 100644 packages/web/src/components/AnonymousBanner.tsx create mode 100644 packages/web/src/components/AnonymousName.tsx create mode 100644 packages/web/src/components/UserName.tsx create mode 100644 packages/web/src/lib/anonymous.ts create mode 100644 packages/worker/src/lib/types.ts diff --git a/packages/web/src/components/AccessGate.tsx b/packages/web/src/components/AccessGate.tsx index e4d38d4..78860d5 100644 --- a/packages/web/src/components/AccessGate.tsx +++ b/packages/web/src/components/AccessGate.tsx @@ -2,13 +2,19 @@ import { Turnstile } from "@marsidev/react-turnstile"; import { REGEXP_ONLY_DIGITS } from "input-otp"; import { Loader2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { InputOTP, InputOTPGroup, InputOTPSlot, } from "@/components/ui/input-otp"; -import { api, SESSION_AUTH_KEY, setSessionToken } from "@/lib/api"; +import { + api, + SESSION_ANON_KEY, + SESSION_AUTH_KEY, + setSessionToken, +} from "@/lib/api"; import { cn } from "@/lib/utils"; import { StatsLayout } from "./layouts/StatsLayout"; @@ -43,6 +49,7 @@ type AccessGateProps = { export function AccessGate({ onAuthenticated }: AccessGateProps) { const [password, setPassword] = useState(getPassParam); const turnstileTokenRef = useRef(null); + const [turnstileReady, setTurnstileReady] = useState(false); const [error, setError] = useState(null); const [turnstileFailed, setTurnstileFailed] = useState(false); const [needsInteraction, setNeedsInteraction] = useState(false); @@ -88,12 +95,14 @@ export function AccessGate({ onAuthenticated }: AccessGateProps) { setError(data.error ?? "Verification failed"); setPassword(""); turnstileTokenRef.current = null; + setTurnstileReady(false); setTurnstileKey((k) => k + 1); } } catch { setError("Network error. Please try again."); setPassword(""); turnstileTokenRef.current = null; + setTurnstileReady(false); setTurnstileKey((k) => k + 1); } finally { loadingRef.current = false; @@ -114,6 +123,7 @@ export function AccessGate({ onAuthenticated }: AccessGateProps) { function handleTurnstileVerify(token: string) { turnstileTokenRef.current = token; + setTurnstileReady(true); setTurnstileFailed(false); setNeedsInteraction(false); if (password.length === 6 && !loadingRef.current) { @@ -123,9 +133,48 @@ export function AccessGate({ onAuthenticated }: AccessGateProps) { function handleTurnstileFailed() { turnstileTokenRef.current = null; + setTurnstileReady(false); setTurnstileFailed(true); } + async function continueAnonymously() { + const token = turnstileTokenRef.current; + if (!token || loadingRef.current) { + return; + } + loadingRef.current = true; + setError(null); + + try { + const res = await api.api.auth.anonymous.$post({ + json: { turnstileToken: token }, + }); + + if (res.ok) { + const data = (await res.json()) as { token?: string }; + if (data.token) { + setSessionToken(data.token); + } + localStorage.setItem(SESSION_AUTH_KEY, "1"); + localStorage.setItem(SESSION_ANON_KEY, "1"); + onAuthenticated(); + } else { + const data = (await res.json()) as { error?: string }; + setError(data.error ?? "Could not start anonymous session"); + turnstileTokenRef.current = null; + setTurnstileReady(false); + setTurnstileKey((k) => k + 1); + } + } catch { + setError("Network error. Please try again."); + turnstileTokenRef.current = null; + setTurnstileReady(false); + setTurnstileKey((k) => k + 1); + } finally { + loadingRef.current = false; + } + } + return ( @@ -168,6 +217,26 @@ export function AccessGate({ onAuthenticated }: AccessGateProps) {

{displayError}

) : null} +
+
+ + or + +
+ +

+ Browse stats with usernames hidden. +

+
+ setNeedsInteraction(true)} onExpire={() => { turnstileTokenRef.current = null; + setTurnstileReady(false); }} options={{ theme: "dark" }} hidden={!needsInteraction} diff --git a/packages/web/src/components/AnonymousAvatar.tsx b/packages/web/src/components/AnonymousAvatar.tsx new file mode 100644 index 0000000..14563b4 --- /dev/null +++ b/packages/web/src/components/AnonymousAvatar.tsx @@ -0,0 +1,43 @@ +import { getIdenticonSeed } from "@/lib/anonymous"; +import { cn } from "@/lib/utils"; + +type AnonymousAvatarProps = { + userId: string; + size?: number; +}; + +const GRID = 5; + +export function AnonymousAvatar({ userId, size = 28 }: AnonymousAvatarProps) { + const { cells, hue } = getIdenticonSeed(userId); + const fg = `oklch(70% 0.18 ${hue})`; + const bg = `oklch(22% 0.05 ${hue})`; + + return ( + + {cells.map((on, i) => { + if (!on) { + return null; + } + const col = i % 3; + const row = Math.floor(i / 3); + const key = `${row}-${col}`; + return ( + + + + + ); + })} + + ); +} diff --git a/packages/web/src/components/AnonymousBanner.tsx b/packages/web/src/components/AnonymousBanner.tsx new file mode 100644 index 0000000..9f0cc78 --- /dev/null +++ b/packages/web/src/components/AnonymousBanner.tsx @@ -0,0 +1,25 @@ +import { clearSession, isAnonymousMode } from "@/lib/api"; + +export function AnonymousBanner() { + if (!isAnonymousMode()) { + return null; + } + + function signIn() { + clearSession(); + window.location.reload(); + } + + return ( +
+ Anonymous mode + +
+ ); +} diff --git a/packages/web/src/components/AnonymousName.tsx b/packages/web/src/components/AnonymousName.tsx new file mode 100644 index 0000000..79070e9 --- /dev/null +++ b/packages/web/src/components/AnonymousName.tsx @@ -0,0 +1,29 @@ +import { getRedactionLength } from "@/lib/anonymous"; +import { cn } from "@/lib/utils"; + +type AnonymousNameProps = { + userId: string; + className?: string; +}; + +export function AnonymousName({ userId, className }: AnonymousNameProps) { + const length = getRedactionLength(userId); + + return ( + + {"█".repeat(length)} + + ); +} diff --git a/packages/web/src/components/Avatar.tsx b/packages/web/src/components/Avatar.tsx index 5adbc7f..f2c0fd9 100644 --- a/packages/web/src/components/Avatar.tsx +++ b/packages/web/src/components/Avatar.tsx @@ -1,18 +1,16 @@ +import { AnonymousAvatar } from "@/components/AnonymousAvatar"; import { cn } from "@/lib/utils"; type AvatarProps = { url?: string | null; name?: string | null; + userId?: string; size?: number; }; -export function Avatar({ url, name, size = 28 }: AvatarProps) { +export function Avatar({ url, name, userId, size = 28 }: AvatarProps) { const sizeClass = size === 40 ? "h-10 w-10" : "h-7 w-7"; - if (!url && !name) { - return null; - } - if (url) { return ( ; + } + + if (!name) { + return null; + } + return (
{displayName}; + } + return ; +} diff --git a/packages/web/src/components/layouts/BaseLayout.tsx b/packages/web/src/components/layouts/BaseLayout.tsx index 3ee0619..321e35d 100644 --- a/packages/web/src/components/layouts/BaseLayout.tsx +++ b/packages/web/src/components/layouts/BaseLayout.tsx @@ -1,8 +1,14 @@ import type { ReactNode } from "react"; +import { AnonymousBanner } from "@/components/AnonymousBanner"; type LayoutProps = { children: ReactNode; }; export function BaseLayout({ children }: LayoutProps) { - return
{children}
; + return ( +
+ {children} + +
+ ); } diff --git a/packages/web/src/components/parrots/ParrotProfileCard.tsx b/packages/web/src/components/parrots/ParrotProfileCard.tsx index eb81349..88f3d00 100644 --- a/packages/web/src/components/parrots/ParrotProfileCard.tsx +++ b/packages/web/src/components/parrots/ParrotProfileCard.tsx @@ -1,4 +1,6 @@ +import type { ReactNode } from "react"; import { Emoji } from "@/components/stats/Emoji"; +import { UserName } from "@/components/UserName"; import { Card } from "@/components/ui/card"; import { useEmojiProfile } from "@/hooks/queries"; @@ -8,8 +10,8 @@ export function ParrotProfileCard({ emoji }: { emoji: string }) { const { data } = useEmojiProfile(emoji); const firstUsedAt = formatMonthYear(data?.firstUsedAt) ?? DASH; - const firstUser = userLabel(data?.firstUser) ?? DASH; - const topUser = userLabel(data?.topUser) ?? DASH; + const firstUser = userNode(data?.firstUser) ?? DASH; + const topUser = userNode(data?.topUser) ?? DASH; const totalCount = data ? data.totalCount.toLocaleString() : DASH; return ( @@ -32,7 +34,7 @@ export function ParrotProfileCard({ emoji }: { emoji: string }) { ); } -function Stat({ label, value }: { label: string; value: string }) { +function Stat({ label, value }: { label: string; value: ReactNode }) { return (
{label}
@@ -41,13 +43,13 @@ function Stat({ label, value }: { label: string; value: string }) { ); } -function userLabel( +function userNode( user: { displayName: string; userId: string } | null | undefined, -): string | null { +): ReactNode { if (!user) { return null; } - return user.displayName || user.userId; + return ; } function formatMonthYear(raw: string | null | undefined): string | null { diff --git a/packages/web/src/components/stats/DetailView.tsx b/packages/web/src/components/stats/DetailView.tsx index b354916..4a98658 100644 --- a/packages/web/src/components/stats/DetailView.tsx +++ b/packages/web/src/components/stats/DetailView.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button"; type DetailViewProps = { icon: ReactNode; - title: string; + title: ReactNode; loading: boolean; error?: string; emptyMessage: string; diff --git a/packages/web/src/components/stats/LeaderboardRow.tsx b/packages/web/src/components/stats/LeaderboardRow.tsx index 01aa355..ce5607c 100644 --- a/packages/web/src/components/stats/LeaderboardRow.tsx +++ b/packages/web/src/components/stats/LeaderboardRow.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from "react"; type LeaderboardRowProps = { rank: number; left: ReactNode; - label: string; + label: ReactNode; count: number; onClick?: () => void; }; diff --git a/packages/web/src/components/stats/Trends.tsx b/packages/web/src/components/stats/Trends.tsx index dca1871..2902c00 100644 --- a/packages/web/src/components/stats/Trends.tsx +++ b/packages/web/src/components/stats/Trends.tsx @@ -12,6 +12,8 @@ import { YAxis, } from "recharts"; import { useMediaQuery } from "usehooks-ts"; +import { AnonymousAvatar } from "@/components/AnonymousAvatar"; +import { UserName } from "@/components/UserName"; import { useCategoryData, useEmojiTrends, @@ -216,11 +218,13 @@ function UserTrendsChart() { {user?.avatar ? ( {user.name} - ) : null} - {user?.name || userId} + ) : ( + + )} + ), color: CHART_COLORS[i % CHART_COLORS.length], diff --git a/packages/web/src/lib/anonymous.ts b/packages/web/src/lib/anonymous.ts new file mode 100644 index 0000000..e83c0f5 --- /dev/null +++ b/packages/web/src/lib/anonymous.ts @@ -0,0 +1,38 @@ +// FNV-1a 32-bit hash. Deterministic, fast, no allocations. +export function hashString(input: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +const REDACT_MIN = 4; +const REDACT_MAX = 9; + +export function getRedactionLength(userId: string): number { + const range = REDACT_MAX - REDACT_MIN + 1; + return REDACT_MIN + (hashString(userId) % range); +} + +export type IdenticonSeed = { + cells: boolean[]; + hue: number; +}; + +// 5x5 horizontally-mirrored identicon (3 cols × 5 rows = 15 bits of pattern). +// The hue rotates around oklch hue (0-360). +export function getIdenticonSeed(userId: string): IdenticonSeed { + const hash = hashString(userId); + const hue = hash % 360; + const cells: boolean[] = []; + let bits = hash; + for (let row = 0; row < 5; row++) { + for (let col = 0; col < 3; col++) { + cells.push((bits & 1) === 1); + bits = bits >>> 1; + } + } + return { cells, hue }; +} diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index f9aa89c..8d83a28 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -3,6 +3,11 @@ import { hc } from "hono/client"; const SESSION_TOKEN_KEY = "catalyst-token"; export const SESSION_AUTH_KEY = "catalyst-auth"; +export const SESSION_ANON_KEY = "catalyst-anon"; + +export function isAnonymousMode(): boolean { + return localStorage.getItem(SESSION_ANON_KEY) === "1"; +} const baseUrl = import.meta.env.VITE_API_URL ? import.meta.env.VITE_API_URL @@ -19,6 +24,7 @@ export function setSessionToken(token: string): void { export function clearSession(): void { localStorage.removeItem(SESSION_TOKEN_KEY); localStorage.removeItem(SESSION_AUTH_KEY); + localStorage.removeItem(SESSION_ANON_KEY); } let sessionExpiredCallback: (() => void) | null = null; diff --git a/packages/web/src/routes/stats/emojis/$emoji.tsx b/packages/web/src/routes/stats/emojis/$emoji.tsx index 356eebc..a7a11b7 100644 --- a/packages/web/src/routes/stats/emojis/$emoji.tsx +++ b/packages/web/src/routes/stats/emojis/$emoji.tsx @@ -3,6 +3,7 @@ import { Avatar } from "@/components/Avatar"; import { DetailView } from "@/components/stats/DetailView"; import { Emoji } from "@/components/stats/Emoji"; import { LeaderboardRow } from "@/components/stats/LeaderboardRow"; +import { UserName } from "@/components/UserName"; import { useEmojiUsers } from "@/hooks/queries"; import { useStatsFilters } from "@/hooks/useStatsFilter"; import { resolveEmojiUnicode } from "@/lib/emoji"; @@ -39,8 +40,16 @@ function EmojiDetailPage() { } - label={entry.displayName || entry.userId} + left={ + + } + label={ + + } count={entry.count} onClick={() => navigate({ diff --git a/packages/web/src/routes/stats/users/$userId.tsx b/packages/web/src/routes/stats/users/$userId.tsx index 59ea3ef..dd3ad57 100644 --- a/packages/web/src/routes/stats/users/$userId.tsx +++ b/packages/web/src/routes/stats/users/$userId.tsx @@ -4,8 +4,10 @@ import { Avatar } from "@/components/Avatar"; import { DetailView } from "@/components/stats/DetailView"; import { Emoji } from "@/components/stats/Emoji"; import { LeaderboardRow } from "@/components/stats/LeaderboardRow"; +import { UserName } from "@/components/UserName"; import { useUserEmojis } from "@/hooks/queries"; import { useStatsFilters } from "@/hooks/useStatsFilter"; +import { isAnonymousMode } from "@/lib/api"; export const Route = createFileRoute("/stats/users/$userId")({ head: () => ({ @@ -22,7 +24,13 @@ function UserDetailPage() { const displayName = data?.user?.displayName; useEffect(() => { - document.title = displayName ? `${displayName} | Catalyst` : "Catalyst"; + if (displayName) { + document.title = `${displayName} | Catalyst`; + } else if (isAnonymousMode()) { + document.title = "Anonymous Reactor | Catalyst"; + } else { + document.title = "Catalyst"; + } }, [displayName]); const user = data?.user; @@ -30,8 +38,15 @@ function UserDetailPage() { return ( } - title={user?.displayName || userId} + icon={ + + } + title={} loading={isPending} error={error?.message} emptyMessage="No reactions found" diff --git a/packages/web/src/routes/stats/users/index.tsx b/packages/web/src/routes/stats/users/index.tsx index 22a4e4f..c5e1cae 100644 --- a/packages/web/src/routes/stats/users/index.tsx +++ b/packages/web/src/routes/stats/users/index.tsx @@ -1,6 +1,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { Avatar } from "@/components/Avatar"; import { LeaderboardRow } from "@/components/stats/LeaderboardRow"; +import { UserName } from "@/components/UserName"; import { useUserRankings } from "@/hooks/queries"; import { useStatsFilters } from "@/hooks/useStatsFilter"; @@ -32,8 +33,16 @@ function UsersPage() { } - label={entry.displayName || entry.userId} + left={ + + } + label={ + + } count={entry.totalCount} onClick={() => navigate({ diff --git a/packages/worker/src/api.ts b/packages/worker/src/api.ts index a1437a3..19f2f70 100644 --- a/packages/worker/src/api.ts +++ b/packages/worker/src/api.ts @@ -4,7 +4,8 @@ import { bearerAuth } from "hono/bearer-auth"; import { cors } from "hono/cors"; import { logger } from "hono/logger"; import { secureHeaders } from "hono/secure-headers"; -import { verifySessionToken } from "./lib/auth"; +import { verifyAnySessionToken } from "./lib/auth"; +import type { AppEnv } from "./lib/types"; import { analyticsRoute } from "./routes/analytics"; import { authRoute } from "./routes/auth"; import { emojisRoute } from "./routes/emojis"; @@ -35,7 +36,7 @@ function isAllowedOrigin(origin: string): boolean { return false; } -export const api = new Hono<{ Bindings: Env }>() +export const api = new Hono() .use("/api/*", logger()) .use("/api/*", secureHeaders()) .use( @@ -60,7 +61,16 @@ export const api = new Hono<{ Bindings: Env }>() bearerAuth({ verifyToken: async (token, c) => { const ttl = Number(c.env.SESSION_TTL_HOURS) || 0; - return verifySessionToken(c.env.SITE_PASSWORD, token, ttl); + const mode = await verifyAnySessionToken( + c.env.SITE_PASSWORD, + token, + ttl, + ); + if (!mode) { + return false; + } + c.set("sessionMode", mode); + return true; }, }), ) diff --git a/packages/worker/src/lib/auth.ts b/packages/worker/src/lib/auth.ts index 33b99c0..4a87ece 100644 --- a/packages/worker/src/lib/auth.ts +++ b/packages/worker/src/lib/auth.ts @@ -1,4 +1,7 @@ const TOKEN_PREFIX = "catalyst-session-v1"; +const ANON_TOKEN_PREFIX = "catalyst-anon-v1"; + +export type SessionMode = "full" | "anonymous"; function currentWindow(ttlHours: number): number { return Math.floor(Date.now() / (ttlHours * 60 * 60 * 1000)); @@ -56,15 +59,70 @@ export async function verifySessionToken( password: string, token: string, ttlHours = 0, +): Promise { + return verifyTokenWithPrefix(password, token, ttlHours, TOKEN_PREFIX); +} + +/** + * Create an anonymous session token. Uses a distinct prefix from the + * password-backed session token so the two can be distinguished at verify + * time, and so anonymous tokens can be revoked independently if needed. + */ +export async function createAnonymousSessionToken( + password: string, + ttlHours = 0, +): Promise { + if (ttlHours <= 0) { + return hmac(password, ANON_TOKEN_PREFIX); + } + const window = currentWindow(ttlHours); + return hmac(password, `${ANON_TOKEN_PREFIX}:${window}`); +} + +export async function verifyAnonymousSessionToken( + password: string, + token: string, + ttlHours = 0, +): Promise { + return verifyTokenWithPrefix(password, token, ttlHours, ANON_TOKEN_PREFIX); +} + +async function verifyTokenWithPrefix( + password: string, + token: string, + ttlHours: number, + prefix: string, ): Promise { if (ttlHours <= 0) { - const expected = await hmac(password, TOKEN_PREFIX); + const expected = await hmac(password, prefix); return timeSafeEqual(token, expected); } const window = currentWindow(ttlHours); const [current, previous] = await Promise.all([ - hmac(password, `${TOKEN_PREFIX}:${window}`), - hmac(password, `${TOKEN_PREFIX}:${window - 1}`), + hmac(password, `${prefix}:${window}`), + hmac(password, `${prefix}:${window - 1}`), ]); return timeSafeEqual(token, current) || timeSafeEqual(token, previous); } + +/** + * Verify either a full or anonymous session token. Always runs both checks + * so the response time doesn't leak which kind of token was attempted. + */ +export async function verifyAnySessionToken( + password: string, + token: string, + ttlHours = 0, +): Promise { + const [full, anon] = await Promise.all([ + verifySessionToken(password, token, ttlHours), + verifyAnonymousSessionToken(password, token, ttlHours), + ]); + if (full) { + return "full"; + } + if (anon) { + return "anonymous"; + } + return null; +} diff --git a/packages/worker/src/lib/types.ts b/packages/worker/src/lib/types.ts new file mode 100644 index 0000000..f0eb213 --- /dev/null +++ b/packages/worker/src/lib/types.ts @@ -0,0 +1,12 @@ +import type { SessionMode } from "./auth"; + +export type AppEnv = { + Bindings: Env; + Variables: { sessionMode: SessionMode }; +}; + +export function isAnonymous(c: { + var: { sessionMode?: SessionMode }; +}): boolean { + return c.var.sessionMode === "anonymous"; +} diff --git a/packages/worker/src/routes/analytics.ts b/packages/worker/src/routes/analytics.ts index 216afc0..897b2dc 100644 --- a/packages/worker/src/routes/analytics.ts +++ b/packages/worker/src/routes/analytics.ts @@ -5,6 +5,8 @@ import { Hono } from "hono"; import { cache } from "hono/cache"; import { z } from "zod"; import { reactions, users } from "../db/schema"; +import type { AppEnv } from "../lib/types"; +import { isAnonymous } from "../lib/types"; import { getCurrentSeason, seasonCondition } from "./util"; const bucketExpr = { @@ -36,14 +38,14 @@ function parseSeason(raw: string | undefined): number { return getCurrentSeason(); } -export const analyticsRoute = new Hono<{ Bindings: Env }>() - .use( - "/*", - cache({ - cacheName: "catalyst-analytics", +export const analyticsRoute = new Hono() + .use("/*", async (c, next) => { + const cacheName = `catalyst-analytics-${c.var.sessionMode ?? "full"}`; + return cache({ + cacheName, cacheControl: "public, max-age=300", - }), - ) + })(c, next); + }) .get("/emoji-trends", zValidator("query", trendsQuery), async (c) => { const db = drizzle(c.env.DB); const { @@ -147,7 +149,10 @@ export const analyticsRoute = new Hono<{ Bindings: Env }>() if (!userIds.length) { return c.json({ userIds: [] as string[], - users: {} as Record, + users: {} as Record< + string, + { name: string | null; avatar: string | null } + >, series: [], }); } @@ -174,11 +179,15 @@ export const analyticsRoute = new Hono<{ Bindings: Env }>() .orderBy(bucket), ]); - const userMap: Record = {}; + const anon = isAnonymous(c); + const userMap: Record< + string, + { name: string | null; avatar: string | null } + > = {}; for (const u of userRows) { userMap[u.userId] = { - name: u.displayName || u.userId, - avatar: u.avatarUrl, + name: anon ? null : u.displayName || u.userId, + avatar: anon ? null : u.avatarUrl, }; } diff --git a/packages/worker/src/routes/auth.ts b/packages/worker/src/routes/auth.ts index cafd5d1..18ad364 100644 --- a/packages/worker/src/routes/auth.ts +++ b/packages/worker/src/routes/auth.ts @@ -1,35 +1,53 @@ import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; import z from "zod"; -import { createSessionToken, timeSafeEqual } from "../lib/auth"; +import { + createAnonymousSessionToken, + createSessionToken, + timeSafeEqual, +} from "../lib/auth"; const verifyBody = z.object({ password: z.string(), turnstileToken: z.string(), }); -export const authRoute = new Hono<{ Bindings: Env }>().post( - "/verify", - zValidator("json", verifyBody), - async (c) => { +const anonymousBody = z.object({ + turnstileToken: z.string(), +}); + +async function verifyTurnstile( + secret: string, + token: string, + remoteIp: string | undefined, +): Promise { + const res = await fetch( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + secret, + response: token, + remoteip: remoteIp, + }), + }, + ); + const result = await res.json<{ success: boolean }>(); + return result.success; +} + +export const authRoute = new Hono<{ Bindings: Env }>() + .post("/verify", zValidator("json", verifyBody), async (c) => { const { password, turnstileToken } = c.req.valid("json"); - const turnstileRes = await fetch( - "https://challenges.cloudflare.com/turnstile/v0/siteverify", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - secret: c.env.TURNSTILE_SECRET_KEY, - response: turnstileToken, - remoteip: c.req.header("CF-Connecting-IP"), - }), - }, + const turnstileOk = await verifyTurnstile( + c.env.TURNSTILE_SECRET_KEY, + turnstileToken, + c.req.header("CF-Connecting-IP"), ); - const turnstileResult = await turnstileRes.json<{ success: boolean }>(); - - if (!turnstileResult.success) { + if (!turnstileOk) { c.header("Cache-Control", "no-store"); return c.json({ ok: false, error: "Turnstile verification failed" }, 403); } @@ -44,5 +62,24 @@ export const authRoute = new Hono<{ Bindings: Env }>().post( c.header("Cache-Control", "no-store"); return c.json({ ok: true, token }); - }, -); + }) + .post("/anonymous", zValidator("json", anonymousBody), async (c) => { + const { turnstileToken } = c.req.valid("json"); + + const turnstileOk = await verifyTurnstile( + c.env.TURNSTILE_SECRET_KEY, + turnstileToken, + c.req.header("CF-Connecting-IP"), + ); + + if (!turnstileOk) { + c.header("Cache-Control", "no-store"); + return c.json({ ok: false, error: "Turnstile verification failed" }, 403); + } + + const ttl = Number(c.env.SESSION_TTL_HOURS) || 0; + const token = await createAnonymousSessionToken(c.env.SITE_PASSWORD, ttl); + + c.header("Cache-Control", "no-store"); + return c.json({ ok: true, token }); + }); diff --git a/packages/worker/src/routes/emojis.ts b/packages/worker/src/routes/emojis.ts index 63291ce..4a01dbb 100644 --- a/packages/worker/src/routes/emojis.ts +++ b/packages/worker/src/routes/emojis.ts @@ -3,9 +3,11 @@ import { and, asc, count, desc, eq, sql } from "drizzle-orm"; import { drizzle } from "drizzle-orm/d1"; import { Hono } from "hono"; import { emojiImages, reactions, reactionTotals, users } from "../db/schema"; +import type { AppEnv } from "../lib/types"; +import { isAnonymous } from "../lib/types"; import { limitSeasonQuery, optionalSeasonQuery, seasonCondition } from "./util"; -export const emojisRoute = new Hono<{ Bindings: Env }>() +export const emojisRoute = new Hono() .get("/", async (c) => { const db = drizzle(c.env.DB); @@ -85,21 +87,23 @@ export const emojisRoute = new Hono<{ Bindings: Env }>() .orderBy(desc(count())) .limit(1); + const anon = isAnonymous(c); + return c.json({ totalCount: totalRow?.totalCount ?? 0, firstUsedAt: firstRow?.createdAt ?? null, firstUser: firstRow ? { userId: firstRow.userId, - displayName: firstRow.displayName ?? "", - avatarUrl: firstRow.avatarUrl ?? "", + displayName: anon ? "" : (firstRow.displayName ?? ""), + avatarUrl: anon ? "" : (firstRow.avatarUrl ?? ""), } : null, topUser: topRow ? { userId: topRow.userId, - displayName: topRow.displayName ?? "", - avatarUrl: topRow.avatarUrl ?? "", + displayName: anon ? "" : (topRow.displayName ?? ""), + avatarUrl: anon ? "" : (topRow.avatarUrl ?? ""), count: topRow.count, } : null, @@ -125,5 +129,15 @@ export const emojisRoute = new Hono<{ Bindings: Env }>() .orderBy(desc(count())) .limit(limit); + if (isAnonymous(c)) { + return c.json( + results.map((r) => ({ + ...r, + displayName: null, + avatarUrl: null, + })), + ); + } + return c.json(results); }); diff --git a/packages/worker/src/routes/rankings.ts b/packages/worker/src/routes/rankings.ts index 77a0b03..e2e35c9 100644 --- a/packages/worker/src/routes/rankings.ts +++ b/packages/worker/src/routes/rankings.ts @@ -3,9 +3,11 @@ import { count, desc, eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/d1"; import { Hono } from "hono"; import { reactions, users } from "../db/schema"; +import type { AppEnv } from "../lib/types"; +import { isAnonymous } from "../lib/types"; import { limitSeasonQuery, seasonCondition } from "./util"; -export const rankingsRoute = new Hono<{ Bindings: Env }>() +export const rankingsRoute = new Hono() .get("/emojis", zValidator("query", limitSeasonQuery), async (c) => { const db = drizzle(c.env.DB); const { limit, season } = c.req.valid("query"); @@ -41,5 +43,15 @@ export const rankingsRoute = new Hono<{ Bindings: Env }>() .orderBy(desc(count())) .limit(limit); + if (isAnonymous(c)) { + return c.json( + results.map((r) => ({ + ...r, + displayName: null, + avatarUrl: null, + })), + ); + } + return c.json(results); }); diff --git a/packages/worker/src/routes/users.ts b/packages/worker/src/routes/users.ts index 4910be4..6c65352 100644 --- a/packages/worker/src/routes/users.ts +++ b/packages/worker/src/routes/users.ts @@ -3,9 +3,11 @@ import { and, count, desc, eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/d1"; import { Hono } from "hono"; import { reactions, users } from "../db/schema"; +import type { AppEnv } from "../lib/types"; +import { isAnonymous } from "../lib/types"; import { limitSeasonQuery, seasonCondition } from "./util"; -export const usersRoute = new Hono<{ Bindings: Env }>().get( +export const usersRoute = new Hono().get( "/:userId/emojis", zValidator("query", limitSeasonQuery), async (c) => { @@ -34,9 +36,22 @@ export const usersRoute = new Hono<{ Bindings: Env }>().get( .where(eq(users.userId, userId)), ])) as [ { emoji: string; count: number }[], - { userId: string; displayName: string; avatarUrl: string }[], + { + userId: string; + displayName: string | null; + avatarUrl: string | null; + }[], ]; + if (isAnonymous(c)) { + return c.json({ + user: user + ? { userId: user.userId, displayName: null, avatarUrl: null } + : null, + emojis, + }); + } + return c.json({ user: user ?? null, emojis }); }, );