From dc21c33867207358c5e1e963775755b232ca9d10 Mon Sep 17 00:00:00 2001 From: moh Date: Sun, 20 Sep 2026 21:36:16 +0200 Subject: [PATCH] ADDED - Admin SEO page, robots/sitemap hardening and media/maintenance security fixes SEO - New Settings > SEO admin page (seo_settings in app_config): indexing switch, Google/Bing verification, X handle, JSON-LD identity (Person/Organization, sameAs), per-locale keywords, readiness checklist and open links for sitemap.xml / robots.txt / manifest. - robots.txt is now dynamic: disallows admin, api, success and coming-soon paths; blocks everything while indexing is off or maintenance is on. - sitemap.xml carries hreflang alternates per URL, lists only categories with published projects, and is empty while hidden. - Metadata: robots + verification meta, og:locale in de_DE/en_US/ar_AR form, alternateLocale, twitter site/creator, project cover as OG image with article type, noindex on /success and /coming-soon. - JSON-LD: WebSite + publisher graph on all public pages, CreativeWork per project (view-mode independent). Security - Maintenance bypass now requires a correctly signed admin cookie; the middleware previously only checked the cookie existed. Token helpers moved to lib/admin-session-token.ts (shared by proxy.ts and lib/admin-auth.ts). - Media uploads: magic-byte validation against the declared type, SVG sanitization (script/handlers/foreignObject/javascript: rejected), upload folder sanitized, kind inferred from the real file. - Media route: fixed prefix-based path check that accepted sibling directories, unknown extensions return 404, nosniff header, CSP sandbox on SVG, gif content type added. - External media URLs: protocol-relative (//host) URLs rejected. Portfolio - Project and category slugs share /portfolio/[slug]; saving now rejects a slug already used on the other side instead of silently shadowing it. Tooling/docs - Lint: ignore scripts/legacy-prisma-seed.cjs, drop unused import. - New docs/SEO.md; FEATURES, ARCHITECTURE (Drizzle instead of Prisma), admin spec and CLAUDE.md updated. - Tests for all of the above (unit + integration); suite green. --- CLAUDE.md | 5 +- app/[locale]/(site)/layout.tsx | 9 +- app/[locale]/(site)/portfolio/[slug]/page.tsx | 27 +- app/[locale]/(site)/success/page.tsx | 1 + app/[locale]/coming-soon/page.tsx | 1 + app/_admin/portfolio/actions.ts | 29 +- app/_admin/site-settings/actions.ts | 71 +++++ app/_admin/site-settings/seo/page.tsx | 104 +++++++ app/admin-internal/site-settings/seo/page.tsx | 1 + app/robots.ts | 40 ++- app/root/site-settings/seo/page.tsx | 1 + app/sitemap.ts | 97 ++++-- app/uploads/media/[...segments]/route.ts | 32 +- components/admin/admin-dashboard-shell.tsx | 6 +- components/admin/seo-settings-form.tsx | 278 ++++++++++++++++++ components/seo/json-ld.tsx | 14 + docs/ARCHITECTURE.md | 12 +- docs/FEATURES.md | 15 +- docs/SEO.md | 83 ++++++ eslint.config.mjs | 2 +- lib/admin-auth.ts | 36 +-- lib/admin-navigation.ts | 10 +- lib/admin-session-token.ts | 47 +++ lib/app-config.ts | 19 ++ lib/media-service.ts | 5 +- lib/media-storage.ts | 66 ++++- lib/media-validation.ts | 4 +- lib/metadata.ts | 233 +++++++++++++-- lib/seo-report.ts | 124 ++++++++ lib/seo-settings.ts | 145 +++++++++ lib/site-icons.tsx | 2 +- proxy.ts | 5 +- specs/admin.md | 2 +- tests/integration/actions-media.test.ts | 11 +- tests/integration/actions-portfolio.test.ts | 15 + .../integration/actions-site-settings.test.ts | 51 +++- tests/integration/media-service.test.ts | 2 +- tests/metadata.test.ts | 2 +- tests/middleware.test.ts | 40 ++- tests/unit/admin-navigation.test.ts | 2 +- tests/unit/admin-session-token.test.ts | 39 +++ tests/unit/media-storage.test.ts | 37 +++ tests/unit/media-validation.test.ts | 5 + tests/unit/metadata.test.ts | 107 ++++++- tests/unit/robots.test.ts | 20 ++ tests/unit/seo-report.test.ts | 51 ++++ tests/unit/seo-settings.test.ts | 77 +++++ 47 files changed, 1854 insertions(+), 131 deletions(-) create mode 100644 app/_admin/site-settings/seo/page.tsx create mode 100644 app/admin-internal/site-settings/seo/page.tsx create mode 100644 app/root/site-settings/seo/page.tsx create mode 100644 components/admin/seo-settings-form.tsx create mode 100644 components/seo/json-ld.tsx create mode 100644 docs/SEO.md create mode 100644 lib/admin-session-token.ts create mode 100644 lib/seo-report.ts create mode 100644 lib/seo-settings.ts create mode 100644 tests/unit/admin-session-token.test.ts create mode 100644 tests/unit/robots.test.ts create mode 100644 tests/unit/seo-report.test.ts create mode 100644 tests/unit/seo-settings.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7076cca..5c16241 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,14 +83,17 @@ The Drizzle client is in `lib/db/index.ts` (postgres.js driver); the schema is i | DB schema | `lib/db/schema.ts` | | AppConfig aggregate | `lib/app-config.ts` | | Portfolio queries | `lib/portfolio.ts` | -| Media handling | `lib/media.ts` | +| Media handling | `lib/media.ts`, `lib/media-storage.ts` | | Contact flow | `lib/mail.ts` | +| SEO (metadata, robots, sitemap, JSON-LD) | `lib/metadata.ts`, `lib/seo-settings.ts`, `app/robots.ts`, `app/sitemap.ts` — see `docs/SEO.md` | +| Admin session token (middleware + auth) | `lib/admin-session-token.ts` | ### Documentation to read by task scope - **Small UI/copy/style fixes**: read only the relevant files - **Feature changes**: read `specs/.md` + `docs/ARCHITECTURE.md` if structure is affected - **Cross-cutting/architecture changes**: read `docs/ARCHITECTURE.md`, `docs/DOMAIN_RULES.md`, `docs/FEATURES.md`, and the relevant `specs/` file +- **SEO / metadata / robots / sitemap**: read `docs/SEO.md` first Update `docs/` and `specs/` only when the change affects feature scope, business rules, architecture, or public behavior. diff --git a/app/[locale]/(site)/layout.tsx b/app/[locale]/(site)/layout.tsx index 3a017c4..695cd2c 100644 --- a/app/[locale]/(site)/layout.tsx +++ b/app/[locale]/(site)/layout.tsx @@ -8,9 +8,11 @@ import { PageTransition } from "@/components/layout/page-transition"; import { SiteDock } from "@/components/layout/site-dock"; import { SiteFooter } from "@/components/layout/site-footer"; import { ScrollSmootherProvider } from "@/components/scroll-smoother-provider"; +import { JsonLd } from "@/components/seo/json-ld"; import { isSuperAdmin } from "@/lib/admin-auth"; -import { getMaintenanceMode, getSiteSettings } from "@/lib/app-config"; +import { getMaintenanceMode, getSeoSettings, getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config"; import { getLocalizedPath, resolveLocale } from "@/lib/locale"; +import { buildSiteJsonLd } from "@/lib/metadata"; type SiteLayoutProps = { children: ReactNode; @@ -25,9 +27,11 @@ export const revalidate = 0; export default async function SiteLayout({ children, params }: SiteLayoutProps) { noStore(); await params; - const [maintenanceEnabled, siteSettings] = await Promise.all([ + const [maintenanceEnabled, siteSettings, seo, mediaBindings] = await Promise.all([ getMaintenanceMode(), getSiteSettings(), + getSeoSettings(), + getSiteSettingsMediaBindings(), ]); const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale); // Server-side decision only. The dock receives just this boolean and uses @@ -42,6 +46,7 @@ export default async function SiteLayout({ children, params }: SiteLayoutProps) return ( <> + diff --git a/app/[locale]/(site)/portfolio/[slug]/page.tsx b/app/[locale]/(site)/portfolio/[slug]/page.tsx index 2ea58e0..90a3e26 100644 --- a/app/[locale]/(site)/portfolio/[slug]/page.tsx +++ b/app/[locale]/(site)/portfolio/[slug]/page.tsx @@ -7,8 +7,9 @@ import { PageHero } from "@/components/layout/page-hero"; import { PortfolioCategoryFilter } from "@/components/site/portfolio-category-filter"; import { PortfolioProjectDetail } from "@/components/site/portfolio-project-detail"; import { PortfolioProjectGrid } from "@/components/site/portfolio-project-grid"; -import { getSiteSettings } from "@/lib/app-config"; -import { buildLocalizedMetadata } from "@/lib/metadata"; +import { JsonLd } from "@/components/seo/json-ld"; +import { getSeoSettings, getSiteSettings } from "@/lib/app-config"; +import { buildLocalizedMetadata, buildProjectJsonLd } from "@/lib/metadata"; import { resolveLocale } from "@/lib/locale"; import { getActivePortfolioCategories, @@ -57,6 +58,9 @@ export async function generateMetadata({ params }: PortfolioSlugPageProps): Prom pathname: `/portfolio/${slug}`, title: getLocalizedValue(resolved.project.title, localeKey), description: getLocalizedValue(resolved.project.summary, localeKey), + image: resolved.project.coverImagePath, + type: "article", + publishedTime: resolved.project.publishedAt, }); } @@ -108,13 +112,30 @@ export default async function PortfolioSlugPage({ params }: PortfolioSlugPagePro } const { project: item } = resolved; - const t = await getTranslations({ locale: localeKey, namespace: "portfolioDetail" }); + const [t, seo] = await Promise.all([ + getTranslations({ locale: localeKey, namespace: "portfolioDetail" }), + getSeoSettings(), + ]); const title = getLocalizedValue(item.title, localeKey); const category = getLocalizedValue(item.category.name, localeKey); const summary = getLocalizedValue(item.summary, localeKey); + const jsonLd = buildProjectJsonLd({ + settings: siteSettings, + seo, + locale: localeKey, + pathname: `/portfolio/${slug}`, + title, + description: summary, + image: item.coverImagePath, + datePublished: item.publishedAt, + genre: category, + keywords: [getLocalizedValue(item.serviceLabel, localeKey), String(item.projectYear)].filter(Boolean), + clientName: item.clientName, + }); return ( <> + ; +}) { + 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 ( + + + + + + ); +} diff --git a/app/admin-internal/site-settings/seo/page.tsx b/app/admin-internal/site-settings/seo/page.tsx new file mode 100644 index 0000000..ee894f9 --- /dev/null +++ b/app/admin-internal/site-settings/seo/page.tsx @@ -0,0 +1 @@ +export { default } from "../../../_admin/site-settings/seo/page"; diff --git a/app/robots.ts b/app/robots.ts index b2a7721..2d04783 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -1,15 +1,49 @@ import type { MetadataRoute } from "next"; +import { unstable_noStore as noStore } from "next/cache"; -export default function robots(): MetadataRoute.Robots { - const siteUrl = new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de"); +import { getMaintenanceMode, getSeoSettings } from "@/lib/app-config"; +import { INTERNAL_ADMIN_PREFIX } from "@/lib/admin-routing"; +import { getSiteUrl } from "@/lib/metadata"; + +export const dynamic = "force-dynamic"; + +/** Paths that must never be crawled even when indexing is enabled. */ +export const ROBOTS_DISALLOWED_PATHS = [ + INTERNAL_ADMIN_PREFIX, + "/root", + "/api/", + "/success", + "/coming-soon", + "/*/success", + "/*/coming-soon", +]; + +export function buildRobots(input: { indexable: boolean }): MetadataRoute.Robots { + const siteUrl = getSiteUrl(); + + if (!input.indexable) { + return { + rules: [{ userAgent: "*", disallow: "/" }], + host: siteUrl.origin, + }; + } return { rules: [ { userAgent: "*", - disallow: ["/admin-internal"], + allow: "/", + disallow: ROBOTS_DISALLOWED_PATHS, }, ], sitemap: new URL("/sitemap.xml", siteUrl).toString(), + host: siteUrl.origin, }; } + +export default async function robots(): Promise { + noStore(); + const [seo, maintenanceEnabled] = await Promise.all([getSeoSettings(), getMaintenanceMode()]); + + return buildRobots({ indexable: seo.allowIndexing && !maintenanceEnabled }); +} diff --git a/app/root/site-settings/seo/page.tsx b/app/root/site-settings/seo/page.tsx new file mode 100644 index 0000000..ee894f9 --- /dev/null +++ b/app/root/site-settings/seo/page.tsx @@ -0,0 +1 @@ +export { default } from "../../../_admin/site-settings/seo/page"; diff --git a/app/sitemap.ts b/app/sitemap.ts index 35650b6..fec5a70 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -2,72 +2,111 @@ import type { MetadataRoute } from "next"; import { unstable_noStore as noStore } from "next/cache"; import { routing } from "@/i18n/routing"; -import { getSiteSettings } from "@/lib/app-config"; -import { getLocalizedPath } from "@/lib/locale"; -import { getPublishedPortfolioProjects } from "@/lib/portfolio"; +import { getMaintenanceMode, getSeoSettings, getSiteSettings } from "@/lib/app-config"; +import { getLocalizedPath, type AppLocale } from "@/lib/locale"; +import { toAbsoluteUrl } from "@/lib/metadata"; +import { getActivePortfolioCategories, getPublishedPortfolioProjects } from "@/lib/portfolio"; -function getSiteUrl(): URL { - return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de"); -} +export const dynamic = "force-dynamic"; -function toAbsoluteUrl(pathname: string): string { - return new URL(pathname, getSiteUrl()).toString(); -} +type EntryOptions = Pick; -function buildLocalizedEntries( +/** + * One entry per locale for a path, each carrying hreflang alternates so search + * engines link the three language versions together. + */ +export function buildLocalizedEntries( pathname: string, - defaultLocale: "de" | "en" | "ar", - options?: Pick, + defaultLocale: AppLocale, + options?: EntryOptions, ): MetadataRoute.Sitemap { + const languages = Object.fromEntries( + routing.locales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPath(locale, pathname, defaultLocale))]), + ) as Record; + return routing.locales.map((locale) => ({ - url: toAbsoluteUrl(getLocalizedPath(locale, pathname, defaultLocale)), + url: languages[locale], lastModified: options?.lastModified, changeFrequency: options?.changeFrequency, priority: options?.priority, + alternates: { + languages: { + ...languages, + "x-default": languages[defaultLocale], + }, + }, })); } export default async function sitemap(): Promise { noStore(); - const siteSettings = await getSiteSettings(); + const [siteSettings, seo, maintenanceEnabled] = await Promise.all([ + getSiteSettings(), + getSeoSettings(), + getMaintenanceMode(), + ]); - let projects: Awaited> = []; - - try { - projects = await getPublishedPortfolioProjects(); - } catch { - projects = []; + // While the site is hidden (maintenance) or indexing is off, publish an + // empty sitemap instead of advertising URLs that redirect or are noindex. + if (maintenanceEnabled || !seo.allowIndexing) { + return []; } - const categories = Array.from( - new Map(projects.map((project) => [project.category.slug, project.category])).values(), + const defaultLocale = siteSettings.defaultLocale; + + let projects: Awaited> = []; + let categories: Awaited> = []; + + 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((latest, project) => { + const date = project.publishedAt ?? undefined; + + return date && (!latest || date > latest) ? date : latest; + }, undefined); + return [ - ...buildLocalizedEntries("/", siteSettings.defaultLocale, { + ...buildLocalizedEntries("/", defaultLocale, { changeFrequency: "weekly", priority: 1, + lastModified: latestProjectDate, }), - ...buildLocalizedEntries("/about", siteSettings.defaultLocale, { + ...buildLocalizedEntries("/about", defaultLocale, { changeFrequency: "monthly", priority: 0.8, }), - ...buildLocalizedEntries("/portfolio", siteSettings.defaultLocale, { + ...buildLocalizedEntries("/portfolio", defaultLocale, { changeFrequency: "weekly", priority: 0.9, + lastModified: latestProjectDate, }), - ...categories.flatMap((category) => - buildLocalizedEntries(`/portfolio/${category.slug}`, siteSettings.defaultLocale, { + ...categoriesWithProjects.flatMap((category) => + buildLocalizedEntries(`/portfolio/${category.slug}`, defaultLocale, { changeFrequency: "weekly", priority: 0.8, + lastModified: latestProjectDate, }), ), - ...buildLocalizedEntries("/contact", siteSettings.defaultLocale, { + ...buildLocalizedEntries("/contact", defaultLocale, { changeFrequency: "monthly", priority: 0.7, }), ...projects.flatMap((project) => - buildLocalizedEntries(`/portfolio/${project.slug}`, siteSettings.defaultLocale, { + buildLocalizedEntries(`/portfolio/${project.slug}`, defaultLocale, { lastModified: project.publishedAt ?? undefined, changeFrequency: "monthly", priority: 0.8, diff --git a/app/uploads/media/[...segments]/route.ts b/app/uploads/media/[...segments]/route.ts index 3af5a18..77c3d8b 100644 --- a/app/uploads/media/[...segments]/route.ts +++ b/app/uploads/media/[...segments]/route.ts @@ -14,6 +14,7 @@ export const dynamic = "force-dynamic"; const CONTENT_TYPES: Record = { ".ico": "image/x-icon", + ".gif": "image/gif", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", @@ -30,15 +31,30 @@ export async function GET(_: Request, { params }: MediaFileRouteProps) { try { const absolutePath = resolveMediaUploadPath(publicPath); const fileBuffer = await readFile(absolutePath); - const contentType = CONTENT_TYPES[path.extname(absolutePath).toLowerCase()] ?? "application/octet-stream"; + const extension = path.extname(absolutePath).toLowerCase(); + const contentType = CONTENT_TYPES[extension]; - return new NextResponse(fileBuffer, { - status: 200, - headers: { - "Content-Type": contentType, - "Cache-Control": "public, max-age=31536000, immutable", - }, - }); + if (!contentType) { + return new NextResponse("Not Found", { status: 404 }); + } + + const headers: Record = { + "Content-Type": contentType, + "Cache-Control": "public, max-age=31536000, immutable", + "X-Content-Type-Options": "nosniff", + }; + + // SVG is an active document type: sandbox it so an uploaded file can never + // run script or reach our origin even if it is opened directly. + if (extension === ".svg") { + headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; + } + + if (extension === ".pdf") { + headers["Content-Disposition"] = "inline"; + } + + return new NextResponse(new Uint8Array(fileBuffer), { status: 200, headers }); } catch { return new NextResponse("Not Found", { status: 404, diff --git a/components/admin/admin-dashboard-shell.tsx b/components/admin/admin-dashboard-shell.tsx index 25e434c..012a15f 100644 --- a/components/admin/admin-dashboard-shell.tsx +++ b/components/admin/admin-dashboard-shell.tsx @@ -8,6 +8,7 @@ import { LogOut, Palette, PlusSquare, + Search, ShieldAlert, SwatchBook, Tags, @@ -40,6 +41,7 @@ type AdminDashboardCopy = { siteSettings: string; brandSettings?: string; localizationSettings?: string; + seoSettings?: string; marquee?: string; smtp?: string; logout: string; @@ -50,7 +52,7 @@ type AdminDashboardShellProps = { copy: AdminDashboardCopy; active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee"; portfolioChild?: "overview" | "projects" | "new-project" | "categories"; - siteSettingsChild?: "brand" | "localization"; + siteSettingsChild?: "brand" | "localization" | "seo"; flash?: FlashMessages; logoutAction: () => Promise; headerTitle: string; @@ -100,6 +102,8 @@ export async function AdminDashboardShell({ : active === "site-settings" ? siteSettingsChild === "localization" ? Languages + : siteSettingsChild === "seo" + ? Search : siteSettingsChild === "brand" ? Palette : Globe2 diff --git a/components/admin/seo-settings-form.tsx b/components/admin/seo-settings-form.tsx new file mode 100644 index 0000000..220ccc1 --- /dev/null +++ b/components/admin/seo-settings-form.tsx @@ -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; + 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 ; + } + + if (status === "warn") { + return ; + } + + return ; +} + +function FileLink({ + href, + label, + description, + icon: Icon, +}: { + href: string; + label: string; + description: string; + icon: typeof MapIcon; +}) { + return ( + +
+
+ +
+
+

{label}

+

{description}

+
+
+ +
+ ); +} + +export function SeoSettingsForm({ action, settings, checks, links, sitemapEntryCount }: SeoSettingsFormProps) { + const summary = summarizeSeoChecklist(checks); + + return ( +
+
+ + + +
+ +
+
+
+
+

Sichtbarkeit

+

+ Steuert robots.txt, die Sitemap und das robots Meta Tag aller oeffentlichen Seiten. +

+
+ + + +
+ +
+
+

Verifizierung & Social

+

+ Codes aus Google Search Console / Bing Webmaster und das X-Handle fuer Twitter Cards. +

+
+ +
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+

Strukturierte Daten

+

+ JSON-LD fuer Google: Wer steht hinter der Seite? Gilt fuer alle Seiten und jede Projekt-Ansicht. +

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ +