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:
+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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user