Files
Marco df05ea5358 Add rate limiting, sliding sessions, email verification, GDPR self-service, order cancellation/returns, and critical-error alerting
Complements Payload's per-account login lockout with per-IP rate limiting
on auth routes; proxy.ts silently refreshes an active customer's session
via Payload's built-in refresh-token endpoint instead of a long-lived
token. Registration now sends a non-blocking email-verification link
(doesn't gate login, since checkout registers and immediately logs in
mid-purchase). /konto/profil gets GDPR export/delete; order detail pages
get self-service cancel/return-request, backed by a Payload hook that
closes a real gap (a customer's JWT could previously PATCH any field of
their own order, not just status). Checkout failures now email an alert
independent of Payload's own health, since Kuma's uptime checks can't see
an order silently failing to persist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 07:28:01 +00:00

72 lines
2.8 KiB
TypeScript

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
// Next.js 16 renamed `middleware.ts` to `proxy.ts` — this file must live
// at the project root (same level as `app/`), not inside `app/`. See
// node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md.
//
// Sliding-session refresh: Payload's customers auth token is valid for
// 7200s (2h, Payload default — see Customers.ts in the Payload repo,
// unchanged). Rather than issuing a long-lived token (harder to reason
// about if one ever leaks) or building a separate refresh-token cookie,
// this silently extends the *existing* token via Payload's own built-in
// refresh-token endpoint whenever it's getting close to expiry and the
// customer is actually still browsing — so an active shopper never gets
// logged out mid-session, but someone who walks away is logged out within
// 2h of their last request, same as before.
const SESSION_COOKIE = "ep_customer_token";
const REFRESH_THRESHOLD_MS = 15 * 60 * 1000; // refresh once < 15min remain
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
function getTokenExpiry(token: string): number | null {
try {
const payloadSegment = token.split(".")[1];
if (!payloadSegment) return null;
// Not verified here — this is only a "should I bother refreshing?"
// heuristic. Payload verifies the token for real on every actual API
// call regardless of what this function decides.
const json = JSON.parse(Buffer.from(payloadSegment, "base64url").toString("utf8"));
return typeof json.exp === "number" ? json.exp * 1000 : null;
} catch {
return null;
}
}
export default async function proxy(request: NextRequest) {
const token = request.cookies.get(SESSION_COOKIE)?.value;
if (!token) return NextResponse.next();
const expiresAt = getTokenExpiry(token);
if (!expiresAt || expiresAt - Date.now() > REFRESH_THRESHOLD_MS) return NextResponse.next();
try {
const res = await fetch(`${PAYLOAD_URL}/api/customers/refresh-token`, {
method: "POST",
headers: { Authorization: `JWT ${token}` },
});
if (!res.ok) return NextResponse.next();
const data: { refreshedToken?: string } = await res.json();
if (!data.refreshedToken) return NextResponse.next();
const response = NextResponse.next();
response.cookies.set(SESSION_COOKIE, data.refreshedToken, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 2,
});
return response;
} catch {
// Refresh failing (Payload briefly unreachable etc.) shouldn't block
// the actual page request — worst case the session just expires
// normally and the customer logs in again.
return NextResponse.next();
}
}
export const config = {
matcher: ["/checkout", "/konto/:path*", "/api/account/:path*"],
};