diff --git a/.DS_Store b/.DS_Store
deleted file mode 100644
index 379c22d..0000000
Binary files a/.DS_Store and /dev/null differ
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
);
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");