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:
+9
-27
@@ -1,4 +1,4 @@
|
||||
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
||||
import { createHash, timingSafeEqual } from "crypto";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
@@ -7,9 +7,13 @@ 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 = "moh_admin_session";
|
||||
const ADMIN_SESSION_VALUE = "superadmin";
|
||||
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";
|
||||
@@ -91,34 +95,12 @@ function getAdminCookieDomain(): string | undefined {
|
||||
return hostname ? `.${hostname}` : undefined;
|
||||
}
|
||||
|
||||
function signValue(value: string): string {
|
||||
return createHmac("sha256", getSecret()).update(value).digest("hex");
|
||||
}
|
||||
|
||||
function buildToken(): string {
|
||||
return `${ADMIN_SESSION_VALUE}.${signValue(ADMIN_SESSION_VALUE)}`;
|
||||
return buildAdminSessionToken();
|
||||
}
|
||||
|
||||
function verifyToken(token: string): boolean {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [value, signature] = parts;
|
||||
if (value !== ADMIN_SESSION_VALUE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expected = signValue(value);
|
||||
const left = Buffer.from(signature);
|
||||
const right = Buffer.from(expected);
|
||||
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(left, right);
|
||||
return verifyAdminSessionToken(token);
|
||||
}
|
||||
|
||||
async function getClientIp(): Promise<string> {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Mail,
|
||||
Palette,
|
||||
PlusSquare,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
SwatchBook,
|
||||
Tags,
|
||||
@@ -24,6 +25,7 @@ type AdminNavigationCopy = {
|
||||
siteSettings: string;
|
||||
brandSettings?: string;
|
||||
localizationSettings?: string;
|
||||
seoSettings?: string;
|
||||
marquee?: string;
|
||||
smtp?: string;
|
||||
};
|
||||
@@ -41,7 +43,7 @@ export function getAdminNavigation(
|
||||
copy: AdminNavigationCopy,
|
||||
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee",
|
||||
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
|
||||
siteSettingsChild?: "brand" | "localization",
|
||||
siteSettingsChild?: "brand" | "localization" | "seo",
|
||||
): AdminNavItem[] {
|
||||
return [
|
||||
{
|
||||
@@ -87,6 +89,12 @@ export function getAdminNavigation(
|
||||
icon: Languages,
|
||||
active: siteSettingsChild === "localization",
|
||||
},
|
||||
{
|
||||
label: copy.seoSettings ?? "SEO",
|
||||
href: getAdminAppPath("/site-settings/seo"),
|
||||
icon: Search,
|
||||
active: siteSettingsChild === "seo",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
|
||||
/**
|
||||
* Pure helpers for the admin session cookie token. Kept free of `next/headers`
|
||||
* and the database so the middleware (`proxy.ts`) can verify a session without
|
||||
* pulling the server-only auth module into the edge/middleware bundle.
|
||||
*/
|
||||
export const ADMIN_SESSION_COOKIE = "moh_admin_session";
|
||||
export const ADMIN_SESSION_VALUE = "superadmin";
|
||||
|
||||
function getSecret(): string {
|
||||
return process.env.ADMIN_AUTH_SECRET ?? "";
|
||||
}
|
||||
|
||||
function signValue(value: string): string {
|
||||
return createHmac("sha256", getSecret()).update(value).digest("hex");
|
||||
}
|
||||
|
||||
export function buildAdminSessionToken(): string {
|
||||
return `${ADMIN_SESSION_VALUE}.${signValue(ADMIN_SESSION_VALUE)}`;
|
||||
}
|
||||
|
||||
export function verifyAdminSessionToken(token: string | undefined | null): boolean {
|
||||
if (!token || !getSecret()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [value, signature] = parts;
|
||||
if (value !== ADMIN_SESSION_VALUE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expected = signValue(value);
|
||||
const left = Buffer.from(signature);
|
||||
const right = Buffer.from(expected);
|
||||
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(left, right);
|
||||
}
|
||||
@@ -67,6 +67,13 @@ import {
|
||||
syncMarqueeSettingsToGermanSource,
|
||||
type MarqueeSettings,
|
||||
} from "./marquee-settings";
|
||||
import { SEO_SETTINGS_KEY, buildDefaultSeoSettings, parseSeoSettingsValue, type SeoSettings } from "./seo-settings";
|
||||
export {
|
||||
SEO_SETTINGS_KEY,
|
||||
buildDefaultSeoSettings,
|
||||
parseSeoSettingsValue,
|
||||
type SeoSettings,
|
||||
} from "./seo-settings";
|
||||
|
||||
// Small helpers over the app_config key/value table (Drizzle).
|
||||
async function readConfigValue(key: string): Promise<string | undefined> {
|
||||
@@ -150,6 +157,18 @@ export async function updateMarqueeSettings(settings: MarqueeSettings): Promise<
|
||||
await upsertConfig(MARQUEE_SETTINGS_KEY, JSON.stringify(normalizedSettings));
|
||||
}
|
||||
|
||||
export async function getSeoSettings(): Promise<SeoSettings> {
|
||||
try {
|
||||
return parseSeoSettingsValue(await readConfigValue(SEO_SETTINGS_KEY));
|
||||
} catch {
|
||||
return buildDefaultSeoSettings();
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSeoSettings(settings: SeoSettings): Promise<void> {
|
||||
await upsertConfig(SEO_SETTINGS_KEY, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
|
||||
try {
|
||||
const usages = await db
|
||||
|
||||
@@ -125,7 +125,8 @@ export async function createStandaloneMediaAsset(input: {
|
||||
uploadFile: FormDataEntryValue | null;
|
||||
}) {
|
||||
if (input.uploadFile instanceof File && input.uploadFile.size > 0) {
|
||||
const savedFile = await saveMediaUpload(input.uploadFile, input.kind.toLowerCase());
|
||||
const kind = getKindFromUploadFile(input.uploadFile);
|
||||
const savedFile = await saveMediaUpload(input.uploadFile, kind.toLowerCase());
|
||||
const derivedLabel = input.uploadFile.name.replace(/\.[^.]+$/, "").trim();
|
||||
const trimmedLabel = input.label.trim() || derivedLabel || "Media asset";
|
||||
|
||||
@@ -135,7 +136,7 @@ export async function createStandaloneMediaAsset(input: {
|
||||
|
||||
return createMediaAsset({
|
||||
source: MediaSource.UPLOAD,
|
||||
kind: input.kind,
|
||||
kind,
|
||||
url: savedFile.url,
|
||||
fileName: savedFile.fileName,
|
||||
label: trimmedLabel,
|
||||
|
||||
+61
-5
@@ -37,16 +37,65 @@ export function resolveMediaUploadPath(filePath: string) {
|
||||
throw new Error("Only managed media uploads can be resolved.");
|
||||
}
|
||||
|
||||
const relativePath = filePath.replace("/uploads/media/", "");
|
||||
const relativePath = filePath.slice("/uploads/media/".length);
|
||||
|
||||
if (!relativePath || relativePath.includes("\0")) {
|
||||
throw new Error("Resolved media upload path escapes the upload root.");
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(MEDIA_UPLOAD_ROOT, relativePath);
|
||||
|
||||
if (!absolutePath.startsWith(MEDIA_UPLOAD_ROOT)) {
|
||||
// `startsWith(root)` alone would accept a sibling directory such as
|
||||
// `.../uploads/media-evil/...`; require the separator so only true children pass.
|
||||
if (absolutePath !== MEDIA_UPLOAD_ROOT && !absolutePath.startsWith(MEDIA_UPLOAD_ROOT + path.sep)) {
|
||||
throw new Error("Resolved media upload path escapes the upload root.");
|
||||
}
|
||||
|
||||
if (absolutePath === MEDIA_UPLOAD_ROOT) {
|
||||
throw new Error("Resolved media upload path escapes the upload root.");
|
||||
}
|
||||
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
const MAGIC_SIGNATURES: Record<string, Array<{ offset: number; bytes: number[] }>> = {
|
||||
".png": [{ offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }],
|
||||
".jpg": [{ offset: 0, bytes: [0xff, 0xd8, 0xff] }],
|
||||
".gif": [{ offset: 0, bytes: [0x47, 0x49, 0x46, 0x38] }],
|
||||
".webp": [
|
||||
{ offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] },
|
||||
{ offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] },
|
||||
],
|
||||
".pdf": [{ offset: 0, bytes: [0x25, 0x50, 0x44, 0x46] }],
|
||||
".ico": [{ offset: 0, bytes: [0x00, 0x00, 0x01, 0x00] }],
|
||||
};
|
||||
|
||||
const SVG_FORBIDDEN_PATTERN = /<script[\s>]|javascript:|on[a-z]+\s*=|<foreignObject|<iframe|<embed|<object|xlink:href\s*=\s*["']\s*(?!#|data:image\/)/i;
|
||||
|
||||
/**
|
||||
* Verify that the file bytes match the extension derived from the declared
|
||||
* MIME type. The browser-supplied `file.type` is untrusted: without this check
|
||||
* an HTML/JS payload could be stored as `.png` and served from our origin.
|
||||
*/
|
||||
export function isMediaContentValid(extension: string, buffer: Buffer): boolean {
|
||||
if (extension === ".svg") {
|
||||
const head = buffer.subarray(0, 4096).toString("utf8").trimStart();
|
||||
const looksLikeSvg = head.startsWith("<svg") || (head.startsWith("<?xml") && /<svg[\s>]/i.test(head));
|
||||
|
||||
return looksLikeSvg && !SVG_FORBIDDEN_PATTERN.test(buffer.toString("utf8"));
|
||||
}
|
||||
|
||||
const signatures = MAGIC_SIGNATURES[extension];
|
||||
|
||||
if (!signatures) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return signatures.every(({ offset, bytes }) =>
|
||||
bytes.every((byte, index) => buffer[offset + index] === byte),
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeManagedMediaFile(filePath: string | null | undefined) {
|
||||
if (!isManagedMediaFilePath(filePath)) {
|
||||
return false;
|
||||
@@ -76,16 +125,23 @@ export async function saveMediaUpload(file: File, folder: string) {
|
||||
throw new Error("File is too large.");
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
if (!isMediaContentValid(extension, buffer)) {
|
||||
throw new Error("File content does not match its declared type.");
|
||||
}
|
||||
|
||||
const safeFolder = sanitizeBaseName(folder) || "misc";
|
||||
const safeBaseName = sanitizeBaseName(file.name.replace(/\.[^.]+$/, "")) || "asset";
|
||||
const finalName = `${safeBaseName}-${randomUUID().slice(0, 8)}${extension}`;
|
||||
const targetDir = path.join(MEDIA_UPLOAD_ROOT, folder);
|
||||
const targetDir = path.join(MEDIA_UPLOAD_ROOT, safeFolder);
|
||||
const targetPath = path.join(targetDir, finalName);
|
||||
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
await writeFile(targetPath, Buffer.from(await file.arrayBuffer()));
|
||||
await writeFile(targetPath, buffer);
|
||||
|
||||
return {
|
||||
url: `/uploads/media/${folder}/${finalName}`,
|
||||
url: `/uploads/media/${safeFolder}/${finalName}`,
|
||||
fileName: finalName,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
|
||||
@@ -30,7 +30,9 @@ export const mediaFieldInputSchema = z
|
||||
});
|
||||
}
|
||||
|
||||
if (value.url && !/^https?:\/\//.test(value.url) && !value.url.startsWith("/")) {
|
||||
const isRootRelative = value.url.startsWith("/") && !value.url.startsWith("//");
|
||||
|
||||
if (value.url && !/^https?:\/\//i.test(value.url) && !isRootRelative) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["url"],
|
||||
|
||||
+208
-25
@@ -11,17 +11,50 @@ import {
|
||||
getSiteSettings,
|
||||
getSiteSettingsMediaBindings,
|
||||
} from "./app-config";
|
||||
import { AppLocale, getLocalizedPath, getLocalizedPathWithDefault, resolveLocale } from "./locale";
|
||||
import { buildSiteIconUrls } from "./site-icons";
|
||||
|
||||
function getSiteUrl(): URL {
|
||||
export function getSiteUrl(): URL {
|
||||
return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
|
||||
}
|
||||
|
||||
function toAbsoluteUrl(pathname: string): string {
|
||||
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))]),
|
||||
@@ -46,7 +79,7 @@ export function applyTitleTemplateFn(title: string, template: string, siteName:
|
||||
.replace(PAGE_TITLE_TOKEN, title);
|
||||
}
|
||||
|
||||
function buildMetadataImages(imageUrl?: string | null) {
|
||||
function buildMetadataImages(imageUrl?: string | null, alt?: string) {
|
||||
if (!imageUrl) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -54,22 +87,29 @@ function buildMetadataImages(imageUrl?: string | null) {
|
||||
return [
|
||||
{
|
||||
url: toAbsoluteUrl(imageUrl),
|
||||
alt,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function buildAppMetadata(): Promise<Metadata> {
|
||||
const [settings, bindings] = await Promise.all([getSiteSettings(), getSiteSettingsMediaBindings()]);
|
||||
const [settings, bindings, seo] = await Promise.all([
|
||||
getSiteSettings(),
|
||||
getSiteSettingsMediaBindings(),
|
||||
getSeoSettings(),
|
||||
]);
|
||||
|
||||
return buildAppMetadataFromConfig(settings, bindings);
|
||||
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);
|
||||
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,
|
||||
@@ -81,6 +121,10 @@ export function buildAppMetadataFromConfig(
|
||||
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 }],
|
||||
@@ -92,7 +136,8 @@ export function buildAppMetadataFromConfig(
|
||||
description: defaultLocaleSettings.siteDescription,
|
||||
url: toAbsoluteUrl(getLocalizedPathWithDefault(settings.defaultLocale, "/", settings.defaultLocale)),
|
||||
siteName: defaultLocaleSettings.siteName,
|
||||
locale: settings.defaultLocale,
|
||||
locale: toOpenGraphLocale(settings.defaultLocale),
|
||||
alternateLocale: appLocales.filter((locale) => locale !== settings.defaultLocale).map(toOpenGraphLocale),
|
||||
type: "website",
|
||||
images: openGraphImages,
|
||||
},
|
||||
@@ -100,12 +145,25 @@ export function buildAppMetadataFromConfig(
|
||||
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 LocalizedMetadataInput = {
|
||||
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;
|
||||
@@ -119,65 +177,190 @@ export async function buildLocalizedMetadata({
|
||||
title,
|
||||
description,
|
||||
applyTitleTemplate,
|
||||
...options
|
||||
}: LocalizedMetadataInput): Promise<Metadata> {
|
||||
const [settings, bindings] = await Promise.all([getSiteSettings(), getSiteSettingsMediaBindings()]);
|
||||
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: {
|
||||
settings: SiteSettings;
|
||||
bindings: SiteSettingsMediaBindings;
|
||||
locale: AppLocale;
|
||||
pathname: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
applyTitleTemplate?: boolean;
|
||||
}): Metadata {
|
||||
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;
|
||||
const resolvedDescription = (description?.trim() || localeSettings.siteDescription).slice(0, 300);
|
||||
const resolvedTitle = applyTitleTemplate
|
||||
? applyTitleTemplateFn(title, localeSettings.titleTemplate, localeSettings.siteName)
|
||||
: title;
|
||||
const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url);
|
||||
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: localeKey,
|
||||
type: "website",
|
||||
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,
|
||||
images: openGraphImages?.map((image) => image.url),
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { appLocales } from "../i18n/routing";
|
||||
import type { SeoSettings } from "./seo-settings";
|
||||
import type { SiteSettings, SiteSettingsMediaBindings } from "./site-settings";
|
||||
|
||||
export type SeoCheckStatus = "ok" | "warn" | "error";
|
||||
|
||||
export type SeoCheck = {
|
||||
id: string;
|
||||
label: string;
|
||||
status: SeoCheckStatus;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure readiness checklist shown on the admin SEO page. Every input comes from
|
||||
* the caller so the same function is testable without a database.
|
||||
*/
|
||||
export function buildSeoChecklist(input: {
|
||||
seo: SeoSettings;
|
||||
settings: SiteSettings;
|
||||
bindings: SiteSettingsMediaBindings;
|
||||
maintenanceEnabled: boolean;
|
||||
publishedProjectCount: number;
|
||||
sitemapEntryCount: number;
|
||||
siteUrl: string;
|
||||
}): SeoCheck[] {
|
||||
const { seo, settings, bindings, maintenanceEnabled, publishedProjectCount, sitemapEntryCount, siteUrl } = input;
|
||||
const checks: SeoCheck[] = [];
|
||||
|
||||
checks.push({
|
||||
id: "indexing",
|
||||
label: "Indexierung",
|
||||
status: seo.allowIndexing && !maintenanceEnabled ? "ok" : "error",
|
||||
detail: maintenanceEnabled
|
||||
? "Wartungsmodus aktiv: robots.txt sperrt alles, Sitemap ist leer."
|
||||
: seo.allowIndexing
|
||||
? "Suchmaschinen duerfen die Seite indexieren."
|
||||
: "Indexierung ist deaktiviert (noindex + robots disallow).",
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "site-url",
|
||||
label: "Oeffentliche URL",
|
||||
status: /^https:\/\//.test(siteUrl) && !/localhost|127\.0\.0\.1/.test(siteUrl) ? "ok" : "warn",
|
||||
detail: `Canonical Basis: ${siteUrl}`,
|
||||
});
|
||||
|
||||
for (const locale of appLocales) {
|
||||
const localeSettings = settings.locales[locale];
|
||||
const descriptionLength = localeSettings.siteDescription.length;
|
||||
const status: SeoCheckStatus =
|
||||
descriptionLength === 0 ? "error" : descriptionLength < 50 || descriptionLength > 160 ? "warn" : "ok";
|
||||
|
||||
checks.push({
|
||||
id: `description-${locale}`,
|
||||
label: `Meta Description (${locale.toUpperCase()})`,
|
||||
status,
|
||||
detail:
|
||||
descriptionLength === 0
|
||||
? "Fehlt. Wird unter Settings > Localization gepflegt."
|
||||
: `${descriptionLength} Zeichen (empfohlen 50-160).`,
|
||||
});
|
||||
}
|
||||
|
||||
checks.push({
|
||||
id: "og-image",
|
||||
label: "Standard Share Bild (OG)",
|
||||
status: bindings.defaultOgImage ? "ok" : "warn",
|
||||
detail: bindings.defaultOgImage
|
||||
? "Gesetzt. Projekte nutzen ihr Cover, alle anderen Seiten dieses Bild."
|
||||
: "Nicht gesetzt. Links ohne Vorschaubild. Unter Settings > Brand pflegen.",
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "favicon",
|
||||
label: "Favicon",
|
||||
status: bindings.favicon ? "ok" : "warn",
|
||||
detail: bindings.favicon ? "Gesetzt." : "Nicht gesetzt (Fallback-Icon wird generiert).",
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "verification",
|
||||
label: "Search Console / Bing",
|
||||
status: seo.googleSiteVerification || seo.bingSiteVerification ? "ok" : "warn",
|
||||
detail:
|
||||
seo.googleSiteVerification || seo.bingSiteVerification
|
||||
? "Verification Meta Tags werden ausgegeben."
|
||||
: "Kein Verification Code hinterlegt.",
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "structured-data",
|
||||
label: "Strukturierte Daten (JSON-LD)",
|
||||
status: seo.structuredDataName || settings.locales[settings.defaultLocale].siteName ? "ok" : "warn",
|
||||
detail: `${seo.structuredDataType} + WebSite auf allen Seiten, CreativeWork pro Projekt.`,
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "projects",
|
||||
label: "Veroeffentlichte Projekte",
|
||||
status: publishedProjectCount > 0 ? "ok" : "warn",
|
||||
detail:
|
||||
publishedProjectCount > 0
|
||||
? `${publishedProjectCount} Projekt(e) in der Sitemap.`
|
||||
: "Noch kein Projekt veroeffentlicht. Portfolio-Seiten sind leer.",
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "sitemap",
|
||||
label: "Sitemap",
|
||||
status: sitemapEntryCount > 0 ? "ok" : maintenanceEnabled || !seo.allowIndexing ? "warn" : "error",
|
||||
detail: `${sitemapEntryCount} URL(s) in /sitemap.xml (alle Sprachen, mit hreflang).`,
|
||||
});
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
export function summarizeSeoChecklist(checks: SeoCheck[]) {
|
||||
return {
|
||||
ok: checks.filter((check) => check.status === "ok").length,
|
||||
warn: checks.filter((check) => check.status === "warn").length,
|
||||
error: checks.filter((check) => check.status === "error").length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { AppLocale } from "./locale";
|
||||
|
||||
/**
|
||||
* Site-wide SEO configuration stored as one JSON blob in `app_config`
|
||||
* (key `seo_settings`). Everything here is pure: parsing/normalizing only.
|
||||
* Read/write goes through `lib/app-config.ts`.
|
||||
*/
|
||||
export const SEO_SETTINGS_KEY = "seo_settings";
|
||||
|
||||
export type SeoStructuredDataType = "Person" | "Organization";
|
||||
|
||||
export type SeoLocaleSettings = {
|
||||
/** Comma-separated keywords (optional, low SEO weight but harmless). */
|
||||
keywords: string;
|
||||
};
|
||||
|
||||
export type SeoSettings = {
|
||||
/** Master switch: false → robots disallow all + `noindex` on every page. */
|
||||
allowIndexing: boolean;
|
||||
/** `google-site-verification` meta value. */
|
||||
googleSiteVerification: string;
|
||||
/** `msvalidate.01` meta value (Bing Webmaster). */
|
||||
bingSiteVerification: string;
|
||||
/** `@handle` used for twitter:site / twitter:creator. */
|
||||
twitterHandle: string;
|
||||
/** Publisher shape used for JSON-LD on the home page. */
|
||||
structuredDataType: SeoStructuredDataType;
|
||||
/** Name shown in JSON-LD (falls back to the site name when empty). */
|
||||
structuredDataName: string;
|
||||
/** Person job title / Organization tagline used in JSON-LD. */
|
||||
structuredDataJobTitle: string;
|
||||
/** Social profile URLs for `sameAs` in JSON-LD. */
|
||||
sameAs: string[];
|
||||
locales: Record<AppLocale, SeoLocaleSettings>;
|
||||
};
|
||||
|
||||
export function buildDefaultSeoSettings(): SeoSettings {
|
||||
return {
|
||||
allowIndexing: true,
|
||||
googleSiteVerification: "",
|
||||
bingSiteVerification: "",
|
||||
twitterHandle: "",
|
||||
structuredDataType: "Person",
|
||||
structuredDataName: "",
|
||||
structuredDataJobTitle: "",
|
||||
sameAs: [],
|
||||
locales: {
|
||||
ar: { keywords: "" },
|
||||
en: { keywords: "" },
|
||||
de: { keywords: "" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown, maxLength = 500): string {
|
||||
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
|
||||
}
|
||||
|
||||
/** Meta verification tokens are alphanumeric with `-` / `_`; anything else is dropped. */
|
||||
export function normalizeVerificationToken(value: unknown): string {
|
||||
const text = normalizeText(value, 200);
|
||||
|
||||
return /^[A-Za-z0-9_-]+$/.test(text) ? text : "";
|
||||
}
|
||||
|
||||
export function normalizeTwitterHandle(value: unknown): string {
|
||||
const text = normalizeText(value, 60).replace(/^https?:\/\/(www\.)?(twitter|x)\.com\//i, "").replace(/^@+/, "");
|
||||
|
||||
return /^[A-Za-z0-9_]{1,15}$/.test(text) ? `@${text}` : "";
|
||||
}
|
||||
|
||||
export function normalizeSameAs(value: unknown): string[] {
|
||||
const rawList = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? value.split(/[\n,]+/)
|
||||
: [];
|
||||
|
||||
const urls = rawList
|
||||
.map((entry) => normalizeText(entry, 500))
|
||||
.filter((entry) => /^https:\/\/[^\s]+$/i.test(entry));
|
||||
|
||||
return Array.from(new Set(urls)).slice(0, 20);
|
||||
}
|
||||
|
||||
export function normalizeStructuredDataType(value: unknown): SeoStructuredDataType {
|
||||
return value === "Organization" ? "Organization" : "Person";
|
||||
}
|
||||
|
||||
export function normalizeKeywords(value: unknown): string {
|
||||
const text = normalizeText(value, 1000);
|
||||
|
||||
return text
|
||||
.split(",")
|
||||
.map((keyword) => keyword.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 30)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
export function parseSeoSettingsValue(rawValue: string | null | undefined): SeoSettings {
|
||||
const defaults = buildDefaultSeoSettings();
|
||||
|
||||
if (!rawValue) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue) as Record<string, unknown>;
|
||||
const locales =
|
||||
parsed.locales && typeof parsed.locales === "object"
|
||||
? (parsed.locales as Record<string, Record<string, unknown> | undefined>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
allowIndexing: parsed.allowIndexing !== false,
|
||||
googleSiteVerification: normalizeVerificationToken(parsed.googleSiteVerification),
|
||||
bingSiteVerification: normalizeVerificationToken(parsed.bingSiteVerification),
|
||||
twitterHandle: normalizeTwitterHandle(parsed.twitterHandle),
|
||||
structuredDataType: normalizeStructuredDataType(parsed.structuredDataType),
|
||||
structuredDataName: normalizeText(parsed.structuredDataName, 120),
|
||||
structuredDataJobTitle: normalizeText(parsed.structuredDataJobTitle, 160),
|
||||
sameAs: normalizeSameAs(parsed.sameAs),
|
||||
locales: {
|
||||
ar: { keywords: normalizeKeywords(locales.ar?.keywords) },
|
||||
en: { keywords: normalizeKeywords(locales.en?.keywords) },
|
||||
de: { keywords: normalizeKeywords(locales.de?.keywords) },
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
/** Map an app locale to the Open Graph `og:locale` format. */
|
||||
export function toOpenGraphLocale(locale: AppLocale): string {
|
||||
switch (locale) {
|
||||
case "ar":
|
||||
return "ar_AR";
|
||||
case "en":
|
||||
return "en_US";
|
||||
default:
|
||||
return "de_DE";
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { isManagedMediaFilePath, resolveMediaUploadPath } from "./media-storage"
|
||||
|
||||
const INTERNAL_FAVICON_PATH = "/favicon.ico";
|
||||
const INTERNAL_APPLE_ICON_PATH = "/apple-icon.png";
|
||||
const INTERNAL_MANIFEST_PATH = "/manifest.webmanifest";
|
||||
export const INTERNAL_MANIFEST_PATH = "/manifest.webmanifest";
|
||||
const DEFAULT_ICON_VERSION = "default";
|
||||
const TRANSPARENT_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9sot5WQAAAAASUVORK5CYII=";
|
||||
|
||||
Reference in New Issue
Block a user