diff --git a/app/src/app/(platform)/moderation/actions.ts b/app/src/app/(platform)/moderation/actions.ts index 3b5fa8e..1c4799b 100644 --- a/app/src/app/(platform)/moderation/actions.ts +++ b/app/src/app/(platform)/moderation/actions.ts @@ -6,6 +6,7 @@ import { findUserForModeration, isModerator, logModeratorAction, + ReportNotOpenError, reviewReport, setPlatformBlock, } from "@/lib/moderation"; @@ -22,14 +23,24 @@ async function requireModerator() { export async function reviewReportAction(reportId: string, formData: FormData): Promise { const moderator = await requireModerator(); const note = String(formData.get("note") ?? ""); - reviewReport(reportId, moderator.id, "reviewed", note); + try { + reviewReport(reportId, moderator.id, "reviewed", note); + } catch (e) { + // Already resolved by another moderator or an earlier click — nothing + // more to do, and re-throwing would 500 what's really a stale-UI race. + if (!(e instanceof ReportNotOpenError)) throw e; + } revalidatePath("/moderation"); } export async function dismissReportAction(reportId: string, formData: FormData): Promise { const moderator = await requireModerator(); const note = String(formData.get("note") ?? ""); - reviewReport(reportId, moderator.id, "dismissed", note); + try { + reviewReport(reportId, moderator.id, "dismissed", note); + } catch (e) { + if (!(e instanceof ReportNotOpenError)) throw e; + } revalidatePath("/moderation"); } diff --git a/app/src/app/(platform)/rings/[slug]/join/actions.ts b/app/src/app/(platform)/rings/[slug]/join/actions.ts index f493448..4943ee6 100644 --- a/app/src/app/(platform)/rings/[slug]/join/actions.ts +++ b/app/src/app/(platform)/rings/[slug]/join/actions.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { getDb } from "@/lib/db"; import { createNotification } from "@/lib/notifications"; +import { checkRateLimit, RateLimitError, rateLimitActorKey } from "@/lib/rateLimit"; import { getCurrentUser } from "@/lib/session"; import { getWebRingBySlug, @@ -20,6 +21,7 @@ export async function joinRingAction(slug: string): Promise { if (!ring) redirect("/rings"); try { + checkRateLimit(await rateLimitActorKey("ring:join", viewer!.id), 20); const result = joinWebRing(ring.id, viewer!.id); if (result === "requested" && ring.creatorUserId) { createNotification(ring.creatorUserId, "ring_join_request", viewer!.handle, { @@ -28,7 +30,7 @@ export async function joinRingAction(slug: string): Promise { }); } } catch (e) { - if (e instanceof WebRingError) redirect(`/explore/ring/${slug}`); + if (e instanceof WebRingError || e instanceof RateLimitError) redirect(`/explore/ring/${slug}`); throw e; } @@ -44,7 +46,13 @@ export async function leaveRingAction(slug: string): Promise { const ring = getWebRingBySlug(slug); if (!ring) redirect("/rings"); - leaveWebRing(ring!.id, viewer!.id); + try { + checkRateLimit(await rateLimitActorKey("ring:leave", viewer!.id), 20); + leaveWebRing(ring!.id, viewer!.id); + } catch (e) { + if (e instanceof RateLimitError) redirect(`/explore/ring/${slug}`); + throw e; + } revalidatePath(`/explore/ring/${slug}`); revalidatePath("/rings"); redirect(`/explore/ring/${slug}`); diff --git a/app/src/app/(platform)/vibe/page.tsx b/app/src/app/(platform)/vibe/page.tsx index f3503ab..e79b2b6 100644 --- a/app/src/app/(platform)/vibe/page.tsx +++ b/app/src/app/(platform)/vibe/page.tsx @@ -4,6 +4,7 @@ import { getCurrentUser } from "@/lib/session"; import { getDb } from "@/lib/db"; import { getProximityOrdered } from "@/lib/proximityGraph"; import { getAmbientStatuses } from "@/lib/ambientStatus"; +import { hasBlockRelationship } from "@/lib/friends"; import { VibeGraph } from "./VibeGraph"; /** Authenticated page showing the current user's proximity-based Vibe Graph. Displays center node + neighbors in radial layout with ambient statuses. */ @@ -47,11 +48,14 @@ export default async function VibeGraphPage() { ) .all(...neighborIds) as unknown as NodeRow[]; - // Preserve proximity order + // Preserve proximity order. A block in either direction must exclude + // that person from the graph, the same as it excludes them from Wander + // and direct profile visits. const rowMap = new Map(rows.map((r) => [r.user_id, r])); for (const id of neighborIds) { const r = rowMap.get(id); if (!r) continue; + if (hasBlockRelationship(user.id, id)) continue; let displayName = r.handle; try { const doc = JSON.parse(r.document_json) as { identity?: { displayName?: string } }; diff --git a/app/src/app/api/activity/route.ts b/app/src/app/api/activity/route.ts index c078556..d296a95 100644 --- a/app/src/app/api/activity/route.ts +++ b/app/src/app/api/activity/route.ts @@ -4,6 +4,7 @@ import { countUnreadMessages } from "@/lib/messages"; import { countIncomingRequests } from "@/lib/friends"; import { countPendingGuestbookEntries } from "@/lib/guestbook"; import { countUnread } from "@/lib/notifications"; +import { checkRateLimit, RateLimitError, rateLimitActorKey } from "@/lib/rateLimit"; /** Lightweight poll endpoint for unread activity counts. Returns 401 when not signed in. */ export async function GET() { @@ -12,6 +13,13 @@ export async function GET() { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + try { + checkRateLimit(await rateLimitActorKey("activity", viewer.id), 60); + } catch (e) { + if (e instanceof RateLimitError) return NextResponse.json({ error: e.message }, { status: 429 }); + throw e; + } + const unreadMessages = countUnreadMessages(viewer.id); const pendingGuestbook = countIncomingRequests(viewer.id) + countPendingGuestbookEntries(viewer.id); diff --git a/app/src/app/api/export/route.ts b/app/src/app/api/export/route.ts index b7e7ece..49b3f19 100644 --- a/app/src/app/api/export/route.ts +++ b/app/src/app/api/export/route.ts @@ -1,5 +1,6 @@ import { getCurrentUser } from "@/lib/session"; import { exportPageAsHtml } from "@/lib/exportPage"; +import { checkRateLimit, RateLimitError, rateLimitActorKey } from "@/lib/rateLimit"; export async function GET() { const viewer = await getCurrentUser(); @@ -7,6 +8,13 @@ export async function GET() { return new Response("Unauthorized", { status: 401 }); } + try { + checkRateLimit(await rateLimitActorKey("export", viewer.id), 10); + } catch (e) { + if (e instanceof RateLimitError) return new Response(e.message, { status: 429 }); + throw e; + } + try { const html = exportPageAsHtml(viewer.id); return new Response(html, { diff --git a/app/src/app/api/presence/route.ts b/app/src/app/api/presence/route.ts index 165cc66..1db3d92 100644 --- a/app/src/app/api/presence/route.ts +++ b/app/src/app/api/presence/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { heartbeat, getPresenceCount } from "@/lib/presence"; +import { checkRateLimit, RateLimitError, rateLimitActorKey } from "@/lib/rateLimit"; export async function POST(req: NextRequest) { let body: { pageOwnerId?: string; token?: string }; @@ -13,6 +14,17 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "pageOwnerId and token required" }, { status: 400 }); } + // token is entirely client-supplied — without a limit here, distinct + // random tokens from one caller inflate the presence count arbitrarily. + try { + checkRateLimit(await rateLimitActorKey("presence", null), 30); + } catch (e) { + if (e instanceof RateLimitError) { + return NextResponse.json({ error: e.message }, { status: 429 }); + } + throw e; + } + heartbeat(pageOwnerId, token); const count = getPresenceCount(pageOwnerId); return NextResponse.json({ count }); diff --git a/app/src/app/api/status/route.ts b/app/src/app/api/status/route.ts index b5265e3..7f65fa2 100644 --- a/app/src/app/api/status/route.ts +++ b/app/src/app/api/status/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getAmbientStatus, setAmbientStatus, AmbientStatusError } from "@/lib/ambientStatus"; +import { checkRateLimit, RateLimitError, rateLimitActorKey } from "@/lib/rateLimit"; import { getCurrentUser } from "@/lib/session"; /** GET /api/status?userId= — returns the current ambient status for a user. */ @@ -8,6 +9,13 @@ export async function GET(req: NextRequest) { if (!userId || typeof userId !== "string") { return NextResponse.json({ error: "userId required" }, { status: 400 }); } + const viewer = await getCurrentUser(); + try { + checkRateLimit(await rateLimitActorKey("status:read", viewer?.id ?? null), 60); + } catch (e) { + if (e instanceof RateLimitError) return NextResponse.json({ error: e.message }, { status: 429 }); + throw e; + } const status = getAmbientStatus(userId); return NextResponse.json({ status: status?.text ?? null, expiresAt: status?.expiresAt ?? null }); } @@ -17,6 +25,13 @@ export async function POST(req: NextRequest) { const user = await getCurrentUser(); if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + try { + checkRateLimit(await rateLimitActorKey("status:write", user.id), 20); + } catch (e) { + if (e instanceof RateLimitError) return NextResponse.json({ error: e.message }, { status: 429 }); + throw e; + } + let body: unknown; try { body = await req.json(); diff --git a/app/src/app/api/visit/route.ts b/app/src/app/api/visit/route.ts index a28a0c0..a35c6e9 100644 --- a/app/src/app/api/visit/route.ts +++ b/app/src/app/api/visit/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { cookies } from "next/headers"; import { randomUUID } from "node:crypto"; import { recordVisit, countVisits } from "@/lib/pageVisits"; +import { checkRateLimit, RateLimitError, rateLimitActorKey } from "@/lib/rateLimit"; const VISITOR_COOKIE = "iofus_visitor"; @@ -17,6 +18,19 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "pageOwnerId required" }, { status: 400 }); } + // The visitor cookie is how legitimate visits are deduplicated, but a + // client can simply not send one to get a fresh, uncounted visit every + // request — this caps the damage from that instead of trusting the + // cookie as the only guard. + try { + checkRateLimit(await rateLimitActorKey("visit", null), 30); + } catch (e) { + if (e instanceof RateLimitError) { + return NextResponse.json({ error: e.message }, { status: 429 }); + } + throw e; + } + const jar = await cookies(); let visitorToken = jar.get(VISITOR_COOKIE)?.value; let isNew = false; diff --git a/app/src/app/nav.css b/app/src/app/nav.css index cefbabd..02d420e 100644 --- a/app/src/app/nav.css +++ b/app/src/app/nav.css @@ -103,6 +103,79 @@ line-height: 1; } +/* ── Nav dropdowns (Discover / Account) ───────────────────────────── */ + +.nav-dropdown { + position: relative; + display: inline-flex; +} + +.nav-dropdown-trigger { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.nav-dropdown-caret { + font-size: 0.7em; + transform: translateY(0.05em); +} + +.nav-dropdown-panel { + position: absolute; + top: calc(100% + 0.35rem); + left: 0; + z-index: 100; + background: var(--paper); + border: 2px solid var(--ink); + display: flex; + flex-direction: column; + min-width: 10rem; +} + +.controls-right .nav-dropdown-panel { + left: auto; + right: 0; +} + +.nav-dropdown-panel a, +.nav-dropdown-panel button { + display: block; + width: 100%; + padding: 0.6rem 0.85rem; + color: var(--ink); + -webkit-text-stroke: 0; + text-decoration: none; + background: none; + border: none; + border-bottom: 1px solid var(--line); + border-radius: 0; + font: inherit; + font-family: "Bebas Neue", "Impact", sans-serif; + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.04em; + text-align: left; + cursor: pointer; + min-height: 44px; + transition: background 0.1s; +} + +.nav-dropdown-panel a:hover, +.nav-dropdown-panel button:hover { + background: var(--accent-wash); +} + +.nav-dropdown-panel a:last-child, +.nav-dropdown-panel button:last-child, +.nav-dropdown-panel form:last-child button { + border-bottom: none; +} + +.nav-dropdown-panel form { + display: block; +} + /* ── Mobile nav hamburger ─────────────────────────────────────────── */ .nav-mobile-right { diff --git a/app/src/components/NavDropdown.tsx b/app/src/components/NavDropdown.tsx new file mode 100644 index 0000000..873f60f --- /dev/null +++ b/app/src/components/NavDropdown.tsx @@ -0,0 +1,69 @@ +"use client"; +import { useEffect, useId, useRef, useState } from "react"; + +interface Props { + label: string; + children: React.ReactNode; + /** Shown as a badge next to the label, e.g. an aggregate pending-items count. */ + badgeCount?: number; +} + +/** A click-to-open nav menu, closing on outside click, Escape, or activating an item inside it. */ +export function NavDropdown({ label, children, badgeCount = 0 }: Props) { + const [open, setOpen] = useState(false); + const wrapRef = useRef(null); + const menuId = useId(); + + useEffect(() => { + if (!open) return; + function onPointerDown(e: PointerEvent) { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false); + } + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + return ( +
+ + {open && ( + + )} +
+ ); +} diff --git a/app/src/components/SiteNav.tsx b/app/src/components/SiteNav.tsx index c7c781a..4d90a7f 100644 --- a/app/src/components/SiteNav.tsx +++ b/app/src/components/SiteNav.tsx @@ -3,6 +3,7 @@ import { getCurrentUser } from "@/lib/session"; import { isModerator } from "@/lib/moderation"; import { getNavCounts } from "@/lib/navCounts"; import { NavDrawer } from "./NavDrawer"; +import { NavDropdown } from "./NavDropdown"; /** Server component that renders the site navigation bar with pending-activity badges for the signed-in user. */ export async function SiteNav() { @@ -12,43 +13,63 @@ export async function SiteNav() { ? getNavCounts(viewer.id) : { pendingCount: 0, unreadMessages: 0, unreadNotifications: 0 }; - const rightLinks = ( + const notificationsLink = ( + + Notifications + {unreadNotifications > 0 && ( + + {unreadNotifications} + + )} + + ); + const messagesLink = ( + + Messages + {unreadMessages > 0 && ( + + {unreadMessages} + + )} + + ); + const settingsLink = ( + + Settings + {pendingCount > 0 && ( + + {pendingCount} + + )} + + ); + const logOutButton = ( +
+ +
+ ); + + // The mobile drawer collapses everything into one scrollable panel, which + // already solves "too many items" on its own — no need to nest dropdowns + // inside it too, so it keeps the full flat list. + const mobileLinks = ( <> + Explore + Wander + {viewer && Feed} + {viewer && Vibe} + {viewer && Rings} Policy {viewer ? ( <> Ask Us - Rings - - Notifications - {unreadNotifications > 0 && ( - - {unreadNotifications} - - )} - - - Messages - {unreadMessages > 0 && ( - - {unreadMessages} - - )} - + {notificationsLink} + {messagesLink} My Page Studio - - Settings - {pendingCount > 0 && ( - - {pendingCount} - - )} - + {settingsLink} {moderator && Moderation} -
- -
+ {logOutButton} ) : ( Log in @@ -59,23 +80,45 @@ export async function SiteNav() { return (
iofus {/* Desktop right nav */} {/* Mobile hamburger — hidden on desktop */}
- {rightLinks} + {mobileLinks}
diff --git a/app/src/lib/activityFeed.test.ts b/app/src/lib/activityFeed.test.ts new file mode 100644 index 0000000..23a1cfe --- /dev/null +++ b/app/src/lib/activityFeed.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { getFriendActivityFeed } from "./activityFeed"; +import { createUser } from "./auth"; +import { blockUser, sendFriendRequest } from "./friends"; +import { signGuestbook } from "./guestbook"; +import { defaultPageDocument, savePageDocument, setPublished, setVisibility } from "./pageDocument"; +import { resetDbForTests } from "./db"; + +process.env.IOFUS_DB_PATH = ":memory:"; + +beforeEach(() => { + resetDbForTests(); +}); + +function befriend(aId: string, bId: string): void { + sendFriendRequest(aId, bId); + sendFriendRequest(bId, aId); // auto-accepts, matching how the UI drives it +} + +function publish(userId: string, displayName: string): void { + savePageDocument(userId, defaultPageDocument(displayName)); + setPublished(userId, true); +} + +describe("getFriendActivityFeed", () => { + it("does not leak a friend's page-decorated activity once they go private", () => { + const viewer = createUser("voidarcade", "correct-horse-battery"); + const friend = createUser("neonorchard", "correct-horse-battery"); + publish(viewer.id, "Void Arcade"); + publish(friend.id, "Neon Orchard"); + befriend(viewer.id, friend.id); + setVisibility(friend.id, "private"); + + const items = getFriendActivityFeed(viewer.id); + expect(items.some((i) => i.actorHandle === "neonorchard")).toBe(false); + }); + + it("does not leak a blocked friend's activity", () => { + const viewer = createUser("voidarcade", "correct-horse-battery"); + const friend = createUser("neonorchard", "correct-horse-battery"); + publish(viewer.id, "Void Arcade"); + publish(friend.id, "Neon Orchard"); + befriend(viewer.id, friend.id); + blockUser(viewer.id, friend.id); + + const items = getFriendActivityFeed(viewer.id); + expect(items.some((i) => i.actorHandle === "neonorchard")).toBe(false); + }); + + it("does not leak a private target's identity through a friend's guestbook activity", () => { + const viewer = createUser("voidarcade", "correct-horse-battery"); + const friend = createUser("neonorchard", "correct-horse-battery"); + const target = createUser("privateuser", "correct-horse-battery"); + publish(viewer.id, "Void Arcade"); + publish(friend.id, "Neon Orchard"); + publish(target.id, "Private User"); + setVisibility(target.id, "private"); + befriend(viewer.id, friend.id); + + signGuestbook(target.id, friend.id, "neonorchard", "hi!", false); + + const items = getFriendActivityFeed(viewer.id); + expect(items.some((i) => i.kind === "guestbook_signed" && i.targetHandle === "privateuser")).toBe(false); + }); + + it("does not leak a guestbook target's identity when the viewer has blocked them", () => { + const viewer = createUser("voidarcade", "correct-horse-battery"); + const friend = createUser("neonorchard", "correct-horse-battery"); + const target = createUser("blockeduser", "correct-horse-battery"); + publish(viewer.id, "Void Arcade"); + publish(friend.id, "Neon Orchard"); + publish(target.id, "Blocked User"); + setVisibility(target.id, "public"); + befriend(viewer.id, friend.id); + blockUser(viewer.id, target.id); + + signGuestbook(target.id, friend.id, "neonorchard", "hi!", false); + + const items = getFriendActivityFeed(viewer.id); + expect(items.some((i) => i.kind === "guestbook_signed" && i.targetHandle === "blockeduser")).toBe(false); + }); + + it("still shows ordinary public activity between friends", () => { + const viewer = createUser("voidarcade", "correct-horse-battery"); + const friend = createUser("neonorchard", "correct-horse-battery"); + publish(viewer.id, "Void Arcade"); + publish(friend.id, "Neon Orchard"); + setVisibility(friend.id, "public"); + befriend(viewer.id, friend.id); + + const items = getFriendActivityFeed(viewer.id); + expect(items.some((i) => i.actorHandle === "neonorchard" && i.kind === "page_decorated")).toBe(true); + }); +}); diff --git a/app/src/lib/activityFeed.ts b/app/src/lib/activityFeed.ts index fa05811..0518a4c 100644 --- a/app/src/lib/activityFeed.ts +++ b/app/src/lib/activityFeed.ts @@ -1,5 +1,5 @@ import { getDb } from "./db"; -import { listFriends } from "./friends"; +import { hasBlockRelationship, listFriends } from "./friends"; import { migrateDocument } from "./pageDocument"; export interface FeedItem { @@ -18,12 +18,24 @@ interface PageDocRow { document_json: string; updated_at: string; is_published: number; + visibility: string; + hidden_from_discovery: number; } interface GuestbookRow { author_handle: string; page_owner_handle: string; + page_owner_id: string; created_at: string; + target_visibility: string; + target_hidden: number; +} + +/** Whether a page at *visibility* is visible to someone other than its owner — same threshold as listPublicFriends. */ +function isVisibleToOthers(visibility: string, hiddenFromDiscovery: boolean): boolean { + if (visibility === "private") return false; + if (visibility === "public" && hiddenFromDiscovery) return false; + return true; } export function getFriendActivityFeed(viewerId: string, limit = 40): FeedItem[] { @@ -35,27 +47,47 @@ export function getFriendActivityFeed(viewerId: string, limit = 40): FeedItem[] const db = getDb(); - // Load page_documents for all friends in one query - const pageRows = db + // Load page_documents for all friends in one query. A friend's private + // page, or a friend the viewer has blocked (or who has blocked the + // viewer), must not surface here even though the two are friends — + // canViewPage() would already reject visiting that page directly. + const allPageRows = db .prepare( - `SELECT pd.user_id, u.handle, pd.document_json, pd.updated_at, pd.is_published + `SELECT pd.user_id, u.handle, pd.document_json, pd.updated_at, pd.is_published, + pd.visibility, pd.hidden_from_discovery FROM page_documents pd JOIN users u ON u.id = pd.user_id WHERE pd.user_id IN (${placeholders}) AND pd.is_published = 1`, ) .all(...friendIds) as unknown as PageDocRow[]; - - // Load guestbook entries authored by friends (approved only) - const guestbookRows = db + const pageRows = allPageRows.filter( + (row) => + isVisibleToOthers(row.visibility, !!row.hidden_from_discovery) && + !hasBlockRelationship(viewerId, row.user_id), + ); + + // Load guestbook entries authored by friends (approved only) — but only + // when the *target* page is one the viewer could actually see, and + // there's no block between the viewer and the target page's owner. + // Otherwise this leaks a private/hidden/blocked-from user's handle and + // existence to the viewer purely through a friend's guestbook activity. + const allGuestbookRows = db .prepare( - `SELECT ge.author_handle, u2.handle as page_owner_handle, ge.created_at + `SELECT ge.author_handle, u2.handle as page_owner_handle, ge.page_owner_id as page_owner_id, + ge.created_at, pd2.visibility as target_visibility, pd2.hidden_from_discovery as target_hidden FROM guestbook_entries ge JOIN users u2 ON u2.id = ge.page_owner_id - WHERE ge.author_id IN (${placeholders}) AND ge.status = 'approved' + JOIN page_documents pd2 ON pd2.user_id = ge.page_owner_id + WHERE ge.author_id IN (${placeholders}) AND ge.status = 'approved' AND pd2.is_published = 1 ORDER BY ge.created_at DESC LIMIT 200`, ) .all(...friendIds) as unknown as GuestbookRow[]; + const guestbookRows = allGuestbookRows.filter( + (row) => + isVisibleToOthers(row.target_visibility, !!row.target_hidden) && + !hasBlockRelationship(viewerId, row.page_owner_id), + ); const items: FeedItem[] = []; diff --git a/app/src/lib/collections.test.ts b/app/src/lib/collections.test.ts new file mode 100644 index 0000000..1e3a359 --- /dev/null +++ b/app/src/lib/collections.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { randomUUID } from "node:crypto"; +import { listCollectionPages } from "./collections"; +import { createUser } from "./auth"; +import { defaultPageDocument, savePageDocument, setHiddenFromDiscovery, setPublished, setVisibility } from "./pageDocument"; +import { getDb, resetDbForTests } from "./db"; +import { setPlatformBlock, ensureModeratorSeed } from "./moderation"; + +process.env.IOFUS_DB_PATH = ":memory:"; +process.env.IOFUS_AUTO_MODERATOR_SEED = "true"; + +beforeEach(() => { + resetDbForTests(); +}); + +function makeCollection(): string { + const id = randomUUID(); + getDb() + .prepare("INSERT INTO collections (id, slug, title, description, created_at) VALUES (?, ?, ?, ?, ?)") + .run(id, "test-collection", "Test Collection", "", new Date().toISOString()); + return id; +} + +function addToCollection(collectionId: string, userId: string): void { + getDb() + .prepare("INSERT INTO collection_pages (collection_id, user_id, position, added_at) VALUES (?, ?, 0, ?)") + .run(collectionId, userId, new Date().toISOString()); +} + +describe("listCollectionPages", () => { + it("includes a published public page", () => { + const collectionId = makeCollection(); + const user = createUser("voidarcade", "correct-horse-battery"); + savePageDocument(user.id, defaultPageDocument("Void Arcade")); + setPublished(user.id, true); + setVisibility(user.id, "public"); + addToCollection(collectionId, user.id); + + expect(listCollectionPages(collectionId).map((p) => p.handle)).toContain("voidarcade"); + }); + + it("excludes a page the owner has hidden from discovery", () => { + const collectionId = makeCollection(); + const user = createUser("voidarcade", "correct-horse-battery"); + savePageDocument(user.id, defaultPageDocument("Void Arcade")); + setPublished(user.id, true); + setVisibility(user.id, "public"); + setHiddenFromDiscovery(user.id, true); + addToCollection(collectionId, user.id); + + expect(listCollectionPages(collectionId).map((p) => p.handle)).not.toContain("voidarcade"); + }); + + it("excludes a page whose owner has been platform-blocked by a moderator", () => { + const mod = createUser("moduser", "correct-horse-battery"); + const collectionId = makeCollection(); + const user = createUser("voidarcade", "correct-horse-battery"); + savePageDocument(user.id, defaultPageDocument("Void Arcade")); + setPublished(user.id, true); + setVisibility(user.id, "public"); + addToCollection(collectionId, user.id); + ensureModeratorSeed(); + setPlatformBlock(user.id, true, mod.id); + + expect(listCollectionPages(collectionId).map((p) => p.handle)).not.toContain("voidarcade"); + }); +}); diff --git a/app/src/lib/collections.ts b/app/src/lib/collections.ts index 527d560..9564400 100644 --- a/app/src/lib/collections.ts +++ b/app/src/lib/collections.ts @@ -39,6 +39,7 @@ export function listCollectionPages(collectionId: string): CollectionPage[] { JOIN page_documents pd ON pd.user_id = cp.user_id WHERE cp.collection_id = ? AND pd.is_published = 1 AND pd.visibility = 'public' + AND pd.hidden_from_discovery = 0 AND u.is_blocked_platform = 0 ORDER BY cp.position ASC`, ) .all(collectionId) as { handle: string; document_json: string; position: number }[]; diff --git a/app/src/lib/friends.ts b/app/src/lib/friends.ts index 446ce50..e0143ab 100644 --- a/app/src/lib/friends.ts +++ b/app/src/lib/friends.ts @@ -39,44 +39,64 @@ export function sendFriendRequest(requesterId: string, addresseeId: string): voi } const db = getDb(); - if (isBlocked(db, requesterId, addresseeId)) { - // Deliberately vague: don't reveal whether the block is theirs or - // ours, or that a block exists at all — just that this isn't - // possible, same principle as not leaking account existence. - throw new FriendRequestError("This friend request can't be sent."); - } + // The existing-link check and the insert/accept must be atomic: two + // concurrent requests in opposite directions (A→B and B→A) can otherwise + // both see "no existing link" and both insert a separate pending row, + // instead of the second one auto-accepting the first into a single + // friendship — a UNIQUE constraint on (requester_id, addressee_id) + // doesn't catch this since the two rows differ by direction. + db.exec("BEGIN IMMEDIATE"); + try { + if (isBlocked(db, requesterId, addresseeId)) { + db.exec("ROLLBACK"); + // Deliberately vague: don't reveal whether the block is theirs or + // ours, or that a block exists at all — just that this isn't + // possible, same principle as not leaking account existence. + throw new FriendRequestError("This friend request can't be sent."); + } - const existing = db - .prepare( - `SELECT id, status, requester_id FROM friend_links - WHERE (requester_id = ? AND addressee_id = ?) OR (requester_id = ? AND addressee_id = ?)`, - ) - .get(requesterId, addresseeId, addresseeId, requesterId) as - | { id: string; status: string; requester_id: string } - | undefined; + const existing = db + .prepare( + `SELECT id, status, requester_id FROM friend_links + WHERE (requester_id = ? AND addressee_id = ?) OR (requester_id = ? AND addressee_id = ?)`, + ) + .get(requesterId, addresseeId, addresseeId, requesterId) as + | { id: string; status: string; requester_id: string } + | undefined; - if (existing) { - if (existing.status === "accepted") { - throw new FriendRequestError("You're already friends."); + if (existing) { + if (existing.status === "accepted") { + db.exec("ROLLBACK"); + throw new FriendRequestError("You're already friends."); + } + if (existing.requester_id === requesterId) { + db.exec("ROLLBACK"); + throw new FriendRequestError("You've already sent a friend request — it's waiting for them to respond."); + } + // They already requested us — accept it instead of creating a duplicate. + acceptFriendRequestLocked(db, requesterId, existing.id); + db.exec("COMMIT"); + return; } - if (existing.requester_id === requesterId) { - throw new FriendRequestError("You've already sent a friend request — it's waiting for them to respond."); + + db.prepare( + `INSERT INTO friend_links (id, requester_id, addressee_id, status, created_at) VALUES (?, ?, ?, 'pending', ?)`, + ).run(randomUUID(), requesterId, addresseeId, new Date().toISOString()); + db.exec("COMMIT"); + } catch (err) { + try { + db.exec("ROLLBACK"); + } catch { + /* already resolved */ } - // They already requested us — accept it instead of creating a duplicate. - acceptFriendRequest(requesterId, existing.id); - return; + throw err; } - - db.prepare( - `INSERT INTO friend_links (id, requester_id, addressee_id, status, created_at) VALUES (?, ?, ?, 'pending', ?)`, - ).run(randomUUID(), requesterId, addresseeId, new Date().toISOString()); } export class FriendLinkNotFoundError extends Error {} -/** Accept the pending friend request *requestId* on behalf of *currentUserId*. Idempotent if already accepted. Throws when the request doesn't exist or *currentUserId* is not the addressee. */ -export function acceptFriendRequest(currentUserId: string, requestId: string): void { - const db = getDb(); +/** Shared implementation for acceptFriendRequest, usable inside a caller-managed transaction (no getDb()/BEGIN of its own). */ +function acceptFriendRequestLocked(db: ReturnType, currentUserId: string, requestId: string): void { const row = db .prepare("SELECT id, addressee_id, status FROM friend_links WHERE id = ?") .get(requestId) as { id: string; addressee_id: string; status: string } | undefined; @@ -93,6 +113,11 @@ export function acceptFriendRequest(currentUserId: string, requestId: string): v ); } +/** Accept the pending friend request *requestId* on behalf of *currentUserId*. Idempotent if already accepted. Throws when the request doesn't exist or *currentUserId* is not the addressee. */ +export function acceptFriendRequest(currentUserId: string, requestId: string): void { + acceptFriendRequestLocked(getDb(), currentUserId, requestId); +} + /** Declining a pending request or unfriending an accepted one both just remove the link — either side can re-request later unless blocked. */ export function removeFriendLink(currentUserId: string, requestId: string): void { const db = getDb(); diff --git a/app/src/lib/handleParam.ts b/app/src/lib/handleParam.ts index ee5737f..2b1229e 100644 --- a/app/src/lib/handleParam.ts +++ b/app/src/lib/handleParam.ts @@ -17,5 +17,11 @@ export function parseHandleParam(rawParam: string): string | null { return null; // malformed percent-encoding (e.g. a lone "%") } if (!decoded.startsWith("@")) return null; - return decoded.slice(1); + const handle = decoded.slice(1); + // A percent-encoded slash (e.g. "%40foo%2Fbar") still matches the single + // [handle] dynamic segment and decodes to a value containing "/" — + // reject anything that isn't shaped like a real handle rather than + // passing it through to callers that assume a single path segment. + if (!/^[a-zA-Z0-9_-]+$/.test(handle)) return null; + return handle; } diff --git a/app/src/lib/messages.ts b/app/src/lib/messages.ts index bdc4c9e..ff8ab83 100644 --- a/app/src/lib/messages.ts +++ b/app/src/lib/messages.ts @@ -99,9 +99,16 @@ export function sendMessage(senderId: string, recipientId: string, body: string) db.exec("BEGIN IMMEDIATE"); try { + // Re-read under the write lock rather than trusting the pre-transaction + // `existing` snapshot: two concurrent first messages between the same + // pair can both see "no conversation" before either transaction opens, + // and whichever commits second would otherwise hit the UNIQUE + // constraint on (user_a_id, user_b_id) and fail outright instead of + // just appending to the thread the other request just created. + const existingLocked = findConversationRow(db, userAId, userBId); let conversationId: string; - if (existing) { - conversationId = existing.id; + if (existingLocked) { + conversationId = existingLocked.id; db.prepare("UPDATE conversations SET last_message_at = ? WHERE id = ?").run(now, conversationId); } else { conversationId = randomUUID(); diff --git a/app/src/lib/moderation.test.ts b/app/src/lib/moderation.test.ts index b686ab3..13f7ef6 100644 --- a/app/src/lib/moderation.test.ts +++ b/app/src/lib/moderation.test.ts @@ -7,6 +7,7 @@ import { isModerator, listModeratorLogs, listOpenReports, + ReportNotOpenError, reviewReport, setPlatformBlock, } from "./moderation"; @@ -86,6 +87,29 @@ describe("report queue", () => { const logs = listModeratorLogs(); expect(logs.some((l) => l.action === "report_reviewed" && l.targetHandle === "neonorchard")).toBe(true); }); + + it("a second review of the same report is rejected, not silently overwritten", () => { + const modA = createUser("moda", "correct-horse-battery"); + const modB = createUser("modb", "correct-horse-battery"); + const reporter = createUser("voidarcade", "correct-horse-battery"); + createUser("neonorchard", "correct-horse-battery"); + ensureModeratorSeed(); + fileReport(reporter.id, "neonorchard", "harassment"); + + const [report] = listOpenReports(); + reviewReport(report!.id, modA.id, "dismissed", "not actionable"); + + expect(() => reviewReport(report!.id, modB.id, "reviewed", "actually escalating")).toThrow( + ReportNotOpenError, + ); + + // The first moderator's outcome must stand — not overwritten by the + // second, contradictory call. + const logs = listModeratorLogs(); + const reportLogs = logs.filter((l) => l.targetHandle === "neonorchard"); + expect(reportLogs).toHaveLength(1); + expect(reportLogs[0]!.action).toBe("report_dismissed"); + }); }); describe("platform block", () => { diff --git a/app/src/lib/moderation.ts b/app/src/lib/moderation.ts index c6df88a..16312bd 100644 --- a/app/src/lib/moderation.ts +++ b/app/src/lib/moderation.ts @@ -29,6 +29,8 @@ export function ensureModeratorSeed(): void { db.prepare("UPDATE users SET is_moderator = 1 WHERE id = ?").run(first.id); } +export class ReportNotOpenError extends Error {} + export function reviewReport( reportId: string, moderatorId: string, @@ -37,9 +39,17 @@ export function reviewReport( ): void { const db = getDb(); const now = new Date().toISOString(); - db.prepare( - "UPDATE reports SET status = ?, moderator_id = ?, moderator_note = ?, reviewed_at = ? WHERE id = ?", - ).run(status, moderatorId, note.trim() || null, now, reportId); + // Only an *open* report may transition — without this, two moderators + // (or two tabs) reviewing the same report can each overwrite the other's + // outcome and both get logged as contradictory moderator actions. + const result = db + .prepare( + "UPDATE reports SET status = ?, moderator_id = ?, moderator_note = ?, reviewed_at = ? WHERE id = ? AND status = 'open'", + ) + .run(status, moderatorId, note.trim() || null, now, reportId); + if (result.changes === 0) { + throw new ReportNotOpenError("This report has already been reviewed."); + } const report = db.prepare("SELECT reported_handle FROM reports WHERE id = ?").get(reportId) as | { reported_handle: string } diff --git a/app/src/lib/proximityGraph.ts b/app/src/lib/proximityGraph.ts index 52d78ac..b2eed4b 100644 --- a/app/src/lib/proximityGraph.ts +++ b/app/src/lib/proximityGraph.ts @@ -97,9 +97,22 @@ export function getProximityOrdered(startUserId: string, limit = 30): string[] { export function getWanderBatch(startUserId: string | null, limit = 30): string[] { const db = getDb(); + // A block in either direction must close this discovery path too, same + // as a direct profile visit would — otherwise Wander is a way around a + // block instead of respecting it. + const blockClause = startUserId + ? `AND NOT EXISTS ( + SELECT 1 FROM blocks b + WHERE (b.blocker_id = ? AND b.blocked_id = pd.user_id) + OR (b.blocker_id = pd.user_id AND b.blocked_id = ?) + )` + : ""; + const blockParams = startUserId ? [startUserId, startUserId] : []; + const discoverableWhere = ` pd.is_published = 1 AND pd.visibility = 'public' AND pd.hidden_from_discovery = 0 AND u.is_blocked_platform = 0 + ${blockClause} `; if (startUserId) { @@ -114,7 +127,7 @@ export function getWanderBatch(startUserId: string | null, limit = 30): string[] JOIN users u ON u.id = pd.user_id WHERE pd.user_id IN (${placeholders}) AND ${discoverableWhere}`, ) - .all(...proximityIds) as { user_id: string; handle: string }[]; + .all(...proximityIds, ...blockParams) as { user_id: string; handle: string }[]; // Preserve proximity order from the graph const idToHandle = new Map(rows.map((r) => [r.user_id, r.handle])); @@ -135,7 +148,7 @@ export function getWanderBatch(startUserId: string | null, limit = 30): string[] ORDER BY RANDOM() LIMIT ?`, ) - .all(...selectedUserIds, remaining) as { handle: string }[]; + .all(...blockParams, ...selectedUserIds, remaining) as { handle: string }[]; return ordered.concat(random.map((r) => r.handle)); } } @@ -150,6 +163,6 @@ export function getWanderBatch(startUserId: string | null, limit = 30): string[] ORDER BY RANDOM() LIMIT ?`, ) - .all(limit) as { handle: string }[]; + .all(...blockParams, limit) as { handle: string }[]; return rows.map((r) => r.handle); } diff --git a/app/src/lib/sharedThemes.test.ts b/app/src/lib/sharedThemes.test.ts index 699ffdb..46298f5 100644 --- a/app/src/lib/sharedThemes.test.ts +++ b/app/src/lib/sharedThemes.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { ensureSeedSharedThemes, listSharedThemes, publishTheme } from "./sharedThemes"; +import { ensureSeedSharedThemes, forkTheme, getSharedTheme, listSharedThemes, publishTheme } from "./sharedThemes"; import { createUser } from "./auth"; import { defaultPageDocument, savePageDocument } from "./pageDocument"; import { resetDbForTests } from "./db"; @@ -25,6 +25,27 @@ describe("sharedThemes", () => { expect(themes.some((t) => t.id === id && t.creatorHandle === "voidarcade")).toBe(true); }); + it("forking a theme records provenance on the new gallery entry", () => { + const creator = createUser("voidarcade", "correct-horse-battery"); + savePageDocument(creator.id, defaultPageDocument("Void Arcade")); + const sourceId = publishTheme( + creator.id, + creator.handle, + "My Look", + "A cozy corner", + ["soft"], + defaultPageDocument("Void").theme, + ); + + const forker = createUser("neonorchard", "correct-horse-battery"); + savePageDocument(forker.id, defaultPageDocument("Neon Orchard")); + const forkId = forkTheme(forker.id, forker.handle, sourceId); + + const forked = getSharedTheme(forkId); + expect(forked?.forkedFromId).toBe(sourceId); + expect(forked?.attributionHandle).toBe("voidarcade"); + }); + it("ensureSeedSharedThemes is idempotent", () => { ensureSeedSharedThemes(); const first = listSharedThemes().length; diff --git a/app/src/lib/sharedThemes.ts b/app/src/lib/sharedThemes.ts index 0332fa2..9f575c5 100644 --- a/app/src/lib/sharedThemes.ts +++ b/app/src/lib/sharedThemes.ts @@ -107,14 +107,17 @@ export function publishTheme( description: string, tags: string[], theme: PageDocument["theme"], + forkedFromId: string | null = null, + attributionHandle: string | null = null, ): string { const db = getDb(); const id = randomUUID(); const now = new Date().toISOString(); db.prepare( - `INSERT INTO shared_themes (id, creator_user_id, name, description, tags_json, version, theme_json, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)`, - ).run(id, userId, name, description, JSON.stringify(tags), JSON.stringify(theme), now, now); + `INSERT INTO shared_themes + (id, creator_user_id, name, description, tags_json, version, theme_json, forked_from_id, attribution_handle, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)`, + ).run(id, userId, name, description, JSON.stringify(tags), JSON.stringify(theme), forkedFromId, attributionHandle, now, now); db.prepare( "INSERT INTO theme_versions (id, theme_id, version, theme_json, created_at) VALUES (?, ?, 1, ?, ?)", ).run(randomUUID(), id, JSON.stringify(theme), now); @@ -132,7 +135,16 @@ export function forkTheme(userId: string, handle: string, sourceId: string): str credit: `Theme forked from @${source.creatorHandle} — ${source.name}`, }, }; - return publishTheme(userId, handle, `${source.name} (fork)`, `Forked from ${source.name}`, source.tags, theme); + return publishTheme( + userId, + handle, + `${source.name} (fork)`, + `Forked from ${source.name}`, + source.tags, + theme, + source.id, + source.creatorHandle, + ); } export function installThemeOnDocument(document: PageDocument, theme: SharedTheme): PageDocument { diff --git a/app/src/lib/stamps.ts b/app/src/lib/stamps.ts index c3a09ef..87a78e4 100644 --- a/app/src/lib/stamps.ts +++ b/app/src/lib/stamps.ts @@ -24,17 +24,35 @@ export function addStamp( const db = getDb(); const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - const recent = db - .prepare( - "SELECT id FROM page_stamps WHERE page_owner_id = ? AND stamper_id = ? AND created_at >= ?", - ) - .get(pageOwnerId, stamperId, since); - if (recent) throw new StampError("You already stamped this page today."); - db.prepare( - `INSERT INTO page_stamps (id, page_owner_id, stamper_id, stamper_handle, stamp_emoji, created_at) - VALUES (?, ?, ?, ?, ?, ?)`, - ).run(randomUUID(), pageOwnerId, stamperId, stamperHandle, emoji, new Date().toISOString()); + // The "already stamped today" check and the insert must be atomic — two + // concurrent requests from the same stamper can otherwise both pass the + // check before either commits, bypassing the one-per-day limit. + db.exec("BEGIN IMMEDIATE"); + try { + const recent = db + .prepare( + "SELECT id FROM page_stamps WHERE page_owner_id = ? AND stamper_id = ? AND created_at >= ?", + ) + .get(pageOwnerId, stamperId, since); + if (recent) { + db.exec("ROLLBACK"); + throw new StampError("You already stamped this page today."); + } + + db.prepare( + `INSERT INTO page_stamps (id, page_owner_id, stamper_id, stamper_handle, stamp_emoji, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run(randomUUID(), pageOwnerId, stamperId, stamperHandle, emoji, new Date().toISOString()); + db.exec("COMMIT"); + } catch (err) { + try { + db.exec("ROLLBACK"); + } catch { + /* already resolved */ + } + throw err; + } } /** Recent stamps on *pageOwnerId*'s page, newest first, up to *limit*. */