Release / build-and-push (push) Has been cancelled
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>
97 lines
3.2 KiB
TypeScript
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).*)",
|
|
],
|
|
};
|