Compare commits

..
6 Commits
Author SHA1 Message Date
mohandClaude Opus 4.8 7543271086 FIX - Type dock bounce ease as const tuple for framer-motion
CI / quality (push) Waiting to run
framer-motion's stricter types reject a cubic-bezier ease inferred as number[]; `as const` makes it a 4-tuple so the production build type check passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-22 10:41:48 +02:00
MOH 3c9af70f98 Merge branch 'main' of https://git.mohfarawati.de/mohs/sass-mohfarawati
CI / quality (push) Canceled after 0s
2026-09-20 22:00:09 +02:00
moh 546b2eadda Merge branch 'main' of https://git.mohfarawati.de/mohs/sass-mohfarawati
CI / quality (push) Canceled after 0s
2026-09-20 21:58:56 +02:00
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
MOH 91ff6b9edb POLISHED - Soften dock bounce animation to match macOS feel 2026-09-20 21:34:50 +02:00
MOH d5e50b84a8 REPLACED - Swap dock SVG icons with Apple-style PNGs for light/dark themes
CI / quality (push) Canceled after 0s
Replace placeholder SVG dock icons with high-quality Apple-style PNG icons
(Contacts, Photos, App Store, Messages) with light and dark variants that
switch based on the active theme via next-themes.
2026-09-20 21:28:13 +02:00
60 changed files with 1869 additions and 197 deletions
+4 -1
View File
@@ -83,14 +83,17 @@ The Drizzle client is in `lib/db/index.ts` (postgres.js driver); the schema is i
| DB schema | `lib/db/schema.ts` |
| AppConfig aggregate | `lib/app-config.ts` |
| Portfolio queries | `lib/portfolio.ts` |
| Media handling | `lib/media.ts` |
| Media handling | `lib/media.ts`, `lib/media-storage.ts` |
| Contact flow | `lib/mail.ts` |
| SEO (metadata, robots, sitemap, JSON-LD) | `lib/metadata.ts`, `lib/seo-settings.ts`, `app/robots.ts`, `app/sitemap.ts` — see `docs/SEO.md` |
| Admin session token (middleware + auth) | `lib/admin-session-token.ts` |
### Documentation to read by task scope
- **Small UI/copy/style fixes**: read only the relevant files
- **Feature changes**: read `specs/<feature>.md` + `docs/ARCHITECTURE.md` if structure is affected
- **Cross-cutting/architecture changes**: read `docs/ARCHITECTURE.md`, `docs/DOMAIN_RULES.md`, `docs/FEATURES.md`, and the relevant `specs/` file
- **SEO / metadata / robots / sitemap**: read `docs/SEO.md` first
Update `docs/` and `specs/` only when the change affects feature scope, business rules, architecture, or public behavior.
+7 -2
View File
@@ -8,9 +8,11 @@ import { PageTransition } from "@/components/layout/page-transition";
import { SiteDock } from "@/components/layout/site-dock";
import { SiteFooter } from "@/components/layout/site-footer";
import { ScrollSmootherProvider } from "@/components/scroll-smoother-provider";
import { JsonLd } from "@/components/seo/json-ld";
import { isSuperAdmin } from "@/lib/admin-auth";
import { getMaintenanceMode, getSiteSettings } from "@/lib/app-config";
import { getMaintenanceMode, getSeoSettings, getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config";
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
import { buildSiteJsonLd } from "@/lib/metadata";
type SiteLayoutProps = {
children: ReactNode;
@@ -25,9 +27,11 @@ export const revalidate = 0;
export default async function SiteLayout({ children, params }: SiteLayoutProps) {
noStore();
await params;
const [maintenanceEnabled, siteSettings] = await Promise.all([
const [maintenanceEnabled, siteSettings, seo, mediaBindings] = await Promise.all([
getMaintenanceMode(),
getSiteSettings(),
getSeoSettings(),
getSiteSettingsMediaBindings(),
]);
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
// Server-side decision only. The dock receives just this boolean and uses
@@ -42,6 +46,7 @@ export default async function SiteLayout({ children, params }: SiteLayoutProps)
return (
<>
<JsonLd data={buildSiteJsonLd({ settings: siteSettings, seo, bindings: mediaBindings, locale: localeKey })} />
<ScrollSmootherProvider />
<SiteAmbientBackdrop />
<SiteDock defaultLocale={siteSettings.defaultLocale} isSuperAdmin={authenticated} />
+24 -3
View File
@@ -7,8 +7,9 @@ import { PageHero } from "@/components/layout/page-hero";
import { PortfolioCategoryFilter } from "@/components/site/portfolio-category-filter";
import { PortfolioProjectDetail } from "@/components/site/portfolio-project-detail";
import { PortfolioProjectGrid } from "@/components/site/portfolio-project-grid";
import { getSiteSettings } from "@/lib/app-config";
import { buildLocalizedMetadata } from "@/lib/metadata";
import { JsonLd } from "@/components/seo/json-ld";
import { getSeoSettings, getSiteSettings } from "@/lib/app-config";
import { buildLocalizedMetadata, buildProjectJsonLd } from "@/lib/metadata";
import { resolveLocale } from "@/lib/locale";
import {
getActivePortfolioCategories,
@@ -57,6 +58,9 @@ export async function generateMetadata({ params }: PortfolioSlugPageProps): Prom
pathname: `/portfolio/${slug}`,
title: getLocalizedValue(resolved.project.title, localeKey),
description: getLocalizedValue(resolved.project.summary, localeKey),
image: resolved.project.coverImagePath,
type: "article",
publishedTime: resolved.project.publishedAt,
});
}
@@ -108,13 +112,30 @@ export default async function PortfolioSlugPage({ params }: PortfolioSlugPagePro
}
const { project: item } = resolved;
const t = await getTranslations({ locale: localeKey, namespace: "portfolioDetail" });
const [t, seo] = await Promise.all([
getTranslations({ locale: localeKey, namespace: "portfolioDetail" }),
getSeoSettings(),
]);
const title = getLocalizedValue(item.title, localeKey);
const category = getLocalizedValue(item.category.name, localeKey);
const summary = getLocalizedValue(item.summary, localeKey);
const jsonLd = buildProjectJsonLd({
settings: siteSettings,
seo,
locale: localeKey,
pathname: `/portfolio/${slug}`,
title,
description: summary,
image: item.coverImagePath,
datePublished: item.publishedAt,
genre: category,
keywords: [getLocalizedValue(item.serviceLabel, localeKey), String(item.projectYear)].filter(Boolean),
clientName: item.clientName,
});
return (
<>
<JsonLd data={jsonLd} />
<PageHero
locale={localeKey}
badge={category}
+1
View File
@@ -30,6 +30,7 @@ export async function generateMetadata({ params }: SuccessPageProps): Promise<Me
pathname: "/success",
title: t("title"),
description: t("text"),
noIndex: true,
});
}
+1
View File
@@ -34,6 +34,7 @@ export async function generateMetadata({ params }: ComingSoonPageProps): Promise
title: siteSettings.locales[localeKey].siteName,
description: t("description"),
applyTitleTemplate: false,
noIndex: true,
});
}
+26 -1
View File
@@ -1,6 +1,6 @@
"use server";
import { and, eq, inArray } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error";
@@ -154,6 +154,19 @@ export async function upsertCategoryAction(formData: FormData) {
isActive: normalizeCheckboxValue(formData, "isActive"),
});
// Categories and projects share the public `/portfolio/[slug]` route, so a
// slug may only exist on one side. Categories win at resolve time, which
// would silently hide a project with the same slug.
const [projectWithSlug] = await db
.select({ id: portfolioProject.id })
.from(portfolioProject)
.where(eq(portfolioProject.slug, parsed.slug))
.limit(1);
if (projectWithSlug) {
throw new Error("Kategorie Slug ist bereits als Projekt Slug vergeben.");
}
if (parsed.id) {
await db.update(category).set(parsed).where(eq(category.id, parsed.id));
} else {
@@ -172,6 +185,8 @@ export async function upsertCategoryAction(formData: FormData) {
? parseZodError(error)
: isUniqueViolation(error)
? "Kategorie Slug muss eindeutig sein."
: error instanceof Error && error.message.includes("Slug")
? error.message
: "Kategorie konnte nicht gespeichert werden.";
redirect(withFlash(redirectPath, { error: message }));
@@ -298,6 +313,16 @@ export async function saveProjectAction(formData: FormData) {
assets,
});
const [categoryWithSlug] = await db
.select({ id: category.id })
.from(category)
.where(eq(category.slug, parsed.slug))
.limit(1);
if (categoryWithSlug) {
throw new Error("Projekt Slug ist bereits als Kategorie Slug vergeben.");
}
const existingProject = parsed.id
? (
await db
+71
View File
@@ -12,7 +12,9 @@ import {
SITE_SETTINGS_FAVICON_FIELD_KEY,
SITE_SETTINGS_LOGO_DARK_FIELD_KEY,
SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
getSeoSettings,
getSiteSettings,
updateSeoSettings,
updateSiteSettings,
} from "@/lib/app-config";
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
@@ -24,6 +26,15 @@ import {
type SiteSettings,
} from "@/lib/site-settings";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import {
normalizeKeywords,
normalizeSameAs,
normalizeStructuredDataType,
normalizeTwitterHandle,
normalizeVerificationToken,
type SeoSettings,
} from "@/lib/seo-settings";
import { isCheckedFormValue } from "@/lib/form-data";
import { replaceEntityMediaUsages } from "@/lib/media";
import { resolveMediaSelection } from "@/lib/media-service";
import { routing } from "@/i18n/routing";
@@ -329,3 +340,63 @@ export async function saveSiteLocalizationSettingsAction(formData: FormData) {
redirect(withFlash(getAdminAppPath("/site-settings/localization"), { error: message }));
}
}
export async function saveSeoSettingsAction(formData: FormData) {
await ensureAdmin();
try {
const currentSettings = await getSeoSettings();
const googleRaw = String(formData.get("googleSiteVerification") ?? "").trim();
const bingRaw = String(formData.get("bingSiteVerification") ?? "").trim();
const twitterRaw = String(formData.get("twitterHandle") ?? "").trim();
const googleSiteVerification = normalizeVerificationToken(googleRaw);
const bingSiteVerification = normalizeVerificationToken(bingRaw);
const twitterHandle = normalizeTwitterHandle(twitterRaw);
if (googleRaw && !googleSiteVerification) {
throw new Error("Google Verification Code darf nur Buchstaben, Zahlen, - und _ enthalten.");
}
if (bingRaw && !bingSiteVerification) {
throw new Error("Bing Verification Code darf nur Buchstaben, Zahlen, - und _ enthalten.");
}
if (twitterRaw && !twitterHandle) {
throw new Error("X/Twitter Handle ist ungueltig (max. 15 Zeichen, Buchstaben/Zahlen/_).");
}
const parsedSettings: SeoSettings = {
...currentSettings,
allowIndexing: isCheckedFormValue(formData.get("allowIndexing")),
googleSiteVerification,
bingSiteVerification,
twitterHandle,
structuredDataType: normalizeStructuredDataType(formData.get("structuredDataType")),
structuredDataName: String(formData.get("structuredDataName") ?? "").trim().slice(0, 120),
structuredDataJobTitle: String(formData.get("structuredDataJobTitle") ?? "").trim().slice(0, 160),
sameAs: normalizeSameAs(formData.get("sameAs")),
locales: {
ar: { keywords: normalizeKeywords(formData.get("keywordsAr")) },
en: { keywords: normalizeKeywords(formData.get("keywordsEn")) },
de: { keywords: normalizeKeywords(formData.get("keywordsDe")) },
},
};
await updateSeoSettings(parsedSettings);
const siteSettings = await getSiteSettings();
await revalidateSiteSettingsPages(siteSettings.defaultLocale);
revalidatePath("/sitemap.xml");
revalidatePath("/robots.txt");
revalidatePath(toInternalAdminPath("/site-settings/seo"));
redirect(withFlash(getAdminAppPath("/site-settings/seo"), { success: "SEO Einstellungen gespeichert." }));
} catch (error) {
if (isRedirectError(error)) {
throw error;
}
const message =
error instanceof Error ? error.message : "SEO Einstellungen konnten nicht gespeichert werden.";
redirect(withFlash(getAdminAppPath("/site-settings/seo"), { error: message }));
}
}
+104
View File
@@ -0,0 +1,104 @@
import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { SeoSettingsForm } from "@/components/admin/seo-settings-form";
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
import { readFlash } from "@/lib/admin-feedback";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { buildSiteUrl, getAdminAppPath } from "@/lib/admin-routing";
import {
getMaintenanceMode,
getSeoSettings,
getSiteSettings,
getSiteSettingsMediaBindings,
} from "@/lib/app-config";
import { getSiteUrl } from "@/lib/metadata";
import { INTERNAL_MANIFEST_PATH } from "@/lib/site-icons";
import { getPublishedPortfolioProjects } from "@/lib/portfolio";
import { buildSeoChecklist } from "@/lib/seo-report";
import buildSitemap from "@/app/sitemap";
import { saveSeoSettingsAction } from "../actions";
export const dynamic = "force-dynamic";
const copy = {
title: "SEO",
subtitle: "Indexierung, Verifizierung, strukturierte Daten, Sitemap und robots.txt.",
overview: "Uebersicht",
maintenance: "Wartungsmodus",
uiKit: "UI Kit",
media: "Media",
siteSettings: "Settings",
brandSettings: "Brand",
localizationSettings: "Localization",
seoSettings: "SEO",
smtp: "SMTP",
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",
};
export default async function AdminSeoSettingsPage({
searchParams,
}: {
searchParams?: Promise<{ success?: string; error?: string }>;
}) {
const flash = readFlash(await searchParams);
if (!(await isAdminAuthenticated())) {
redirect(getAdminAppPath("/"));
}
async function logoutAction() {
"use server";
await clearAdminSessionCookie();
redirect(getAdminAppPath("/"));
}
const [seo, siteSettings, bindings, maintenanceEnabled, projects, sitemapEntries] = await Promise.all([
getSeoSettings(),
getSiteSettings(),
getSiteSettingsMediaBindings(),
getMaintenanceMode(),
getPublishedPortfolioProjects().catch(() => []),
buildSitemap().catch(() => []),
]);
const checks = buildSeoChecklist({
seo,
settings: siteSettings,
bindings,
maintenanceEnabled,
publishedProjectCount: projects.length,
sitemapEntryCount: sitemapEntries.length,
siteUrl: getSiteUrl().origin,
});
return (
<AdminDashboardShell
copy={copy}
active="site-settings"
flash={flash}
siteSettingsChild="seo"
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
>
<MotionFade delay={0.16}>
<SeoSettingsForm
action={saveSeoSettingsAction}
settings={seo}
checks={checks}
sitemapEntryCount={sitemapEntries.length}
links={{
sitemap: buildSiteUrl("/sitemap.xml"),
robots: buildSiteUrl("/robots.txt"),
manifest: buildSiteUrl(INTERNAL_MANIFEST_PATH),
}}
/>
</MotionFade>
</AdminDashboardShell>
);
}
@@ -0,0 +1 @@
export { default } from "../../../_admin/site-settings/seo/page";
+37 -3
View File
@@ -1,15 +1,49 @@
import type { MetadataRoute } from "next";
import { unstable_noStore as noStore } from "next/cache";
export default function robots(): MetadataRoute.Robots {
const siteUrl = new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
import { getMaintenanceMode, getSeoSettings } from "@/lib/app-config";
import { INTERNAL_ADMIN_PREFIX } from "@/lib/admin-routing";
import { getSiteUrl } from "@/lib/metadata";
export const dynamic = "force-dynamic";
/** Paths that must never be crawled even when indexing is enabled. */
export const ROBOTS_DISALLOWED_PATHS = [
INTERNAL_ADMIN_PREFIX,
"/root",
"/api/",
"/success",
"/coming-soon",
"/*/success",
"/*/coming-soon",
];
export function buildRobots(input: { indexable: boolean }): MetadataRoute.Robots {
const siteUrl = getSiteUrl();
if (!input.indexable) {
return {
rules: [{ userAgent: "*", disallow: "/" }],
host: siteUrl.origin,
};
}
return {
rules: [
{
userAgent: "*",
disallow: ["/admin-internal"],
allow: "/",
disallow: ROBOTS_DISALLOWED_PATHS,
},
],
sitemap: new URL("/sitemap.xml", siteUrl).toString(),
host: siteUrl.origin,
};
}
export default async function robots(): Promise<MetadataRoute.Robots> {
noStore();
const [seo, maintenanceEnabled] = await Promise.all([getSeoSettings(), getMaintenanceMode()]);
return buildRobots({ indexable: seo.allowIndexing && !maintenanceEnabled });
}
+1
View File
@@ -0,0 +1 @@
export { default } from "../../../_admin/site-settings/seo/page";
+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,
+22 -6
View File
@@ -14,6 +14,7 @@ export const dynamic = "force-dynamic";
const CONTENT_TYPES: Record<string, string> = {
".ico": "image/x-icon",
".gif": "image/gif",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
@@ -30,15 +31,30 @@ export async function GET(_: Request, { params }: MediaFileRouteProps) {
try {
const absolutePath = resolveMediaUploadPath(publicPath);
const fileBuffer = await readFile(absolutePath);
const contentType = CONTENT_TYPES[path.extname(absolutePath).toLowerCase()] ?? "application/octet-stream";
const extension = path.extname(absolutePath).toLowerCase();
const contentType = CONTENT_TYPES[extension];
return new NextResponse(fileBuffer, {
status: 200,
headers: {
if (!contentType) {
return new NextResponse("Not Found", { status: 404 });
}
const headers: Record<string, string> = {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
"X-Content-Type-Options": "nosniff",
};
// SVG is an active document type: sandbox it so an uploaded file can never
// run script or reach our origin even if it is opened directly.
if (extension === ".svg") {
headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
}
if (extension === ".pdf") {
headers["Content-Disposition"] = "inline";
}
return new NextResponse(new Uint8Array(fileBuffer), { status: 200, headers });
} catch {
return new NextResponse("Not Found", {
status: 404,
+5 -1
View File
@@ -8,6 +8,7 @@ import {
LogOut,
Palette,
PlusSquare,
Search,
ShieldAlert,
SwatchBook,
Tags,
@@ -40,6 +41,7 @@ type AdminDashboardCopy = {
siteSettings: string;
brandSettings?: string;
localizationSettings?: string;
seoSettings?: string;
marquee?: string;
smtp?: string;
logout: string;
@@ -50,7 +52,7 @@ type AdminDashboardShellProps = {
copy: AdminDashboardCopy;
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee";
portfolioChild?: "overview" | "projects" | "new-project" | "categories";
siteSettingsChild?: "brand" | "localization";
siteSettingsChild?: "brand" | "localization" | "seo";
flash?: FlashMessages;
logoutAction: () => Promise<void>;
headerTitle: string;
@@ -100,6 +102,8 @@ export async function AdminDashboardShell({
: active === "site-settings"
? siteSettingsChild === "localization"
? Languages
: siteSettingsChild === "seo"
? Search
: siteSettingsChild === "brand"
? Palette
: Globe2
+278
View File
@@ -0,0 +1,278 @@
import { ExternalLink, FileCode2, Map as MapIcon, Bot, CheckCircle2, AlertTriangle, XCircle } from "lucide-react";
import Link from "next/link";
import { StatsCard } from "@/components/dashboard/stats-card";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { SeoCheck } from "@/lib/seo-report";
import { summarizeSeoChecklist } from "@/lib/seo-report";
import type { SeoSettings } from "@/lib/seo-settings";
import { cn } from "@/lib/utils";
type SeoSettingsFormProps = {
action: (formData: FormData) => Promise<void>;
settings: SeoSettings;
checks: SeoCheck[];
links: {
sitemap: string;
robots: string;
manifest: string;
};
sitemapEntryCount: number;
};
const localeKeywordFields = [
{ key: "de", name: "keywordsDe", label: "Keywords (Deutsch)" },
{ key: "en", name: "keywordsEn", label: "Keywords (English)" },
{ key: "ar", name: "keywordsAr", label: "Keywords (Arabic)" },
] as const;
function StatusIcon({ status }: { status: SeoCheck["status"] }) {
if (status === "ok") {
return <CheckCircle2 className="h-4 w-4 text-status-success" aria-label="OK" />;
}
if (status === "warn") {
return <AlertTriangle className="h-4 w-4 text-status-warning" aria-label="Hinweis" />;
}
return <XCircle className="h-4 w-4 text-destructive" aria-label="Fehler" />;
}
function FileLink({
href,
label,
description,
icon: Icon,
}: {
href: string;
label: string;
description: string;
icon: typeof MapIcon;
}) {
return (
<AppCard level={2} padding="sm" contentClassName="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-nested border border-border bg-muted/50 text-muted-foreground">
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{label}</p>
<p className="truncate text-xs text-muted-foreground">{description}</p>
</div>
</div>
<Button asChild variant="outline" size="sm">
<Link href={href} target="_blank" rel="noreferrer">
Oeffnen
<ExternalLink className="ml-1.5 h-3.5 w-3.5" />
</Link>
</Button>
</AppCard>
);
}
export function SeoSettingsForm({ action, settings, checks, links, sitemapEntryCount }: SeoSettingsFormProps) {
const summary = summarizeSeoChecklist(checks);
return (
<div className="space-y-6">
<div className="grid gap-3 sm:grid-cols-3">
<StatsCard title="Bereit" value={String(summary.ok)} description="Checks bestanden" />
<StatsCard title="Hinweise" value={String(summary.warn)} description="Empfohlen zu pruefen" />
<StatsCard title="Fehler" value={String(summary.error)} description="Blockiert Sichtbarkeit" />
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<form id="seo-settings-form" action={action} className="space-y-6">
<section className="space-y-3">
<div>
<h2 className="text-lg font-semibold text-foreground">Sichtbarkeit</h2>
<p className="text-sm text-muted-foreground">
Steuert robots.txt, die Sitemap und das robots Meta Tag aller oeffentlichen Seiten.
</p>
</div>
<AppCard level={2} padding="sm" contentClassName="space-y-4">
<label className="flex items-start gap-3">
<input
type="checkbox"
name="allowIndexing"
value="on"
defaultChecked={settings.allowIndexing}
className="mt-1 h-4 w-4 rounded border-border accent-primary"
/>
<span>
<span className="block text-sm font-medium text-foreground">Indexierung erlauben</span>
<span className="block text-xs text-muted-foreground">
Aus = noindex auf allen Seiten, robots.txt sperrt alles, Sitemap wird leer. Der Wartungsmodus
sperrt zusaetzlich automatisch.
</span>
</span>
</label>
</AppCard>
</section>
<section className="space-y-3">
<div>
<h2 className="text-lg font-semibold text-foreground">Verifizierung & Social</h2>
<p className="text-sm text-muted-foreground">
Codes aus Google Search Console / Bing Webmaster und das X-Handle fuer Twitter Cards.
</p>
</div>
<AppCard level={2} padding="sm" contentClassName="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="googleSiteVerification">Google Verification</Label>
<Input
id="googleSiteVerification"
name="googleSiteVerification"
defaultValue={settings.googleSiteVerification}
placeholder="google-site-verification Wert"
className="font-mono"
/>
</div>
<div className="space-y-2">
<Label htmlFor="bingSiteVerification">Bing Verification</Label>
<Input
id="bingSiteVerification"
name="bingSiteVerification"
defaultValue={settings.bingSiteVerification}
placeholder="msvalidate.01 Wert"
className="font-mono"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="twitterHandle">X / Twitter Handle</Label>
<Input
id="twitterHandle"
name="twitterHandle"
defaultValue={settings.twitterHandle}
placeholder="@handle"
/>
</div>
</AppCard>
</section>
<section className="space-y-3">
<div>
<h2 className="text-lg font-semibold text-foreground">Strukturierte Daten</h2>
<p className="text-sm text-muted-foreground">
JSON-LD fuer Google: Wer steht hinter der Seite? Gilt fuer alle Seiten und jede Projekt-Ansicht.
</p>
</div>
<AppCard level={2} padding="sm" contentClassName="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="structuredDataType">Typ</Label>
<select
id="structuredDataType"
name="structuredDataType"
defaultValue={settings.structuredDataType}
className="flex h-10 w-full rounded-nested border border-input bg-background px-3 py-2 text-sm"
>
<option value="Person">Person (Freelancer / Portfolio)</option>
<option value="Organization">Organization (Studio / Firma)</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="structuredDataName">Name</Label>
<Input
id="structuredDataName"
name="structuredDataName"
defaultValue={settings.structuredDataName}
placeholder="Leer = Site Name"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="structuredDataJobTitle">Job Title / Slogan</Label>
<Input
id="structuredDataJobTitle"
name="structuredDataJobTitle"
defaultValue={settings.structuredDataJobTitle}
placeholder="z.B. Brand & Motion Designer"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="sameAs">Social Profile (eine https-URL pro Zeile)</Label>
<Textarea
id="sameAs"
name="sameAs"
rows={4}
defaultValue={settings.sameAs.join("\n")}
placeholder={"https://www.behance.net/...\nhttps://www.linkedin.com/in/..."}
className="font-mono text-xs"
/>
</div>
</AppCard>
</section>
<section className="space-y-3">
<div>
<h2 className="text-lg font-semibold text-foreground">Keywords</h2>
<p className="text-sm text-muted-foreground">
Kommagetrennt, pro Sprache. Geringe Gewichtung bei Google, aber nuetzlich fuer Bing und Struktur.
</p>
</div>
<AppCard level={2} padding="sm" contentClassName="grid gap-4">
{localeKeywordFields.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={field.name}>{field.label}</Label>
<Input
id={field.name}
name={field.name}
defaultValue={settings.locales[field.key].keywords}
dir={field.key === "ar" ? "rtl" : "ltr"}
placeholder="branding, motion design, berlin"
/>
</div>
))}
</AppCard>
</section>
<div className="flex justify-end">
<Button type="submit">Save SEO Settings</Button>
</div>
</form>
<aside className="space-y-6">
<section className="space-y-3">
<div>
<h2 className="text-lg font-semibold text-foreground">Dateien</h2>
<p className="text-sm text-muted-foreground">Werden live aus den Einstellungen generiert.</p>
</div>
<div className="space-y-2">
<FileLink
href={links.sitemap}
label="sitemap.xml"
description={`${sitemapEntryCount} URLs, hreflang fuer DE/EN/AR`}
icon={MapIcon}
/>
<FileLink href={links.robots} label="robots.txt" description="Crawler-Regeln + Sitemap-Verweis" icon={Bot} />
<FileLink href={links.manifest} label="manifest.webmanifest" description="PWA / Icons" icon={FileCode2} />
</div>
</section>
<section className="space-y-3">
<div>
<h2 className="text-lg font-semibold text-foreground">Checkliste</h2>
<p className="text-sm text-muted-foreground">Status der wichtigsten SEO-Bausteine.</p>
</div>
<AppCard level={2} padding="sm" contentClassName="divide-y divide-border/60">
{checks.map((check) => (
<div key={check.id} className={cn("flex items-start gap-3 py-2.5 first:pt-0 last:pb-0")}>
<span className="mt-0.5 shrink-0">
<StatusIcon status={check.status} />
</span>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{check.label}</p>
<p className="text-xs text-muted-foreground">{check.detail}</p>
</div>
</div>
))}
</AppCard>
</section>
</aside>
</div>
</div>
);
}
+15 -12
View File
@@ -5,6 +5,7 @@ import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import { useState } from "react";
import { FloatingControls } from "@/components/layout/floating-controls";
@@ -28,17 +29,15 @@ type NavKey = "about" | "portfolio" | "products" | "contact";
type NavItem = {
key: NavKey;
path: string;
/** Icon file under /public. Swap these for real macOS app icons anytime. */
icon: string;
disabled?: boolean;
};
// "Home" is intentionally omitted — the logo (first dock icon) is the home link.
const navItems: NavItem[] = [
{ key: "about", path: "/about", icon: "/dock/about.svg" },
{ key: "portfolio", path: "/portfolio", icon: "/dock/portfolio.svg" },
{ key: "products", path: "/products", icon: "/dock/products.svg", disabled: true },
{ key: "contact", path: "/contact", icon: "/dock/contact.svg" },
{ key: "about", path: "/about" },
{ key: "portfolio", path: "/portfolio" },
{ key: "products", path: "/products", disabled: true },
{ key: "contact", path: "/contact" },
];
function isNavItemActive(currentPath: string, itemPath: string) {
@@ -54,6 +53,8 @@ export function SiteDock({ defaultLocale, isSuperAdmin = false }: SiteDockProps)
const t = useTranslations("navigation");
const reducedMotion = useReducedMotion();
const [bouncing, setBouncing] = useState<string | null>(null);
const { theme, resolvedTheme } = useTheme();
const activeTheme = theme === "system" ? resolvedTheme : theme;
const currentPath = stripLocalePrefix(pathname);
const homeHref = getLocalizedPath(locale, "/", defaultLocale);
@@ -64,8 +65,8 @@ export function SiteDock({ defaultLocale, isSuperAdmin = false }: SiteDockProps)
? {}
: {
onClick: () => setBouncing(id),
animate: bouncing === id ? { y: [0, -18, 0, -6, 0] } : { y: 0 },
transition: { duration: 0.5, ease: "easeOut" as const },
animate: bouncing === id ? { y: [0, -5, 0, -2, 0] } : { y: 0 },
transition: { duration: 0.35, ease: [0.22, 1, 0.36, 1] as const },
onAnimationComplete: () => setBouncing((cur) => (cur === id ? null : cur)),
};
@@ -111,23 +112,25 @@ export function SiteDock({ defaultLocale, isSuperAdmin = false }: SiteDockProps)
<div className="mx-0.5 h-9 w-px self-center bg-border/70" aria-hidden />
{/* ── Pages (icon art carries its own squircle shape) ── */}
{navItems.map(({ key, path, icon: iconSrc, disabled }) => {
{navItems.map(({ key, path, disabled }) => {
const itemPath = path || "/";
const isActive = isNavItemActive(currentPath, itemPath);
const label = t(key);
const variant = activeTheme === "dark" ? "dark" : "light";
const src = `/dock/${key}-${variant}.png`;
const icon = (
<motion.span
className="flex h-full w-full items-center justify-center"
className="flex h-full w-full items-center justify-center overflow-hidden rounded-[22%]"
{...(disabled ? {} : bounceProps(key))}
>
<Image
src={iconSrc}
src={src}
alt={label}
width={104}
height={104}
sizes="104px"
className={cn("h-full w-full object-contain", disabled && "opacity-55")}
className={cn("h-[110%] w-[110%] object-cover", disabled && "opacity-55")}
/>
</motion.span>
);
+14
View File
@@ -0,0 +1,14 @@
import { serializeJsonLd } from "@/lib/metadata";
/**
* Renders a JSON-LD `<script>` block. Server component only — the payload is
* serialized with `<` escaped so it can never break out of the script tag.
*/
export function JsonLd({ data }: { data: Record<string, unknown> }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
/>
);
}
+9 -3
View File
@@ -181,8 +181,8 @@ i18n/routing.ts
Admin routing
lib/admin-routing.ts
Prisma access
lib/prisma.ts
Database access (Drizzle)
lib/db/index.ts, lib/db/schema.ts
Application configuration
lib/app-config.ts
@@ -191,7 +191,13 @@ Portfolio logic
lib/portfolio.ts
Media handling
lib/media.ts
lib/media.ts (DB), lib/media-storage.ts (filesystem + content validation), lib/media-service.ts
SEO / metadata
lib/metadata.ts, lib/seo-settings.ts, lib/seo-report.ts, app/robots.ts, app/sitemap.ts (docs/SEO.md)
Admin session token (shared by middleware and server auth)
lib/admin-session-token.ts
---
+11 -4
View File
@@ -14,8 +14,11 @@
- Contact form with:
- validation
- email delivery
- Success page after contact submission
- Maintenance redirect flow
- Success page after contact submission (noindex)
- Maintenance redirect flow (bypass requires a *signed* admin session cookie)
- SEO: localized metadata with canonical + hreflang, OG/Twitter cards (project
cover as share image), JSON-LD (WebSite + Person/Organization, CreativeWork per
project), dynamic `robots.txt` and hreflang `sitemap.xml` — see `docs/SEO.md`
### Admin
@@ -24,8 +27,12 @@
- Portfolio category management
- Portfolio project creation and editing
- Section and asset management inside each project
- Media library with usage bindings
- Site settings management
- Media library with usage bindings (uploads are magic-byte checked, SVGs are
sanitized and served sandboxed)
- Site settings management (Brand, Localization, SEO)
- SEO page: indexing switch, Search Console/Bing verification, X handle,
structured-data identity, per-locale keywords, readiness checklist and links
to sitemap/robots/manifest
- SMTP settings and test email
- Marquee settings
- Maintenance toggle
+83
View File
@@ -0,0 +1,83 @@
# SEO
How search visibility works in this project and where each piece is controlled.
Everything is data-driven from the admin; no code change is needed to adjust
titles, descriptions, indexing, verification, or structured data.
## Admin: Settings → SEO (`/site-settings/seo`)
Canonical page: `app/_admin/site-settings/seo/page.tsx` (mirrored under
`app/admin-internal/` and `app/root/`). Form: `components/admin/seo-settings-form.tsx`.
Action: `saveSeoSettingsAction` in `app/_admin/site-settings/actions.ts`.
Stored as one JSON blob in `app_config` under key `seo_settings`
(`lib/seo-settings.ts` parses/normalizes; `lib/app-config.ts` exposes
`getSeoSettings` / `updateSeoSettings`).
| Field | Effect |
|---|---|
| Indexierung erlauben | Off → `noindex,nofollow` meta on every page, `robots.txt` disallows `/`, `sitemap.xml` becomes empty. Maintenance mode forces the same automatically. |
| Google / Bing Verification | `<meta name="google-site-verification">` and `<meta name="msvalidate.01">` on all pages. Tokens are restricted to `[A-Za-z0-9_-]`. |
| X / Twitter Handle | `twitter:site` + `twitter:creator`. |
| Strukturierte Daten | Type (`Person` / `Organization`), name, job title/slogan, `sameAs` profile URLs → JSON-LD publisher on every public page. |
| Keywords (per locale) | `<meta name="keywords">` per language. |
The page also shows a **checklist** (`lib/seo-report.ts`) — indexing state, public
URL, meta description length per locale, OG image, favicon, verification,
structured data, published projects, sitemap URL count — and **open buttons** for
`/sitemap.xml`, `/robots.txt`, `/manifest.webmanifest`.
Titles, descriptions and the title template per locale live under
**Settings → Localization**; logos, favicon and the default OG image under
**Settings → Brand**.
## Generated files
- `app/robots.ts``/robots.txt`. Indexable: allow `/`, disallow admin
(`/admin-internal`, `/root`), `/api/`, `/success`, `/coming-soon` (+ locale
variants), plus the sitemap URL. Not indexable (setting off or maintenance):
disallow everything.
- `app/sitemap.ts``/sitemap.xml`. One entry per locale for home, about,
portfolio, contact, every category that has published projects, and every
published project — each with `xhtml:link hreflang` alternates and `x-default`.
Empty while maintenance mode is on or indexing is disabled.
- `app/manifest.ts``/manifest.webmanifest` (icons from Brand settings).
## Per-page metadata (`lib/metadata.ts`)
- `buildAppMetadata()` — root layout: `metadataBase`, robots, verification,
keywords, icons, manifest, OG (`og:locale` as `de_DE`/`en_US`/`ar_AR` +
`alternateLocale`), Twitter.
- `buildLocalizedMetadata({...})` — every public page: templated title,
description (≤300 chars), canonical + hreflang alternates, robots, OG, Twitter.
Options: `image` (page-specific share image), `noIndex`, `type: "article"`,
`publishedTime`.
- Portfolio project pages pass the project **cover** as OG image and `article`
type. This is independent of the project's view mode (`GRID` / `STORY` /
`CASE_STUDY`), so new view modes inherit full SEO automatically.
- `/success` and `/coming-soon` are `noindex`.
## Structured data (JSON-LD)
Rendered via `components/seo/json-ld.tsx` (server component; `<` is escaped).
- Site layout: `WebSite` + publisher (`Person` or `Organization`) graph linked by
`@id` (`buildSiteJsonLd`).
- Project page: `CreativeWork` with url, headline, description, image, genre
(category), keywords (service label, year), `datePublished`, author `@id`,
client as `sourceOrganization` (`buildProjectJsonLd`).
## Slugs
Categories and projects share `/portfolio/[slug]`; categories win at resolve
time. The admin therefore rejects a project slug that equals an existing
category slug and vice versa (`app/_admin/portfolio/actions.ts`).
## Operational checklist before launch
1. `NEXT_PUBLIC_SITE_URL` must be the public `https://` origin (canonical base).
2. Settings → Localization: site name + 50160 char description in DE/EN/AR.
3. Settings → Brand: default OG image (1200×630) + favicon.
4. Settings → SEO: indexing on, verification codes, Person/Organization data.
5. Maintenance mode off. Verify `/robots.txt` and `/sitemap.xml` from the SEO page.
6. Submit the sitemap in Google Search Console / Bing Webmaster.
+1 -1
View File
@@ -17,7 +17,7 @@ const config = [
},
},
{
ignores: ["prisma/seed.js"],
ignores: ["prisma/seed.js", "scripts/legacy-prisma-seed.cjs"],
},
];
+9 -27
View File
@@ -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> {
+9 -1
View File
@@ -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",
},
],
},
{
+47
View File
@@ -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);
}
+19
View File
@@ -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
+3 -2
View File
@@ -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
View File
@@ -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,
+3 -1
View File
@@ -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"],
+201 -18
View File
@@ -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: {
export function buildLocalizedMetadataFromConfig(
input: LocalizedMetadataOptions & {
settings: SiteSettings;
bindings: SiteSettingsMediaBindings;
seo?: SeoSettings;
locale: AppLocale;
pathname: string;
title: string;
description?: string;
applyTitleTemplate?: boolean;
}): Metadata {
},
): 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");
}
+124
View File
@@ -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,
};
}
+145
View File
@@ -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
View File
@@ -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=";
+2 -3
View File
@@ -16,6 +16,7 @@ import {
isLegacyAdminPath,
toInternalAdminPath,
} from "./lib/admin-routing";
import { ADMIN_SESSION_COOKIE, verifyAdminSessionToken } from "./lib/admin-session-token";
import {
FALLBACK_LOCALE,
getLocalizedPathWithDefault,
@@ -23,8 +24,6 @@ import {
stripLocalePrefix,
} from "./lib/locale";
const ADMIN_SESSION_COOKIE = "moh_admin_session";
type SiteRuntimeState = {
defaultLocale: (typeof appLocales)[number];
maintenanceEnabled: boolean;
@@ -220,7 +219,7 @@ export default async function middleware(request: NextRequest) {
if (
siteRuntimeState.maintenanceEnabled &&
!request.cookies.has(ADMIN_SESSION_COOKIE) &&
!verifyAdminSessionToken(request.cookies.get(ADMIN_SESSION_COOKIE)?.value) &&
!isComingSoonPath(pathname)
) {
const locale = getPathLocale(pathname, configuredDefaultLocale);
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

-12
View File
@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#454b57"/><stop offset="1" stop-color="#1c2027"/></linearGradient>
<linearGradient id="gl" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity="0.26"/><stop offset="0.5" stop-color="#fff" stop-opacity="0"/></linearGradient>
</defs>
<rect x="0" y="0" width="100" height="100" rx="22" fill="url(#bg)"/>
<rect x="0" y="0" width="100" height="100" rx="22" fill="url(#gl)"/>
<rect x="0.75" y="0.75" width="98.5" height="98.5" rx="21.25" fill="none" stroke="#fff" stroke-opacity="0.14" stroke-width="1.5"/>
<g fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
<circle cx="50" cy="41" r="11"/><path d="M30 72 C30 57, 70 57, 70 72"/></g>
</svg>

Before

Width:  |  Height:  |  Size: 853 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

-12
View File
@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#454b57"/><stop offset="1" stop-color="#1c2027"/></linearGradient>
<linearGradient id="gl" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity="0.26"/><stop offset="0.5" stop-color="#fff" stop-opacity="0"/></linearGradient>
</defs>
<rect x="0" y="0" width="100" height="100" rx="22" fill="url(#bg)"/>
<rect x="0" y="0" width="100" height="100" rx="22" fill="url(#gl)"/>
<rect x="0.75" y="0.75" width="98.5" height="98.5" rx="21.25" fill="none" stroke="#fff" stroke-opacity="0.14" stroke-width="1.5"/>
<g fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
<rect x="27" y="35" width="46" height="30" rx="6"/><path d="M30 40 L50 54 L70 40"/></g>
</svg>

Before

Width:  |  Height:  |  Size: 865 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

-18
View File
@@ -1,18 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#4aa8ff"/><stop offset="1" stop-color="#7b5cff"/></linearGradient>
<linearGradient id="gl" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity="0.30"/><stop offset="0.5" stop-color="#fff" stop-opacity="0"/></linearGradient>
<clipPath id="card"><rect x="27" y="31" width="46" height="38" rx="7"/></clipPath>
</defs>
<rect x="0" y="0" width="100" height="100" rx="22" fill="url(#bg)"/>
<rect x="0" y="0" width="100" height="100" rx="22" fill="url(#gl)"/>
<rect x="0.75" y="0.75" width="98.5" height="98.5" rx="21.25" fill="none" stroke="#fff" stroke-opacity="0.18" stroke-width="1.5"/>
<rect x="27" y="31" width="46" height="38" rx="7" fill="#ffffff"/>
<g clip-path="url(#card)">
<circle cx="40" cy="44" r="5.5" fill="#ffc23d"/>
<path d="M27 69 L43 52 L53 61 L61 51 L73 69 Z" fill="#37b877"/>
<path d="M55 69 L66 57 L73 64 L73 69 Z" fill="#2c9c86"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

-12
View File
@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#454b57"/><stop offset="1" stop-color="#1c2027"/></linearGradient>
<linearGradient id="gl" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity="0.26"/><stop offset="0.5" stop-color="#fff" stop-opacity="0"/></linearGradient>
</defs>
<rect x="0" y="0" width="100" height="100" rx="22" fill="url(#bg)"/>
<rect x="0" y="0" width="100" height="100" rx="22" fill="url(#gl)"/>
<rect x="0.75" y="0.75" width="98.5" height="98.5" rx="21.25" fill="none" stroke="#fff" stroke-opacity="0.14" stroke-width="1.5"/>
<g fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
<path d="M50 28 L71 40 L71 60 L50 72 L29 60 L29 40 Z"/><path d="M29 40 L50 52 L71 40"/><path d="M50 52 L50 72"/></g>
</svg>

Before

Width:  |  Height:  |  Size: 894 B

+1 -1
View File
@@ -27,7 +27,7 @@
- Overview dashboard
- Maintenance mode
- Media Library
- Site Settings
- Site Settings (Brand / Localization / SEO — see `docs/SEO.md`)
- Marquee Settings
- SMTP Settings
- Contact Protection
+10 -1
View File
@@ -32,7 +32,7 @@ describe("createMediaAssetAction", () => {
});
it.skipIf(!canManageUploads)("creates an asset from an uploaded file", async () => {
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "pic.png", { type: "image/png" });
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])], "pic.png", { type: "image/png" });
const url = await captureRedirect(() =>
createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "Pic", file })),
);
@@ -43,6 +43,15 @@ describe("createMediaAssetAction", () => {
await removeManagedMediaFile(assets[0].url);
});
it("rejects an upload whose bytes do not match the declared image type", async () => {
const file = new File(["<html><script>alert(1)</script></html>"], "evil.png", { type: "image/png" });
const url = await captureRedirect(() =>
createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "Evil", file })),
);
expect(url).toContain("error=");
expect((await db.select().from(mediaAsset)).length).toBe(0);
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "L" })));
@@ -104,6 +104,14 @@ describe("upsertCategoryAction", () => {
expect(category?.nameEn).toBe("Renamed");
});
it("rejects a category slug that already belongs to a project", async () => {
const other = await createCategory({ slug: "other" });
await createProject({ categoryId: other.id, slug: "taken" });
const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "taken" })));
expect(new URL(url, "http://test").searchParams.get("error")).toContain("Projekt Slug vergeben");
expect(await db.query.category.findFirst({ where: eq(categoryTable.slug, "taken") })).toBeUndefined();
});
it("reports a unique-constraint violation on duplicate slugs", async () => {
await createCategory({ slug: "branding" });
const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "branding" })));
@@ -141,6 +149,13 @@ describe("deleteCategoryAction", () => {
});
describe("saveProjectAction", () => {
it("rejects a project slug that already belongs to a category", async () => {
const category = await createCategory({ slug: "branding" });
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id, { slug: "branding" })));
expect(new URL(url, "http://test").searchParams.get("error")).toContain("Kategorie Slug vergeben");
expect(await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.slug, "branding") })).toBeUndefined();
});
it("creates a published project with cover and asset media usages", async () => {
const category = await createCategory();
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
@@ -11,10 +11,11 @@ vi.mock("@/lib/admin-auth", async () => {
});
import {
saveSeoSettingsAction,
saveSiteBrandSettingsAction,
saveSiteLocalizationSettingsAction,
} from "@/app/_admin/site-settings/actions";
import { getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config";
import { getSeoSettings, getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config";
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
beforeEach(() => {
@@ -102,3 +103,51 @@ describe("saveSiteLocalizationSettingsAction", () => {
expect(url).toBe("/");
});
});
describe("saveSeoSettingsAction", () => {
it("persists normalized seo settings", async () => {
const url = await captureRedirect(() =>
saveSeoSettingsAction(
formDataFrom({
allowIndexing: "on",
googleSiteVerification: "g-1",
twitterHandle: "moh",
structuredDataType: "Organization",
structuredDataName: "Studio",
sameAs: "https://a.com\nhttps://b.com",
keywordsDe: "a, b",
}),
),
);
expect(url).toContain("success=");
const seo = await getSeoSettings();
expect(seo).toMatchObject({
allowIndexing: true,
googleSiteVerification: "g-1",
twitterHandle: "@moh",
structuredDataType: "Organization",
sameAs: ["https://a.com", "https://b.com"],
});
expect(seo.locales.de.keywords).toBe("a, b");
});
it("turns indexing off when the checkbox is missing", async () => {
await captureRedirect(() => saveSeoSettingsAction(formDataFrom({})));
expect((await getSeoSettings()).allowIndexing).toBe(false);
});
it("rejects an invalid verification token without saving", async () => {
await captureRedirect(() => saveSeoSettingsAction(formDataFrom({ allowIndexing: "on", googleSiteVerification: "ok" })));
const url = await captureRedirect(() =>
saveSeoSettingsAction(formDataFrom({ allowIndexing: "on", googleSiteVerification: "<bad>" })),
);
expect(url).toContain("error=");
expect((await getSeoSettings()).googleSiteVerification).toBe("ok");
});
it("redirects unauthenticated users", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => saveSeoSettingsAction(formDataFrom({})));
expect(url).not.toContain("success=");
});
});
+1 -1
View File
@@ -102,7 +102,7 @@ describe("resolveMediaSelection — missing configuration", () => {
describe("resolveMediaSelection — upload mode (filesystem)", () => {
it.skipIf(!canManageUploads)("saves the file and creates an UPLOAD asset", async () => {
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "shot.png", { type: "image/png" });
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])], "shot.png", { type: "image/png" });
const result = await resolveMediaSelection({
media: { mode: "upload", assetId: "", url: "", label: "Shot", kind: "IMAGE" },
uploadFile: file,
+1 -1
View File
@@ -42,7 +42,7 @@ describe("metadata helpers", () => {
apple: [{ url: "/apple-icon.png?v=v1" }],
});
expect(metadata.openGraph).toMatchObject({
locale: "ar",
locale: "ar_AR",
url: "https://mohfarawati.de/",
});
expect(metadata.twitter).toMatchObject({
+38 -2
View File
@@ -20,7 +20,7 @@ vi.mock("../lib/admin-routing", () => ({
toInternalAdminPath: (pathname: string) => pathname,
}));
function createMockRequest(url: string) {
function createMockRequest(url: string, cookieValue?: string) {
const nextUrl = new URL(url) as URL & { clone: () => URL };
nextUrl.clone = () => new URL(nextUrl.toString());
@@ -31,11 +31,25 @@ function createMockRequest(url: string) {
host: nextUrl.host,
}),
cookies: {
has: vi.fn(() => false),
has: vi.fn(() => cookieValue !== undefined),
get: vi.fn(() => (cookieValue !== undefined ? { name: "moh_admin_session", value: cookieValue } : undefined)),
},
};
}
function stubMaintenanceRuntime() {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
json: async () => ({
defaultLocale: "de",
maintenanceEnabled: true,
}),
})),
);
}
describe("middleware locale runtime config", () => {
beforeEach(() => {
vi.resetModules();
@@ -123,6 +137,28 @@ describe("middleware locale runtime config", () => {
expect(intlHandlerMock).not.toHaveBeenCalled();
});
it("does not let a forged admin cookie bypass maintenance mode", async () => {
vi.stubEnv("ADMIN_AUTH_SECRET", "test-secret");
stubMaintenanceRuntime();
const { default: middleware } = await import("../proxy");
const response = await middleware(createMockRequest("https://example.com/about", "superadmin.forged") as never);
expect(response.headers.get("location")).toBe("https://example.com/coming-soon");
expect(intlHandlerMock).not.toHaveBeenCalled();
});
it("lets a correctly signed admin cookie through maintenance mode", async () => {
vi.stubEnv("ADMIN_AUTH_SECRET", "test-secret");
stubMaintenanceRuntime();
const { buildAdminSessionToken } = await import("../lib/admin-session-token");
const { default: middleware } = await import("../proxy");
await middleware(createMockRequest("https://example.com/about", buildAdminSessionToken()) as never);
expect(intlHandlerMock).toHaveBeenCalledTimes(1);
});
it("prefers the configured internal runtime origin when provided", async () => {
process.env.SITE_RUNTIME_ORIGIN = "http://app:3000";
+1 -1
View File
@@ -73,7 +73,7 @@ describe("getAdminNavigation", () => {
const minimal = { ...copy, brandSettings: undefined, localizationSettings: undefined, marquee: undefined, smtp: undefined };
const nav = getAdminNavigation(minimal, "overview");
const siteSettings = nav.find((item) => item.label === "Site Settings");
expect(siteSettings?.children?.map((c) => c.label)).toEqual(["Brand", "Localization"]);
expect(siteSettings?.children?.map((c) => c.label)).toEqual(["Brand", "Localization", "SEO"]);
expect(nav.find((item) => item.href.endsWith("/marquee"))?.label).toBe("Marquee");
expect(nav.find((item) => item.href.endsWith("/smtp"))?.label).toBe("SMTP");
});
+39
View File
@@ -0,0 +1,39 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildAdminSessionToken, verifyAdminSessionToken } from "@/lib/admin-session-token";
const originalSecret = process.env.ADMIN_AUTH_SECRET;
beforeEach(() => {
process.env.ADMIN_AUTH_SECRET = "unit-test-secret";
});
afterEach(() => {
process.env.ADMIN_AUTH_SECRET = originalSecret;
});
describe("admin session token", () => {
it("round-trips a signed token", () => {
expect(verifyAdminSessionToken(buildAdminSessionToken())).toBe(true);
});
it("rejects forged, malformed, or missing tokens", () => {
expect(verifyAdminSessionToken("superadmin")).toBe(false);
expect(verifyAdminSessionToken("superadmin.deadbeef")).toBe(false);
expect(verifyAdminSessionToken("other." + buildAdminSessionToken().split(".")[1])).toBe(false);
expect(verifyAdminSessionToken("")).toBe(false);
expect(verifyAdminSessionToken(undefined)).toBe(false);
expect(verifyAdminSessionToken("1")).toBe(false);
});
it("rejects a token signed with a different secret", () => {
const token = buildAdminSessionToken();
process.env.ADMIN_AUTH_SECRET = "rotated";
expect(verifyAdminSessionToken(token)).toBe(false);
});
it("never validates when no secret is configured", () => {
process.env.ADMIN_AUTH_SECRET = "";
expect(verifyAdminSessionToken("superadmin.anything")).toBe(false);
});
});
+37
View File
@@ -7,6 +7,7 @@ import {
MEDIA_UPLOAD_ROOT,
getExtensionForMimeType,
isManagedMediaFilePath,
isMediaContentValid,
removeManagedMediaFile,
resolveMediaUploadPath,
sanitizeBaseName,
@@ -69,6 +70,42 @@ describe("resolveMediaUploadPath", () => {
it("throws when a traversal attempt escapes the root", () => {
expect(() => resolveMediaUploadPath("/uploads/media/../../etc/passwd")).toThrow(/escapes/i);
});
it("rejects a sibling directory that merely shares the root prefix", () => {
// `.../uploads/media-evil` starts with `.../uploads/media` as a string.
expect(() => resolveMediaUploadPath("/uploads/media/../media-evil/x.png")).toThrow(/escapes/i);
});
it("rejects the root itself, empty paths and null bytes", () => {
expect(() => resolveMediaUploadPath("/uploads/media/")).toThrow(/escapes/i);
expect(() => resolveMediaUploadPath("/uploads/media/./")).toThrow(/escapes/i);
expect(() => resolveMediaUploadPath("/uploads/media/a\0.png")).toThrow(/escapes/i);
});
});
describe("isMediaContentValid", () => {
it("accepts files whose magic bytes match the extension", () => {
expect(isMediaContentValid(".png", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2]))).toBe(true);
expect(isMediaContentValid(".jpg", Buffer.from([0xff, 0xd8, 0xff, 0xe0]))).toBe(true);
expect(isMediaContentValid(".gif", Buffer.from("GIF89a"))).toBe(true);
expect(isMediaContentValid(".pdf", Buffer.from("%PDF-1.7"))).toBe(true);
expect(isMediaContentValid(".webp", Buffer.from("RIFF\0\0\0\0WEBPVP8 "))).toBe(true);
});
it("rejects mismatched bytes (e.g. HTML disguised as an image)", () => {
expect(isMediaContentValid(".png", Buffer.from("<html><script>alert(1)</script>"))).toBe(false);
expect(isMediaContentValid(".jpg", Buffer.from("GIF89a"))).toBe(false);
expect(isMediaContentValid(".exe", Buffer.from("MZ"))).toBe(false);
});
it("accepts plain svg and rejects active content", () => {
expect(isMediaContentValid(".svg", Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'))).toBe(true);
expect(isMediaContentValid(".svg", Buffer.from('<?xml version="1.0"?>\n<svg><circle/></svg>'))).toBe(true);
expect(isMediaContentValid(".svg", Buffer.from("<svg><script>alert(1)</script></svg>"))).toBe(false);
expect(isMediaContentValid(".svg", Buffer.from('<svg onload="alert(1)"></svg>'))).toBe(false);
expect(isMediaContentValid(".svg", Buffer.from('<svg><a xlink:href="javascript:x"/></svg>'))).toBe(false);
expect(isMediaContentValid(".svg", Buffer.from("<html><svg/></html>"))).toBe(false);
});
});
describe("removeManagedMediaFile", () => {
+5
View File
@@ -25,6 +25,11 @@ describe("mediaFieldInputSchema", () => {
expect(parsed.url).toBe("/uploads/media/x.png");
});
it("rejects protocol-relative and javascript urls", () => {
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "external", url: "//evil.com/x.png" })).toThrow(/URL/i);
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "external", url: "javascript:alert(1)" })).toThrow(/URL/i);
});
it("requires a url in external mode", () => {
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "external" })).toThrow(/URL/i);
});
+106 -1
View File
@@ -1,11 +1,15 @@
import { describe, expect, it } from "vitest";
import { buildDefaultSeoSettings } from "@/lib/seo-settings";
import { buildDefaultSiteSettings } from "@/lib/site-settings";
import {
applyTitleTemplateFn,
buildAppMetadataFromConfig,
buildLocaleAlternates,
buildLocalizedMetadataFromConfig,
buildProjectJsonLd,
buildSiteJsonLd,
serializeJsonLd,
} from "@/lib/metadata";
const noBindings = {
@@ -82,7 +86,7 @@ describe("buildLocalizedMetadataFromConfig", () => {
});
expect(metadata.title).toBe("About | Studio");
expect(metadata.description).toBe("English description");
expect(metadata.openGraph?.locale).toBe("en");
expect(metadata.openGraph?.locale).toBe("en_US");
});
it("can skip the title template (homepage)", () => {
@@ -111,3 +115,104 @@ describe("buildLocalizedMetadataFromConfig", () => {
expect(metadata.description).toBe("Custom desc");
});
});
describe("seo-aware metadata", () => {
const settings = buildDefaultSiteSettings("Studio");
it("indexes by default and emits verification + twitter handle when configured", () => {
const seo = {
...buildDefaultSeoSettings(),
googleSiteVerification: "g123",
bingSiteVerification: "b456",
twitterHandle: "@moh",
};
const metadata = buildAppMetadataFromConfig(settings, noBindings, seo);
expect(metadata.robots).toMatchObject({ index: true, follow: true });
expect(metadata.verification).toEqual({ google: "g123", other: { "msvalidate.01": "b456" } });
expect(metadata.twitter).toMatchObject({ site: "@moh", creator: "@moh" });
expect(metadata.openGraph).toMatchObject({ locale: "de_DE", alternateLocale: ["en_US", "ar_AR"] });
});
it("emits noindex everywhere when indexing is disabled", () => {
const seo = { ...buildDefaultSeoSettings(), allowIndexing: false };
expect(buildAppMetadataFromConfig(settings, noBindings, seo).robots).toMatchObject({ index: false });
const page = buildLocalizedMetadataFromConfig({ settings, bindings: noBindings, seo, locale: "en", pathname: "/about", title: "About" });
expect(page.robots).toMatchObject({ index: false, follow: false });
});
it("supports per-page noindex, article type and a page-specific image", () => {
const published = new Date("2026-01-02T00:00:00Z");
const page = buildLocalizedMetadataFromConfig({
settings,
bindings: { ...noBindings, defaultOgImage: { assetId: "a", url: "/og.png", version: "1" } },
locale: "en",
pathname: "/portfolio/x",
title: "X",
image: "/uploads/media/covers/x.png",
type: "article",
publishedTime: published,
});
expect(page.robots).toMatchObject({ index: true });
expect(page.openGraph).toMatchObject({
type: "article",
publishedTime: published.toISOString(),
images: [{ url: "https://mohfarawati.de/uploads/media/covers/x.png", alt: "X" }],
});
const thanks = buildLocalizedMetadataFromConfig({ settings, bindings: noBindings, locale: "en", pathname: "/success", title: "Thanks", noIndex: true });
expect(thanks.robots).toMatchObject({ index: false });
});
it("falls back to the default og image when no page image is given", () => {
const page = buildLocalizedMetadataFromConfig({
settings,
bindings: { ...noBindings, defaultOgImage: { assetId: "a", url: "/og.png", version: "1" } },
locale: "de",
pathname: "/",
title: "Home",
});
expect(page.openGraph).toMatchObject({ images: [{ url: "https://mohfarawati.de/og.png", alt: "Home" }] });
});
});
describe("json-ld", () => {
const settings = buildDefaultSiteSettings("Studio");
it("builds a WebSite + Person graph linked by @id", () => {
const seo = { ...buildDefaultSeoSettings(), structuredDataName: "Moh", structuredDataJobTitle: "Designer", sameAs: ["https://x.com/moh"] };
const graph = buildSiteJsonLd({ settings, seo, bindings: noBindings, locale: "de" })["@graph"] as Array<Record<string, unknown>>;
expect(graph[0]).toMatchObject({ "@type": "WebSite", publisher: { "@id": "https://mohfarawati.de/#person" } });
expect(graph[1]).toMatchObject({ "@type": "Person", name: "Moh", jobTitle: "Designer", sameAs: ["https://x.com/moh"] });
});
it("uses Organization shape when configured", () => {
const seo = { ...buildDefaultSeoSettings(), structuredDataType: "Organization" as const, structuredDataJobTitle: "Studio" };
const graph = buildSiteJsonLd({ settings, seo, bindings: noBindings, locale: "en" })["@graph"] as Array<Record<string, unknown>>;
expect(graph[1]).toMatchObject({ "@type": "Organization", slogan: "Studio" });
});
it("builds a CreativeWork per project with localized url", () => {
const work = buildProjectJsonLd({
settings,
seo: buildDefaultSeoSettings(),
locale: "en",
pathname: "/portfolio/x",
title: "X",
description: "D",
image: "/c.png",
datePublished: new Date("2026-01-01T00:00:00Z"),
clientName: "ACME",
});
expect(work).toMatchObject({
"@type": "CreativeWork",
url: "https://mohfarawati.de/en/portfolio/x",
image: "https://mohfarawati.de/c.png",
sourceOrganization: { "@type": "Organization", name: "ACME" },
author: { "@id": "https://mohfarawati.de/#person" },
});
});
it("escapes < so the payload cannot close the script tag", () => {
expect(serializeJsonLd({ name: "</script><script>alert(1)</script>" })).not.toContain("</script>");
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { ROBOTS_DISALLOWED_PATHS, buildRobots } from "@/app/robots";
describe("buildRobots", () => {
it("blocks everything when not indexable and omits the sitemap", () => {
const robots = buildRobots({ indexable: false });
expect(robots.rules).toEqual([{ userAgent: "*", disallow: "/" }]);
expect(robots.sitemap).toBeUndefined();
});
it("allows crawling but hides admin, api and utility pages when indexable", () => {
const robots = buildRobots({ indexable: true });
const rule = Array.isArray(robots.rules) ? robots.rules[0] : robots.rules;
expect(rule.allow).toBe("/");
expect(rule.disallow).toEqual(ROBOTS_DISALLOWED_PATHS);
expect(rule.disallow).toEqual(expect.arrayContaining(["/admin-internal", "/root", "/api/", "/success", "/coming-soon"]));
expect(robots.sitemap).toBe("https://mohfarawati.de/sitemap.xml");
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { buildSeoChecklist, summarizeSeoChecklist } from "@/lib/seo-report";
import { buildDefaultSeoSettings } from "@/lib/seo-settings";
import { buildDefaultSiteSettings, getDefaultSiteSettingsMediaBindings } from "@/lib/site-settings";
function run(overrides: Partial<Parameters<typeof buildSeoChecklist>[0]> = {}) {
return buildSeoChecklist({
seo: buildDefaultSeoSettings(),
settings: buildDefaultSiteSettings(),
bindings: getDefaultSiteSettingsMediaBindings(),
maintenanceEnabled: false,
publishedProjectCount: 2,
sitemapEntryCount: 12,
siteUrl: "https://mohfarawati.de",
...overrides,
});
}
describe("buildSeoChecklist", () => {
it("flags maintenance mode as an indexing error", () => {
const check = run({ maintenanceEnabled: true }).find((entry) => entry.id === "indexing");
expect(check?.status).toBe("error");
expect(check?.detail).toMatch(/Wartungsmodus/);
});
it("flags disabled indexing and passes when enabled", () => {
const seo = { ...buildDefaultSeoSettings(), allowIndexing: false };
expect(run({ seo }).find((entry) => entry.id === "indexing")?.status).toBe("error");
expect(run().find((entry) => entry.id === "indexing")?.status).toBe("ok");
});
it("warns about localhost as public url", () => {
expect(run({ siteUrl: "http://localhost:3014" }).find((entry) => entry.id === "site-url")?.status).toBe("warn");
});
it("grades description length per locale", () => {
const settings = buildDefaultSiteSettings();
settings.locales.de.siteDescription = "";
settings.locales.en.siteDescription = "x".repeat(80);
const checks = run({ settings });
expect(checks.find((entry) => entry.id === "description-de")?.status).toBe("error");
expect(checks.find((entry) => entry.id === "description-en")?.status).toBe("ok");
expect(checks.find((entry) => entry.id === "description-ar")?.status).toBe("warn");
});
it("summarizes counts", () => {
const summary = summarizeSeoChecklist(run());
expect(summary.ok + summary.warn + summary.error).toBe(run().length);
});
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
buildDefaultSeoSettings,
normalizeKeywords,
normalizeSameAs,
normalizeTwitterHandle,
normalizeVerificationToken,
parseSeoSettingsValue,
toOpenGraphLocale,
} from "@/lib/seo-settings";
describe("parseSeoSettingsValue", () => {
it("returns defaults for empty or invalid JSON", () => {
expect(parseSeoSettingsValue(undefined)).toEqual(buildDefaultSeoSettings());
expect(parseSeoSettingsValue("{not json")).toEqual(buildDefaultSeoSettings());
expect(parseSeoSettingsValue(null).allowIndexing).toBe(true);
});
it("only disables indexing on an explicit false", () => {
expect(parseSeoSettingsValue(JSON.stringify({ allowIndexing: false })).allowIndexing).toBe(false);
expect(parseSeoSettingsValue(JSON.stringify({ allowIndexing: "no" })).allowIndexing).toBe(true);
});
it("normalizes every field and drops junk", () => {
const parsed = parseSeoSettingsValue(
JSON.stringify({
googleSiteVerification: "abc-123_XYZ",
bingSiteVerification: "<script>",
twitterHandle: "https://x.com/moh_farawati",
structuredDataType: "Company",
sameAs: ["https://behance.net/x", "http://insecure", "javascript:alert(1)", "https://behance.net/x"],
locales: { de: { keywords: " a , ,b,, c " } },
}),
);
expect(parsed.googleSiteVerification).toBe("abc-123_XYZ");
expect(parsed.bingSiteVerification).toBe("");
expect(parsed.twitterHandle).toBe("@moh_farawati");
expect(parsed.structuredDataType).toBe("Person");
expect(parsed.sameAs).toEqual(["https://behance.net/x"]);
expect(parsed.locales.de.keywords).toBe("a, b, c");
expect(parsed.locales.en.keywords).toBe("");
});
});
describe("normalizers", () => {
it("rejects verification tokens with unsafe characters", () => {
expect(normalizeVerificationToken("ok_token-1")).toBe("ok_token-1");
expect(normalizeVerificationToken('x" onload="1')).toBe("");
expect(normalizeVerificationToken(42)).toBe("");
});
it("normalizes twitter handles with or without @ / URL", () => {
expect(normalizeTwitterHandle("@moh")).toBe("@moh");
expect(normalizeTwitterHandle("moh")).toBe("@moh");
expect(normalizeTwitterHandle("https://twitter.com/moh")).toBe("@moh");
expect(normalizeTwitterHandle("this-has-dashes")).toBe("");
expect(normalizeTwitterHandle("a".repeat(16))).toBe("");
});
it("accepts newline or comma separated https URLs only", () => {
expect(normalizeSameAs("https://a.com\nhttps://b.com, ftp://c")).toEqual(["https://a.com", "https://b.com"]);
expect(normalizeSameAs(null)).toEqual([]);
});
it("caps keywords at 30 entries", () => {
const keywords = normalizeKeywords(Array.from({ length: 40 }, (_, index) => `k${index}`).join(","));
expect(keywords.split(", ")).toHaveLength(30);
});
it("maps locales to og:locale codes", () => {
expect(toOpenGraphLocale("de")).toBe("de_DE");
expect(toOpenGraphLocale("en")).toBe("en_US");
expect(toOpenGraphLocale("ar")).toBe("ar_AR");
});
});