From c35bd5877e218ae7de40251f755502e7776744ec Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Fri, 3 Jul 2026 22:59:08 +0200 Subject: [PATCH 01/11] chore(ops): harden deployment and runtime setup --- .DS_Store | Bin 8196 -> 0 bytes .dockerignore | 3 + .env.example | 28 +++++-- .gitignore | 8 +- Dockerfile | 16 ++-- README.md | 125 ++++++++++++++++++++++++++-- app/admin/login/page.tsx | 3 +- app/admin/system/page.tsx | 50 ++++++----- app/api/health/route.ts | 20 +++++ app/error.tsx | 19 +++++ app/global-error.tsx | 18 ++++ app/globals.css | 63 ++++++++++++++ app/not-found.tsx | 17 ++++ components/Footer.tsx | 3 +- components/admin/AdminLoginForm.tsx | 9 +- components/admin/AdminShell.tsx | 2 + data/.gitkeep | 1 + data/contact-inquiries.example.json | 12 +++ data/repair-inquiries.example.json | 18 ++++ docker-compose.yml | 25 ++++-- lib/admin/store.ts | 10 +-- lib/runtime/config.ts | 39 +++++++++ lib/runtime/version.ts | 1 + package-lock.json | 4 +- package.json | 2 +- scripts/backup.sh | 13 +++ scripts/deploy.sh | 19 +++++ scripts/healthcheck.sh | 14 ++++ scripts/restore.sh | 17 ++++ scripts/runtime-start.js | 36 ++++++++ 30 files changed, 534 insertions(+), 61 deletions(-) delete mode 100644 .DS_Store create mode 100644 app/api/health/route.ts create mode 100644 app/error.tsx create mode 100644 app/global-error.tsx create mode 100644 app/not-found.tsx create mode 100644 data/.gitkeep create mode 100644 data/contact-inquiries.example.json create mode 100644 data/repair-inquiries.example.json create mode 100644 lib/runtime/config.ts create mode 100644 lib/runtime/version.ts create mode 100755 scripts/backup.sh create mode 100755 scripts/deploy.sh create mode 100755 scripts/healthcheck.sh create mode 100755 scripts/restore.sh create mode 100644 scripts/runtime-start.js diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 379c22d995b04dca0d238f942dd9a4d0c611da7a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMYitx%6u#fIlo>m~v=%7HWVbG_;9|F>AO*o?TVB!%vMt?~V%^8kj-Afbo!Kpq z)RY+CCK_LfzlhJDjT(&@gQCAod_~icNFpI>BL49ge=q?(Gk2EQg_@Wc13EXEd*+;b z&Y3x9zjJTTlre@xA=kuMJ!4Fy%cV*+P1hLix_ezU1Yc4~6lBks&a#;?do*KMnX9w| zL6CtU13?CY3b3yhpnK{AJe#mj<363>)H5ZYynBFe3ctkwH>dTmt4=iT(+N(wIRiIm@ShC7LxJ~n zvI~3a3~^3_ImkeefvYp%mQNKMWd)XDMp^%UA~R~}wiAn;hEP#Ct7^75TdWojB!{fA zq?2|Eb|RhM?{Zz1Wn@bC>C8S|8yisS+APya>!z0X4D_5vD*ZjWnX|?cR>92Ko^6sZ zh>|D`DD_8=Hnp}y!_Dj0jz_~sTbpCiaLbx?n~FyLg|*JQ+n1illl>DK$&M#U^|K3lh=wAP*2t} z3L~aHpwwj3Mz&z29c_zYxXL7(XJ_-4VeGMN-O(+xH|1!DoRXtyOQ}BF$~$eIvjIgZ z*xFu=eCb84ojj7OTK8$YM_o7eJ}qw(jl^R-L}_yF!qC!{tDEn*Gro0a=e<+&l$u(( zPF~Pc)NMUuXx(Fa&Kc@W+gZ)b>E>V`W!H4{gSwWNY7mvW%=CyhRa?Jk@hwYKwOqU8 zB~{4i4;FOiuo4kP{)`4SA}Q~Q{W*HcPAlhn%y}zngStW#_q+18yhbw@%d6C=q@=|z zn-aubG|EkqVoP1a1OaZ^BCivboYZZkGn&Cgt@1`u*(LQ9dE{JJC*LJXsjM|JLc!Qv zmQdS8-CWD0)9E$z7AJIo+}HUN-J& zP}N@7)s|dN%Tdsa>lf9kNwr&4z9kz)H;Xz!o_-6Cd@)kOTqM4V-$qUipY!g#97}a2 z1)sVHoXVE7^>mCID40!WFN6F*=cr`{ltD{7uj#@61$8F$f(5UdF3<4R7F0OyYfRPzQlhT>EEz3 z>>NAKF0hNJLNyeGuoAbUg^;}sI|$t!=p=Nf(1-o#hlWAuc!03(pol{_j3am$kKi#p zjwcA|FW^PIgyVRH(Ed8%eG+fsZM=gIaS|Wn6h85AKZWn{1AZ(+a_4j;<3c%-i^;rc zSmq$fOX#0&azk?!{O#-A{(tMt-~Vsu7=xD&G7x0ouQPzk_GEj4bY|M)++I6O*Zp+4 z!_}MM)H9*UYsZOt?KsgZ|1hNcFsZsWai30bN)j6X`iFqO^XGPO{s-s(fAswS1BSYE A)c^nh diff --git a/.dockerignore b/.dockerignore index afe0bde..9b5cf41 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,6 @@ README.md data/*.json public/uploads/images/* !public/uploads/images/.gitkeep +*.tmp +*.temp +.DS_Store diff --git a/.env.example b/.env.example index 4f85a3e..e020556 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,27 @@ -NEXT_PUBLIC_SITE_URL=http://localhost:3010 +# Public base URL used for canonical URLs, sitemap, robots.txt and absolute metadata. +# Local example: http://localhost:3010 +# Production example: https://funktechnik-schubert.de +NEXT_PUBLIC_SITE_URL= -# Admin Foundation -ADMIN_EMAIL=admin@funktechnik-schubert.local -ADMIN_PASSWORD=change-me -ADMIN_SESSION_SECRET=change-me-with-a-long-random-secret +# Admin login email address. Required for /admin. +ADMIN_EMAIL= + +# Admin login password. Required for /admin. +# Use a long unique password and never commit a real value. +ADMIN_PASSWORD= + +# Secret for signing the HttpOnly admin session cookie. Required. +# Use a long random value, for example generated by: openssl rand -base64 48 +ADMIN_SESSION_SECRET= + +# Set to true in production behind HTTPS so admin cookies are Secure. +# Use false only for local HTTP development. AUTH_COOKIE_SECURE=false -# Optional spaetere Integration. Nicht im Browser verwenden. +# Optional future server-side Olympus intake endpoint. +# Leave empty until the Olympus integration is explicitly implemented. OLYMPUS_INTAKE_API_URL= + +# Optional future server-side Olympus intake token. +# Leave empty until the Olympus integration is explicitly implemented. OLYMPUS_INTAKE_API_TOKEN= diff --git a/.gitignore b/.gitignore index 3338a15..eb72981 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,13 @@ dist .env .env.local npm-debug.log* -data/*.json +data/contact-inquiries.json +data/repair-inquiries.json +data/site-settings.json +data/smtp-settings.json public/uploads/images/* !public/uploads/.gitkeep !public/uploads/images/.gitkeep -data/ .DS_Store +*.tmp +*.temp diff --git a/Dockerfile b/Dockerfile index 5c607bb..90bfbd4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,16 +14,22 @@ FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 +ENV NEXT_PUBLIC_APP_VERSION=0.2.2 +ENV DOCKER_ENV=true +ENV PORT=3010 RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs -COPY --from=builder /app/public ./public -COPY --from=builder /app/.next/standalone ./ -COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/scripts/runtime-start.js ./runtime-start.js + +RUN mkdir -p /app/data /app/public/uploads/images /app/.next/cache \ + && chown -R nextjs:nodejs /app/data /app/public/uploads /app/.next/cache USER nextjs EXPOSE 3010 -ENV PORT=3010 -CMD ["node", "server.js"] +CMD ["node", "runtime-start.js"] diff --git a/README.md b/README.md index 00c7f1c..6c92c07 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Eigenständige öffentliche Firmenwebsite für Funktechnik Schubert. Dieses Proj - Nginx/Reverse-Proxy-fähig - SEO Metadata, Sitemap und robots.txt +Version: `0.2.2` + ## Seiten - `/` Startseite @@ -38,6 +40,7 @@ Eigenständige öffentliche Firmenwebsite für Funktechnik Schubert. Dieses Proj ```bash npm install +cp .env.example .env npm run dev ``` @@ -47,6 +50,14 @@ Die Website läuft lokal unter: http://localhost:3010 ``` +Für den Admin-Bereich müssen in `.env` mindestens gesetzt sein: + +```text +ADMIN_EMAIL +ADMIN_PASSWORD +ADMIN_SESSION_SECRET +``` + ## Checks ```bash @@ -73,6 +84,14 @@ Port: 3010:3010 ``` +Die Runtime-Daten werden über Docker-Volumes gespeichert: + +- `funktechnik-data` → `/app/data` +- `funktechnik-uploads` → `/app/public/uploads/images` +- `funktechnik-next-cache` → `/app/.next/cache` + +Dadurch sind keine manuellen `chmod`- oder `chown`-Befehle notwendig. + ## Umgebung `.env.example` kopieren: @@ -83,13 +102,13 @@ cp .env.example .env Variablen: -- `NEXT_PUBLIC_SITE_URL` -- `ADMIN_EMAIL` -- `ADMIN_PASSWORD` -- `ADMIN_SESSION_SECRET` -- `AUTH_COOKIE_SECURE` -- `OLYMPUS_INTAKE_API_URL` -- `OLYMPUS_INTAKE_API_TOKEN` +- `NEXT_PUBLIC_SITE_URL`: öffentliche Basis-URL für SEO, Sitemap und Metadaten +- `ADMIN_EMAIL`: Admin-Login E-Mail +- `ADMIN_PASSWORD`: Admin-Login Passwort +- `ADMIN_SESSION_SECRET`: langer Zufallswert zum Signieren der Admin-Session +- `AUTH_COOKIE_SECURE`: `true` in Produktion mit HTTPS, lokal `false` +- `OLYMPUS_INTAKE_API_URL`: vorbereitet für spätere serverseitige Olympus-Anbindung +- `OLYMPUS_INTAKE_API_TOKEN`: vorbereitet für spätere serverseitige Olympus-Anbindung Die Olympus-Variablen sind nur für eine spätere serverseitige Integration vorbereitet. Sie werden nicht im Browser verwendet. @@ -106,6 +125,7 @@ Für lokale Entwicklung kann `AUTH_COOKIE_SECURE=false` bleiben. Produktiv muss - `POST /api/admin/settings` - `POST /api/admin/smtp` - `GET/POST /api/admin/media` +- `GET /api/health` Aktuell validieren die Routen serverseitig, geben klare JSON-Antworten zurück und schreiben nur technische Metadaten in Server-Logs. Es werden keine Nachrichteninhalte oder Tokens geloggt. Kontakt- und Reparaturanfragen werden lokal unter `data/*.json` gespeichert und nicht versioniert. @@ -127,6 +147,97 @@ Funktionen: SMTP-Versand, Publishing von Website-Inhalten und Olympus-Übernahme sind bewusst noch nicht aktiv gekoppelt. +## Runtime Data + +Im Repository liegen nur: + +```text +data/.gitkeep +data/contact-inquiries.example.json +data/repair-inquiries.example.json +``` + +Echte Runtime-Dateien werden nicht committed: + +```text +data/contact-inquiries.json +data/repair-inquiries.json +data/site-settings.json +data/smtp-settings.json +``` + +Uploads unter `public/uploads/images/` werden ebenfalls nicht committed. + +## Healthcheck + +```bash +scripts/healthcheck.sh +``` + +Oder direkt: + +```bash +curl http://localhost:3010/api/health +``` + +Antwort: + +```json +{ + "status": "ok", + "version": "0.2.2", + "storage": "ok", + "admin": "configured", + "timestamp": "..." +} +``` + +## Deployment + +```bash +scripts/deploy.sh +``` + +Das Skript führt aus: + +- `docker compose config` +- `docker compose build` +- `docker compose up -d` +- Healthcheck über `/api/health` + +Produktiv muss die `.env` auf dem Zielsystem gepflegt werden. Secrets werden nicht ins Repository aufgenommen. + +## Update + +```bash +git pull +docker compose config +docker compose build +docker compose up -d +scripts/healthcheck.sh +``` + +## Backup + +```bash +scripts/backup.sh +``` + +Das Backup enthält `data/` und `public/uploads/`. Die `.env` wird bewusst nicht automatisch gesichert. Sie muss separat sicher abgelegt werden. + +## Restore + +```bash +scripts/restore.sh backups/funktechnik-data-YYYYMMDD-HHMMSS.tar.gz +``` + +Danach Container neu starten: + +```bash +docker compose up -d +scripts/healthcheck.sh +``` + ## Nginx Siehe `nginx.example.conf`. diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx index 0c98f59..b981cb3 100644 --- a/app/admin/login/page.tsx +++ b/app/admin/login/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import AdminLoginForm from "@/components/admin/AdminLoginForm"; +import { adminConfigurationStatus } from "@/lib/runtime/config"; export const metadata: Metadata = { title: "Admin Login | Funktechnik Schubert", @@ -9,5 +10,5 @@ export const metadata: Metadata = { export default async function AdminLoginPage({ searchParams }: { searchParams: Promise<{ next?: string }> }) { const params = await searchParams; const nextPath = params.next?.startsWith("/admin") ? params.next : "/admin"; - return ; + return ; } diff --git a/app/admin/system/page.tsx b/app/admin/system/page.tsx index 9243eca..4e9b3ad 100644 --- a/app/admin/system/page.tsx +++ b/app/admin/system/page.tsx @@ -1,9 +1,17 @@ import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; import { requireAdminSession } from "@/lib/admin/auth"; +import { adminConfigurationStatus, appVersion, checkStorage, dataDirectory, isDockerEnvironment, olympusStatus, smtpStatus, uploadDirectory } from "@/lib/runtime/config"; export default async function AdminSystemPage() { if (!await requireAdminSession()) redirect("/admin/login"); + let storage = "ok"; + + try { + await checkStorage(); + } catch { + storage = "error"; + } return ( @@ -11,26 +19,28 @@ export default async function AdminSystemPage() {

