From c35bd5877e218ae7de40251f755502e7776744ec Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Fri, 3 Jul 2026 22:59:08 +0200 Subject: [PATCH] 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");