Files
famapp/src/middleware.ts
T
ginnoirandClaude Opus 4.7 9612a54e52
Release / build-and-push (push) Has been cancelled
Apply paper-and-ink design system across all surfaces
Replaces the generic shadcn/ui gray theme + horizontal top-bar shell with
the paper-and-ink language from the Claude Design handoff bundle: warm
off-white paper, near-black ink, Source Serif 4 + Inter, hairline borders,
muted ink accents (clay/indigo/sage/plum/ochre) used functionally for
calendars and share scopes.

Theme switcher expanded from 2 dimensions (theme × mode) to 7: palette ×
mode × fontPair × density × dashLayout × calView × navStyle. All exposed
in Settings → Appearance and persisted on the users row. Pre-paint script
applies all four data-* attributes from localStorage so reload doesn't
flash.

App shell restructured to a CSS-grid driven by data-nav on <html>: sidebar
on desktop, bottom-nav + FAB under 760px. Four desktop nav modes wired
(sidebar/rail/top/fab-only). Topbar gets a search-→-CommandPalette button,
notification bell, "+ New" quick-add, avatar.

Dashboard, calendar, lists, notes, settings, login, public share viewer,
and quick-add sheet all reskinned. Dashboard editor gains a Preset menu
(classic/split/glance) that fills the layout from the registered widgets.
FullCalendar wrapped in .fc-skin and inherits all paper-and-ink tokens via
CSS variable overrides. Public share viewer (/s/<token>) rebuilt around
ShareFrame: expiration banner, brand strip, eyebrow chip, 38px serif
title, mini-day + mini-map cards, share-rows.

Schema: drops users.theme; adds theme_palette, theme_font_pair,
theme_density, theme_dash_layout, theme_cal_view, theme_nav_style with
defaults that match the design (clay / serif-sans / regular / classic /
month / rail-desktop). Migration 0014_paper_ink_theme.

Middleware sets x-pathname so the AppShell server component can render
bare for /s/* and /login without a route-group refactor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 01:03:21 -05:00

97 lines
3.2 KiB
TypeScript

import { NextResponse, type NextRequest } from "next/server";
import { consume } from "@/lib/rate-limit";
const PUBLIC_PREFIXES = ["/api/auth/", "/s/"];
const PUBLIC_PATHS = new Set(["/login"]);
const SESSION_COOKIE_NAMES = ["authjs.session-token", "__Secure-authjs.session-token"];
// Token-prefix length used as part of the rate-limit bucket key.
const RL_PREFIX_LEN = 8;
export function middleware(request: NextRequest) {
const start = performance.now();
const response = route(request);
logRequest(request, response.status, Math.round(performance.now() - start));
return response;
}
function withPathname(response: NextResponse, pathname: string): NextResponse {
response.headers.set("x-pathname", pathname);
return response;
}
function route(request: NextRequest): NextResponse {
const { pathname } = request.nextUrl;
// Rate-limit the public share-link viewer (/s/<token>).
// The rate limiter runs in the Edge runtime and has its own module-level bucket
// Map (separate from the Node.js runtime used by page components). All requests
// to /s/<token> are counted here regardless of whether the token resolves; page
// components additionally track per-failure counts via recordFailure() for
// accurate auditing. Switch to Redis for cross-runtime / multi-replica accuracy.
if (pathname.startsWith("/s/") && pathname.length > 3) {
const token = pathname.slice(3); // strip "/s/"
const ip = clientIp(request);
const key = `${ip}:${token.slice(0, RL_PREFIX_LEN)}`;
if (!consume(key)) {
return new NextResponse("Too Many Requests", {
status: 429,
headers: { "Retry-After": "60", "Content-Type": "text/plain" },
});
}
}
if (PUBLIC_PATHS.has(pathname) || PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))) {
return withPathname(NextResponse.next(), pathname);
}
if (hasSessionCookie(request)) return withPathname(NextResponse.next(), pathname);
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", request.url);
return NextResponse.redirect(loginUrl);
}
function clientIp(request: NextRequest): string {
return (
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
request.headers.get("x-real-ip") ??
"0.0.0.0"
);
}
function hasSessionCookie(request: NextRequest) {
return request.cookies
.getAll()
.some((cookie) =>
SESSION_COOKIE_NAMES.some(
(name) => cookie.name === name || cookie.name.startsWith(`${name}.`),
),
);
}
// Structured request log — output as JSON so it's machine-readable in production.
// pino is not available in the Edge runtime; console.log goes to server stdout.
// userId is omitted: decoding the session token requires a DB lookup unavailable here.
function logRequest(request: NextRequest, status: number, ms: number) {
const authenticated = hasSessionCookie(request);
const entry = {
level: "info",
time: Date.now(),
method: request.method,
path: request.nextUrl.pathname,
status,
ms,
authenticated,
};
console.log(JSON.stringify(entry));
}
export const config = {
matcher: [
// Skip Next.js internals and static files
"/((?!_next/static|_next/image|favicon.ico).*)",
],
};