Betrieb

System

-
-
-

Technik

-
    -
  • Next.js 16 App Router
  • -
  • TypeScript strict
  • -
  • HttpOnly Admin-Session
  • -
  • Lokale Foundation-Datenablage in JSON-Dateien
  • -
-
-
-

Vorbereitet

-
    -
  • Olympus-Integration bleibt deaktiviert
  • -
  • SMTP-Konfiguration ohne Versandlogik
  • -
  • Medien-Upload mit Dateityp- und Größenprüfung
  • -
  • SEO- und Inhaltsverwaltung als Admin-Grundlage
  • -
-
-
+
+

Runtime Informationen

+
+
Version
{appVersion}
+
Node Version
{process.version}
+
Docker Environment
{isDockerEnvironment() ? "ja" : "nein"}
+
Storage Status
{storage}
+
Data Directory
{dataDirectory}
+
Upload Directory
{uploadDirectory}
+
Admin Konfiguration
{adminConfigurationStatus()}
+
Olympus Verbindung
{olympusStatus()}
+
SMTP
{smtpStatus()}
+
+
+
+

Betriebsregeln

+
    +
  • Runtime-Daten liegen unter data/ und werden nicht versioniert.
  • +
  • Uploads liegen unter public/uploads/images/ und werden nicht versioniert.
  • +
  • Olympus- und SMTP-Integration sind vorbereitet, aber nicht aktiv implementiert.
  • +
