Files
sass-mohfarawati/lib/metadata.ts
T
moh dc21c33867 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.
2026-09-20 21:36:16 +02:00

367 lines
12 KiB
TypeScript

import type { Metadata } from "next";
import { appLocales } from "../i18n/routing";
import {
PAGE_TITLE_TOKEN,
SITE_NAME_TOKEN,
type SiteSettings,
type SiteSettingsMediaBindings,
} from "./site-settings";
import {
getSiteSettings,
getSiteSettingsMediaBindings,
} from "./app-config";
export function getSiteUrl(): URL {
return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
}
export function toAbsoluteUrl(pathname: string): string {
return new URL(pathname, getSiteUrl()).toString();
}
const NOINDEX_ROBOTS: Metadata["robots"] = {
index: false,
follow: false,
nocache: true,
googleBot: { index: false, follow: false, noimageindex: true },
};
const INDEX_ROBOTS: Metadata["robots"] = {
index: true,
follow: true,
googleBot: { index: true, follow: true, "max-image-preview": "large", "max-snippet": -1, "max-video-preview": -1 },
};
export function buildRobotsMetadata(seo: SeoSettings, noIndex = false): Metadata["robots"] {
return seo.allowIndexing && !noIndex ? INDEX_ROBOTS : NOINDEX_ROBOTS;
}
function buildVerification(seo: SeoSettings): Metadata["verification"] {
const verification: NonNullable<Metadata["verification"]> = {};
if (seo.googleSiteVerification) {
verification.google = seo.googleSiteVerification;
}
if (seo.bingSiteVerification) {
verification.other = { "msvalidate.01": seo.bingSiteVerification };
}
return Object.keys(verification).length > 0 ? verification : undefined;
}
import { AppLocale, getLocalizedPath, getLocalizedPathWithDefault, resolveLocale } from "./locale";
import { getSeoSettings } from "./app-config";
import { buildDefaultSeoSettings, toOpenGraphLocale, type SeoSettings } from "./seo-settings";
import { buildSiteIconUrls } from "./site-icons";
export function buildLocaleAlternates(pathname: string, defaultLocale: AppLocale) {
const languages = Object.fromEntries(
appLocales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPathWithDefault(locale, pathname, defaultLocale))]),
) as Record<AppLocale, string>;
return {
canonical: toAbsoluteUrl(getLocalizedPathWithDefault(defaultLocale, pathname, defaultLocale)),
languages: {
...languages,
"x-default": toAbsoluteUrl(getLocalizedPathWithDefault(defaultLocale, pathname, defaultLocale)),
},
};
}
export function applyTitleTemplateFn(title: string, template: string, siteName: string): string {
const safeTemplate = template.includes(PAGE_TITLE_TOKEN)
? template
: `${PAGE_TITLE_TOKEN} | ${SITE_NAME_TOKEN}`;
return safeTemplate
.replaceAll(SITE_NAME_TOKEN, siteName)
.replace(PAGE_TITLE_TOKEN, title);
}
function buildMetadataImages(imageUrl?: string | null, alt?: string) {
if (!imageUrl) {
return undefined;
}
return [
{
url: toAbsoluteUrl(imageUrl),
alt,
},
];
}
export async function buildAppMetadata(): Promise<Metadata> {
const [settings, bindings, seo] = await Promise.all([
getSiteSettings(),
getSiteSettingsMediaBindings(),
getSeoSettings(),
]);
return buildAppMetadataFromConfig(settings, bindings, seo);
}
export function buildAppMetadataFromConfig(
settings: SiteSettings,
bindings: SiteSettingsMediaBindings,
seo: SeoSettings = buildDefaultSeoSettings(),
): Metadata {
const defaultLocaleSettings = settings.locales[settings.defaultLocale];
const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url, defaultLocaleSettings.siteName);
const keywords = seo.locales[settings.defaultLocale].keywords;
const siteIconUrls = buildSiteIconUrls({
siteName: defaultLocaleSettings.siteName,
faviconVersion: bindings.favicon?.version,
faviconUrl: bindings.favicon?.url,
});
return {
metadataBase: getSiteUrl(),
title: defaultLocaleSettings.siteName,
description: defaultLocaleSettings.siteDescription,
applicationName: defaultLocaleSettings.siteName,
keywords: keywords ? keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : undefined,
robots: buildRobotsMetadata(seo),
verification: buildVerification(seo),
formatDetection: { telephone: false },
manifest: siteIconUrls.manifestHref,
icons: {
icon: [{ url: siteIconUrls.faviconHref }],
shortcut: [{ url: siteIconUrls.faviconHref }],
apple: [{ url: siteIconUrls.appleIconHref }],
},
openGraph: {
title: defaultLocaleSettings.siteName,
description: defaultLocaleSettings.siteDescription,
url: toAbsoluteUrl(getLocalizedPathWithDefault(settings.defaultLocale, "/", settings.defaultLocale)),
siteName: defaultLocaleSettings.siteName,
locale: toOpenGraphLocale(settings.defaultLocale),
alternateLocale: appLocales.filter((locale) => locale !== settings.defaultLocale).map(toOpenGraphLocale),
type: "website",
images: openGraphImages,
},
twitter: {
card: openGraphImages ? "summary_large_image" : "summary",
title: defaultLocaleSettings.siteName,
description: defaultLocaleSettings.siteDescription,
site: seo.twitterHandle || undefined,
creator: seo.twitterHandle || undefined,
images: openGraphImages?.map((image) => image.url),
},
};
}
type LocalizedMetadataOptions = {
/** Page-specific share image (e.g. a project cover). Falls back to the default OG image. */
image?: string | null;
/** Force `noindex` (thank-you pages, coming-soon, etc.). */
noIndex?: boolean;
/** Open Graph object type. Portfolio projects use `article`. */
type?: "website" | "article";
publishedTime?: Date | null;
modifiedTime?: Date | null;
};
type LocalizedMetadataInput = LocalizedMetadataOptions & {
locale: string;
pathname: string;
title: string;
description?: string;
applyTitleTemplate?: boolean;
};
export async function buildLocalizedMetadata({
locale,
pathname,
title,
description,
applyTitleTemplate,
...options
}: LocalizedMetadataInput): Promise<Metadata> {
const [settings, bindings, seo] = await Promise.all([
getSiteSettings(),
getSiteSettingsMediaBindings(),
getSeoSettings(),
]);
const localeKey = resolveLocale(locale, settings.defaultLocale);
return buildLocalizedMetadataFromConfig({
settings,
bindings,
seo,
locale: localeKey,
pathname,
title,
description,
applyTitleTemplate,
...options,
});
}
export function buildLocalizedMetadataFromConfig(
input: LocalizedMetadataOptions & {
settings: SiteSettings;
bindings: SiteSettingsMediaBindings;
seo?: SeoSettings;
locale: AppLocale;
pathname: string;
title: string;
description?: string;
applyTitleTemplate?: boolean;
},
): Metadata {
const {
settings,
bindings,
seo = buildDefaultSeoSettings(),
locale,
pathname,
title,
description,
applyTitleTemplate = true,
image,
noIndex = false,
type = "website",
publishedTime,
modifiedTime,
} = input;
const localeKey = resolveLocale(locale, settings.defaultLocale);
const localeSettings = settings.locales[localeKey];
const resolvedDescription = (description?.trim() || localeSettings.siteDescription).slice(0, 300);
const resolvedTitle = applyTitleTemplate
? applyTitleTemplateFn(title, localeSettings.titleTemplate, localeSettings.siteName)
: title;
const openGraphImages = buildMetadataImages(image || bindings.defaultOgImage?.url, title);
const keywords = seo.locales[localeKey].keywords;
return {
title: resolvedTitle,
description: resolvedDescription,
keywords: keywords ? keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : undefined,
robots: buildRobotsMetadata(seo, noIndex),
alternates: buildLocaleAlternates(pathname, settings.defaultLocale),
openGraph: {
title: resolvedTitle,
description: resolvedDescription,
url: toAbsoluteUrl(getLocalizedPath(localeKey, pathname, settings.defaultLocale)),
siteName: localeSettings.siteName,
locale: toOpenGraphLocale(localeKey),
alternateLocale: appLocales.filter((entry) => entry !== localeKey).map(toOpenGraphLocale),
images: openGraphImages,
...(type === "article"
? {
type: "article" as const,
publishedTime: publishedTime?.toISOString(),
modifiedTime: (modifiedTime ?? publishedTime)?.toISOString(),
}
: { type: "website" as const }),
},
twitter: {
card: openGraphImages ? "summary_large_image" : "summary",
title: resolvedTitle,
description: resolvedDescription,
site: seo.twitterHandle || undefined,
creator: seo.twitterHandle || undefined,
images: openGraphImages?.map((entry) => entry.url),
},
};
}
// --- JSON-LD ----------------------------------------------------------------
type JsonLd = Record<string, unknown>;
/** WebSite + publisher (Person/Organization) graph for the home page. */
export function buildSiteJsonLd(input: {
settings: SiteSettings;
seo: SeoSettings;
bindings: SiteSettingsMediaBindings;
locale: AppLocale;
}): JsonLd {
const { settings, seo, bindings, locale } = input;
const localeSettings = settings.locales[locale];
const siteUrl = getSiteUrl().toString();
const publisherName = seo.structuredDataName || localeSettings.siteName;
const logoUrl = bindings.siteLogoLight?.url ?? bindings.defaultOgImage?.url ?? null;
const publisher: JsonLd = {
"@type": seo.structuredDataType,
"@id": `${siteUrl}#${seo.structuredDataType.toLowerCase()}`,
name: publisherName,
url: siteUrl,
};
if (seo.structuredDataJobTitle) {
publisher[seo.structuredDataType === "Person" ? "jobTitle" : "slogan"] = seo.structuredDataJobTitle;
}
if (logoUrl) {
publisher[seo.structuredDataType === "Person" ? "image" : "logo"] = toAbsoluteUrl(logoUrl);
}
if (seo.sameAs.length > 0) {
publisher.sameAs = seo.sameAs;
}
return {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": `${siteUrl}#website`,
url: siteUrl,
name: localeSettings.siteName,
description: localeSettings.siteDescription || undefined,
inLanguage: appLocales,
publisher: { "@id": publisher["@id"] },
},
publisher,
],
};
}
/** CreativeWork for a single portfolio project (any view mode). */
export function buildProjectJsonLd(input: {
settings: SiteSettings;
seo: SeoSettings;
locale: AppLocale;
pathname: string;
title: string;
description: string;
image?: string | null;
datePublished?: Date | null;
dateModified?: Date | null;
genre?: string;
keywords?: string[];
clientName?: string;
}): JsonLd {
const { settings, seo, locale, pathname } = input;
const siteUrl = getSiteUrl().toString();
const url = toAbsoluteUrl(getLocalizedPath(locale, pathname, settings.defaultLocale));
return {
"@context": "https://schema.org",
"@type": "CreativeWork",
"@id": `${url}#work`,
url,
name: input.title,
headline: input.title,
description: input.description || undefined,
image: input.image ? toAbsoluteUrl(input.image) : undefined,
inLanguage: locale,
genre: input.genre || undefined,
keywords: input.keywords && input.keywords.length > 0 ? input.keywords.join(", ") : undefined,
datePublished: input.datePublished?.toISOString(),
dateModified: (input.dateModified ?? input.datePublished)?.toISOString(),
author: { "@id": `${siteUrl}#${seo.structuredDataType.toLowerCase()}` },
sourceOrganization: input.clientName ? { "@type": "Organization", name: input.clientName } : undefined,
isPartOf: { "@id": `${siteUrl}#website` },
};
}
/** Serialize JSON-LD safely for a `<script type="application/ld+json">` tag. */
export function serializeJsonLd(data: JsonLd): string {
return JSON.stringify(data).replace(/</g, "\\u003c");
}