Skip to content
Draft
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
15 changes: 13 additions & 2 deletions app/src/app/(platform)/moderation/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
findUserForModeration,
isModerator,
logModeratorAction,
ReportNotOpenError,
reviewReport,
setPlatformBlock,
} from "@/lib/moderation";
Expand All @@ -22,14 +23,24 @@ async function requireModerator() {
export async function reviewReportAction(reportId: string, formData: FormData): Promise<void> {
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<void> {
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");
}

Expand Down
12 changes: 10 additions & 2 deletions app/src/app/(platform)/rings/[slug]/join/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,6 +21,7 @@ export async function joinRingAction(slug: string): Promise<void> {
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, {
Expand All @@ -28,7 +30,7 @@ export async function joinRingAction(slug: string): Promise<void> {
});
}
} catch (e) {
if (e instanceof WebRingError) redirect(`/explore/ring/${slug}`);
if (e instanceof WebRingError || e instanceof RateLimitError) redirect(`/explore/ring/${slug}`);
throw e;
}

Expand All @@ -44,7 +46,13 @@ export async function leaveRingAction(slug: string): Promise<void> {
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}`);
Expand Down
6 changes: 5 additions & 1 deletion app/src/app/(platform)/vibe/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 } };
Expand Down
8 changes: 8 additions & 0 deletions app/src/app/api/activity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions app/src/app/api/export/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
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();
if (!viewer) {
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, {
Expand Down
12 changes: 12 additions & 0 deletions app/src/app/api/presence/route.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand All @@ -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 });
Expand Down
15 changes: 15 additions & 0 deletions app/src/app/api/status/route.ts
Original file line number Diff line number Diff line change
@@ -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=<id> — returns the current ambient status for a user. */
Expand All @@ -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 });
}
Expand All @@ -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();
Expand Down
14 changes: 14 additions & 0 deletions app/src/app/api/visit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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;
Expand Down
73 changes: 73 additions & 0 deletions app/src/app/nav.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
69 changes: 69 additions & 0 deletions app/src/components/NavDropdown.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div className="nav-dropdown" ref={wrapRef}>
<button
type="button"
className="nav-dropdown-trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={menuId}
onClick={() => setOpen((o) => !o)}
>
{label}
{badgeCount > 0 && (
<span className="nav-badge" aria-label={`${badgeCount} pending`}>
{badgeCount}
</span>
)}
<span className="nav-dropdown-caret" aria-hidden="true">
</span>
</button>
{open && (
<div
id={menuId}
role="menu"
className="nav-dropdown-panel"
onClick={(e) => {
// Activating a link or the logout button inside the menu should
// close it, same as any normal nav click would.
if ((e.target as HTMLElement).closest("a, button")) setOpen(false);
}}
>
{children}
</div>
)}
</div>
);
}
Loading