SEO - New Settings > SEO admin page (seo_settings in app_config): indexing switch, Google/Bing verification, X handle, JSON-LD identity (Person/Organization, sameAs), per-locale keywords, readiness checklist and open links for sitemap.xml / robots.txt / manifest. - robots.txt is now dynamic: disallows admin, api, success and coming-soon paths; blocks everything while indexing is off or maintenance is on. - sitemap.xml carries hreflang alternates per URL, lists only categories with published projects, and is empty while hidden. - Metadata: robots + verification meta, og:locale in de_DE/en_US/ar_AR form, alternateLocale, twitter site/creator, project cover as OG image with article type, noindex on /success and /coming-soon. - JSON-LD: WebSite + publisher graph on all public pages, CreativeWork per project (view-mode independent). Security - Maintenance bypass now requires a correctly signed admin cookie; the middleware previously only checked the cookie existed. Token helpers moved to lib/admin-session-token.ts (shared by proxy.ts and lib/admin-auth.ts). - Media uploads: magic-byte validation against the declared type, SVG sanitization (script/handlers/foreignObject/javascript: rejected), upload folder sanitized, kind inferred from the real file. - Media route: fixed prefix-based path check that accepted sibling directories, unknown extensions return 404, nosniff header, CSP sandbox on SVG, gif content type added. - External media URLs: protocol-relative (//host) URLs rejected. Portfolio - Project and category slugs share /portfolio/[slug]; saving now rejects a slug already used on the other side instead of silently shadowing it. Tooling/docs - Lint: ignore scripts/legacy-prisma-seed.cjs, drop unused import. - New docs/SEO.md; FEATURES, ARCHITECTURE (Drizzle instead of Prisma), admin spec and CLAUDE.md updated. - Tests for all of the above (unit + integration); suite green.
314 lines
8.6 KiB
TypeScript
314 lines
8.6 KiB
TypeScript
import { createHash, timingSafeEqual } from "crypto";
|
|
import { cookies, headers } from "next/headers";
|
|
import { redirect } from "next/navigation";
|
|
|
|
import { and, eq, like, lt } from "drizzle-orm";
|
|
|
|
import { db } from "./db";
|
|
import { appConfig } from "./db/schema";
|
|
import { getAdminAppPath } from "./admin-routing";
|
|
import {
|
|
ADMIN_SESSION_COOKIE as SHARED_ADMIN_SESSION_COOKIE,
|
|
buildAdminSessionToken,
|
|
verifyAdminSessionToken,
|
|
} from "./admin-session-token";
|
|
|
|
export const ADMIN_SESSION_COOKIE = SHARED_ADMIN_SESSION_COOKIE;
|
|
const MAX_FAILED_ATTEMPTS = 5;
|
|
const LOCKOUT_SECONDS = 15 * 60;
|
|
const ADMIN_LOCKOUT_KEY_PREFIX = "admin_lockout";
|
|
|
|
function getSecret(): string {
|
|
return process.env.ADMIN_AUTH_SECRET ?? "";
|
|
}
|
|
|
|
function getPassword(): string {
|
|
return process.env.ADMIN_PASSWORD ?? "";
|
|
}
|
|
|
|
function parseHostname(value: string | undefined): string | undefined {
|
|
if (!value) {
|
|
return undefined;
|
|
}
|
|
|
|
const trimmed = value.trim().toLowerCase();
|
|
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
return new URL(trimmed).hostname.toLowerCase();
|
|
} catch {
|
|
return trimmed.replace(/^https?:\/\//, "").split("/")[0]?.replace(/:\d+$/, "") || undefined;
|
|
}
|
|
}
|
|
|
|
function isCookieDomainCandidate(hostname: string | undefined): hostname is string {
|
|
return Boolean(
|
|
hostname &&
|
|
hostname !== "localhost" &&
|
|
!hostname.endsWith(".localhost") &&
|
|
!/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) &&
|
|
!hostname.includes(":"),
|
|
);
|
|
}
|
|
|
|
function getSharedCookieHostname(hostnames: string[]): string | undefined {
|
|
const validHostnames = hostnames.filter(isCookieDomainCandidate);
|
|
|
|
if (validHostnames.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
if (validHostnames.length === 1) {
|
|
return validHostnames[0];
|
|
}
|
|
|
|
const reversedSegments = validHostnames.map((hostname) => hostname.split(".").reverse());
|
|
const sharedSegments: string[] = [];
|
|
|
|
for (let index = 0; index < reversedSegments[0].length; index += 1) {
|
|
const segment = reversedSegments[0][index];
|
|
|
|
if (!segment || reversedSegments.some((parts) => parts[index] !== segment)) {
|
|
break;
|
|
}
|
|
|
|
sharedSegments.push(segment);
|
|
}
|
|
|
|
if (sharedSegments.length < 2) {
|
|
return validHostnames[0];
|
|
}
|
|
|
|
return sharedSegments.reverse().join(".");
|
|
}
|
|
|
|
function getAdminCookieDomain(): string | undefined {
|
|
const hostname = getSharedCookieHostname([
|
|
parseHostname(process.env.NEXT_PUBLIC_SITE_URL),
|
|
parseHostname(process.env.NEXT_PUBLIC_ADMIN_URL),
|
|
parseHostname(process.env.ADMIN_HOST),
|
|
].filter((value): value is string => Boolean(value)));
|
|
|
|
return hostname ? `.${hostname}` : undefined;
|
|
}
|
|
|
|
function buildToken(): string {
|
|
return buildAdminSessionToken();
|
|
}
|
|
|
|
function verifyToken(token: string): boolean {
|
|
return verifyAdminSessionToken(token);
|
|
}
|
|
|
|
async function getClientIp(): Promise<string> {
|
|
const requestHeaders = await headers();
|
|
const forwardedFor = requestHeaders.get("x-forwarded-for");
|
|
|
|
if (forwardedFor) {
|
|
return forwardedFor.split(",")[0]?.trim() ?? "unknown";
|
|
}
|
|
|
|
return requestHeaders.get("x-real-ip")?.trim() ?? "unknown";
|
|
}
|
|
|
|
function getLockoutKey(ip: string): string {
|
|
const hash = createHash("sha256").update(ip).digest("hex").slice(0, 16);
|
|
return `${ADMIN_LOCKOUT_KEY_PREFIX}:${hash}`;
|
|
}
|
|
|
|
async function cleanupExpiredLockouts(): Promise<void> {
|
|
try {
|
|
const cutoff = new Date(Date.now() - LOCKOUT_SECONDS * 2 * 1000);
|
|
await db
|
|
.delete(appConfig)
|
|
.where(and(like(appConfig.key, `${ADMIN_LOCKOUT_KEY_PREFIX}:%`), lt(appConfig.updatedAt, cutoff)));
|
|
} catch {
|
|
// Non-critical — ignore cleanup errors.
|
|
}
|
|
}
|
|
|
|
export function isAdminAuthConfigured(): boolean {
|
|
return getPassword().length > 0 && getSecret().length > 0;
|
|
}
|
|
|
|
export function isPasswordValid(password: string): boolean {
|
|
if (!isAdminAuthConfigured()) {
|
|
return false;
|
|
}
|
|
|
|
const provided = Buffer.from(password);
|
|
const expected = Buffer.from(getPassword());
|
|
|
|
if (provided.length !== expected.length) {
|
|
return false;
|
|
}
|
|
|
|
return timingSafeEqual(provided, expected);
|
|
}
|
|
|
|
export async function setAdminSessionCookie(): Promise<void> {
|
|
const store = await cookies();
|
|
store.set(ADMIN_SESSION_COOKIE, buildToken(), {
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
secure: process.env.NODE_ENV === "production",
|
|
path: "/",
|
|
domain: getAdminCookieDomain(),
|
|
maxAge: 60 * 60 * 8,
|
|
});
|
|
}
|
|
|
|
export async function clearAdminSessionCookie(): Promise<void> {
|
|
const store = await cookies();
|
|
store.set(ADMIN_SESSION_COOKIE, "", {
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
secure: process.env.NODE_ENV === "production",
|
|
path: "/",
|
|
domain: getAdminCookieDomain(),
|
|
maxAge: 0,
|
|
});
|
|
}
|
|
|
|
type FailState = {
|
|
attempts: number;
|
|
lockUntil: number;
|
|
};
|
|
|
|
function parseFailState(rawValue: string | undefined): FailState {
|
|
if (!rawValue) {
|
|
return { attempts: 0, lockUntil: 0 };
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(rawValue) as Partial<FailState>;
|
|
|
|
return {
|
|
attempts: Number(parsed.attempts ?? 0),
|
|
lockUntil: Number(parsed.lockUntil ?? 0),
|
|
};
|
|
} catch {
|
|
return { attempts: 0, lockUntil: 0 };
|
|
}
|
|
}
|
|
|
|
export async function getAdminLockState(): Promise<{ locked: boolean; remainingSeconds: number }> {
|
|
try {
|
|
const ip = await getClientIp();
|
|
const key = getLockoutKey(ip);
|
|
const [config] = await db
|
|
.select({ value: appConfig.value })
|
|
.from(appConfig)
|
|
.where(eq(appConfig.key, key))
|
|
.limit(1);
|
|
const state = parseFailState(config?.value);
|
|
const now = Date.now();
|
|
|
|
if (state.lockUntil > now) {
|
|
return {
|
|
locked: true,
|
|
remainingSeconds: Math.ceil((state.lockUntil - now) / 1000),
|
|
};
|
|
}
|
|
} catch {
|
|
// DB unavailable — fail open to avoid blocking the login page.
|
|
}
|
|
|
|
return { locked: false, remainingSeconds: 0 };
|
|
}
|
|
|
|
export async function registerFailedAdminAttempt(): Promise<{ locked: boolean; remainingSeconds: number }> {
|
|
try {
|
|
const ip = await getClientIp();
|
|
const key = getLockoutKey(ip);
|
|
const now = Date.now();
|
|
|
|
await cleanupExpiredLockouts();
|
|
|
|
const [config] = await db
|
|
.select({ value: appConfig.value })
|
|
.from(appConfig)
|
|
.where(eq(appConfig.key, key))
|
|
.limit(1);
|
|
|
|
const current = parseFailState(config?.value);
|
|
// If a previous lockout has expired, reset the counter.
|
|
const baseAttempts = current.lockUntil > 0 && current.lockUntil < now ? 0 : current.attempts;
|
|
const attempts = baseAttempts + 1;
|
|
const locked = attempts >= MAX_FAILED_ATTEMPTS;
|
|
const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0;
|
|
|
|
await db
|
|
.insert(appConfig)
|
|
.values({ key, value: JSON.stringify({ attempts, lockUntil }) })
|
|
.onConflictDoUpdate({
|
|
target: appConfig.key,
|
|
set: { value: JSON.stringify({ attempts, lockUntil }) },
|
|
});
|
|
|
|
return {
|
|
locked,
|
|
remainingSeconds: locked ? LOCKOUT_SECONDS : 0,
|
|
};
|
|
} catch {
|
|
// DB error — don't lock out so admin can still log in.
|
|
return { locked: false, remainingSeconds: 0 };
|
|
}
|
|
}
|
|
|
|
export async function resetAdminFailedAttempts(): Promise<void> {
|
|
try {
|
|
const ip = await getClientIp();
|
|
const key = getLockoutKey(ip);
|
|
await db.delete(appConfig).where(eq(appConfig.key, key));
|
|
} catch {
|
|
// Non-critical — ignore.
|
|
}
|
|
}
|
|
|
|
export async function isAdminAuthenticated(): Promise<boolean> {
|
|
if (!isAdminAuthConfigured()) {
|
|
return false;
|
|
}
|
|
|
|
const store = await cookies();
|
|
const token = store.get(ADMIN_SESSION_COOKIE)?.value;
|
|
|
|
if (!token) {
|
|
return false;
|
|
}
|
|
|
|
return verifyToken(token);
|
|
}
|
|
|
|
/**
|
|
* This project has no user accounts or roles table — there is a single
|
|
* privileged session: the authenticated admin cookie checked above. There is
|
|
* no separate "regular user" tier, so an authenticated admin session is by
|
|
* definition the only "Super Admin". This is a readable alias only, kept as
|
|
* a thin wrapper around `isAdminAuthenticated()` (not a new auth mechanism).
|
|
*
|
|
* Security note: this function (and any UI it gates, like the header's admin
|
|
* shortcut button) is NOT the access-control boundary. Every admin page and
|
|
* server action must independently guard itself with `requireAdminAuth()` or
|
|
* an equivalent inline `isAdminAuthenticated()` check — never rely on a link
|
|
* being hidden as the thing that keeps the admin area protected.
|
|
*/
|
|
export async function isSuperAdmin(): Promise<boolean> {
|
|
return isAdminAuthenticated();
|
|
}
|
|
|
|
/**
|
|
* Call at the top of every authenticated admin page or server action.
|
|
* Clears the session cookie and redirects to the login page if not authenticated.
|
|
*/
|
|
export async function requireAdminAuth(): Promise<void> {
|
|
if (!(await isAdminAuthenticated())) {
|
|
await clearAdminSessionCookie();
|
|
redirect(getAdminAppPath("/"));
|
|
}
|
|
}
|