ADDED - Admin SEO page, robots/sitemap hardening and media/maintenance security fixes

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.
This commit is contained in:
moh
2026-09-20 21:36:16 +02:00
parent 0b513551ca
commit dc21c33867
47 changed files with 1854 additions and 131 deletions
+68 -29
View File
@@ -2,72 +2,111 @@ import type { MetadataRoute } from "next";
import { unstable_noStore as noStore } from "next/cache";
import { routing } from "@/i18n/routing";
import { getSiteSettings } from "@/lib/app-config";
import { getLocalizedPath } from "@/lib/locale";
import { getPublishedPortfolioProjects } from "@/lib/portfolio";
import { getMaintenanceMode, getSeoSettings, getSiteSettings } from "@/lib/app-config";
import { getLocalizedPath, type AppLocale } from "@/lib/locale";
import { toAbsoluteUrl } from "@/lib/metadata";
import { getActivePortfolioCategories, getPublishedPortfolioProjects } from "@/lib/portfolio";
function getSiteUrl(): URL {
return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
}
export const dynamic = "force-dynamic";
function toAbsoluteUrl(pathname: string): string {
return new URL(pathname, getSiteUrl()).toString();
}
type EntryOptions = Pick<MetadataRoute.Sitemap[number], "changeFrequency" | "priority" | "lastModified">;
function buildLocalizedEntries(
/**
* One entry per locale for a path, each carrying hreflang alternates so search
* engines link the three language versions together.
*/
export function buildLocalizedEntries(
pathname: string,
defaultLocale: "de" | "en" | "ar",
options?: Pick<MetadataRoute.Sitemap[number], "changeFrequency" | "priority" | "lastModified">,
defaultLocale: AppLocale,
options?: EntryOptions,
): MetadataRoute.Sitemap {
const languages = Object.fromEntries(
routing.locales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPath(locale, pathname, defaultLocale))]),
) as Record<AppLocale, string>;
return routing.locales.map((locale) => ({
url: toAbsoluteUrl(getLocalizedPath(locale, pathname, defaultLocale)),
url: languages[locale],
lastModified: options?.lastModified,
changeFrequency: options?.changeFrequency,
priority: options?.priority,
alternates: {
languages: {
...languages,
"x-default": languages[defaultLocale],
},
},
}));
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
noStore();
const siteSettings = await getSiteSettings();
const [siteSettings, seo, maintenanceEnabled] = await Promise.all([
getSiteSettings(),
getSeoSettings(),
getMaintenanceMode(),
]);
let projects: Awaited<ReturnType<typeof getPublishedPortfolioProjects>> = [];
try {
projects = await getPublishedPortfolioProjects();
} catch {
projects = [];
// While the site is hidden (maintenance) or indexing is off, publish an
// empty sitemap instead of advertising URLs that redirect or are noindex.
if (maintenanceEnabled || !seo.allowIndexing) {
return [];
}
const categories = Array.from(
new Map(projects.map((project) => [project.category.slug, project.category])).values(),
const defaultLocale = siteSettings.defaultLocale;
let projects: Awaited<ReturnType<typeof getPublishedPortfolioProjects>> = [];
let categories: Awaited<ReturnType<typeof getActivePortfolioCategories>> = [];
try {
[projects, categories] = await Promise.all([
getPublishedPortfolioProjects(),
getActivePortfolioCategories(),
]);
} catch {
projects = [];
categories = [];
}
// Only categories that actually have published work get a landing URL;
// an empty category page has nothing to index.
const categoriesWithProjects = categories.filter((category) =>
projects.some((project) => project.category.slug === category.slug),
);
const latestProjectDate = projects.reduce<Date | undefined>((latest, project) => {
const date = project.publishedAt ?? undefined;
return date && (!latest || date > latest) ? date : latest;
}, undefined);
return [
...buildLocalizedEntries("/", siteSettings.defaultLocale, {
...buildLocalizedEntries("/", defaultLocale, {
changeFrequency: "weekly",
priority: 1,
lastModified: latestProjectDate,
}),
...buildLocalizedEntries("/about", siteSettings.defaultLocale, {
...buildLocalizedEntries("/about", defaultLocale, {
changeFrequency: "monthly",
priority: 0.8,
}),
...buildLocalizedEntries("/portfolio", siteSettings.defaultLocale, {
...buildLocalizedEntries("/portfolio", defaultLocale, {
changeFrequency: "weekly",
priority: 0.9,
lastModified: latestProjectDate,
}),
...categories.flatMap((category) =>
buildLocalizedEntries(`/portfolio/${category.slug}`, siteSettings.defaultLocale, {
...categoriesWithProjects.flatMap((category) =>
buildLocalizedEntries(`/portfolio/${category.slug}`, defaultLocale, {
changeFrequency: "weekly",
priority: 0.8,
lastModified: latestProjectDate,
}),
),
...buildLocalizedEntries("/contact", siteSettings.defaultLocale, {
...buildLocalizedEntries("/contact", defaultLocale, {
changeFrequency: "monthly",
priority: 0.7,
}),
...projects.flatMap((project) =>
buildLocalizedEntries(`/portfolio/${project.slug}`, siteSettings.defaultLocale, {
buildLocalizedEntries(`/portfolio/${project.slug}`, defaultLocale, {
lastModified: project.publishedAt ?? undefined,
changeFrequency: "monthly",
priority: 0.8,