+
); } diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..30791a0 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { adminConfigurationStatus, appVersion, checkStorage } from "@/lib/runtime/config"; + +export async function GET() { + let storage = "ok"; + + try { + await checkStorage(); + } catch { + storage = "error"; + } + + return NextResponse.json({ + status: storage === "ok" ? "ok" : "error", + version: appVersion, + storage, + admin: adminConfigurationStatus(), + timestamp: new Date().toISOString(), + }); +} diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..8cddc26 --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,19 @@ +"use client"; + +import Link from "next/link"; + +export default function ErrorPage({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+
+

500

+

Die Seite konnte nicht geladen werden

+

Es ist ein technischer Fehler aufgetreten. Bitte versuchen Sie es erneut.

+
+ + Zur Startseite +
+
+
+ ); +} diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 0000000..ba1647d --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,18 @@ +"use client"; + +export default function GlobalErrorPage({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( + + +
+
+

Runtime

+

Ein unerwarteter Fehler ist aufgetreten

+

Die Anwendung konnte diesen Bereich nicht laden. Bitte versuchen Sie es erneut.

+ +
+
+ + + ); +} diff --git a/app/globals.css b/app/globals.css index 67911fc..50962f6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -461,6 +461,33 @@ h3 { font-size: 26px; } +.error-page { + min-height: 70vh; + display: grid; + place-items: center; + padding: 48px 16px; + background: + linear-gradient(90deg, rgba(6, 24, 52, 0.94), rgba(8, 42, 96, 0.76)), + url("/workbench-signal.svg"), + #07172d; + background-size: cover; +} + +.error-card { + width: min(720px, 100%); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 8px; + background: rgba(255, 255, 255, 0.96); + padding: 34px; + box-shadow: var(--shadow); +} + +.error-card h1 { + color: var(--ink); + font-size: clamp(34px, 5vw, 56px); + line-height: 1; +} + .admin-login-page { min-height: 100vh; display: grid; @@ -578,6 +605,13 @@ h3 { cursor: pointer; } +.admin-version { + margin: 18px 12px 0; + color: rgba(255, 255, 255, 0.56); + font-size: 13px; + font-weight: 800; +} + .admin-main { min-width: 0; padding: 28px; @@ -738,6 +772,30 @@ h3 { object-fit: cover; } +.system-list { + display: grid; + gap: 0; + margin: 0; +} + +.system-list div { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 18px; + border-bottom: 1px solid var(--line); + padding: 12px 0; +} + +.system-list dt { + color: var(--muted); + font-weight: 800; +} + +.system-list dd { + margin: 0; + overflow-wrap: anywhere; +} + @media (max-width: 1080px) and (min-width: 861px) { .brand-logo { width: 236px; @@ -837,4 +895,9 @@ h3 { .admin-upload { display: grid; } + + .system-list div { + grid-template-columns: 1fr; + gap: 4px; + } } diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..20c0e88 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,17 @@ +import Link from "next/link"; + +export default function NotFoundPage() { + return ( +
+
+

404

+

Seite nicht gefunden

+

Die angeforderte Seite existiert nicht oder wurde verschoben.

+
+ Zur Startseite + Kontakt aufnehmen +
+
+
+ ); +} diff --git a/components/Footer.tsx b/components/Footer.tsx index c8fb150..9c11eb4 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -1,5 +1,6 @@ import Image from "next/image"; import Link from "next/link"; +import { appVersion } from "@/lib/runtime/version"; export default function Footer() { return ( @@ -14,7 +15,7 @@ export default function Footer() { className="footer-logo" />

Funktechnik Schubert – Service und technische Unterstützung für Funkgeräte, Messtechnik und Kommunikationstechnik.

-

© Funktechnik Schubert

+

© Funktechnik Schubert · v{appVersion}

Website

diff --git a/components/admin/AdminLoginForm.tsx b/components/admin/AdminLoginForm.tsx index de2ec8a..a98eb36 100644 --- a/components/admin/AdminLoginForm.tsx +++ b/components/admin/AdminLoginForm.tsx @@ -4,7 +4,7 @@ import Image from "next/image"; import { useRouter } from "next/navigation"; import { useState, type FormEvent } from "react"; -export default function AdminLoginForm({ nextPath = "/admin" }: { nextPath?: string }) { +export default function AdminLoginForm({ nextPath = "/admin", adminConfigured = true }: { nextPath?: string; adminConfigured?: boolean }) { const router = useRouter(); const [error, setError] = useState(""); const [pending, setPending] = useState(false); @@ -34,6 +34,11 @@ export default function AdminLoginForm({ nextPath = "/admin" }: { nextPath?: str
Funktechnik Schubert

Administration Login

+ {!adminConfigured && ( +

+ Admin-Zugang ist nicht konfiguriert. Bitte ADMIN_EMAIL, ADMIN_PASSWORD und ADMIN_SESSION_SECRET in der Umgebung setzen. +

+ )} {error &&

{error}

} - +
); diff --git a/components/admin/AdminShell.tsx b/components/admin/AdminShell.tsx index 6588e61..254411e 100644 --- a/components/admin/AdminShell.tsx +++ b/components/admin/AdminShell.tsx @@ -4,6 +4,7 @@ import Image from "next/image"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import type { ReactNode } from "react"; +import { appVersion } from "@/lib/runtime/version"; const navItems = [ ["Dashboard", "/admin"], @@ -40,6 +41,7 @@ export default function AdminShell({ children }: { children: ReactNode }) { ))} +

v{appVersion}

{children}
diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/contact-inquiries.example.json b/data/contact-inquiries.example.json new file mode 100644 index 0000000..bf9400d --- /dev/null +++ b/data/contact-inquiries.example.json @@ -0,0 +1,12 @@ +[ + { + "id": "contact_example", + "createdAt": "2026-07-03T00:00:00.000Z", + "status": "new", + "name": "Max Mustermann", + "email": "max@example.invalid", + "phone": "", + "subject": "Allgemeine Anfrage", + "message": "Beispieldatensatz fuer lokale Tests. Nicht fuer produktive Daten verwenden." + } +] diff --git a/data/repair-inquiries.example.json b/data/repair-inquiries.example.json new file mode 100644 index 0000000..698b793 --- /dev/null +++ b/data/repair-inquiries.example.json @@ -0,0 +1,18 @@ +[ + { + "id": "repair_example", + "createdAt": "2026-07-03T00:00:00.000Z", + "status": "new", + "name": "Max Mustermann", + "email": "max@example.invalid", + "phone": "", + "manufacturer": "Beispielhersteller", + "model": "Beispielmodell", + "serialNumber": "", + "deviceType": "cb-radio", + "accessories": "Mikrofon", + "opened": "unknown", + "previousWork": "", + "description": "Beispieldatensatz fuer lokale Tests. Nicht fuer produktive Daten verwenden." + } +] diff --git a/docker-compose.yml b/docker-compose.yml index 97264e3..8ad4b29 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,13 +7,22 @@ services: ports: - "3010:3010" environment: - NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL} - ADMIN_EMAIL: ${ADMIN_EMAIL} - ADMIN_PASSWORD: ${ADMIN_PASSWORD} - ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET} - AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE} - OLYMPUS_INTAKE_API_URL: ${OLYMPUS_INTAKE_API_URL} - OLYMPUS_INTAKE_API_TOKEN: ${OLYMPUS_INTAKE_API_TOKEN} + NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010} + NEXT_PUBLIC_APP_VERSION: "0.2.2" + ADMIN_EMAIL: ${ADMIN_EMAIL:-} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} + ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-} + AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-false} + OLYMPUS_INTAKE_API_URL: ${OLYMPUS_INTAKE_API_URL:-} + OLYMPUS_INTAKE_API_TOKEN: ${OLYMPUS_INTAKE_API_TOKEN:-} + DOCKER_ENV: "true" volumes: - - ./data:/app/data + - funktechnik-data:/app/data + - funktechnik-uploads:/app/public/uploads/images + - funktechnik-next-cache:/app/.next/cache restart: unless-stopped + +volumes: + funktechnik-data: + funktechnik-uploads: + funktechnik-next-cache: diff --git a/lib/admin/store.ts b/lib/admin/store.ts index f89aa40..02757a8 100644 --- a/lib/admin/store.ts +++ b/lib/admin/store.ts @@ -1,27 +1,25 @@ import { mkdir, readFile, writeFile } from "fs/promises"; import path from "path"; +import { dataDirectory } from "@/lib/runtime/config"; import type { ContactInquiry, InquiryStatus, RepairInquiry, SiteSettings, SmtpSettings } from "./types"; -const dataDir = path.join(process.cwd(), "data"); - async function ensureDataDir() { - await mkdir(dataDir, { recursive: true }); + await mkdir(dataDirectory, { recursive: true }); } async function readJson(fileName: string, fallback: T): Promise { await ensureDataDir(); try { - const file = await readFile(path.join(dataDir, fileName), "utf8"); + const file = await readFile(path.join(dataDirectory, fileName), "utf8"); return JSON.parse(file) as T; } catch { - await writeJson(fileName, fallback); return fallback; } } async function writeJson(fileName: string, value: T) { await ensureDataDir(); - await writeFile(path.join(dataDir, fileName), `${JSON.stringify(value, null, 2)}\n`, "utf8"); + await writeFile(path.join(dataDirectory, fileName), `${JSON.stringify(value, null, 2)}\n`, "utf8"); } function id(prefix: string) { diff --git a/lib/runtime/config.ts b/lib/runtime/config.ts new file mode 100644 index 0000000..a412dcd --- /dev/null +++ b/lib/runtime/config.ts @@ -0,0 +1,39 @@ +import { existsSync } from "fs"; +import { access, mkdir, writeFile, rm } from "fs/promises"; +import path from "path"; +import { appVersion } from "./version"; + +export const dataDirectory = path.join(process.cwd(), "data"); +export const uploadDirectory = path.join(process.cwd(), "public", "uploads", "images"); + +export function adminConfigurationStatus() { + return process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD && process.env.ADMIN_SESSION_SECRET ? "configured" : "missing"; +} + +export function isDockerEnvironment() { + return process.env.DOCKER_ENV === "true" || process.env.CONTAINER === "true" || existsSync("/.dockerenv"); +} + +export async function ensureRuntimeDirectories() { + await mkdir(dataDirectory, { recursive: true }); + await mkdir(uploadDirectory, { recursive: true }); +} + +export async function checkStorage() { + await ensureRuntimeDirectories(); + const probe = path.join(dataDirectory, `.storage-check-${Date.now()}`); + await writeFile(probe, "ok", "utf8"); + await rm(probe, { force: true }); + await access(uploadDirectory); + return "ok"; +} + +export function olympusStatus() { + return process.env.OLYMPUS_INTAKE_API_URL && process.env.OLYMPUS_INTAKE_API_TOKEN ? "configured" : "not_configured"; +} + +export function smtpStatus() { + return "prepared"; +} + +export { appVersion }; diff --git a/lib/runtime/version.ts b/lib/runtime/version.ts new file mode 100644 index 0000000..e8563d7 --- /dev/null +++ b/lib/runtime/version.ts @@ -0,0 +1 @@ +export const appVersion = "0.2.2"; diff --git a/package-lock.json b/package-lock.json index 42614bf..01bef7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "funktechnik-schubert-website", - "version": "0.1.0", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "funktechnik-schubert-website", - "version": "0.1.0", + "version": "0.2.2", "dependencies": { "next": "16.2.10", "react": "19.2.3", diff --git a/package.json b/package.json index ded3af7..23eedb3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "funktechnik-schubert-website", - "version": "0.1.0", + "version": "0.2.2", "private": true, "scripts": { "dev": "next dev -p 3010", diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..6430d9b --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env sh +set -eu + +BACKUP_DIR="${BACKUP_DIR:-backups}" +STAMP="$(date +%Y%m%d-%H%M%S)" +TARGET="${BACKUP_DIR}/funktechnik-data-${STAMP}.tar.gz" + +mkdir -p "$BACKUP_DIR" +tar -czf "$TARGET" data public/uploads + +echo "Backup erstellt: $TARGET" +echo "Hinweis: .env wird aus Sicherheitsgruenden nicht automatisch gesichert." +echo "Bitte .env separat und sicher ausserhalb des Repositories sichern." diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..f87dd96 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env sh +set -eu + +COMPOSE="${COMPOSE:-docker compose}" +BASE_URL="${BASE_URL:-http://localhost:3010}" + +echo "Pruefe Docker Compose Konfiguration..." +$COMPOSE config >/dev/null + +echo "Baue Container..." +$COMPOSE build + +echo "Starte Container..." +$COMPOSE up -d + +echo "Fuehre Healthcheck aus..." +BASE_URL="$BASE_URL" scripts/healthcheck.sh + +echo "Deployment abgeschlossen." diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh new file mode 100755 index 0000000..9e17c56 --- /dev/null +++ b/scripts/healthcheck.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env sh +set -eu + +BASE_URL="${BASE_URL:-http://localhost:3010}" +HEALTH_URL="${BASE_URL%/}/api/health" + +echo "Checking ${HEALTH_URL}" +response="$(curl -fsS "$HEALTH_URL")" +echo "$response" + +case "$response" in + *'"status":"ok"'*) exit 0 ;; + *) echo "Healthcheck failed" >&2; exit 1 ;; +esac diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100755 index 0000000..e6f49b7 --- /dev/null +++ b/scripts/restore.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env sh +set -eu + +if [ "${1:-}" = "" ]; then + echo "Usage: scripts/restore.sh " >&2 + exit 1 +fi + +BACKUP_FILE="$1" + +if [ ! -f "$BACKUP_FILE" ]; then + echo "Backup nicht gefunden: $BACKUP_FILE" >&2 + exit 1 +fi + +tar -xzf "$BACKUP_FILE" +echo "Restore abgeschlossen: $BACKUP_FILE" diff --git a/scripts/runtime-start.js b/scripts/runtime-start.js new file mode 100644 index 0000000..31eb0a8 --- /dev/null +++ b/scripts/runtime-start.js @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +const fs = require("fs"); +const path = require("path"); + +const requiredEnv = ["ADMIN_EMAIL", "ADMIN_PASSWORD", "ADMIN_SESSION_SECRET"]; +const requiredDirs = [ + path.join(process.cwd(), "data"), + path.join(process.cwd(), "public", "uploads", "images"), + path.join(process.cwd(), ".next", "cache"), +]; + +function fail(message) { + process.stderr.write(`[funktechnik-website] ${message}\n`); + process.exit(1); +} + +function ensureWritableDirectory(directory) { + fs.mkdirSync(directory, { recursive: true }); + const probe = path.join(directory, `.write-check-${Date.now()}`); + fs.writeFileSync(probe, "ok", "utf8"); + fs.rmSync(probe, { force: true }); +} + +const missingEnv = requiredEnv.filter((name) => !process.env[name]); +if (missingEnv.length > 0) { + fail(`Start abgebrochen: fehlende Umgebungsvariablen: ${missingEnv.join(", ")}. Bitte .env anhand von .env.example konfigurieren.`); +} + +try { + for (const directory of requiredDirs) ensureWritableDirectory(directory); +} catch (error) { + fail(`Start abgebrochen: Runtime-Verzeichnis ist nicht beschreibbar. Details: ${error instanceof Error ? error.message : "unbekannter Fehler"}`); +} + +process.stdout.write(`[funktechnik-website] Runtime checks ok. Starting version ${process.env.NEXT_PUBLIC_APP_VERSION ?? "0.2.2"}.\n`); +require("./server.js"); From 203c94b12883932b6ba8bd3772a06702d6265fe8 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Fri, 3 Jul 2026 23:15:25 +0200 Subject: [PATCH 02/11] fix(admin): repair media upload redirect --- README.md | 4 +- app/admin/medien/page.tsx | 65 +++++++++++++------- app/api/admin/media/route.ts | 36 +++++------ app/api/admin/settings/route.ts | 2 +- app/api/admin/smtp/route.ts | 2 +- app/globals.css | 72 ++++++++++++++++++++++ app/seo.ts | 2 +- docker-compose.yml | 2 +- lib/admin/media.ts | 106 ++++++++++++++++++++++++++++++++ proxy.ts | 4 +- scripts/deploy.sh | 2 +- scripts/healthcheck.sh | 2 +- 12 files changed, 248 insertions(+), 51 deletions(-) create mode 100644 lib/admin/media.ts diff --git a/README.md b/README.md index 6c92c07..f2de241 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run dev Die Website läuft lokal unter: ```text -http://localhost:3010 +http://127.0.0.1:3010 ``` Für den Admin-Bereich müssen in `.env` mindestens gesetzt sein: @@ -177,7 +177,7 @@ scripts/healthcheck.sh Oder direkt: ```bash -curl http://localhost:3010/api/health +curl http://127.0.0.1:3010/api/health ``` Antwort: diff --git a/app/admin/medien/page.tsx b/app/admin/medien/page.tsx index 9717547..f7232b7 100644 --- a/app/admin/medien/page.tsx +++ b/app/admin/medien/page.tsx @@ -1,20 +1,26 @@ -import { mkdir, readdir } from "fs/promises"; -import path from "path"; import Image from "next/image"; import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; import { requireAdminSession } from "@/lib/admin/auth"; +import { listMediaFiles } from "@/lib/admin/media"; -const uploadDir = path.join(process.cwd(), "public", "uploads", "images"); - -async function getFiles() { - await mkdir(uploadDir, { recursive: true }); - return readdir(uploadDir); +function formatBytes(size: number) { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / 1024 / 1024).toFixed(1)} MB`; } -export default async function AdminMediaPage() { +function uploadErrorMessage(error?: string) { + if (!error) return ""; + if (error === "missing-file") return "Bitte wählen Sie eine Datei aus."; + if (error === "storage") return "Die Datei konnte nicht gespeichert werden. Bitte Upload-Speicher und Rechte prüfen."; + return error; +} + +export default async function AdminMediaPage({ searchParams }: { searchParams: Promise<{ uploaded?: string; error?: string }> }) { if (!await requireAdminSession()) redirect("/admin/login"); - const files = await getFiles(); + const [files, params] = await Promise.all([listMediaFiles(), searchParams]); + const error = uploadErrorMessage(params.error); return ( @@ -22,25 +28,40 @@ export default async function AdminMediaPage() {

Assets

Medienverwaltung

+ {params.uploaded === "1" &&

Datei wurde erfolgreich hochgeladen.

} + {error &&

Upload fehlgeschlagen: {error}

}

Uploads

-
- {files.map((file) => { - const url = `/uploads/images/${file}`; - return ( - - {/\.(png|jpe?g|webp)$/i.test(file) - ? {file} - : {file}} - - ); - })} - {files.length === 0 &&

Noch keine Uploads vorhanden.

} -
+ {files.length === 0 ? ( +
+

Noch keine Medien vorhanden

+

Hochgeladene Bilder und PDF-Dateien erscheinen hier mit Vorschau und Metadaten.

+
+ ) : ( +
+ {files.map((file) => ( + + ))} +
+ )}
); diff --git a/app/api/admin/media/route.ts b/app/api/admin/media/route.ts index 4a54fe1..134e0b8 100644 --- a/app/api/admin/media/route.ts +++ b/app/api/admin/media/route.ts @@ -1,38 +1,34 @@ -import { mkdir, readdir, writeFile } from "fs/promises"; -import path from "path"; import { NextResponse } from "next/server"; import { requireAdminSession } from "@/lib/admin/auth"; +import { listMediaFiles, saveMediaFile } from "@/lib/admin/media"; -const uploadDir = path.join(process.cwd(), "public", "uploads", "images"); -const allowedTypes = new Set(["image/jpeg", "image/png", "image/webp", "application/pdf"]); - -async function ensureUploadDir() { - await mkdir(uploadDir, { recursive: true }); +function relativeRedirect(target: string) { + return new NextResponse(null, { + status: 303, + headers: { Location: target }, + }); } export async function GET() { if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); - await ensureUploadDir(); - const files = await readdir(uploadDir); - return NextResponse.json({ - files: files.map((file) => ({ name: file, url: `/uploads/images/${file}` })), - }); + return NextResponse.json({ files: await listMediaFiles() }); } export async function POST(request: Request) { if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); - await ensureUploadDir(); const form = await request.formData(); const file = form.get("file"); - if (!(file instanceof File) || !allowedTypes.has(file.type) || file.size > 5 * 1024 * 1024) { - return NextResponse.json({ message: "Bitte eine JPG-, PNG-, WebP- oder PDF-Datei bis 5 MB hochladen." }, { status: 400 }); + if (!(file instanceof File)) { + return relativeRedirect("/admin/medien?error=missing-file"); } - const extension = path.extname(file.name).toLowerCase(); - const safeName = `${Date.now()}-${file.name.toLowerCase().replace(/[^a-z0-9.-]/g, "-")}`; - const target = safeName.endsWith(extension) ? safeName : `${safeName}${extension}`; - await writeFile(path.join(uploadDir, target), Buffer.from(await file.arrayBuffer())); + try { + const result = await saveMediaFile(file); + if (result.error) return relativeRedirect(`/admin/medien?error=${encodeURIComponent(result.error)}`); + } catch { + return relativeRedirect("/admin/medien?error=storage"); + } - return NextResponse.redirect(new URL("/admin/medien?uploaded=1", request.url)); + return relativeRedirect("/admin/medien?uploaded=1"); } diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts index e2256ea..977e900 100644 --- a/app/api/admin/settings/route.ts +++ b/app/api/admin/settings/route.ts @@ -5,5 +5,5 @@ import { saveSiteSettings } from "@/lib/admin/store"; export async function POST(request: Request) { if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); await saveSiteSettings(await request.formData()); - return NextResponse.redirect(new URL("/admin/einstellungen?saved=1", request.url)); + return new NextResponse(null, { status: 303, headers: { Location: "/admin/einstellungen?saved=1" } }); } diff --git a/app/api/admin/smtp/route.ts b/app/api/admin/smtp/route.ts index 7ce6da3..ea05bb2 100644 --- a/app/api/admin/smtp/route.ts +++ b/app/api/admin/smtp/route.ts @@ -5,5 +5,5 @@ import { saveSmtpSettings } from "@/lib/admin/store"; export async function POST(request: Request) { if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); await saveSmtpSettings(await request.formData()); - return NextResponse.redirect(new URL("/admin/smtp?saved=1", request.url)); + return new NextResponse(null, { status: 303, headers: { Location: "/admin/smtp?saved=1" } }); } diff --git a/app/globals.css b/app/globals.css index 50962f6..eb29a89 100644 --- a/app/globals.css +++ b/app/globals.css @@ -750,6 +750,10 @@ h3 { gap: 14px; } +.admin-message { + margin-bottom: 18px; +} + .media-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -772,6 +776,69 @@ h3 { object-fit: cover; } +.media-empty { + border: 1px dashed var(--line); + border-radius: 8px; + background: #f8fafc; + padding: 24px; +} + +.media-empty p { + color: var(--muted); +} + +.media-list { + display: grid; + gap: 14px; +} + +.media-row { + display: grid; + grid-template-columns: 180px minmax(0, 1fr); + gap: 16px; + align-items: center; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + padding: 12px; +} + +.media-preview { + display: grid; + min-height: 110px; + place-items: center; + border: 1px solid var(--line); + border-radius: 8px; + background: #f8fafc; + color: var(--navy); + font-weight: 800; + overflow: hidden; +} + +.media-preview img { + width: 100%; + height: 110px; + object-fit: cover; +} + +.media-meta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin: 0; +} + +.media-meta dt { + color: var(--muted); + font-size: 13px; + font-weight: 800; +} + +.media-meta dd { + margin: 0; + overflow-wrap: anywhere; +} + .system-list { display: grid; gap: 0; @@ -896,6 +963,11 @@ h3 { display: grid; } + .media-row, + .media-meta { + grid-template-columns: 1fr; + } + .system-list div { grid-template-columns: 1fr; gap: 4px; diff --git a/app/seo.ts b/app/seo.ts index d019f77..0d2de93 100644 --- a/app/seo.ts +++ b/app/seo.ts @@ -2,7 +2,7 @@ import type { Metadata } from "next"; const siteName = "Funktechnik Schubert"; const defaultTitle = "Funktechnik Schubert | Funkgeräte-Service & Messtechnik"; -const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3010"; +const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://127.0.0.1:3010"; const keywords = [ "Funktechnik Schubert", "Funkgeräte Reparatur", diff --git a/docker-compose.yml b/docker-compose.yml index 8ad4b29..47148c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: ports: - "3010:3010" environment: - NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010} + NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://127.0.0.1:3010} NEXT_PUBLIC_APP_VERSION: "0.2.2" ADMIN_EMAIL: ${ADMIN_EMAIL:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} diff --git a/lib/admin/media.ts b/lib/admin/media.ts new file mode 100644 index 0000000..cbe4b22 --- /dev/null +++ b/lib/admin/media.ts @@ -0,0 +1,106 @@ +import { randomUUID } from "crypto"; +import { mkdir, readdir, rm, stat, writeFile } from "fs/promises"; +import path from "path"; +import { uploadDirectory } from "@/lib/runtime/config"; + +const maxUploadSize = 5 * 1024 * 1024; + +const allowedMimeTypes = new Map([ + ["image/jpeg", [".jpg", ".jpeg"]], + ["image/png", [".png"]], + ["image/webp", [".webp"]], + ["application/pdf", [".pdf"]], +]); + +export type MediaFile = { + name: string; + url: string; + type: string; + size: number; + uploadedAt: string; + isImage: boolean; +}; + +export async function ensureUploadDirectory() { + await mkdir(uploadDirectory, { recursive: true }); + const probe = path.join(uploadDirectory, `.write-check-${Date.now()}`); + await writeFile(probe, "ok", "utf8"); + await rm(probe, { force: true }); +} + +export function validateUpload(file: File) { + const extensions = allowedMimeTypes.get(file.type); + const extension = path.extname(file.name).toLowerCase(); + + if (!extensions || !extensions.includes(extension)) { + return "Bitte eine JPG-, PNG-, WebP- oder PDF-Datei hochladen."; + } + + if (file.size <= 0) { + return "Die Datei ist leer."; + } + + if (file.size > maxUploadSize) { + return "Die Datei darf maximal 5 MB groß sein."; + } + + return ""; +} + +export function createSafeUploadName(originalName: string) { + const extension = path.extname(originalName).toLowerCase(); + const rawBaseName = path.basename(originalName, extension); + const safeBaseName = rawBaseName + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80) || "upload"; + + return `${Date.now()}-${randomUUID().slice(0, 8)}-${safeBaseName}${extension}`; +} + +export function resolveUploadTarget(fileName: string) { + const target = path.resolve(uploadDirectory, fileName); + const uploadRoot = path.resolve(uploadDirectory); + + if (!target.startsWith(`${uploadRoot}${path.sep}`)) { + throw new Error("Ungültiger Upload-Pfad."); + } + + return target; +} + +export async function listMediaFiles(): Promise { + await mkdir(uploadDirectory, { recursive: true }); + const entries = await readdir(uploadDirectory); + const visibleEntries = entries.filter((entry) => !entry.startsWith(".")); + const files = await Promise.all(visibleEntries.map(async (entry) => { + const fileStat = await stat(path.join(uploadDirectory, entry)); + const extension = path.extname(entry).toLowerCase(); + + return { + name: entry, + url: `/uploads/images/${encodeURIComponent(entry)}`, + type: extension.replace(".", "").toUpperCase() || "Datei", + size: fileStat.size, + uploadedAt: fileStat.birthtime.toISOString(), + isImage: [".jpg", ".jpeg", ".png", ".webp"].includes(extension), + }; + })); + + return files.sort((left, right) => right.uploadedAt.localeCompare(left.uploadedAt)); +} + +export async function saveMediaFile(file: File) { + const error = validateUpload(file); + if (error) return { error }; + + await ensureUploadDirectory(); + const safeName = createSafeUploadName(file.name); + const target = resolveUploadTarget(safeName); + await writeFile(target, Buffer.from(await file.arrayBuffer())); + + return { fileName: safeName }; +} diff --git a/proxy.ts b/proxy.ts index 31d43e4..44871a4 100644 --- a/proxy.ts +++ b/proxy.ts @@ -7,7 +7,9 @@ export function proxy(request: NextRequest) { if (!pathname.startsWith("/admin") || pathname === "/admin/login") return NextResponse.next(); if (!request.cookies.get(adminSessionCookie)?.value) { - const loginUrl = new URL("/admin/login", request.url); + const loginUrl = request.nextUrl.clone(); + loginUrl.pathname = "/admin/login"; + loginUrl.search = ""; loginUrl.searchParams.set("next", pathname); return NextResponse.redirect(loginUrl); } diff --git a/scripts/deploy.sh b/scripts/deploy.sh index f87dd96..0b17c4a 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -2,7 +2,7 @@ set -eu COMPOSE="${COMPOSE:-docker compose}" -BASE_URL="${BASE_URL:-http://localhost:3010}" +BASE_URL="${BASE_URL:-http://127.0.0.1:3010}" echo "Pruefe Docker Compose Konfiguration..." $COMPOSE config >/dev/null diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh index 9e17c56..9abf19b 100755 --- a/scripts/healthcheck.sh +++ b/scripts/healthcheck.sh @@ -1,7 +1,7 @@ #!/usr/bin/env sh set -eu -BASE_URL="${BASE_URL:-http://localhost:3010}" +BASE_URL="${BASE_URL:-http://127.0.0.1:3010}" HEALTH_URL="${BASE_URL%/}/api/health" echo "Checking ${HEALTH_URL}" From ec30f69c72cf2cf1b89b5b6f64be87981224539a Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Fri, 3 Jul 2026 23:25:41 +0200 Subject: [PATCH 03/11] fix(admin): complete media manager --- app/admin/medien/page.tsx | 56 +------- app/api/admin/media/route.ts | 29 ++-- app/globals.css | 179 ++++++++++++++++++++---- components/admin/MediaManager.tsx | 220 ++++++++++++++++++++++++++++++ lib/admin/media.ts | 29 +++- 5 files changed, 417 insertions(+), 96 deletions(-) create mode 100644 components/admin/MediaManager.tsx diff --git a/app/admin/medien/page.tsx b/app/admin/medien/page.tsx index f7232b7..9f90659 100644 --- a/app/admin/medien/page.tsx +++ b/app/admin/medien/page.tsx @@ -1,26 +1,12 @@ -import Image from "next/image"; import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; +import MediaManager from "@/components/admin/MediaManager"; import { requireAdminSession } from "@/lib/admin/auth"; import { listMediaFiles } from "@/lib/admin/media"; -function formatBytes(size: number) { - if (size < 1024) return `${size} B`; - if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; - return `${(size / 1024 / 1024).toFixed(1)} MB`; -} - -function uploadErrorMessage(error?: string) { - if (!error) return ""; - if (error === "missing-file") return "Bitte wählen Sie eine Datei aus."; - if (error === "storage") return "Die Datei konnte nicht gespeichert werden. Bitte Upload-Speicher und Rechte prüfen."; - return error; -} - -export default async function AdminMediaPage({ searchParams }: { searchParams: Promise<{ uploaded?: string; error?: string }> }) { +export default async function AdminMediaPage() { if (!await requireAdminSession()) redirect("/admin/login"); - const [files, params] = await Promise.all([listMediaFiles(), searchParams]); - const error = uploadErrorMessage(params.error); + const files = await listMediaFiles(); return ( @@ -28,41 +14,7 @@ export default async function AdminMediaPage({ searchParams }: { searchParams: P

Assets

Medienverwaltung

- {params.uploaded === "1" &&

Datei wurde erfolgreich hochgeladen.

} - {error &&

Upload fehlgeschlagen: {error}

} -
- - -
-
-

Uploads

- {files.length === 0 ? ( -
-

Noch keine Medien vorhanden

-

Hochgeladene Bilder und PDF-Dateien erscheinen hier mit Vorschau und Metadaten.

-
- ) : ( -
- {files.map((file) => ( - - ))} -
- )} -
+
); } diff --git a/app/api/admin/media/route.ts b/app/api/admin/media/route.ts index 134e0b8..3ba158a 100644 --- a/app/api/admin/media/route.ts +++ b/app/api/admin/media/route.ts @@ -1,13 +1,6 @@ import { NextResponse } from "next/server"; import { requireAdminSession } from "@/lib/admin/auth"; -import { listMediaFiles, saveMediaFile } from "@/lib/admin/media"; - -function relativeRedirect(target: string) { - return new NextResponse(null, { - status: 303, - headers: { Location: target }, - }); -} +import { deleteMediaFile, listMediaFiles, saveMediaFile } from "@/lib/admin/media"; export async function GET() { if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); @@ -20,15 +13,27 @@ export async function POST(request: Request) { const file = form.get("file"); if (!(file instanceof File)) { - return relativeRedirect("/admin/medien?error=missing-file"); + return NextResponse.json({ message: "Bitte wählen Sie eine Datei aus." }, { status: 400 }); } try { const result = await saveMediaFile(file); - if (result.error) return relativeRedirect(`/admin/medien?error=${encodeURIComponent(result.error)}`); + if (result.error) return NextResponse.json({ message: result.error }, { status: 400 }); } catch { - return relativeRedirect("/admin/medien?error=storage"); + return NextResponse.json({ message: "Die Datei konnte nicht gespeichert werden. Bitte Upload-Speicher und Rechte prüfen." }, { status: 500 }); } - return relativeRedirect("/admin/medien?uploaded=1"); + return NextResponse.json({ message: "Datei erfolgreich hochgeladen.", files: await listMediaFiles() }); +} + +export async function DELETE(request: Request) { + if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); + + const body = await request.json() as { name?: string }; + if (!body.name) return NextResponse.json({ message: "Dateiname fehlt." }, { status: 400 }); + + const result = await deleteMediaFile(body.name); + if (result.error) return NextResponse.json({ message: result.error }, { status: 400 }); + + return NextResponse.json({ message: "Datei gelöscht.", files: await listMediaFiles() }); } diff --git a/app/globals.css b/app/globals.css index eb29a89..25bc51b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -754,26 +754,29 @@ h3 { margin-bottom: 18px; } -.media-grid { +.media-toolbar { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; + grid-template-columns: minmax(220px, 1.2fr) minmax(180px, 1fr) 150px 150px auto; + gap: 14px; + align-items: end; } -.media-item { +.media-toolbar label { display: grid; - min-height: 130px; - place-items: center; - border: 1px solid var(--line); - border-radius: 8px; - background: #f8fafc; - overflow: hidden; + gap: 7px; + color: var(--ink); + font-size: 14px; + font-weight: 800; } -.media-item img { +.media-toolbar input, +.media-toolbar select { width: 100%; - height: 150px; - object-fit: cover; + border: 1px solid #cfd8e5; + border-radius: 8px; + background: #fff; + padding: 11px 12px; + color: var(--ink); } .media-empty { @@ -787,47 +790,81 @@ h3 { color: var(--muted); } -.media-list { +.media-card-grid { display: grid; - gap: 14px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; } -.media-row { +.media-card { display: grid; - grid-template-columns: 180px minmax(0, 1fr); - gap: 16px; - align-items: center; border: 1px solid var(--line); border-radius: 8px; background: #fff; - padding: 12px; + overflow: hidden; + box-shadow: 0 10px 26px rgba(8, 42, 96, 0.06); } -.media-preview { +.media-card-preview { display: grid; - min-height: 110px; + width: 100%; + aspect-ratio: 16 / 10; place-items: center; - border: 1px solid var(--line); - border-radius: 8px; + border: 0; + border-bottom: 1px solid var(--line); background: #f8fafc; color: var(--navy); font-weight: 800; overflow: hidden; + cursor: pointer; + padding: 0; } -.media-preview img { +.media-card-preview img { width: 100%; - height: 110px; + height: 100%; object-fit: cover; } +.pdf-icon { + display: grid; + width: 72px; + height: 92px; + place-items: center; + border: 2px solid var(--navy); + border-radius: 6px; + background: #fff; + color: var(--navy); + font-size: 18px; + font-weight: 900; +} + +.media-card-body { + display: grid; + gap: 14px; + padding: 16px; +} + +.media-card-body h3 { + overflow: hidden; + margin: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + .media-meta { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: 1fr; gap: 10px; margin: 0; } +.media-meta div { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 8px; +} + .media-meta dt { color: var(--muted); font-size: 13px; @@ -839,6 +876,91 @@ h3 { overflow-wrap: anywhere; } +.media-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.media-actions button, +.media-actions a { + display: inline-flex; + min-height: 36px; + align-items: center; + justify-content: center; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + color: var(--navy); + font-weight: 800; + padding: 0 10px; + text-align: center; +} + +.media-actions .danger { + border-color: #f1b5ad; + color: #b42318; +} + +.lightbox { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + background: rgba(5, 16, 32, 0.9); + color: #fff; +} + +.lightbox-toolbar { + position: relative; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(5, 16, 32, 0.96); + padding: 12px 18px; +} + +.lightbox-toolbar div { + display: flex; + gap: 8px; +} + +.lightbox-toolbar button { + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + background: rgba(255, 255, 255, 0.08); + color: #fff; + font-weight: 800; + padding: 8px 10px; +} + +.lightbox-backdrop { + position: fixed; + inset: 0; + border: 0; + background: transparent; +} + +.lightbox-stage { + position: relative; + z-index: 1; + display: grid; + place-items: center; + overflow: auto; + padding: 28px; +} + +.lightbox-stage img { + max-width: min(92vw, 1400px); + max-height: 82vh; + transform-origin: center; + transition: transform 140ms ease; +} + .system-list { display: grid; gap: 0; @@ -963,7 +1085,8 @@ h3 { display: grid; } - .media-row, + .media-toolbar, + .media-card-grid, .media-meta { grid-template-columns: 1fr; } diff --git a/components/admin/MediaManager.tsx b/components/admin/MediaManager.tsx new file mode 100644 index 0000000..d36a4ab --- /dev/null +++ b/components/admin/MediaManager.tsx @@ -0,0 +1,220 @@ +"use client"; + +/* eslint-disable @next/next/no-img-element */ + +import { useEffect, useMemo, useState, type ChangeEvent } from "react"; +import type { MediaFile } from "@/lib/admin/media"; + +type SortKey = "date" | "name" | "size"; +type FilterKey = "all" | "images" | "pdf"; + +type ApiResponse = { + message?: string; + files?: MediaFile[]; +}; + +function formatBytes(size: number) { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / 1024 / 1024).toFixed(1)} MB`; +} + +function sortFiles(files: MediaFile[], sort: SortKey) { + return [...files].sort((left, right) => { + if (sort === "name") return left.name.localeCompare(right.name); + if (sort === "size") return right.size - left.size; + return right.uploadedAt.localeCompare(left.uploadedAt); + }); +} + +export default function MediaManager({ initialFiles }: { initialFiles: MediaFile[] }) { + const [files, setFiles] = useState(initialFiles); + const [filter, setFilter] = useState("all"); + const [sort, setSort] = useState("date"); + const [query, setQuery] = useState(""); + const [toast, setToast] = useState(""); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + const [preview, setPreview] = useState(null); + const [zoom, setZoom] = useState(1); + + useEffect(() => { + function closeOnEscape(event: KeyboardEvent) { + if (event.key === "Escape") { + setPreview(null); + setZoom(1); + } + } + + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, []); + + const visibleFiles = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + const filtered = files.filter((file) => { + if (filter === "images" && !file.isImage) return false; + if (filter === "pdf" && !file.isPdf) return false; + if (!normalizedQuery) return true; + return file.name.toLowerCase().includes(normalizedQuery) || file.extension.toLowerCase().includes(normalizedQuery); + }); + return sortFiles(filtered, sort); + }, [files, filter, query, sort]); + + async function upload(event: ChangeEvent) { + const file = event.currentTarget.files?.[0]; + if (!file) return; + + setPending(true); + setToast(""); + setError(""); + + const form = new FormData(); + form.set("file", file); + + try { + const response = await fetch("/api/admin/media", { method: "POST", body: form }); + const result = await response.json() as ApiResponse; + if (!response.ok) throw new Error(result.message ?? "Upload fehlgeschlagen."); + if (result.files) setFiles(result.files); + setToast(result.message ?? "Datei erfolgreich hochgeladen."); + event.currentTarget.value = ""; + } catch (err) { + setError(err instanceof Error ? err.message : "Upload fehlgeschlagen."); + } finally { + setPending(false); + } + } + + async function refresh() { + const response = await fetch("/api/admin/media", { method: "GET" }); + const result = await response.json() as ApiResponse; + if (response.ok && result.files) setFiles(result.files); + } + + async function remove(file: MediaFile) { + if (!window.confirm(`Datei wirklich löschen?\n\n${file.name}`)) return; + setToast(""); + setError(""); + + try { + const response = await fetch("/api/admin/media", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: file.name }), + }); + const result = await response.json() as ApiResponse; + if (!response.ok) throw new Error(result.message ?? "Datei konnte nicht gelöscht werden."); + if (result.files) setFiles(result.files); + setToast(result.message ?? "Datei gelöscht."); + } catch (err) { + setError(err instanceof Error ? err.message : "Datei konnte nicht gelöscht werden."); + } + } + + async function copyUrl(file: MediaFile) { + try { + await navigator.clipboard.writeText(file.url); + setToast("Relative URL wurde kopiert."); + setError(""); + } catch { + setError("URL konnte nicht kopiert werden."); + } + } + + function openPreview(file: MediaFile) { + if (file.isImage) { + setPreview(file); + setZoom(1); + return; + } + + window.open(file.url, "_blank", "noopener,noreferrer"); + } + + return ( + <> + {toast &&

{toast}

} + {error &&

{error}

} + +
+ + + + + +
+ +
+

Uploads

+ {visibleFiles.length === 0 ? ( +
+

Noch keine Medien vorhanden

+

Hochgeladene Bilder und PDF-Dateien erscheinen hier mit Vorschau, Metadaten und Aktionen.

+
+ ) : ( +
+ {visibleFiles.map((file) => ( +
+ +
+

{file.name}

+
+
Typ
{file.extension}
+
Größe
{formatBytes(file.size)}
+
Upload
{new Date(file.uploadedAt).toLocaleString("de-DE")}
+
+
+ + Download + + +
+
+
+ ))} +
+ )} +
+ + {preview && ( +
+
+ {preview.name} +
+ + + +
+
+
+ )} + + ); +} diff --git a/lib/admin/media.ts b/lib/admin/media.ts index cbe4b22..411b718 100644 --- a/lib/admin/media.ts +++ b/lib/admin/media.ts @@ -15,10 +15,12 @@ const allowedMimeTypes = new Map([ export type MediaFile = { name: string; url: string; - type: string; + type: "image" | "pdf"; + extension: string; size: number; uploadedAt: string; isImage: boolean; + isPdf: boolean; }; export async function ensureUploadDirectory() { @@ -72,21 +74,31 @@ export function resolveUploadTarget(fileName: string) { return target; } +function isAllowedStoredFile(fileName: string) { + const extension = path.extname(fileName).toLowerCase(); + const allowedExtensions = [...allowedMimeTypes.values()].flat(); + return !fileName.startsWith(".") && allowedExtensions.includes(extension); +} + export async function listMediaFiles(): Promise { await mkdir(uploadDirectory, { recursive: true }); const entries = await readdir(uploadDirectory); - const visibleEntries = entries.filter((entry) => !entry.startsWith(".")); + const visibleEntries = entries.filter(isAllowedStoredFile); const files = await Promise.all(visibleEntries.map(async (entry) => { const fileStat = await stat(path.join(uploadDirectory, entry)); const extension = path.extname(entry).toLowerCase(); + const isImage = [".jpg", ".jpeg", ".png", ".webp"].includes(extension); + const isPdf = extension === ".pdf"; return { name: entry, url: `/uploads/images/${encodeURIComponent(entry)}`, - type: extension.replace(".", "").toUpperCase() || "Datei", + type: isPdf ? "pdf" as const : "image" as const, + extension: extension.replace(".", "").toUpperCase() || "Datei", size: fileStat.size, uploadedAt: fileStat.birthtime.toISOString(), - isImage: [".jpg", ".jpeg", ".png", ".webp"].includes(extension), + isImage, + isPdf, }; })); @@ -104,3 +116,12 @@ export async function saveMediaFile(file: File) { return { fileName: safeName }; } + +export async function deleteMediaFile(fileName: string) { + if (!isAllowedStoredFile(fileName) || path.basename(fileName) !== fileName) { + return { error: "Ungültiger Dateiname." }; + } + + await rm(resolveUploadTarget(fileName), { force: true }); + return {}; +} From d9da86dd466568f0c1b139047c7e02de30dafe29 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Fri, 3 Jul 2026 23:35:16 +0200 Subject: [PATCH 04/11] fix(admin): clear media upload input safely --- components/admin/MediaManager.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/components/admin/MediaManager.tsx b/components/admin/MediaManager.tsx index d36a4ab..1a5a023 100644 --- a/components/admin/MediaManager.tsx +++ b/components/admin/MediaManager.tsx @@ -2,7 +2,7 @@ /* eslint-disable @next/next/no-img-element */ -import { useEffect, useMemo, useState, type ChangeEvent } from "react"; +import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import type { MediaFile } from "@/lib/admin/media"; type SortKey = "date" | "name" | "size"; @@ -28,6 +28,7 @@ function sortFiles(files: MediaFile[], sort: SortKey) { } export default function MediaManager({ initialFiles }: { initialFiles: MediaFile[] }) { + const fileInputRef = useRef(null); const [files, setFiles] = useState(initialFiles); const [filter, setFilter] = useState("all"); const [sort, setSort] = useState("date"); @@ -78,7 +79,7 @@ export default function MediaManager({ initialFiles }: { initialFiles: MediaFile if (!response.ok) throw new Error(result.message ?? "Upload fehlgeschlagen."); if (result.files) setFiles(result.files); setToast(result.message ?? "Datei erfolgreich hochgeladen."); - event.currentTarget.value = ""; + if (fileInputRef.current) fileInputRef.current.value = ""; } catch (err) { setError(err instanceof Error ? err.message : "Upload fehlgeschlagen."); } finally { @@ -140,7 +141,7 @@ export default function MediaManager({ initialFiles }: { initialFiles: MediaFile
diff --git a/app/api/media/[filename]/route.ts b/app/api/media/[filename]/route.ts new file mode 100644 index 0000000..4d4c315 --- /dev/null +++ b/app/api/media/[filename]/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { readMediaFile } from "@/lib/admin/media"; + +export async function GET(_request: Request, { params }: { params: Promise<{ filename: string }> }) { + const { filename } = await params; + const media = await readMediaFile(decodeURIComponent(filename)); + + if (!media) { + return NextResponse.json({ message: "Datei nicht gefunden." }, { status: 404 }); + } + + return new NextResponse(media.data, { + status: 200, + headers: { + "Content-Type": media.mimeType, + "Content-Length": String(media.size), + "Content-Disposition": "inline", + "Cache-Control": "private, max-age=300", + "X-Content-Type-Options": "nosniff", + }, + }); +} diff --git a/docker-compose.yml b/docker-compose.yml index 47148c1..4b670a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,11 +18,11 @@ services: DOCKER_ENV: "true" volumes: - funktechnik-data:/app/data - - funktechnik-uploads:/app/public/uploads/images + - funktechnik-storage:/app/storage - funktechnik-next-cache:/app/.next/cache restart: unless-stopped volumes: funktechnik-data: - funktechnik-uploads: + funktechnik-storage: funktechnik-next-cache: diff --git a/lib/admin/media.ts b/lib/admin/media.ts index 411b718..74a6e37 100644 --- a/lib/admin/media.ts +++ b/lib/admin/media.ts @@ -1,7 +1,7 @@ import { randomUUID } from "crypto"; -import { mkdir, readdir, rm, stat, writeFile } from "fs/promises"; +import { mkdir, readFile, readdir, rm, stat, writeFile } from "fs/promises"; import path from "path"; -import { uploadDirectory } from "@/lib/runtime/config"; +import { migrateLegacyUploads, uploadDirectory } from "@/lib/runtime/config"; const maxUploadSize = 5 * 1024 * 1024; @@ -23,6 +23,15 @@ export type MediaFile = { isPdf: boolean; }; +export function getMediaMimeType(fileName: string) { + const extension = path.extname(fileName).toLowerCase(); + if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg"; + if (extension === ".png") return "image/png"; + if (extension === ".webp") return "image/webp"; + if (extension === ".pdf") return "application/pdf"; + return ""; +} + export async function ensureUploadDirectory() { await mkdir(uploadDirectory, { recursive: true }); const probe = path.join(uploadDirectory, `.write-check-${Date.now()}`); @@ -81,6 +90,7 @@ function isAllowedStoredFile(fileName: string) { } export async function listMediaFiles(): Promise { + await migrateLegacyUploads(); await mkdir(uploadDirectory, { recursive: true }); const entries = await readdir(uploadDirectory); const visibleEntries = entries.filter(isAllowedStoredFile); @@ -92,7 +102,7 @@ export async function listMediaFiles(): Promise { return { name: entry, - url: `/uploads/images/${encodeURIComponent(entry)}`, + url: `/api/media/${encodeURIComponent(entry)}`, type: isPdf ? "pdf" as const : "image" as const, extension: extension.replace(".", "").toUpperCase() || "Datei", size: fileStat.size, @@ -117,6 +127,24 @@ export async function saveMediaFile(file: File) { return { fileName: safeName }; } +export async function readMediaFile(fileName: string) { + if (!isAllowedStoredFile(fileName) || path.basename(fileName) !== fileName) { + return null; + } + + const mimeType = getMediaMimeType(fileName); + if (!mimeType) return null; + + const target = resolveUploadTarget(fileName); + + try { + const [data, fileStat] = await Promise.all([readFile(target), stat(target)]); + return { data, mimeType, size: fileStat.size, uploadedAt: fileStat.birthtime.toISOString() }; + } catch { + return null; + } +} + export async function deleteMediaFile(fileName: string) { if (!isAllowedStoredFile(fileName) || path.basename(fileName) !== fileName) { return { error: "Ungültiger Dateiname." }; diff --git a/lib/runtime/config.ts b/lib/runtime/config.ts index a412dcd..42eab92 100644 --- a/lib/runtime/config.ts +++ b/lib/runtime/config.ts @@ -1,10 +1,12 @@ import { existsSync } from "fs"; -import { access, mkdir, writeFile, rm } from "fs/promises"; +import { access, copyFile, mkdir, readdir, rm, writeFile } from "fs/promises"; import path from "path"; import { appVersion } from "./version"; export const dataDirectory = path.join(process.cwd(), "data"); -export const uploadDirectory = path.join(process.cwd(), "public", "uploads", "images"); +export const storageDirectory = path.join(process.cwd(), "storage"); +export const uploadDirectory = path.join(storageDirectory, "uploads", "images"); +export const legacyPublicUploadDirectory = path.join(process.cwd(), "public", "uploads", "images"); export function adminConfigurationStatus() { return process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD && process.env.ADMIN_SESSION_SECRET ? "configured" : "missing"; @@ -17,6 +19,7 @@ export function isDockerEnvironment() { export async function ensureRuntimeDirectories() { await mkdir(dataDirectory, { recursive: true }); await mkdir(uploadDirectory, { recursive: true }); + await migrateLegacyUploads(); } export async function checkStorage() { @@ -36,4 +39,20 @@ export function smtpStatus() { return "prepared"; } +export async function migrateLegacyUploads() { + if (!existsSync(legacyPublicUploadDirectory)) return; + + await mkdir(uploadDirectory, { recursive: true }); + const entries = await readdir(legacyPublicUploadDirectory, { withFileTypes: true }); + + await Promise.all(entries.map(async (entry) => { + if (!entry.isFile() || entry.name.startsWith(".")) return; + + const source = path.join(legacyPublicUploadDirectory, entry.name); + const target = path.join(uploadDirectory, entry.name); + + if (!existsSync(target)) await copyFile(source, target); + })); +} + export { appVersion }; diff --git a/scripts/backup.sh b/scripts/backup.sh index 6430d9b..3214006 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -6,7 +6,7 @@ STAMP="$(date +%Y%m%d-%H%M%S)" TARGET="${BACKUP_DIR}/funktechnik-data-${STAMP}.tar.gz" mkdir -p "$BACKUP_DIR" -tar -czf "$TARGET" data public/uploads +tar -czf "$TARGET" data storage echo "Backup erstellt: $TARGET" echo "Hinweis: .env wird aus Sicherheitsgruenden nicht automatisch gesichert." diff --git a/scripts/runtime-start.js b/scripts/runtime-start.js index 31eb0a8..c9c835f 100644 --- a/scripts/runtime-start.js +++ b/scripts/runtime-start.js @@ -5,7 +5,7 @@ const path = require("path"); const requiredEnv = ["ADMIN_EMAIL", "ADMIN_PASSWORD", "ADMIN_SESSION_SECRET"]; const requiredDirs = [ path.join(process.cwd(), "data"), - path.join(process.cwd(), "public", "uploads", "images"), + path.join(process.cwd(), "storage", "uploads", "images"), path.join(process.cwd(), ".next", "cache"), ]; @@ -21,6 +21,26 @@ function ensureWritableDirectory(directory) { fs.rmSync(probe, { force: true }); } +function migrateLegacyUploads() { + const legacyDirectory = path.join(process.cwd(), "public", "uploads", "images"); + const storageDirectory = path.join(process.cwd(), "storage", "uploads", "images"); + + if (!fs.existsSync(legacyDirectory)) return; + + fs.mkdirSync(storageDirectory, { recursive: true }); + for (const fileName of fs.readdirSync(legacyDirectory)) { + if (fileName.startsWith(".")) continue; + + const source = path.join(legacyDirectory, fileName); + const target = path.join(storageDirectory, fileName); + const sourceStat = fs.statSync(source); + + if (sourceStat.isFile() && !fs.existsSync(target)) { + fs.copyFileSync(source, target); + } + } +} + const missingEnv = requiredEnv.filter((name) => !process.env[name]); if (missingEnv.length > 0) { fail(`Start abgebrochen: fehlende Umgebungsvariablen: ${missingEnv.join(", ")}. Bitte .env anhand von .env.example konfigurieren.`); @@ -28,6 +48,7 @@ if (missingEnv.length > 0) { try { for (const directory of requiredDirs) ensureWritableDirectory(directory); + migrateLegacyUploads(); } catch (error) { fail(`Start abgebrochen: Runtime-Verzeichnis ist nicht beschreibbar. Details: ${error instanceof Error ? error.message : "unbekannter Fehler"}`); } diff --git a/public/uploads/.gitkeep b/storage/.gitkeep similarity index 100% rename from public/uploads/.gitkeep rename to storage/.gitkeep diff --git a/public/uploads/images/.gitkeep b/storage/uploads/.gitkeep similarity index 100% rename from public/uploads/images/.gitkeep rename to storage/uploads/.gitkeep diff --git a/storage/uploads/images/.gitkeep b/storage/uploads/images/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/storage/uploads/images/.gitkeep @@ -0,0 +1 @@ + From 3456b04ed4c6fad8a16154dbad9908606f08092c Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sat, 4 Jul 2026 00:06:20 +0200 Subject: [PATCH 06/11] feat(mail): add smtp email delivery --- .dockerignore | 2 + .gitignore | 2 + Dockerfile | 2 +- README.md | 49 ++++++++++-- app/admin/page.tsx | 5 +- app/admin/smtp/page.tsx | 14 +--- app/admin/system/page.tsx | 12 ++- app/api/admin/smtp/route.ts | 23 +++++- app/api/contact/route.ts | 4 +- app/api/health/route.ts | 4 + app/api/repair/route.ts | 4 +- components/admin/SmtpSettingsForm.tsx | 94 ++++++++++++++++++++++ docker-compose.yml | 2 +- lib/admin/store.ts | 28 ++----- lib/admin/types.ts | 10 ++- lib/mail/config.ts | 109 ++++++++++++++++++++++++++ lib/mail/smtp.ts | 100 +++++++++++++++++++++++ lib/mail/templates.ts | 74 +++++++++++++++++ lib/mail/types.ts | 13 +++ lib/runtime/config.ts | 2 + lib/runtime/version.ts | 2 +- package-lock.json | 26 +++++- package.json | 4 +- scripts/runtime-start.js | 3 +- storage/config/.gitkeep | 1 + 25 files changed, 529 insertions(+), 60 deletions(-) create mode 100644 components/admin/SmtpSettingsForm.tsx create mode 100644 lib/mail/config.ts create mode 100644 lib/mail/smtp.ts create mode 100644 lib/mail/templates.ts create mode 100644 lib/mail/types.ts create mode 100644 storage/config/.gitkeep diff --git a/.dockerignore b/.dockerignore index fb3f6f5..145fa92 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,8 @@ npm-debug.log* README.md data/*.json storage/uploads/images/* +storage/config/* +!storage/config/.gitkeep !storage/uploads/images/.gitkeep *.tmp *.temp diff --git a/.gitignore b/.gitignore index cbe8f57..204a542 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,9 @@ data/repair-inquiries.json data/site-settings.json data/smtp-settings.json storage/uploads/images/* +storage/config/* !storage/.gitkeep +!storage/config/.gitkeep !storage/uploads/.gitkeep !storage/uploads/images/.gitkeep .DS_Store diff --git a/Dockerfile b/Dockerfile index 60c8b8b..c0bb863 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/scripts/runtime-start.js ./runtime-start.js -RUN mkdir -p /app/data /app/storage/uploads/images /app/.next/cache \ +RUN mkdir -p /app/data /app/storage/config /app/storage/uploads/images /app/.next/cache \ && chown -R nextjs:nodejs /app/data /app/storage /app/.next/cache USER nextjs diff --git a/README.md b/README.md index 329ed24..4c9a96d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Eigenständige öffentliche Firmenwebsite für Funktechnik Schubert. Dieses Proj - Nginx/Reverse-Proxy-fähig - SEO Metadata, Sitemap und robots.txt -Version: `0.2.2` +Version: `0.3.0` ## Seiten @@ -140,12 +140,12 @@ Funktionen: - Reparaturanfragen verwalten - Website-Inhaltsverwaltung vorbereitet - Firmendaten pflegen -- SMTP-Konfiguration vorbereiten +- SMTP-Konfiguration speichern und Testmail senden - Medien hochladen - SEO-Übersicht - Systemübersicht -SMTP-Versand, Publishing von Website-Inhalten und Olympus-Übernahme sind bewusst noch nicht aktiv gekoppelt. +Publishing von Website-Inhalten und Olympus-Übernahme sind bewusst noch nicht aktiv gekoppelt. SMTP-Versand fuer Kontakt- und Reparaturanfragen ist serverseitig angebunden, sofern `/admin/smtp` vollstaendig konfiguriert ist. ## Runtime Data @@ -163,9 +163,10 @@ Echte Runtime-Dateien werden nicht committed: data/contact-inquiries.json data/repair-inquiries.json data/site-settings.json -data/smtp-settings.json ``` +SMTP-Konfiguration wird unter `storage/config/smtp.json` gespeichert und nicht committed. Das SMTP-Passwort wird nicht im Admin-Formular ausgegeben. + Uploads liegen unter `storage/uploads/images/`, werden über `/api/media/[filename]` ausgeliefert und nicht committed. Alte Uploads aus `public/uploads/images` werden beim Start einmalig nach `storage/uploads/images` migriert, falls sie dort noch nicht vorhanden sind. @@ -187,13 +188,50 @@ Antwort: ```json { "status": "ok", - "version": "0.2.2", + "version": "0.3.0", "storage": "ok", "admin": "configured", + "smtp": "configured", "timestamp": "..." } ``` +## SMTP + +SMTP wird im Adminbereich unter `/admin/smtp` konfiguriert. Die Konfiguration wird persistent unter `storage/config/smtp.json` gespeichert und liegt damit im Docker-Storage-Volume, nicht in `public/`. + +Pflichtfelder: + +- SMTP Host +- SMTP Port +- SMTP Benutzername +- SMTP Passwort +- Verschlüsselung +- Absenderadresse +- Empfängeradresse + +Für Apple Mail/iCloud Mail: + +```text +Host: smtp.mail.me.com +Port: 587 +Verschlüsselung: STARTTLS +Benutzername: vollständige E-Mail-Adresse +Passwort: app-spezifisches Passwort +``` + +Nicht das normale Apple-ID-Passwort verwenden. In der Apple-ID-Verwaltung ein app-spezifisches Passwort erzeugen und dieses als SMTP-Passwort speichern. + +Nach dem Speichern kann im Adminbereich eine Testmail gesendet werden. Kontakt- und Reparaturanfragen werden weiterhin lokal gespeichert. Wenn SMTP konfiguriert ist, wird zusaetzlich eine E-Mail an die konfigurierte Empfaengeradresse gesendet. Schlaegt der Mailversand fehl, bleibt die Anfrage gespeichert; der Besucher sieht keine technische Fehlermeldung. + +Troubleshooting: + +- Host, Port und Verschlüsselung prüfen +- bei Apple Mail STARTTLS und Port 587 verwenden +- vollständige E-Mail-Adresse als Benutzername verwenden +- app-spezifisches Passwort neu erzeugen +- letzte Testmail und letzte Fehlermeldung unter `/admin/smtp` prüfen + ## Deployment ```bash @@ -259,6 +297,5 @@ certbot --nginx -d funktechnik-schubert.de -d www.funktechnik-schubert.de ## Offene manuelle Punkte - TODO: Rechtliche Angaben ergänzen -- Produktives Kontakt-/Mail-System anbinden - Spätere Olympus-Reparaturannahme serverseitig anbinden - Finale Domain und HTTPS-Konfiguration setzen diff --git a/app/admin/page.tsx b/app/admin/page.tsx index b479b0d..9142ef2 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; import { requireAdminSession } from "@/lib/admin/auth"; import { getContactInquiries, getRepairInquiries } from "@/lib/admin/store"; +import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config"; function countNew(items: T[]) { return items.filter((item) => item.status === "new").length; @@ -9,7 +10,7 @@ function countNew(items: T[]) { export default async function AdminDashboardPage() { if (!await requireAdminSession()) redirect("/admin/login"); - const [contacts, repairs] = await Promise.all([getContactInquiries(), getRepairInquiries()]); + const [contacts, repairs, smtpSettings] = await Promise.all([getContactInquiries(), getRepairInquiries(), getSmtpSettings()]); const latest = [...contacts, ...repairs].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 6); return ( @@ -22,7 +23,7 @@ export default async function AdminDashboardPage() {
Kontaktanfragen{countNew(contacts)}neu
Reparaturanfragen{countNew(repairs)}neu
WebsiteOnlineOK
-
SMTPVorbereitetnicht aktiv
+
SMTP{isSmtpConfigured(smtpSettings) ? "OK" : "Fehlt"}{smtpSettings.lastTestStatus ?? "kein Test"}

Letzte Anfragen

diff --git a/app/admin/smtp/page.tsx b/app/admin/smtp/page.tsx index ae13637..662eeb9 100644 --- a/app/admin/smtp/page.tsx +++ b/app/admin/smtp/page.tsx @@ -1,7 +1,8 @@ import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; +import SmtpSettingsForm from "@/components/admin/SmtpSettingsForm"; import { requireAdminSession } from "@/lib/admin/auth"; -import { getSmtpSettings } from "@/lib/admin/store"; +import { getSmtpSettings, isSmtpConfigured, toPublicSmtpSettings } from "@/lib/mail/config"; export default async function AdminSmtpPage() { if (!await requireAdminSession()) redirect("/admin/login"); @@ -13,16 +14,7 @@ export default async function AdminSmtpPage() {

E-Mail

SMTP Einstellungen

-
- - - - - - - - -
+ ); } diff --git a/app/admin/system/page.tsx b/app/admin/system/page.tsx index d71e400..c6f59ff 100644 --- a/app/admin/system/page.tsx +++ b/app/admin/system/page.tsx @@ -1,7 +1,8 @@ import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; import { requireAdminSession } from "@/lib/admin/auth"; -import { adminConfigurationStatus, appVersion, checkStorage, dataDirectory, isDockerEnvironment, olympusStatus, smtpStatus, uploadDirectory } from "@/lib/runtime/config"; +import { adminConfigurationStatus, appVersion, checkStorage, configDirectory, dataDirectory, isDockerEnvironment, olympusStatus, uploadDirectory } from "@/lib/runtime/config"; +import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config"; export default async function AdminSystemPage() { if (!await requireAdminSession()) redirect("/admin/login"); @@ -12,6 +13,8 @@ export default async function AdminSystemPage() { } catch { storage = "error"; } + const smtpSettings = await getSmtpSettings(); + const smtpConfigured = isSmtpConfigured(smtpSettings); return ( @@ -27,10 +30,13 @@ export default async function AdminSystemPage() {
Docker Environment
{isDockerEnvironment() ? "ja" : "nein"}
Storage Status
{storage}
Data Directory
{dataDirectory}
+
Config Directory
{configDirectory}
Upload Directory
{uploadDirectory}
Admin Konfiguration
{adminConfigurationStatus()}
Olympus Verbindung
{olympusStatus()}
-
SMTP
{smtpStatus()}
+
SMTP
{smtpConfigured ? "configured" : "missing"}
+
Letzte SMTP-Testmail
{smtpSettings.lastTestAt ? `${new Date(smtpSettings.lastTestAt).toLocaleString("de-DE")} (${smtpSettings.lastTestStatus})` : "keine"}
+
Letzter SMTP-Formularversand
{smtpSettings.lastDeliveryAt ? `${new Date(smtpSettings.lastDeliveryAt).toLocaleString("de-DE")} (${smtpSettings.lastDeliveryStatus})` : "keiner"}
@@ -38,7 +44,7 @@ export default async function AdminSystemPage() {
  • Runtime-Daten liegen unter data/ und werden nicht versioniert.
  • Uploads liegen unter storage/uploads/images/ und werden nicht versioniert.
  • -
  • Olympus- und SMTP-Integration sind vorbereitet, aber nicht aktiv implementiert.
  • +
  • SMTP-Versand ist serverseitig aktiv, sofern eine vollständige Konfiguration gespeichert ist.
diff --git a/app/api/admin/smtp/route.ts b/app/api/admin/smtp/route.ts index ea05bb2..5b4f368 100644 --- a/app/api/admin/smtp/route.ts +++ b/app/api/admin/smtp/route.ts @@ -1,9 +1,26 @@ import { NextResponse } from "next/server"; import { requireAdminSession } from "@/lib/admin/auth"; -import { saveSmtpSettings } from "@/lib/admin/store"; +import { saveSmtpSettings, toPublicSmtpSettings } from "@/lib/mail/config"; +import { sendTestMail } from "@/lib/mail/smtp"; export async function POST(request: Request) { if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); - await saveSmtpSettings(await request.formData()); - return new NextResponse(null, { status: 303, headers: { Location: "/admin/smtp?saved=1" } }); + const form = await request.formData(); + const action = String(form.get("action") ?? "save"); + + if (action === "test") { + const settings = await saveSmtpSettings(form); + const result = await sendTestMail(); + return NextResponse.json({ + message: result.ok ? "Test-E-Mail wurde gesendet." : result.error ?? "Test-E-Mail fehlgeschlagen.", + ok: result.ok, + settings: toPublicSmtpSettings(settings), + }, { status: result.ok ? 200 : 400 }); + } + + const settings = await saveSmtpSettings(form); + return NextResponse.json({ + message: "SMTP-Konfiguration gespeichert.", + settings: toPublicSmtpSettings(settings), + }); } diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts index ee384f7..07555e4 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { addContactInquiry } from "@/lib/admin/store"; +import { sendContactInquiryMail } from "@/lib/mail/smtp"; function text(value: FormDataEntryValue | null) { return typeof value === "string" ? value.trim() : ""; @@ -16,7 +17,8 @@ export async function POST(request: Request) { return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 }); } - await addContactInquiry(form); + const inquiry = await addContactInquiry(form); + await sendContactInquiryMail(inquiry); return NextResponse.json({ message: "Ihre Nachricht wurde erfasst. Sie erhalten eine Rückmeldung.", diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 30791a0..68745af 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { adminConfigurationStatus, appVersion, checkStorage } from "@/lib/runtime/config"; +import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config"; export async function GET() { let storage = "ok"; @@ -10,11 +11,14 @@ export async function GET() { storage = "error"; } + const smtpSettings = await getSmtpSettings(); + return NextResponse.json({ status: storage === "ok" ? "ok" : "error", version: appVersion, storage, admin: adminConfigurationStatus(), + smtp: isSmtpConfigured(smtpSettings) ? "configured" : "missing", timestamp: new Date().toISOString(), }); } diff --git a/app/api/repair/route.ts b/app/api/repair/route.ts index 6b98806..5fb6c1b 100644 --- a/app/api/repair/route.ts +++ b/app/api/repair/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { addRepairInquiry } from "@/lib/admin/store"; +import { sendRepairInquiryMail } from "@/lib/mail/smtp"; function required(value: FormDataEntryValue | null) { return typeof value === "string" && value.trim().length > 0; @@ -19,7 +20,8 @@ export async function POST(request: Request) { return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 }); } - await addRepairInquiry(form); + const inquiry = await addRepairInquiry(form); + await sendRepairInquiryMail(inquiry); return NextResponse.json({ message: "Ihre Reparaturanfrage wurde erfasst. Sie erhalten nach Prüfung eine Rückmeldung.", diff --git a/components/admin/SmtpSettingsForm.tsx b/components/admin/SmtpSettingsForm.tsx new file mode 100644 index 0000000..359cdae --- /dev/null +++ b/components/admin/SmtpSettingsForm.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useRef, useState, type FormEvent } from "react"; +import type { PublicSmtpSettings } from "@/lib/mail/types"; + +type ApiResponse = { + message?: string; + ok?: boolean; + settings?: PublicSmtpSettings; +}; + +export default function SmtpSettingsForm({ initialSettings, configured }: { initialSettings: PublicSmtpSettings; configured: boolean }) { + const formRef = useRef(null); + const [settings, setSettings] = useState(initialSettings); + const [isConfigured, setIsConfigured] = useState(configured); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + + async function submitForm(form: HTMLFormElement, action: "save" | "test") { + setMessage(""); + setError(""); + setPending(true); + + const formData = new FormData(form); + formData.set("action", action); + + try { + const response = await fetch("/api/admin/smtp", { method: "POST", body: formData }); + const result = await response.json() as ApiResponse; + if (result.settings) { + setSettings(result.settings); + setIsConfigured(Boolean(result.settings.host && result.settings.username && result.settings.hasPassword && result.settings.fromAddress && result.settings.recipientAddress)); + } + if (!response.ok) throw new Error(result.message ?? "SMTP-Aktion fehlgeschlagen."); + setMessage(result.message ?? "Aktion erfolgreich."); + } catch (err) { + setError(err instanceof Error ? err.message : "SMTP-Aktion fehlgeschlagen."); + } finally { + setPending(false); + } + } + + function submit(event: FormEvent) { + event.preventDefault(); + void submitForm(event.currentTarget, "save"); + } + + function test() { + if (formRef.current) void submitForm(formRef.current, "test"); + } + + return ( + <> +
+

Status

+

SMTP ist {isConfigured ? "konfiguriert" : "nicht konfiguriert"}.

+ {settings.lastTestAt &&

Letzte Testmail: {new Date(settings.lastTestAt).toLocaleString("de-DE")} ({settings.lastTestStatus})

} + {settings.lastDeliveryAt &&

Letzter Formularversand: {new Date(settings.lastDeliveryAt).toLocaleString("de-DE")} ({settings.lastDeliveryStatus})

} + {settings.lastError &&

Letzter Fehler: {settings.lastError}

} +

Für Apple Mail/iCloud bitte smtp.mail.me.com, Port 587, STARTTLS und ein app-spezifisches Passwort verwenden.

+
+ + {message &&

{message}

} + {error &&

{error}

} + +
+ + + + + + + + + +
+ + +
+
+ + ); +} diff --git a/docker-compose.yml b/docker-compose.yml index 4b670a7..b31f6ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: - "3010:3010" environment: NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://127.0.0.1:3010} - NEXT_PUBLIC_APP_VERSION: "0.2.2" + NEXT_PUBLIC_APP_VERSION: "0.3.0" ADMIN_EMAIL: ${ADMIN_EMAIL:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-} diff --git a/lib/admin/store.ts b/lib/admin/store.ts index 02757a8..28182c2 100644 --- a/lib/admin/store.ts +++ b/lib/admin/store.ts @@ -1,7 +1,7 @@ import { mkdir, readFile, writeFile } from "fs/promises"; import path from "path"; -import { dataDirectory } from "@/lib/runtime/config"; -import type { ContactInquiry, InquiryStatus, RepairInquiry, SiteSettings, SmtpSettings } from "./types"; +import { configDirectory, dataDirectory } from "@/lib/runtime/config"; +import type { ContactInquiry, InquiryStatus, RepairInquiry, SiteSettings } from "./types"; async function ensureDataDir() { await mkdir(dataDirectory, { recursive: true }); @@ -112,26 +112,8 @@ export async function saveSiteSettings(form: FormData) { return settings; } -export async function getSmtpSettings() { - return readJson("smtp-settings.json", { - host: "", - port: "587", - username: "", - fromAddress: "", - replyToAddress: "", - tls: true, - }); +export async function ensureConfigDir() { + await mkdir(configDirectory, { recursive: true }); } -export async function saveSmtpSettings(form: FormData) { - const settings: SmtpSettings = { - host: text(form.get("host")), - port: text(form.get("port")), - username: text(form.get("username")), - fromAddress: text(form.get("fromAddress")), - replyToAddress: text(form.get("replyToAddress")), - tls: form.get("tls") === "on", - }; - await writeJson("smtp-settings.json", settings); - return settings; -} +export { text }; diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 11ae88b..3411cde 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -42,7 +42,15 @@ export type SmtpSettings = { host: string; port: string; username: string; + password?: string; + security: "starttls" | "tls" | "none"; fromAddress: string; replyToAddress: string; - tls: boolean; + recipientAddress: string; + bccAddress?: string; + lastTestAt?: string; + lastTestStatus?: "success" | "error"; + lastDeliveryAt?: string; + lastDeliveryStatus?: "success" | "error"; + lastError?: string; }; diff --git a/lib/mail/config.ts b/lib/mail/config.ts new file mode 100644 index 0000000..c08a6ff --- /dev/null +++ b/lib/mail/config.ts @@ -0,0 +1,109 @@ +import { mkdir, readFile, writeFile } from "fs/promises"; +import path from "path"; +import { configDirectory } from "@/lib/runtime/config"; +import type { SmtpSettings } from "@/lib/admin/types"; +import type { PublicSmtpSettings } from "./types"; + +const smtpConfigFile = path.join(configDirectory, "smtp.json"); + +const defaultSmtpSettings: SmtpSettings = { + host: "", + port: "587", + username: "", + password: "", + security: "starttls", + fromAddress: "", + replyToAddress: "", + recipientAddress: "", + bccAddress: "", +}; + +function text(value: FormDataEntryValue | null) { + return typeof value === "string" ? value.trim() : ""; +} + +function normalizeSecurity(value: string): SmtpSettings["security"] { + if (value === "tls" || value === "none") return value; + return "starttls"; +} + +async function ensureConfigDirectory() { + await mkdir(configDirectory, { recursive: true }); +} + +export async function getSmtpSettings(): Promise { + await ensureConfigDirectory(); + try { + const file = await readFile(smtpConfigFile, "utf8"); + return { ...defaultSmtpSettings, ...JSON.parse(file) as SmtpSettings }; + } catch { + return defaultSmtpSettings; + } +} + +export function toPublicSmtpSettings(settings: SmtpSettings): PublicSmtpSettings { + return { + host: settings.host, + port: settings.port, + username: settings.username, + security: settings.security, + fromAddress: settings.fromAddress, + replyToAddress: settings.replyToAddress, + recipientAddress: settings.recipientAddress, + bccAddress: settings.bccAddress, + lastTestAt: settings.lastTestAt, + lastTestStatus: settings.lastTestStatus, + lastError: settings.lastError, + hasPassword: Boolean(settings.password), + }; +} + +export function isSmtpConfigured(settings: SmtpSettings) { + return Boolean( + settings.host && + settings.port && + settings.username && + settings.password && + settings.fromAddress && + settings.recipientAddress, + ); +} + +export async function saveSmtpSettings(form: FormData) { + const current = await getSmtpSettings(); + const nextPassword = text(form.get("password")); + const settings: SmtpSettings = { + ...current, + host: text(form.get("host")), + port: text(form.get("port")) || "587", + username: text(form.get("username")), + password: nextPassword || current.password || "", + security: normalizeSecurity(text(form.get("security"))), + fromAddress: text(form.get("fromAddress")), + replyToAddress: text(form.get("replyToAddress")), + recipientAddress: text(form.get("recipientAddress")), + bccAddress: text(form.get("bccAddress")), + }; + + await ensureConfigDirectory(); + await writeFile(smtpConfigFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); + return settings; +} + +export async function updateSmtpTestStatus(status: Pick) { + const current = await getSmtpSettings(); + const settings: SmtpSettings = { ...current, ...status }; + await ensureConfigDirectory(); + await writeFile(smtpConfigFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); + return settings; +} + +export async function updateSmtpDeliveryStatus(status: Pick) { + const current = await getSmtpSettings(); + const settings: SmtpSettings = { ...current, ...status }; + await ensureConfigDirectory(); + await writeFile(smtpConfigFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); + return settings; +} + +export { smtpConfigFile }; diff --git a/lib/mail/smtp.ts b/lib/mail/smtp.ts new file mode 100644 index 0000000..4c3925a --- /dev/null +++ b/lib/mail/smtp.ts @@ -0,0 +1,100 @@ +import nodemailer from "nodemailer"; +import type SMTPTransport from "nodemailer/lib/smtp-transport"; +import type { SmtpSettings } from "@/lib/admin/types"; +import { getSmtpSettings, isSmtpConfigured, updateSmtpDeliveryStatus, updateSmtpTestStatus } from "./config"; +import { contactTemplate, repairTemplate, testTemplate } from "./templates"; +import type { ContactMailInput, MailSendResult, RepairMailInput } from "./types"; + +function sanitizeMailError(error: unknown) { + const message = error instanceof Error ? error.message : "Unbekannter SMTP-Fehler"; + return message + .replace(/AUTH PLAIN\s+\S+/gi, "AUTH PLAIN [redacted]") + .replace(/AUTH LOGIN\s+\S+/gi, "AUTH LOGIN [redacted]") + .replace(/password[=:]\S+/gi, "password=[redacted]") + .slice(0, 500); +} + +function createTransport(settings: SmtpSettings) { + const secure = settings.security === "tls"; + const requireTLS = settings.security === "starttls"; + const options: SMTPTransport.Options = { + host: settings.host, + port: Number(settings.port), + secure, + requireTLS, + auth: { + user: settings.username, + pass: settings.password, + }, + }; + + if (settings.security === "none") { + options.auth = settings.username && settings.password ? options.auth : undefined; + options.ignoreTLS = true; + } + + return nodemailer.createTransport(options); +} + +async function sendConfiguredMail(input: { + subject: string; + text: string; + html: string; + replyTo?: string; +}): Promise { + const settings = await getSmtpSettings(); + if (!isSmtpConfigured(settings)) return { ok: false, error: "SMTP nicht konfiguriert." }; + + try { + const transport = createTransport(settings); + await transport.sendMail({ + from: settings.fromAddress, + to: settings.recipientAddress, + bcc: settings.bccAddress || undefined, + replyTo: input.replyTo || settings.replyToAddress || undefined, + subject: input.subject, + text: input.text, + html: input.html, + }); + return { ok: true }; + } catch (error) { + return { ok: false, error: sanitizeMailError(error) }; + } +} + +export async function sendTestMail() { + const template = testTemplate(); + const result = await sendConfiguredMail(template); + await updateSmtpTestStatus({ + lastTestAt: new Date().toISOString(), + lastTestStatus: result.ok ? "success" : "error", + lastError: result.ok ? "" : result.error, + }); + return result; +} + +export async function sendContactInquiryMail(input: ContactMailInput) { + const template = contactTemplate(input); + const result = await sendConfiguredMail({ ...template, replyTo: input.email }); + if (result.error !== "SMTP nicht konfiguriert.") { + await updateSmtpDeliveryStatus({ + lastDeliveryAt: new Date().toISOString(), + lastDeliveryStatus: result.ok ? "success" : "error", + lastError: result.ok ? "" : result.error, + }); + } + return result; +} + +export async function sendRepairInquiryMail(input: RepairMailInput) { + const template = repairTemplate(input); + const result = await sendConfiguredMail({ ...template, replyTo: input.email }); + if (result.error !== "SMTP nicht konfiguriert.") { + await updateSmtpDeliveryStatus({ + lastDeliveryAt: new Date().toISOString(), + lastDeliveryStatus: result.ok ? "success" : "error", + lastError: result.ok ? "" : result.error, + }); + } + return result; +} diff --git a/lib/mail/templates.ts b/lib/mail/templates.ts new file mode 100644 index 0000000..fd1ba44 --- /dev/null +++ b/lib/mail/templates.ts @@ -0,0 +1,74 @@ +import type { ContactMailInput, RepairMailInput } from "./types"; + +function escapeHtml(value?: string) { + return (value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function rows(entries: Array<[string, string | undefined]>) { + return entries + .filter(([, value]) => value) + .map(([label, value]) => `${escapeHtml(label)}${escapeHtml(value)}`) + .join(""); +} + +function textRows(entries: Array<[string, string | undefined]>) { + return entries + .filter(([, value]) => value) + .map(([label, value]) => `${label}: ${value}`) + .join("\n"); +} + +export function contactTemplate(input: ContactMailInput) { + const subject = `Kontaktanfrage: ${input.subject ?? "Funktechnik Schubert"}`; + const entries: Array<[string, string | undefined]> = [ + ["Datum", new Date(input.createdAt).toLocaleString("de-DE")], + ["Name", input.name], + ["E-Mail", input.email], + ["Telefon", input.phone], + ["Betreff", input.subject], + ["Nachricht", input.message], + ]; + + return { + subject, + text: `Neue Kontaktanfrage\n\n${textRows(entries)}`, + html: `

Neue Kontaktanfrage

${rows(entries)}
`, + }; +} + +export function repairTemplate(input: RepairMailInput) { + const subject = `Reparaturanfrage: ${input.manufacturer} ${input.model}`; + const entries: Array<[string, string | undefined]> = [ + ["Datum", new Date(input.createdAt).toLocaleString("de-DE")], + ["Name", input.name], + ["E-Mail", input.email], + ["Telefon", input.phone], + ["Hersteller", input.manufacturer], + ["Modell", input.model], + ["Geräteart", input.deviceType], + ["Seriennummer", input.serialNumber], + ["Fehlerbeschreibung", input.description], + ["Zubehör", input.accessories], + ["Gerät geöffnet?", input.opened], + ["Vorarbeiten", input.previousWork], + ]; + + return { + subject, + text: `Neue Reparaturanfrage\n\n${textRows(entries)}`, + html: `

Neue Reparaturanfrage

${rows(entries)}
`, + }; +} + +export function testTemplate() { + return { + subject: "SMTP Testmail - Funktechnik Schubert", + text: "Diese Testmail wurde aus der Funktechnik Schubert Website-Administration gesendet.", + html: "

SMTP Testmail

Diese Testmail wurde aus der Funktechnik Schubert Website-Administration gesendet.

", + }; +} diff --git a/lib/mail/types.ts b/lib/mail/types.ts new file mode 100644 index 0000000..c9448bb --- /dev/null +++ b/lib/mail/types.ts @@ -0,0 +1,13 @@ +import type { ContactInquiry, RepairInquiry, SmtpSettings } from "@/lib/admin/types"; + +export type PublicSmtpSettings = Omit & { + hasPassword: boolean; +}; + +export type MailSendResult = { + ok: boolean; + error?: string; +}; + +export type ContactMailInput = ContactInquiry; +export type RepairMailInput = RepairInquiry; diff --git a/lib/runtime/config.ts b/lib/runtime/config.ts index 42eab92..84f0f29 100644 --- a/lib/runtime/config.ts +++ b/lib/runtime/config.ts @@ -5,6 +5,7 @@ import { appVersion } from "./version"; export const dataDirectory = path.join(process.cwd(), "data"); export const storageDirectory = path.join(process.cwd(), "storage"); +export const configDirectory = path.join(storageDirectory, "config"); export const uploadDirectory = path.join(storageDirectory, "uploads", "images"); export const legacyPublicUploadDirectory = path.join(process.cwd(), "public", "uploads", "images"); @@ -18,6 +19,7 @@ export function isDockerEnvironment() { export async function ensureRuntimeDirectories() { await mkdir(dataDirectory, { recursive: true }); + await mkdir(configDirectory, { recursive: true }); await mkdir(uploadDirectory, { recursive: true }); await migrateLegacyUploads(); } diff --git a/lib/runtime/version.ts b/lib/runtime/version.ts index e8563d7..dcc838e 100644 --- a/lib/runtime/version.ts +++ b/lib/runtime/version.ts @@ -1 +1 @@ -export const appVersion = "0.2.2"; +export const appVersion = "0.3.0"; diff --git a/package-lock.json b/package-lock.json index 01bef7c..bed248b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "funktechnik-schubert-website", - "version": "0.2.2", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "funktechnik-schubert-website", - "version": "0.2.2", + "version": "0.3.0", "dependencies": { + "@types/nodemailer": "^8.0.1", "next": "16.2.10", + "nodemailer": "^9.0.3", "react": "19.2.3", "react-dom": "19.2.3" }, @@ -1343,12 +1345,20 @@ "version": "24.13.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -4669,6 +4679,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5912,7 +5931,6 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { diff --git a/package.json b/package.json index 23eedb3..b917003 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "funktechnik-schubert-website", - "version": "0.2.2", + "version": "0.3.0", "private": true, "scripts": { "dev": "next dev -p 3010", @@ -9,7 +9,9 @@ "lint": "eslint" }, "dependencies": { + "@types/nodemailer": "^8.0.1", "next": "16.2.10", + "nodemailer": "^9.0.3", "react": "19.2.3", "react-dom": "19.2.3" }, diff --git a/scripts/runtime-start.js b/scripts/runtime-start.js index c9c835f..805ae13 100644 --- a/scripts/runtime-start.js +++ b/scripts/runtime-start.js @@ -5,6 +5,7 @@ const path = require("path"); const requiredEnv = ["ADMIN_EMAIL", "ADMIN_PASSWORD", "ADMIN_SESSION_SECRET"]; const requiredDirs = [ path.join(process.cwd(), "data"), + path.join(process.cwd(), "storage", "config"), path.join(process.cwd(), "storage", "uploads", "images"), path.join(process.cwd(), ".next", "cache"), ]; @@ -53,5 +54,5 @@ try { fail(`Start abgebrochen: Runtime-Verzeichnis ist nicht beschreibbar. Details: ${error instanceof Error ? error.message : "unbekannter Fehler"}`); } -process.stdout.write(`[funktechnik-website] Runtime checks ok. Starting version ${process.env.NEXT_PUBLIC_APP_VERSION ?? "0.2.2"}.\n`); +process.stdout.write(`[funktechnik-website] Runtime checks ok. Starting version ${process.env.NEXT_PUBLIC_APP_VERSION ?? "0.3.0"}.\n`); require("./server.js"); diff --git a/storage/config/.gitkeep b/storage/config/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/storage/config/.gitkeep @@ -0,0 +1 @@ + From 7d5689cfa4c63b0adb73a404feaf0d1d4a9bc2c4 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sat, 4 Jul 2026 00:31:13 +0200 Subject: [PATCH 07/11] fix(forms): repair public form validation and reset --- app/api/contact/route.ts | 2 +- app/api/repair/route.ts | 4 ++-- components/ContactForm.tsx | 14 ++++++++------ components/RepairForm.tsx | 21 +++++++++++++-------- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts index 07555e4..6ea6537 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -13,7 +13,7 @@ export async function POST(request: Request) { const subject = text(form.get("subject")); const message = text(form.get("message")); - if (!name || !email.includes("@") || !subject || message.length < 10 || form.get("privacy") !== "on") { + if (!name || !email.includes("@") || !subject || message.length < 10 || form.get("privacyAccepted") !== "on") { return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 }); } diff --git a/app/api/repair/route.ts b/app/api/repair/route.ts index 5fb6c1b..81c771c 100644 --- a/app/api/repair/route.ts +++ b/app/api/repair/route.ts @@ -14,9 +14,9 @@ export async function POST(request: Request) { const model = form.get("model"); const deviceType = form.get("deviceType"); const description = form.get("description"); - const privacy = form.get("privacy"); + const privacyAccepted = form.get("privacyAccepted"); - if (!required(name) || !required(email) || !required(manufacturer) || !required(model) || !required(deviceType) || !required(description) || privacy !== "on") { + if (!required(name) || !required(email) || !required(manufacturer) || !required(model) || !required(deviceType) || !required(description) || privacyAccepted !== "on") { return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 }); } diff --git a/components/ContactForm.tsx b/components/ContactForm.tsx index 12082e6..4fe05c0 100644 --- a/components/ContactForm.tsx +++ b/components/ContactForm.tsx @@ -1,8 +1,9 @@ "use client"; -import { useState, type FormEvent } from "react"; +import { useRef, useState, type FormEvent } from "react"; export default function ContactForm() { + const formRef = useRef(null); const [message, setMessage] = useState(""); const [error, setError] = useState(""); const [pending, setPending] = useState(false); @@ -16,8 +17,9 @@ export default function ContactForm() { const email = String(form.get("email") ?? "").trim(); const subject = String(form.get("subject") ?? "").trim(); const text = String(form.get("message") ?? "").trim(); + const privacyAccepted = form.get("privacyAccepted") === "on"; - if (!name || !email.includes("@") || !subject || text.length < 10 || form.get("privacy") !== "on") { + if (!name || !email.includes("@") || !subject || text.length < 10 || !privacyAccepted) { setError("Bitte füllen Sie alle Pflichtfelder aus und bestätigen Sie die Datenschutz-Hinweise."); return; } @@ -27,17 +29,17 @@ export default function ContactForm() { const response = await fetch("/api/contact", { method: "POST", body: form }); const result = await response.json() as { message?: string }; if (!response.ok) throw new Error(result.message ?? "Nachricht konnte nicht gesendet werden."); - event.currentTarget.reset(); + formRef.current?.reset(); setMessage(result.message ?? "Ihre Nachricht wurde vorbereitet."); } catch (err) { - setError(err instanceof Error ? err.message : "Nachricht konnte nicht gesendet werden."); + setError(err instanceof Error ? err.message : "Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es später erneut."); } finally { setPending(false); } } return ( -
+
@@ -59,7 +61,7 @@ export default function ContactForm() {