Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
546b2eadda | ||
|
|
dc21c33867 |
@@ -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` |
|
| DB schema | `lib/db/schema.ts` |
|
||||||
| AppConfig aggregate | `lib/app-config.ts` |
|
| AppConfig aggregate | `lib/app-config.ts` |
|
||||||
| Portfolio queries | `lib/portfolio.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` |
|
| 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
|
### Documentation to read by task scope
|
||||||
|
|
||||||
- **Small UI/copy/style fixes**: read only the relevant files
|
- **Small UI/copy/style fixes**: read only the relevant files
|
||||||
- **Feature changes**: read `specs/<feature>.md` + `docs/ARCHITECTURE.md` if structure is affected
|
- **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
|
- **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.
|
Update `docs/` and `specs/` only when the change affects feature scope, business rules, architecture, or public behavior.
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import { PageTransition } from "@/components/layout/page-transition";
|
|||||||
import { SiteDock } from "@/components/layout/site-dock";
|
import { SiteDock } from "@/components/layout/site-dock";
|
||||||
import { SiteFooter } from "@/components/layout/site-footer";
|
import { SiteFooter } from "@/components/layout/site-footer";
|
||||||
import { ScrollSmootherProvider } from "@/components/scroll-smoother-provider";
|
import { ScrollSmootherProvider } from "@/components/scroll-smoother-provider";
|
||||||
|
import { JsonLd } from "@/components/seo/json-ld";
|
||||||
import { isSuperAdmin } from "@/lib/admin-auth";
|
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 { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
||||||
|
import { buildSiteJsonLd } from "@/lib/metadata";
|
||||||
|
|
||||||
type SiteLayoutProps = {
|
type SiteLayoutProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -25,9 +27,11 @@ export const revalidate = 0;
|
|||||||
export default async function SiteLayout({ children, params }: SiteLayoutProps) {
|
export default async function SiteLayout({ children, params }: SiteLayoutProps) {
|
||||||
noStore();
|
noStore();
|
||||||
await params;
|
await params;
|
||||||
const [maintenanceEnabled, siteSettings] = await Promise.all([
|
const [maintenanceEnabled, siteSettings, seo, mediaBindings] = await Promise.all([
|
||||||
getMaintenanceMode(),
|
getMaintenanceMode(),
|
||||||
getSiteSettings(),
|
getSiteSettings(),
|
||||||
|
getSeoSettings(),
|
||||||
|
getSiteSettingsMediaBindings(),
|
||||||
]);
|
]);
|
||||||
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
|
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
|
||||||
// Server-side decision only. The dock receives just this boolean and uses
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<JsonLd data={buildSiteJsonLd({ settings: siteSettings, seo, bindings: mediaBindings, locale: localeKey })} />
|
||||||
<ScrollSmootherProvider />
|
<ScrollSmootherProvider />
|
||||||
<SiteAmbientBackdrop />
|
<SiteAmbientBackdrop />
|
||||||
<SiteDock defaultLocale={siteSettings.defaultLocale} isSuperAdmin={authenticated} />
|
<SiteDock defaultLocale={siteSettings.defaultLocale} isSuperAdmin={authenticated} />
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import { PageHero } from "@/components/layout/page-hero";
|
|||||||
import { PortfolioCategoryFilter } from "@/components/site/portfolio-category-filter";
|
import { PortfolioCategoryFilter } from "@/components/site/portfolio-category-filter";
|
||||||
import { PortfolioProjectDetail } from "@/components/site/portfolio-project-detail";
|
import { PortfolioProjectDetail } from "@/components/site/portfolio-project-detail";
|
||||||
import { PortfolioProjectGrid } from "@/components/site/portfolio-project-grid";
|
import { PortfolioProjectGrid } from "@/components/site/portfolio-project-grid";
|
||||||
import { getSiteSettings } from "@/lib/app-config";
|
import { JsonLd } from "@/components/seo/json-ld";
|
||||||
import { buildLocalizedMetadata } from "@/lib/metadata";
|
import { getSeoSettings, getSiteSettings } from "@/lib/app-config";
|
||||||
|
import { buildLocalizedMetadata, buildProjectJsonLd } from "@/lib/metadata";
|
||||||
import { resolveLocale } from "@/lib/locale";
|
import { resolveLocale } from "@/lib/locale";
|
||||||
import {
|
import {
|
||||||
getActivePortfolioCategories,
|
getActivePortfolioCategories,
|
||||||
@@ -57,6 +58,9 @@ export async function generateMetadata({ params }: PortfolioSlugPageProps): Prom
|
|||||||
pathname: `/portfolio/${slug}`,
|
pathname: `/portfolio/${slug}`,
|
||||||
title: getLocalizedValue(resolved.project.title, localeKey),
|
title: getLocalizedValue(resolved.project.title, localeKey),
|
||||||
description: getLocalizedValue(resolved.project.summary, 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 { 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 title = getLocalizedValue(item.title, localeKey);
|
||||||
const category = getLocalizedValue(item.category.name, localeKey);
|
const category = getLocalizedValue(item.category.name, localeKey);
|
||||||
const summary = getLocalizedValue(item.summary, 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<JsonLd data={jsonLd} />
|
||||||
<PageHero
|
<PageHero
|
||||||
locale={localeKey}
|
locale={localeKey}
|
||||||
badge={category}
|
badge={category}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export async function generateMetadata({ params }: SuccessPageProps): Promise<Me
|
|||||||
pathname: "/success",
|
pathname: "/success",
|
||||||
title: t("title"),
|
title: t("title"),
|
||||||
description: t("text"),
|
description: t("text"),
|
||||||
|
noIndex: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export async function generateMetadata({ params }: ComingSoonPageProps): Promise
|
|||||||
title: siteSettings.locales[localeKey].siteName,
|
title: siteSettings.locales[localeKey].siteName,
|
||||||
description: t("description"),
|
description: t("description"),
|
||||||
applyTitleTemplate: false,
|
applyTitleTemplate: false,
|
||||||
|
noIndex: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { isRedirectError } from "next/dist/client/components/redirect-error";
|
import { isRedirectError } from "next/dist/client/components/redirect-error";
|
||||||
@@ -154,6 +154,19 @@ export async function upsertCategoryAction(formData: FormData) {
|
|||||||
isActive: normalizeCheckboxValue(formData, "isActive"),
|
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) {
|
if (parsed.id) {
|
||||||
await db.update(category).set(parsed).where(eq(category.id, parsed.id));
|
await db.update(category).set(parsed).where(eq(category.id, parsed.id));
|
||||||
} else {
|
} else {
|
||||||
@@ -172,6 +185,8 @@ export async function upsertCategoryAction(formData: FormData) {
|
|||||||
? parseZodError(error)
|
? parseZodError(error)
|
||||||
: isUniqueViolation(error)
|
: isUniqueViolation(error)
|
||||||
? "Kategorie Slug muss eindeutig sein."
|
? "Kategorie Slug muss eindeutig sein."
|
||||||
|
: error instanceof Error && error.message.includes("Slug")
|
||||||
|
? error.message
|
||||||
: "Kategorie konnte nicht gespeichert werden.";
|
: "Kategorie konnte nicht gespeichert werden.";
|
||||||
|
|
||||||
redirect(withFlash(redirectPath, { error: message }));
|
redirect(withFlash(redirectPath, { error: message }));
|
||||||
@@ -298,6 +313,16 @@ export async function saveProjectAction(formData: FormData) {
|
|||||||
assets,
|
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
|
const existingProject = parsed.id
|
||||||
? (
|
? (
|
||||||
await db
|
await db
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import {
|
|||||||
SITE_SETTINGS_FAVICON_FIELD_KEY,
|
SITE_SETTINGS_FAVICON_FIELD_KEY,
|
||||||
SITE_SETTINGS_LOGO_DARK_FIELD_KEY,
|
SITE_SETTINGS_LOGO_DARK_FIELD_KEY,
|
||||||
SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
|
SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
|
||||||
|
getSeoSettings,
|
||||||
getSiteSettings,
|
getSiteSettings,
|
||||||
|
updateSeoSettings,
|
||||||
updateSiteSettings,
|
updateSiteSettings,
|
||||||
} from "@/lib/app-config";
|
} from "@/lib/app-config";
|
||||||
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
|
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
|
||||||
@@ -24,6 +26,15 @@ import {
|
|||||||
type SiteSettings,
|
type SiteSettings,
|
||||||
} from "@/lib/site-settings";
|
} from "@/lib/site-settings";
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
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 { replaceEntityMediaUsages } from "@/lib/media";
|
||||||
import { resolveMediaSelection } from "@/lib/media-service";
|
import { resolveMediaSelection } from "@/lib/media-service";
|
||||||
import { routing } from "@/i18n/routing";
|
import { routing } from "@/i18n/routing";
|
||||||
@@ -329,3 +340,63 @@ export async function saveSiteLocalizationSettingsAction(formData: FormData) {
|
|||||||
redirect(withFlash(getAdminAppPath("/site-settings/localization"), { error: message }));
|
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 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,15 +1,49 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
|
import { unstable_noStore as noStore } from "next/cache";
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
import { getMaintenanceMode, getSeoSettings } from "@/lib/app-config";
|
||||||
const siteUrl = new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
|
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 {
|
return {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
userAgent: "*",
|
userAgent: "*",
|
||||||
disallow: ["/admin-internal"],
|
allow: "/",
|
||||||
|
disallow: ROBOTS_DISALLOWED_PATHS,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sitemap: new URL("/sitemap.xml", siteUrl).toString(),
|
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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "../../../_admin/site-settings/seo/page";
|
||||||
+68
-29
@@ -2,72 +2,111 @@ import type { MetadataRoute } from "next";
|
|||||||
import { unstable_noStore as noStore } from "next/cache";
|
import { unstable_noStore as noStore } from "next/cache";
|
||||||
|
|
||||||
import { routing } from "@/i18n/routing";
|
import { routing } from "@/i18n/routing";
|
||||||
import { getSiteSettings } from "@/lib/app-config";
|
import { getMaintenanceMode, getSeoSettings, getSiteSettings } from "@/lib/app-config";
|
||||||
import { getLocalizedPath } from "@/lib/locale";
|
import { getLocalizedPath, type AppLocale } from "@/lib/locale";
|
||||||
import { getPublishedPortfolioProjects } from "@/lib/portfolio";
|
import { toAbsoluteUrl } from "@/lib/metadata";
|
||||||
|
import { getActivePortfolioCategories, getPublishedPortfolioProjects } from "@/lib/portfolio";
|
||||||
|
|
||||||
function getSiteUrl(): URL {
|
export const dynamic = "force-dynamic";
|
||||||
return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
|
|
||||||
}
|
|
||||||
|
|
||||||
function toAbsoluteUrl(pathname: string): string {
|
type EntryOptions = Pick<MetadataRoute.Sitemap[number], "changeFrequency" | "priority" | "lastModified">;
|
||||||
return new URL(pathname, getSiteUrl()).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
pathname: string,
|
||||||
defaultLocale: "de" | "en" | "ar",
|
defaultLocale: AppLocale,
|
||||||
options?: Pick<MetadataRoute.Sitemap[number], "changeFrequency" | "priority" | "lastModified">,
|
options?: EntryOptions,
|
||||||
): MetadataRoute.Sitemap {
|
): 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) => ({
|
return routing.locales.map((locale) => ({
|
||||||
url: toAbsoluteUrl(getLocalizedPath(locale, pathname, defaultLocale)),
|
url: languages[locale],
|
||||||
lastModified: options?.lastModified,
|
lastModified: options?.lastModified,
|
||||||
changeFrequency: options?.changeFrequency,
|
changeFrequency: options?.changeFrequency,
|
||||||
priority: options?.priority,
|
priority: options?.priority,
|
||||||
|
alternates: {
|
||||||
|
languages: {
|
||||||
|
...languages,
|
||||||
|
"x-default": languages[defaultLocale],
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
noStore();
|
noStore();
|
||||||
const siteSettings = await getSiteSettings();
|
const [siteSettings, seo, maintenanceEnabled] = await Promise.all([
|
||||||
|
getSiteSettings(),
|
||||||
|
getSeoSettings(),
|
||||||
|
getMaintenanceMode(),
|
||||||
|
]);
|
||||||
|
|
||||||
let projects: Awaited<ReturnType<typeof getPublishedPortfolioProjects>> = [];
|
// While the site is hidden (maintenance) or indexing is off, publish an
|
||||||
|
// empty sitemap instead of advertising URLs that redirect or are noindex.
|
||||||
try {
|
if (maintenanceEnabled || !seo.allowIndexing) {
|
||||||
projects = await getPublishedPortfolioProjects();
|
return [];
|
||||||
} catch {
|
|
||||||
projects = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const categories = Array.from(
|
const defaultLocale = siteSettings.defaultLocale;
|
||||||
new Map(projects.map((project) => [project.category.slug, project.category])).values(),
|
|
||||||
|
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 [
|
return [
|
||||||
...buildLocalizedEntries("/", siteSettings.defaultLocale, {
|
...buildLocalizedEntries("/", defaultLocale, {
|
||||||
changeFrequency: "weekly",
|
changeFrequency: "weekly",
|
||||||
priority: 1,
|
priority: 1,
|
||||||
|
lastModified: latestProjectDate,
|
||||||
}),
|
}),
|
||||||
...buildLocalizedEntries("/about", siteSettings.defaultLocale, {
|
...buildLocalizedEntries("/about", defaultLocale, {
|
||||||
changeFrequency: "monthly",
|
changeFrequency: "monthly",
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
}),
|
}),
|
||||||
...buildLocalizedEntries("/portfolio", siteSettings.defaultLocale, {
|
...buildLocalizedEntries("/portfolio", defaultLocale, {
|
||||||
changeFrequency: "weekly",
|
changeFrequency: "weekly",
|
||||||
priority: 0.9,
|
priority: 0.9,
|
||||||
|
lastModified: latestProjectDate,
|
||||||
}),
|
}),
|
||||||
...categories.flatMap((category) =>
|
...categoriesWithProjects.flatMap((category) =>
|
||||||
buildLocalizedEntries(`/portfolio/${category.slug}`, siteSettings.defaultLocale, {
|
buildLocalizedEntries(`/portfolio/${category.slug}`, defaultLocale, {
|
||||||
changeFrequency: "weekly",
|
changeFrequency: "weekly",
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
|
lastModified: latestProjectDate,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
...buildLocalizedEntries("/contact", siteSettings.defaultLocale, {
|
...buildLocalizedEntries("/contact", defaultLocale, {
|
||||||
changeFrequency: "monthly",
|
changeFrequency: "monthly",
|
||||||
priority: 0.7,
|
priority: 0.7,
|
||||||
}),
|
}),
|
||||||
...projects.flatMap((project) =>
|
...projects.flatMap((project) =>
|
||||||
buildLocalizedEntries(`/portfolio/${project.slug}`, siteSettings.defaultLocale, {
|
buildLocalizedEntries(`/portfolio/${project.slug}`, defaultLocale, {
|
||||||
lastModified: project.publishedAt ?? undefined,
|
lastModified: project.publishedAt ?? undefined,
|
||||||
changeFrequency: "monthly",
|
changeFrequency: "monthly",
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
const CONTENT_TYPES: Record<string, string> = {
|
const CONTENT_TYPES: Record<string, string> = {
|
||||||
".ico": "image/x-icon",
|
".ico": "image/x-icon",
|
||||||
|
".gif": "image/gif",
|
||||||
".jpg": "image/jpeg",
|
".jpg": "image/jpeg",
|
||||||
".jpeg": "image/jpeg",
|
".jpeg": "image/jpeg",
|
||||||
".png": "image/png",
|
".png": "image/png",
|
||||||
@@ -30,15 +31,30 @@ export async function GET(_: Request, { params }: MediaFileRouteProps) {
|
|||||||
try {
|
try {
|
||||||
const absolutePath = resolveMediaUploadPath(publicPath);
|
const absolutePath = resolveMediaUploadPath(publicPath);
|
||||||
const fileBuffer = await readFile(absolutePath);
|
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, {
|
if (!contentType) {
|
||||||
status: 200,
|
return new NextResponse("Not Found", { status: 404 });
|
||||||
headers: {
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": contentType,
|
"Content-Type": contentType,
|
||||||
"Cache-Control": "public, max-age=31536000, immutable",
|
"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 {
|
} catch {
|
||||||
return new NextResponse("Not Found", {
|
return new NextResponse("Not Found", {
|
||||||
status: 404,
|
status: 404,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Palette,
|
Palette,
|
||||||
PlusSquare,
|
PlusSquare,
|
||||||
|
Search,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
Tags,
|
Tags,
|
||||||
@@ -40,6 +41,7 @@ type AdminDashboardCopy = {
|
|||||||
siteSettings: string;
|
siteSettings: string;
|
||||||
brandSettings?: string;
|
brandSettings?: string;
|
||||||
localizationSettings?: string;
|
localizationSettings?: string;
|
||||||
|
seoSettings?: string;
|
||||||
marquee?: string;
|
marquee?: string;
|
||||||
smtp?: string;
|
smtp?: string;
|
||||||
logout: string;
|
logout: string;
|
||||||
@@ -50,7 +52,7 @@ type AdminDashboardShellProps = {
|
|||||||
copy: AdminDashboardCopy;
|
copy: AdminDashboardCopy;
|
||||||
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee";
|
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee";
|
||||||
portfolioChild?: "overview" | "projects" | "new-project" | "categories";
|
portfolioChild?: "overview" | "projects" | "new-project" | "categories";
|
||||||
siteSettingsChild?: "brand" | "localization";
|
siteSettingsChild?: "brand" | "localization" | "seo";
|
||||||
flash?: FlashMessages;
|
flash?: FlashMessages;
|
||||||
logoutAction: () => Promise<void>;
|
logoutAction: () => Promise<void>;
|
||||||
headerTitle: string;
|
headerTitle: string;
|
||||||
@@ -100,6 +102,8 @@ export async function AdminDashboardShell({
|
|||||||
: active === "site-settings"
|
: active === "site-settings"
|
||||||
? siteSettingsChild === "localization"
|
? siteSettingsChild === "localization"
|
||||||
? Languages
|
? Languages
|
||||||
|
: siteSettingsChild === "seo"
|
||||||
|
? Search
|
||||||
: siteSettingsChild === "brand"
|
: siteSettingsChild === "brand"
|
||||||
? Palette
|
? Palette
|
||||||
: Globe2
|
: Globe2
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -181,8 +181,8 @@ i18n/routing.ts
|
|||||||
Admin routing
|
Admin routing
|
||||||
lib/admin-routing.ts
|
lib/admin-routing.ts
|
||||||
|
|
||||||
Prisma access
|
Database access (Drizzle)
|
||||||
lib/prisma.ts
|
lib/db/index.ts, lib/db/schema.ts
|
||||||
|
|
||||||
Application configuration
|
Application configuration
|
||||||
lib/app-config.ts
|
lib/app-config.ts
|
||||||
@@ -191,7 +191,13 @@ Portfolio logic
|
|||||||
lib/portfolio.ts
|
lib/portfolio.ts
|
||||||
|
|
||||||
Media handling
|
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
@@ -14,8 +14,11 @@
|
|||||||
- Contact form with:
|
- Contact form with:
|
||||||
- validation
|
- validation
|
||||||
- email delivery
|
- email delivery
|
||||||
- Success page after contact submission
|
- Success page after contact submission (noindex)
|
||||||
- Maintenance redirect flow
|
- 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
|
### Admin
|
||||||
|
|
||||||
@@ -24,8 +27,12 @@
|
|||||||
- Portfolio category management
|
- Portfolio category management
|
||||||
- Portfolio project creation and editing
|
- Portfolio project creation and editing
|
||||||
- Section and asset management inside each project
|
- Section and asset management inside each project
|
||||||
- Media library with usage bindings
|
- Media library with usage bindings (uploads are magic-byte checked, SVGs are
|
||||||
- Site settings management
|
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
|
- SMTP settings and test email
|
||||||
- Marquee settings
|
- Marquee settings
|
||||||
- Maintenance toggle
|
- Maintenance toggle
|
||||||
|
|||||||
+83
@@ -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 + 50–160 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
@@ -17,7 +17,7 @@ const config = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ignores: ["prisma/seed.js"],
|
ignores: ["prisma/seed.js", "scripts/legacy-prisma-seed.cjs"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+9
-27
@@ -1,4 +1,4 @@
|
|||||||
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
import { createHash, timingSafeEqual } from "crypto";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
@@ -7,9 +7,13 @@ import { and, eq, like, lt } from "drizzle-orm";
|
|||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { appConfig } from "./db/schema";
|
import { appConfig } from "./db/schema";
|
||||||
import { getAdminAppPath } from "./admin-routing";
|
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";
|
export const ADMIN_SESSION_COOKIE = SHARED_ADMIN_SESSION_COOKIE;
|
||||||
const ADMIN_SESSION_VALUE = "superadmin";
|
|
||||||
const MAX_FAILED_ATTEMPTS = 5;
|
const MAX_FAILED_ATTEMPTS = 5;
|
||||||
const LOCKOUT_SECONDS = 15 * 60;
|
const LOCKOUT_SECONDS = 15 * 60;
|
||||||
const ADMIN_LOCKOUT_KEY_PREFIX = "admin_lockout";
|
const ADMIN_LOCKOUT_KEY_PREFIX = "admin_lockout";
|
||||||
@@ -91,34 +95,12 @@ function getAdminCookieDomain(): string | undefined {
|
|||||||
return hostname ? `.${hostname}` : undefined;
|
return hostname ? `.${hostname}` : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function signValue(value: string): string {
|
|
||||||
return createHmac("sha256", getSecret()).update(value).digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildToken(): string {
|
function buildToken(): string {
|
||||||
return `${ADMIN_SESSION_VALUE}.${signValue(ADMIN_SESSION_VALUE)}`;
|
return buildAdminSessionToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
function verifyToken(token: string): boolean {
|
function verifyToken(token: string): boolean {
|
||||||
const parts = token.split(".");
|
return verifyAdminSessionToken(token);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getClientIp(): Promise<string> {
|
async function getClientIp(): Promise<string> {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
Palette,
|
Palette,
|
||||||
PlusSquare,
|
PlusSquare,
|
||||||
|
Search,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
Tags,
|
Tags,
|
||||||
@@ -24,6 +25,7 @@ type AdminNavigationCopy = {
|
|||||||
siteSettings: string;
|
siteSettings: string;
|
||||||
brandSettings?: string;
|
brandSettings?: string;
|
||||||
localizationSettings?: string;
|
localizationSettings?: string;
|
||||||
|
seoSettings?: string;
|
||||||
marquee?: string;
|
marquee?: string;
|
||||||
smtp?: string;
|
smtp?: string;
|
||||||
};
|
};
|
||||||
@@ -41,7 +43,7 @@ export function getAdminNavigation(
|
|||||||
copy: AdminNavigationCopy,
|
copy: AdminNavigationCopy,
|
||||||
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee",
|
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee",
|
||||||
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
|
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
|
||||||
siteSettingsChild?: "brand" | "localization",
|
siteSettingsChild?: "brand" | "localization" | "seo",
|
||||||
): AdminNavItem[] {
|
): AdminNavItem[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -87,6 +89,12 @@ export function getAdminNavigation(
|
|||||||
icon: Languages,
|
icon: Languages,
|
||||||
active: siteSettingsChild === "localization",
|
active: siteSettingsChild === "localization",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: copy.seoSettings ?? "SEO",
|
||||||
|
href: getAdminAppPath("/site-settings/seo"),
|
||||||
|
icon: Search,
|
||||||
|
active: siteSettingsChild === "seo",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from "crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure helpers for the admin session cookie token. Kept free of `next/headers`
|
||||||
|
* and the database so the middleware (`proxy.ts`) can verify a session without
|
||||||
|
* pulling the server-only auth module into the edge/middleware bundle.
|
||||||
|
*/
|
||||||
|
export const ADMIN_SESSION_COOKIE = "moh_admin_session";
|
||||||
|
export const ADMIN_SESSION_VALUE = "superadmin";
|
||||||
|
|
||||||
|
function getSecret(): string {
|
||||||
|
return process.env.ADMIN_AUTH_SECRET ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function signValue(value: string): string {
|
||||||
|
return createHmac("sha256", getSecret()).update(value).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAdminSessionToken(): string {
|
||||||
|
return `${ADMIN_SESSION_VALUE}.${signValue(ADMIN_SESSION_VALUE)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyAdminSessionToken(token: string | undefined | null): boolean {
|
||||||
|
if (!token || !getSecret()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [value, signature] = parts;
|
||||||
|
if (value !== ADMIN_SESSION_VALUE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = signValue(value);
|
||||||
|
const left = Buffer.from(signature);
|
||||||
|
const right = Buffer.from(expected);
|
||||||
|
|
||||||
|
if (left.length !== right.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return timingSafeEqual(left, right);
|
||||||
|
}
|
||||||
@@ -67,6 +67,13 @@ import {
|
|||||||
syncMarqueeSettingsToGermanSource,
|
syncMarqueeSettingsToGermanSource,
|
||||||
type MarqueeSettings,
|
type MarqueeSettings,
|
||||||
} from "./marquee-settings";
|
} 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).
|
// Small helpers over the app_config key/value table (Drizzle).
|
||||||
async function readConfigValue(key: string): Promise<string | undefined> {
|
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));
|
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> {
|
export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
|
||||||
try {
|
try {
|
||||||
const usages = await db
|
const usages = await db
|
||||||
|
|||||||
@@ -125,7 +125,8 @@ export async function createStandaloneMediaAsset(input: {
|
|||||||
uploadFile: FormDataEntryValue | null;
|
uploadFile: FormDataEntryValue | null;
|
||||||
}) {
|
}) {
|
||||||
if (input.uploadFile instanceof File && input.uploadFile.size > 0) {
|
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 derivedLabel = input.uploadFile.name.replace(/\.[^.]+$/, "").trim();
|
||||||
const trimmedLabel = input.label.trim() || derivedLabel || "Media asset";
|
const trimmedLabel = input.label.trim() || derivedLabel || "Media asset";
|
||||||
|
|
||||||
@@ -135,7 +136,7 @@ export async function createStandaloneMediaAsset(input: {
|
|||||||
|
|
||||||
return createMediaAsset({
|
return createMediaAsset({
|
||||||
source: MediaSource.UPLOAD,
|
source: MediaSource.UPLOAD,
|
||||||
kind: input.kind,
|
kind,
|
||||||
url: savedFile.url,
|
url: savedFile.url,
|
||||||
fileName: savedFile.fileName,
|
fileName: savedFile.fileName,
|
||||||
label: trimmedLabel,
|
label: trimmedLabel,
|
||||||
|
|||||||
+61
-5
@@ -37,16 +37,65 @@ export function resolveMediaUploadPath(filePath: string) {
|
|||||||
throw new Error("Only managed media uploads can be resolved.");
|
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);
|
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.");
|
throw new Error("Resolved media upload path escapes the upload root.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return absolutePath;
|
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) {
|
export async function removeManagedMediaFile(filePath: string | null | undefined) {
|
||||||
if (!isManagedMediaFilePath(filePath)) {
|
if (!isManagedMediaFilePath(filePath)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -76,16 +125,23 @@ export async function saveMediaUpload(file: File, folder: string) {
|
|||||||
throw new Error("File is too large.");
|
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 safeBaseName = sanitizeBaseName(file.name.replace(/\.[^.]+$/, "")) || "asset";
|
||||||
const finalName = `${safeBaseName}-${randomUUID().slice(0, 8)}${extension}`;
|
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);
|
const targetPath = path.join(targetDir, finalName);
|
||||||
|
|
||||||
await mkdir(targetDir, { recursive: true });
|
await mkdir(targetDir, { recursive: true });
|
||||||
await writeFile(targetPath, Buffer.from(await file.arrayBuffer()));
|
await writeFile(targetPath, buffer);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: `/uploads/media/${folder}/${finalName}`,
|
url: `/uploads/media/${safeFolder}/${finalName}`,
|
||||||
fileName: finalName,
|
fileName: finalName,
|
||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ export const mediaFieldInputSchema = z
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.url && !/^https?:\/\//.test(value.url) && !value.url.startsWith("/")) {
|
const isRootRelative = value.url.startsWith("/") && !value.url.startsWith("//");
|
||||||
|
|
||||||
|
if (value.url && !/^https?:\/\//i.test(value.url) && !isRootRelative) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ["url"],
|
path: ["url"],
|
||||||
|
|||||||
+201
-18
@@ -11,17 +11,50 @@ import {
|
|||||||
getSiteSettings,
|
getSiteSettings,
|
||||||
getSiteSettingsMediaBindings,
|
getSiteSettingsMediaBindings,
|
||||||
} from "./app-config";
|
} 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");
|
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();
|
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) {
|
export function buildLocaleAlternates(pathname: string, defaultLocale: AppLocale) {
|
||||||
const languages = Object.fromEntries(
|
const languages = Object.fromEntries(
|
||||||
appLocales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPathWithDefault(locale, pathname, defaultLocale))]),
|
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);
|
.replace(PAGE_TITLE_TOKEN, title);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMetadataImages(imageUrl?: string | null) {
|
function buildMetadataImages(imageUrl?: string | null, alt?: string) {
|
||||||
if (!imageUrl) {
|
if (!imageUrl) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -54,22 +87,29 @@ function buildMetadataImages(imageUrl?: string | null) {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: toAbsoluteUrl(imageUrl),
|
url: toAbsoluteUrl(imageUrl),
|
||||||
|
alt,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildAppMetadata(): Promise<Metadata> {
|
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(
|
export function buildAppMetadataFromConfig(
|
||||||
settings: SiteSettings,
|
settings: SiteSettings,
|
||||||
bindings: SiteSettingsMediaBindings,
|
bindings: SiteSettingsMediaBindings,
|
||||||
|
seo: SeoSettings = buildDefaultSeoSettings(),
|
||||||
): Metadata {
|
): Metadata {
|
||||||
const defaultLocaleSettings = settings.locales[settings.defaultLocale];
|
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({
|
const siteIconUrls = buildSiteIconUrls({
|
||||||
siteName: defaultLocaleSettings.siteName,
|
siteName: defaultLocaleSettings.siteName,
|
||||||
faviconVersion: bindings.favicon?.version,
|
faviconVersion: bindings.favicon?.version,
|
||||||
@@ -81,6 +121,10 @@ export function buildAppMetadataFromConfig(
|
|||||||
title: defaultLocaleSettings.siteName,
|
title: defaultLocaleSettings.siteName,
|
||||||
description: defaultLocaleSettings.siteDescription,
|
description: defaultLocaleSettings.siteDescription,
|
||||||
applicationName: defaultLocaleSettings.siteName,
|
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,
|
manifest: siteIconUrls.manifestHref,
|
||||||
icons: {
|
icons: {
|
||||||
icon: [{ url: siteIconUrls.faviconHref }],
|
icon: [{ url: siteIconUrls.faviconHref }],
|
||||||
@@ -92,7 +136,8 @@ export function buildAppMetadataFromConfig(
|
|||||||
description: defaultLocaleSettings.siteDescription,
|
description: defaultLocaleSettings.siteDescription,
|
||||||
url: toAbsoluteUrl(getLocalizedPathWithDefault(settings.defaultLocale, "/", settings.defaultLocale)),
|
url: toAbsoluteUrl(getLocalizedPathWithDefault(settings.defaultLocale, "/", settings.defaultLocale)),
|
||||||
siteName: defaultLocaleSettings.siteName,
|
siteName: defaultLocaleSettings.siteName,
|
||||||
locale: settings.defaultLocale,
|
locale: toOpenGraphLocale(settings.defaultLocale),
|
||||||
|
alternateLocale: appLocales.filter((locale) => locale !== settings.defaultLocale).map(toOpenGraphLocale),
|
||||||
type: "website",
|
type: "website",
|
||||||
images: openGraphImages,
|
images: openGraphImages,
|
||||||
},
|
},
|
||||||
@@ -100,12 +145,25 @@ export function buildAppMetadataFromConfig(
|
|||||||
card: openGraphImages ? "summary_large_image" : "summary",
|
card: openGraphImages ? "summary_large_image" : "summary",
|
||||||
title: defaultLocaleSettings.siteName,
|
title: defaultLocaleSettings.siteName,
|
||||||
description: defaultLocaleSettings.siteDescription,
|
description: defaultLocaleSettings.siteDescription,
|
||||||
|
site: seo.twitterHandle || undefined,
|
||||||
|
creator: seo.twitterHandle || undefined,
|
||||||
images: openGraphImages?.map((image) => image.url),
|
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;
|
locale: string;
|
||||||
pathname: string;
|
pathname: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -119,65 +177,190 @@ export async function buildLocalizedMetadata({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
applyTitleTemplate,
|
applyTitleTemplate,
|
||||||
|
...options
|
||||||
}: LocalizedMetadataInput): Promise<Metadata> {
|
}: 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);
|
const localeKey = resolveLocale(locale, settings.defaultLocale);
|
||||||
|
|
||||||
return buildLocalizedMetadataFromConfig({
|
return buildLocalizedMetadataFromConfig({
|
||||||
settings,
|
settings,
|
||||||
bindings,
|
bindings,
|
||||||
|
seo,
|
||||||
locale: localeKey,
|
locale: localeKey,
|
||||||
pathname,
|
pathname,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
applyTitleTemplate,
|
applyTitleTemplate,
|
||||||
|
...options,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildLocalizedMetadataFromConfig(input: {
|
export function buildLocalizedMetadataFromConfig(
|
||||||
|
input: LocalizedMetadataOptions & {
|
||||||
settings: SiteSettings;
|
settings: SiteSettings;
|
||||||
bindings: SiteSettingsMediaBindings;
|
bindings: SiteSettingsMediaBindings;
|
||||||
|
seo?: SeoSettings;
|
||||||
locale: AppLocale;
|
locale: AppLocale;
|
||||||
pathname: string;
|
pathname: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
applyTitleTemplate?: boolean;
|
applyTitleTemplate?: boolean;
|
||||||
}): Metadata {
|
},
|
||||||
|
): Metadata {
|
||||||
const {
|
const {
|
||||||
settings,
|
settings,
|
||||||
bindings,
|
bindings,
|
||||||
|
seo = buildDefaultSeoSettings(),
|
||||||
locale,
|
locale,
|
||||||
pathname,
|
pathname,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
applyTitleTemplate = true,
|
applyTitleTemplate = true,
|
||||||
|
image,
|
||||||
|
noIndex = false,
|
||||||
|
type = "website",
|
||||||
|
publishedTime,
|
||||||
|
modifiedTime,
|
||||||
} = input;
|
} = input;
|
||||||
const localeKey = resolveLocale(locale, settings.defaultLocale);
|
const localeKey = resolveLocale(locale, settings.defaultLocale);
|
||||||
const localeSettings = settings.locales[localeKey];
|
const localeSettings = settings.locales[localeKey];
|
||||||
const resolvedDescription = description?.trim() || localeSettings.siteDescription;
|
const resolvedDescription = (description?.trim() || localeSettings.siteDescription).slice(0, 300);
|
||||||
const resolvedTitle = applyTitleTemplate
|
const resolvedTitle = applyTitleTemplate
|
||||||
? applyTitleTemplateFn(title, localeSettings.titleTemplate, localeSettings.siteName)
|
? applyTitleTemplateFn(title, localeSettings.titleTemplate, localeSettings.siteName)
|
||||||
: title;
|
: title;
|
||||||
const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url);
|
const openGraphImages = buildMetadataImages(image || bindings.defaultOgImage?.url, title);
|
||||||
|
const keywords = seo.locales[localeKey].keywords;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: resolvedTitle,
|
title: resolvedTitle,
|
||||||
description: resolvedDescription,
|
description: resolvedDescription,
|
||||||
|
keywords: keywords ? keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : undefined,
|
||||||
|
robots: buildRobotsMetadata(seo, noIndex),
|
||||||
alternates: buildLocaleAlternates(pathname, settings.defaultLocale),
|
alternates: buildLocaleAlternates(pathname, settings.defaultLocale),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: resolvedTitle,
|
title: resolvedTitle,
|
||||||
description: resolvedDescription,
|
description: resolvedDescription,
|
||||||
url: toAbsoluteUrl(getLocalizedPath(localeKey, pathname, settings.defaultLocale)),
|
url: toAbsoluteUrl(getLocalizedPath(localeKey, pathname, settings.defaultLocale)),
|
||||||
siteName: localeSettings.siteName,
|
siteName: localeSettings.siteName,
|
||||||
locale: localeKey,
|
locale: toOpenGraphLocale(localeKey),
|
||||||
type: "website",
|
alternateLocale: appLocales.filter((entry) => entry !== localeKey).map(toOpenGraphLocale),
|
||||||
images: openGraphImages,
|
images: openGraphImages,
|
||||||
|
...(type === "article"
|
||||||
|
? {
|
||||||
|
type: "article" as const,
|
||||||
|
publishedTime: publishedTime?.toISOString(),
|
||||||
|
modifiedTime: (modifiedTime ?? publishedTime)?.toISOString(),
|
||||||
|
}
|
||||||
|
: { type: "website" as const }),
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: openGraphImages ? "summary_large_image" : "summary",
|
card: openGraphImages ? "summary_large_image" : "summary",
|
||||||
title: resolvedTitle,
|
title: resolvedTitle,
|
||||||
description: resolvedDescription,
|
description: resolvedDescription,
|
||||||
images: openGraphImages?.map((image) => image.url),
|
site: seo.twitterHandle || undefined,
|
||||||
|
creator: seo.twitterHandle || undefined,
|
||||||
|
images: openGraphImages?.map((entry) => entry.url),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- JSON-LD ----------------------------------------------------------------
|
||||||
|
|
||||||
|
type JsonLd = Record<string, unknown>;
|
||||||
|
|
||||||
|
/** WebSite + publisher (Person/Organization) graph for the home page. */
|
||||||
|
export function buildSiteJsonLd(input: {
|
||||||
|
settings: SiteSettings;
|
||||||
|
seo: SeoSettings;
|
||||||
|
bindings: SiteSettingsMediaBindings;
|
||||||
|
locale: AppLocale;
|
||||||
|
}): JsonLd {
|
||||||
|
const { settings, seo, bindings, locale } = input;
|
||||||
|
const localeSettings = settings.locales[locale];
|
||||||
|
const siteUrl = getSiteUrl().toString();
|
||||||
|
const publisherName = seo.structuredDataName || localeSettings.siteName;
|
||||||
|
const logoUrl = bindings.siteLogoLight?.url ?? bindings.defaultOgImage?.url ?? null;
|
||||||
|
|
||||||
|
const publisher: JsonLd = {
|
||||||
|
"@type": seo.structuredDataType,
|
||||||
|
"@id": `${siteUrl}#${seo.structuredDataType.toLowerCase()}`,
|
||||||
|
name: publisherName,
|
||||||
|
url: siteUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (seo.structuredDataJobTitle) {
|
||||||
|
publisher[seo.structuredDataType === "Person" ? "jobTitle" : "slogan"] = seo.structuredDataJobTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logoUrl) {
|
||||||
|
publisher[seo.structuredDataType === "Person" ? "image" : "logo"] = toAbsoluteUrl(logoUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seo.sameAs.length > 0) {
|
||||||
|
publisher.sameAs = seo.sameAs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"@type": "WebSite",
|
||||||
|
"@id": `${siteUrl}#website`,
|
||||||
|
url: siteUrl,
|
||||||
|
name: localeSettings.siteName,
|
||||||
|
description: localeSettings.siteDescription || undefined,
|
||||||
|
inLanguage: appLocales,
|
||||||
|
publisher: { "@id": publisher["@id"] },
|
||||||
|
},
|
||||||
|
publisher,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CreativeWork for a single portfolio project (any view mode). */
|
||||||
|
export function buildProjectJsonLd(input: {
|
||||||
|
settings: SiteSettings;
|
||||||
|
seo: SeoSettings;
|
||||||
|
locale: AppLocale;
|
||||||
|
pathname: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
image?: string | null;
|
||||||
|
datePublished?: Date | null;
|
||||||
|
dateModified?: Date | null;
|
||||||
|
genre?: string;
|
||||||
|
keywords?: string[];
|
||||||
|
clientName?: string;
|
||||||
|
}): JsonLd {
|
||||||
|
const { settings, seo, locale, pathname } = input;
|
||||||
|
const siteUrl = getSiteUrl().toString();
|
||||||
|
const url = toAbsoluteUrl(getLocalizedPath(locale, pathname, settings.defaultLocale));
|
||||||
|
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "CreativeWork",
|
||||||
|
"@id": `${url}#work`,
|
||||||
|
url,
|
||||||
|
name: input.title,
|
||||||
|
headline: input.title,
|
||||||
|
description: input.description || undefined,
|
||||||
|
image: input.image ? toAbsoluteUrl(input.image) : undefined,
|
||||||
|
inLanguage: locale,
|
||||||
|
genre: input.genre || undefined,
|
||||||
|
keywords: input.keywords && input.keywords.length > 0 ? input.keywords.join(", ") : undefined,
|
||||||
|
datePublished: input.datePublished?.toISOString(),
|
||||||
|
dateModified: (input.dateModified ?? input.datePublished)?.toISOString(),
|
||||||
|
author: { "@id": `${siteUrl}#${seo.structuredDataType.toLowerCase()}` },
|
||||||
|
sourceOrganization: input.clientName ? { "@type": "Organization", name: input.clientName } : undefined,
|
||||||
|
isPartOf: { "@id": `${siteUrl}#website` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialize JSON-LD safely for a `<script type="application/ld+json">` tag. */
|
||||||
|
export function serializeJsonLd(data: JsonLd): string {
|
||||||
|
return JSON.stringify(data).replace(/</g, "\\u003c");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { appLocales } from "../i18n/routing";
|
||||||
|
import type { SeoSettings } from "./seo-settings";
|
||||||
|
import type { SiteSettings, SiteSettingsMediaBindings } from "./site-settings";
|
||||||
|
|
||||||
|
export type SeoCheckStatus = "ok" | "warn" | "error";
|
||||||
|
|
||||||
|
export type SeoCheck = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
status: SeoCheckStatus;
|
||||||
|
detail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure readiness checklist shown on the admin SEO page. Every input comes from
|
||||||
|
* the caller so the same function is testable without a database.
|
||||||
|
*/
|
||||||
|
export function buildSeoChecklist(input: {
|
||||||
|
seo: SeoSettings;
|
||||||
|
settings: SiteSettings;
|
||||||
|
bindings: SiteSettingsMediaBindings;
|
||||||
|
maintenanceEnabled: boolean;
|
||||||
|
publishedProjectCount: number;
|
||||||
|
sitemapEntryCount: number;
|
||||||
|
siteUrl: string;
|
||||||
|
}): SeoCheck[] {
|
||||||
|
const { seo, settings, bindings, maintenanceEnabled, publishedProjectCount, sitemapEntryCount, siteUrl } = input;
|
||||||
|
const checks: SeoCheck[] = [];
|
||||||
|
|
||||||
|
checks.push({
|
||||||
|
id: "indexing",
|
||||||
|
label: "Indexierung",
|
||||||
|
status: seo.allowIndexing && !maintenanceEnabled ? "ok" : "error",
|
||||||
|
detail: maintenanceEnabled
|
||||||
|
? "Wartungsmodus aktiv: robots.txt sperrt alles, Sitemap ist leer."
|
||||||
|
: seo.allowIndexing
|
||||||
|
? "Suchmaschinen duerfen die Seite indexieren."
|
||||||
|
: "Indexierung ist deaktiviert (noindex + robots disallow).",
|
||||||
|
});
|
||||||
|
|
||||||
|
checks.push({
|
||||||
|
id: "site-url",
|
||||||
|
label: "Oeffentliche URL",
|
||||||
|
status: /^https:\/\//.test(siteUrl) && !/localhost|127\.0\.0\.1/.test(siteUrl) ? "ok" : "warn",
|
||||||
|
detail: `Canonical Basis: ${siteUrl}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const locale of appLocales) {
|
||||||
|
const localeSettings = settings.locales[locale];
|
||||||
|
const descriptionLength = localeSettings.siteDescription.length;
|
||||||
|
const status: SeoCheckStatus =
|
||||||
|
descriptionLength === 0 ? "error" : descriptionLength < 50 || descriptionLength > 160 ? "warn" : "ok";
|
||||||
|
|
||||||
|
checks.push({
|
||||||
|
id: `description-${locale}`,
|
||||||
|
label: `Meta Description (${locale.toUpperCase()})`,
|
||||||
|
status,
|
||||||
|
detail:
|
||||||
|
descriptionLength === 0
|
||||||
|
? "Fehlt. Wird unter Settings > Localization gepflegt."
|
||||||
|
: `${descriptionLength} Zeichen (empfohlen 50-160).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
checks.push({
|
||||||
|
id: "og-image",
|
||||||
|
label: "Standard Share Bild (OG)",
|
||||||
|
status: bindings.defaultOgImage ? "ok" : "warn",
|
||||||
|
detail: bindings.defaultOgImage
|
||||||
|
? "Gesetzt. Projekte nutzen ihr Cover, alle anderen Seiten dieses Bild."
|
||||||
|
: "Nicht gesetzt. Links ohne Vorschaubild. Unter Settings > Brand pflegen.",
|
||||||
|
});
|
||||||
|
|
||||||
|
checks.push({
|
||||||
|
id: "favicon",
|
||||||
|
label: "Favicon",
|
||||||
|
status: bindings.favicon ? "ok" : "warn",
|
||||||
|
detail: bindings.favicon ? "Gesetzt." : "Nicht gesetzt (Fallback-Icon wird generiert).",
|
||||||
|
});
|
||||||
|
|
||||||
|
checks.push({
|
||||||
|
id: "verification",
|
||||||
|
label: "Search Console / Bing",
|
||||||
|
status: seo.googleSiteVerification || seo.bingSiteVerification ? "ok" : "warn",
|
||||||
|
detail:
|
||||||
|
seo.googleSiteVerification || seo.bingSiteVerification
|
||||||
|
? "Verification Meta Tags werden ausgegeben."
|
||||||
|
: "Kein Verification Code hinterlegt.",
|
||||||
|
});
|
||||||
|
|
||||||
|
checks.push({
|
||||||
|
id: "structured-data",
|
||||||
|
label: "Strukturierte Daten (JSON-LD)",
|
||||||
|
status: seo.structuredDataName || settings.locales[settings.defaultLocale].siteName ? "ok" : "warn",
|
||||||
|
detail: `${seo.structuredDataType} + WebSite auf allen Seiten, CreativeWork pro Projekt.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
checks.push({
|
||||||
|
id: "projects",
|
||||||
|
label: "Veroeffentlichte Projekte",
|
||||||
|
status: publishedProjectCount > 0 ? "ok" : "warn",
|
||||||
|
detail:
|
||||||
|
publishedProjectCount > 0
|
||||||
|
? `${publishedProjectCount} Projekt(e) in der Sitemap.`
|
||||||
|
: "Noch kein Projekt veroeffentlicht. Portfolio-Seiten sind leer.",
|
||||||
|
});
|
||||||
|
|
||||||
|
checks.push({
|
||||||
|
id: "sitemap",
|
||||||
|
label: "Sitemap",
|
||||||
|
status: sitemapEntryCount > 0 ? "ok" : maintenanceEnabled || !seo.allowIndexing ? "warn" : "error",
|
||||||
|
detail: `${sitemapEntryCount} URL(s) in /sitemap.xml (alle Sprachen, mit hreflang).`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return checks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeSeoChecklist(checks: SeoCheck[]) {
|
||||||
|
return {
|
||||||
|
ok: checks.filter((check) => check.status === "ok").length,
|
||||||
|
warn: checks.filter((check) => check.status === "warn").length,
|
||||||
|
error: checks.filter((check) => check.status === "error").length,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import type { AppLocale } from "./locale";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site-wide SEO configuration stored as one JSON blob in `app_config`
|
||||||
|
* (key `seo_settings`). Everything here is pure: parsing/normalizing only.
|
||||||
|
* Read/write goes through `lib/app-config.ts`.
|
||||||
|
*/
|
||||||
|
export const SEO_SETTINGS_KEY = "seo_settings";
|
||||||
|
|
||||||
|
export type SeoStructuredDataType = "Person" | "Organization";
|
||||||
|
|
||||||
|
export type SeoLocaleSettings = {
|
||||||
|
/** Comma-separated keywords (optional, low SEO weight but harmless). */
|
||||||
|
keywords: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SeoSettings = {
|
||||||
|
/** Master switch: false → robots disallow all + `noindex` on every page. */
|
||||||
|
allowIndexing: boolean;
|
||||||
|
/** `google-site-verification` meta value. */
|
||||||
|
googleSiteVerification: string;
|
||||||
|
/** `msvalidate.01` meta value (Bing Webmaster). */
|
||||||
|
bingSiteVerification: string;
|
||||||
|
/** `@handle` used for twitter:site / twitter:creator. */
|
||||||
|
twitterHandle: string;
|
||||||
|
/** Publisher shape used for JSON-LD on the home page. */
|
||||||
|
structuredDataType: SeoStructuredDataType;
|
||||||
|
/** Name shown in JSON-LD (falls back to the site name when empty). */
|
||||||
|
structuredDataName: string;
|
||||||
|
/** Person job title / Organization tagline used in JSON-LD. */
|
||||||
|
structuredDataJobTitle: string;
|
||||||
|
/** Social profile URLs for `sameAs` in JSON-LD. */
|
||||||
|
sameAs: string[];
|
||||||
|
locales: Record<AppLocale, SeoLocaleSettings>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildDefaultSeoSettings(): SeoSettings {
|
||||||
|
return {
|
||||||
|
allowIndexing: true,
|
||||||
|
googleSiteVerification: "",
|
||||||
|
bingSiteVerification: "",
|
||||||
|
twitterHandle: "",
|
||||||
|
structuredDataType: "Person",
|
||||||
|
structuredDataName: "",
|
||||||
|
structuredDataJobTitle: "",
|
||||||
|
sameAs: [],
|
||||||
|
locales: {
|
||||||
|
ar: { keywords: "" },
|
||||||
|
en: { keywords: "" },
|
||||||
|
de: { keywords: "" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value: unknown, maxLength = 500): string {
|
||||||
|
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meta verification tokens are alphanumeric with `-` / `_`; anything else is dropped. */
|
||||||
|
export function normalizeVerificationToken(value: unknown): string {
|
||||||
|
const text = normalizeText(value, 200);
|
||||||
|
|
||||||
|
return /^[A-Za-z0-9_-]+$/.test(text) ? text : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTwitterHandle(value: unknown): string {
|
||||||
|
const text = normalizeText(value, 60).replace(/^https?:\/\/(www\.)?(twitter|x)\.com\//i, "").replace(/^@+/, "");
|
||||||
|
|
||||||
|
return /^[A-Za-z0-9_]{1,15}$/.test(text) ? `@${text}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSameAs(value: unknown): string[] {
|
||||||
|
const rawList = Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: typeof value === "string"
|
||||||
|
? value.split(/[\n,]+/)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const urls = rawList
|
||||||
|
.map((entry) => normalizeText(entry, 500))
|
||||||
|
.filter((entry) => /^https:\/\/[^\s]+$/i.test(entry));
|
||||||
|
|
||||||
|
return Array.from(new Set(urls)).slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStructuredDataType(value: unknown): SeoStructuredDataType {
|
||||||
|
return value === "Organization" ? "Organization" : "Person";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeKeywords(value: unknown): string {
|
||||||
|
const text = normalizeText(value, 1000);
|
||||||
|
|
||||||
|
return text
|
||||||
|
.split(",")
|
||||||
|
.map((keyword) => keyword.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 30)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSeoSettingsValue(rawValue: string | null | undefined): SeoSettings {
|
||||||
|
const defaults = buildDefaultSeoSettings();
|
||||||
|
|
||||||
|
if (!rawValue) {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawValue) as Record<string, unknown>;
|
||||||
|
const locales =
|
||||||
|
parsed.locales && typeof parsed.locales === "object"
|
||||||
|
? (parsed.locales as Record<string, Record<string, unknown> | undefined>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowIndexing: parsed.allowIndexing !== false,
|
||||||
|
googleSiteVerification: normalizeVerificationToken(parsed.googleSiteVerification),
|
||||||
|
bingSiteVerification: normalizeVerificationToken(parsed.bingSiteVerification),
|
||||||
|
twitterHandle: normalizeTwitterHandle(parsed.twitterHandle),
|
||||||
|
structuredDataType: normalizeStructuredDataType(parsed.structuredDataType),
|
||||||
|
structuredDataName: normalizeText(parsed.structuredDataName, 120),
|
||||||
|
structuredDataJobTitle: normalizeText(parsed.structuredDataJobTitle, 160),
|
||||||
|
sameAs: normalizeSameAs(parsed.sameAs),
|
||||||
|
locales: {
|
||||||
|
ar: { keywords: normalizeKeywords(locales.ar?.keywords) },
|
||||||
|
en: { keywords: normalizeKeywords(locales.en?.keywords) },
|
||||||
|
de: { keywords: normalizeKeywords(locales.de?.keywords) },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map an app locale to the Open Graph `og:locale` format. */
|
||||||
|
export function toOpenGraphLocale(locale: AppLocale): string {
|
||||||
|
switch (locale) {
|
||||||
|
case "ar":
|
||||||
|
return "ar_AR";
|
||||||
|
case "en":
|
||||||
|
return "en_US";
|
||||||
|
default:
|
||||||
|
return "de_DE";
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -6,7 +6,7 @@ import { isManagedMediaFilePath, resolveMediaUploadPath } from "./media-storage"
|
|||||||
|
|
||||||
const INTERNAL_FAVICON_PATH = "/favicon.ico";
|
const INTERNAL_FAVICON_PATH = "/favicon.ico";
|
||||||
const INTERNAL_APPLE_ICON_PATH = "/apple-icon.png";
|
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 DEFAULT_ICON_VERSION = "default";
|
||||||
const TRANSPARENT_PNG_BASE64 =
|
const TRANSPARENT_PNG_BASE64 =
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9sot5WQAAAAASUVORK5CYII=";
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9sot5WQAAAAASUVORK5CYII=";
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
isLegacyAdminPath,
|
isLegacyAdminPath,
|
||||||
toInternalAdminPath,
|
toInternalAdminPath,
|
||||||
} from "./lib/admin-routing";
|
} from "./lib/admin-routing";
|
||||||
|
import { ADMIN_SESSION_COOKIE, verifyAdminSessionToken } from "./lib/admin-session-token";
|
||||||
import {
|
import {
|
||||||
FALLBACK_LOCALE,
|
FALLBACK_LOCALE,
|
||||||
getLocalizedPathWithDefault,
|
getLocalizedPathWithDefault,
|
||||||
@@ -23,8 +24,6 @@ import {
|
|||||||
stripLocalePrefix,
|
stripLocalePrefix,
|
||||||
} from "./lib/locale";
|
} from "./lib/locale";
|
||||||
|
|
||||||
const ADMIN_SESSION_COOKIE = "moh_admin_session";
|
|
||||||
|
|
||||||
type SiteRuntimeState = {
|
type SiteRuntimeState = {
|
||||||
defaultLocale: (typeof appLocales)[number];
|
defaultLocale: (typeof appLocales)[number];
|
||||||
maintenanceEnabled: boolean;
|
maintenanceEnabled: boolean;
|
||||||
@@ -220,7 +219,7 @@ export default async function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
siteRuntimeState.maintenanceEnabled &&
|
siteRuntimeState.maintenanceEnabled &&
|
||||||
!request.cookies.has(ADMIN_SESSION_COOKIE) &&
|
!verifyAdminSessionToken(request.cookies.get(ADMIN_SESSION_COOKIE)?.value) &&
|
||||||
!isComingSoonPath(pathname)
|
!isComingSoonPath(pathname)
|
||||||
) {
|
) {
|
||||||
const locale = getPathLocale(pathname, configuredDefaultLocale);
|
const locale = getPathLocale(pathname, configuredDefaultLocale);
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@
|
|||||||
- Overview dashboard
|
- Overview dashboard
|
||||||
- Maintenance mode
|
- Maintenance mode
|
||||||
- Media Library
|
- Media Library
|
||||||
- Site Settings
|
- Site Settings (Brand / Localization / SEO — see `docs/SEO.md`)
|
||||||
- Marquee Settings
|
- Marquee Settings
|
||||||
- SMTP Settings
|
- SMTP Settings
|
||||||
- Contact Protection
|
- Contact Protection
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ describe("createMediaAssetAction", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.skipIf(!canManageUploads)("creates an asset from an uploaded file", async () => {
|
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(() =>
|
const url = await captureRedirect(() =>
|
||||||
createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "Pic", file })),
|
createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "Pic", file })),
|
||||||
);
|
);
|
||||||
@@ -43,6 +43,15 @@ describe("createMediaAssetAction", () => {
|
|||||||
await removeManagedMediaFile(assets[0].url);
|
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 () => {
|
it("redirects unauthenticated callers to the admin root", async () => {
|
||||||
adminAuth.authenticated = false;
|
adminAuth.authenticated = false;
|
||||||
const url = await captureRedirect(() => createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "L" })));
|
const url = await captureRedirect(() => createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "L" })));
|
||||||
|
|||||||
@@ -104,6 +104,14 @@ describe("upsertCategoryAction", () => {
|
|||||||
expect(category?.nameEn).toBe("Renamed");
|
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 () => {
|
it("reports a unique-constraint violation on duplicate slugs", async () => {
|
||||||
await createCategory({ slug: "branding" });
|
await createCategory({ slug: "branding" });
|
||||||
const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "branding" })));
|
const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "branding" })));
|
||||||
@@ -141,6 +149,13 @@ describe("deleteCategoryAction", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("saveProjectAction", () => {
|
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 () => {
|
it("creates a published project with cover and asset media usages", async () => {
|
||||||
const category = await createCategory();
|
const category = await createCategory();
|
||||||
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
|
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ vi.mock("@/lib/admin-auth", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
saveSeoSettingsAction,
|
||||||
saveSiteBrandSettingsAction,
|
saveSiteBrandSettingsAction,
|
||||||
saveSiteLocalizationSettingsAction,
|
saveSiteLocalizationSettingsAction,
|
||||||
} from "@/app/_admin/site-settings/actions";
|
} 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";
|
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -102,3 +103,51 @@ describe("saveSiteLocalizationSettingsAction", () => {
|
|||||||
expect(url).toBe("/");
|
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=");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ describe("resolveMediaSelection — missing configuration", () => {
|
|||||||
|
|
||||||
describe("resolveMediaSelection — upload mode (filesystem)", () => {
|
describe("resolveMediaSelection — upload mode (filesystem)", () => {
|
||||||
it.skipIf(!canManageUploads)("saves the file and creates an UPLOAD asset", async () => {
|
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({
|
const result = await resolveMediaSelection({
|
||||||
media: { mode: "upload", assetId: "", url: "", label: "Shot", kind: "IMAGE" },
|
media: { mode: "upload", assetId: "", url: "", label: "Shot", kind: "IMAGE" },
|
||||||
uploadFile: file,
|
uploadFile: file,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe("metadata helpers", () => {
|
|||||||
apple: [{ url: "/apple-icon.png?v=v1" }],
|
apple: [{ url: "/apple-icon.png?v=v1" }],
|
||||||
});
|
});
|
||||||
expect(metadata.openGraph).toMatchObject({
|
expect(metadata.openGraph).toMatchObject({
|
||||||
locale: "ar",
|
locale: "ar_AR",
|
||||||
url: "https://mohfarawati.de/",
|
url: "https://mohfarawati.de/",
|
||||||
});
|
});
|
||||||
expect(metadata.twitter).toMatchObject({
|
expect(metadata.twitter).toMatchObject({
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ vi.mock("../lib/admin-routing", () => ({
|
|||||||
toInternalAdminPath: (pathname: string) => pathname,
|
toInternalAdminPath: (pathname: string) => pathname,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function createMockRequest(url: string) {
|
function createMockRequest(url: string, cookieValue?: string) {
|
||||||
const nextUrl = new URL(url) as URL & { clone: () => URL };
|
const nextUrl = new URL(url) as URL & { clone: () => URL };
|
||||||
nextUrl.clone = () => new URL(nextUrl.toString());
|
nextUrl.clone = () => new URL(nextUrl.toString());
|
||||||
|
|
||||||
@@ -31,11 +31,25 @@ function createMockRequest(url: string) {
|
|||||||
host: nextUrl.host,
|
host: nextUrl.host,
|
||||||
}),
|
}),
|
||||||
cookies: {
|
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", () => {
|
describe("middleware locale runtime config", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -123,6 +137,28 @@ describe("middleware locale runtime config", () => {
|
|||||||
expect(intlHandlerMock).not.toHaveBeenCalled();
|
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 () => {
|
it("prefers the configured internal runtime origin when provided", async () => {
|
||||||
process.env.SITE_RUNTIME_ORIGIN = "http://app:3000";
|
process.env.SITE_RUNTIME_ORIGIN = "http://app:3000";
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ describe("getAdminNavigation", () => {
|
|||||||
const minimal = { ...copy, brandSettings: undefined, localizationSettings: undefined, marquee: undefined, smtp: undefined };
|
const minimal = { ...copy, brandSettings: undefined, localizationSettings: undefined, marquee: undefined, smtp: undefined };
|
||||||
const nav = getAdminNavigation(minimal, "overview");
|
const nav = getAdminNavigation(minimal, "overview");
|
||||||
const siteSettings = nav.find((item) => item.label === "Site Settings");
|
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("/marquee"))?.label).toBe("Marquee");
|
||||||
expect(nav.find((item) => item.href.endsWith("/smtp"))?.label).toBe("SMTP");
|
expect(nav.find((item) => item.href.endsWith("/smtp"))?.label).toBe("SMTP");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
MEDIA_UPLOAD_ROOT,
|
MEDIA_UPLOAD_ROOT,
|
||||||
getExtensionForMimeType,
|
getExtensionForMimeType,
|
||||||
isManagedMediaFilePath,
|
isManagedMediaFilePath,
|
||||||
|
isMediaContentValid,
|
||||||
removeManagedMediaFile,
|
removeManagedMediaFile,
|
||||||
resolveMediaUploadPath,
|
resolveMediaUploadPath,
|
||||||
sanitizeBaseName,
|
sanitizeBaseName,
|
||||||
@@ -69,6 +70,42 @@ describe("resolveMediaUploadPath", () => {
|
|||||||
it("throws when a traversal attempt escapes the root", () => {
|
it("throws when a traversal attempt escapes the root", () => {
|
||||||
expect(() => resolveMediaUploadPath("/uploads/media/../../etc/passwd")).toThrow(/escapes/i);
|
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", () => {
|
describe("removeManagedMediaFile", () => {
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ describe("mediaFieldInputSchema", () => {
|
|||||||
expect(parsed.url).toBe("/uploads/media/x.png");
|
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", () => {
|
it("requires a url in external mode", () => {
|
||||||
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "external" })).toThrow(/URL/i);
|
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "external" })).toThrow(/URL/i);
|
||||||
});
|
});
|
||||||
|
|||||||
+106
-1
@@ -1,11 +1,15 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { buildDefaultSeoSettings } from "@/lib/seo-settings";
|
||||||
import { buildDefaultSiteSettings } from "@/lib/site-settings";
|
import { buildDefaultSiteSettings } from "@/lib/site-settings";
|
||||||
import {
|
import {
|
||||||
applyTitleTemplateFn,
|
applyTitleTemplateFn,
|
||||||
buildAppMetadataFromConfig,
|
buildAppMetadataFromConfig,
|
||||||
buildLocaleAlternates,
|
buildLocaleAlternates,
|
||||||
buildLocalizedMetadataFromConfig,
|
buildLocalizedMetadataFromConfig,
|
||||||
|
buildProjectJsonLd,
|
||||||
|
buildSiteJsonLd,
|
||||||
|
serializeJsonLd,
|
||||||
} from "@/lib/metadata";
|
} from "@/lib/metadata";
|
||||||
|
|
||||||
const noBindings = {
|
const noBindings = {
|
||||||
@@ -82,7 +86,7 @@ describe("buildLocalizedMetadataFromConfig", () => {
|
|||||||
});
|
});
|
||||||
expect(metadata.title).toBe("About | Studio");
|
expect(metadata.title).toBe("About | Studio");
|
||||||
expect(metadata.description).toBe("English description");
|
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)", () => {
|
it("can skip the title template (homepage)", () => {
|
||||||
@@ -111,3 +115,104 @@ describe("buildLocalizedMetadataFromConfig", () => {
|
|||||||
expect(metadata.description).toBe("Custom desc");
|
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>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user