Compare commits
7 Commits
f9ad00a7e7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f360480ae | ||
|
|
28d346d0f5 | ||
|
|
15dd78c848 | ||
|
|
e5811f03fb | ||
|
|
405639db7d | ||
|
|
2323f1d360 | ||
|
|
4a59b5ca0b |
14
.env.example
14
.env.example
@@ -32,3 +32,17 @@ NODE_ENV=development
|
||||
# Gitea Registry Login für Watchtower (automatische Image-Updates)
|
||||
GITEA_REGISTRY_USER=adminpepe
|
||||
GITEA_REGISTRY_PASS=dein_gitea_token_oder_passwort
|
||||
|
||||
# --- Backup (automatischer DB- + Datei-Backup-Container) ---
|
||||
# Aufbewahrungsdauer in Tagen (muss zur Datenschutzerklaerung passen)
|
||||
BACKUP_RETENTION_DAYS=30
|
||||
# Intervall in Stunden (24 = taeglich)
|
||||
BACKUP_INTERVAL_HOURS=24
|
||||
|
||||
# --- GUI-Backup (SFTP/Nextcloud, verschluesselt) ---
|
||||
# Noetig fuer den automatischen Zeitplan (interner Scheduler ruft /api/cron/backup):
|
||||
CRON_SECRET=langer-zufaelliger-wert-min-32-zeichen
|
||||
# Optional: eigener Schluessel zum Verschluesseln der gespeicherten Zugangsdaten
|
||||
# (Default: NEXTAUTH_SECRET). Wenn gesetzt, NICHT mehr aendern (sonst sind gespeicherte
|
||||
# Zugangsdaten/Passphrase nicht mehr entschluesselbar):
|
||||
# BACKUP_ENC_KEY=eigener-langer-schluessel
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -65,3 +65,6 @@ Reglement_*/
|
||||
|
||||
# Stack env (contains secrets)
|
||||
stack.env
|
||||
|
||||
# Private rechtliche Nachweise (NICHT committen: z.B. FKS-Freigabe-E-Mail)
|
||||
private-legal-evidence/
|
||||
|
||||
@@ -28,7 +28,8 @@ RUN --mount=type=cache,target=/app/.next/cache npm run build
|
||||
|
||||
# Stage 3: Runner
|
||||
FROM node:20-alpine AS runner
|
||||
RUN apk add --no-cache openssl
|
||||
# openssl (Prisma) + postgresql16-client (pg_dump/pg_restore für GUI-Backup, passend zu postgres:16)
|
||||
RUN apk add --no-cache openssl postgresql16-client
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
@@ -44,7 +45,7 @@ USER nextjs
|
||||
|
||||
# Install only the unbundled runtime deps needed by the custom server.
|
||||
# Running as USER nextjs means files are already correctly owned — no slow chown -R needed afterwards.
|
||||
RUN npm install --omit=dev --legacy-peer-deps socket.io@4.7.4 @react-pdf/renderer@4.3.2 qrcode@1.5.4 --no-save
|
||||
RUN npm install --omit=dev --legacy-peer-deps socket.io@4.7.4 @react-pdf/renderer@4.3.2 qrcode@1.5.4 ssh2-sftp-client@12.1.1 webdav@5.10.0 archiver@8.0.0 --no-save
|
||||
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/.next/standalone ./
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/.next/static ./.next/static
|
||||
|
||||
@@ -86,11 +86,57 @@ services:
|
||||
networks:
|
||||
- lageplan-net
|
||||
|
||||
# --- Automatisches Backup: täglicher DB-Dump + Datei-Backup (30 Tage) ---
|
||||
# Backups im Volume "backups_data". Für Off-Site auf einen NAS-Pfad umstellen, z.B.:
|
||||
# volumes: - /volume1/backups/lageplan:/backups
|
||||
backup:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGHOST: db
|
||||
PGUSER: ${POSTGRES_USER}
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||
PGDATABASE: ${POSTGRES_DB}
|
||||
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-30}
|
||||
BACKUP_INTERVAL_HOURS: ${BACKUP_INTERVAL_HOURS:-24}
|
||||
volumes:
|
||||
- backups_data:/backups
|
||||
- minio_data:/minio:ro
|
||||
networks:
|
||||
- lageplan-net
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
echo "[backup] Start - alle $${BACKUP_INTERVAL_HOURS}h, Aufbewahrung $${BACKUP_RETENTION_DAYS} Tage";
|
||||
while true; do
|
||||
TS=$$(date +%Y-%m-%d_%H%M%S);
|
||||
mkdir -p /backups;
|
||||
echo "[backup] $$TS Datenbank sichern...";
|
||||
if pg_dump -h "$$PGHOST" -U "$$PGUSER" -d "$$PGDATABASE" -Fc -f "/backups/db_$$TS.dump"; then
|
||||
echo "[backup] DB ok: db_$$TS.dump";
|
||||
else
|
||||
echo "[backup] DB FEHLGESCHLAGEN"; rm -f "/backups/db_$$TS.dump";
|
||||
fi;
|
||||
if [ -d /minio ]; then
|
||||
tar czf "/backups/files_$$TS.tar.gz" -C /minio . 2>/dev/null && echo "[backup] Dateien ok: files_$$TS.tar.gz" || echo "[backup] Dateien uebersprungen";
|
||||
fi;
|
||||
find /backups -name "db_*.dump" -mtime +$$BACKUP_RETENTION_DAYS -delete 2>/dev/null || true;
|
||||
find /backups -name "files_*.tar.gz" -mtime +$$BACKUP_RETENTION_DAYS -delete 2>/dev/null || true;
|
||||
echo "[backup] fertig - naechstes in $${BACKUP_INTERVAL_HOURS}h";
|
||||
sleep $$(( BACKUP_INTERVAL_HOURS * 3600 ));
|
||||
done
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
minio_data:
|
||||
driver: local
|
||||
backups_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
lageplan-net:
|
||||
|
||||
@@ -84,6 +84,49 @@ services:
|
||||
volumes:
|
||||
- ./prisma:/app/prisma
|
||||
|
||||
# Automatisches Backup: täglicher Postgres-Dump + Datei-Backup (MinIO), 30 Tage Aufbewahrung.
|
||||
# Backups liegen im Volume "backups_data" (für Off-Site: unten auf einen Host-Pfad umstellen).
|
||||
backup:
|
||||
image: postgres:16-alpine
|
||||
container_name: lageplan-backup
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGHOST: db
|
||||
PGUSER: ${POSTGRES_USER:-lageplan}
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD:-lageplan_secret}
|
||||
PGDATABASE: ${POSTGRES_DB:-lageplan}
|
||||
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-30}
|
||||
BACKUP_INTERVAL_HOURS: ${BACKUP_INTERVAL_HOURS:-24}
|
||||
volumes:
|
||||
- backups_data:/backups
|
||||
- minio_data:/minio:ro
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
echo "[backup] Start - alle $${BACKUP_INTERVAL_HOURS}h, Aufbewahrung $${BACKUP_RETENTION_DAYS} Tage";
|
||||
while true; do
|
||||
TS=$$(date +%Y-%m-%d_%H%M%S);
|
||||
mkdir -p /backups;
|
||||
echo "[backup] $$TS Datenbank sichern...";
|
||||
if pg_dump -h "$$PGHOST" -U "$$PGUSER" -d "$$PGDATABASE" -Fc -f "/backups/db_$$TS.dump"; then
|
||||
echo "[backup] DB ok: db_$$TS.dump";
|
||||
else
|
||||
echo "[backup] DB FEHLGESCHLAGEN"; rm -f "/backups/db_$$TS.dump";
|
||||
fi;
|
||||
if [ -d /minio ]; then
|
||||
tar czf "/backups/files_$$TS.tar.gz" -C /minio . 2>/dev/null && echo "[backup] Dateien ok: files_$$TS.tar.gz" || echo "[backup] Dateien uebersprungen";
|
||||
fi;
|
||||
find /backups -name "db_*.dump" -mtime +$$BACKUP_RETENTION_DAYS -delete 2>/dev/null || true;
|
||||
find /backups -name "files_*.tar.gz" -mtime +$$BACKUP_RETENTION_DAYS -delete 2>/dev/null || true;
|
||||
echo "[backup] fertig - naechstes in $${BACKUP_INTERVAL_HOURS}h";
|
||||
sleep $$(( BACKUP_INTERVAL_HOURS * 3600 ));
|
||||
done
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
backups_data:
|
||||
|
||||
115
docs/BACKUP.md
Normal file
115
docs/BACKUP.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Backup & Restore — Lageplan
|
||||
|
||||
Automatisches Backup als eigener Container (`backup`). Sichert **täglich**:
|
||||
- **Datenbank** (PostgreSQL) → `db_<zeitstempel>.dump` (pg_dump, custom/komprimiert)
|
||||
- **Hochgeladene Dateien** (MinIO: Pläne, Logos, Symbole) → `files_<zeitstempel>.tar.gz`
|
||||
|
||||
**Aufbewahrung:** 30 Tage (ältere werden automatisch gelöscht) — passt zur Datenschutzerklärung.
|
||||
Einstellbar über `BACKUP_RETENTION_DAYS` und `BACKUP_INTERVAL_HOURS` (siehe `.env.example`).
|
||||
|
||||
Die Backups liegen im Docker-Volume **`backups_data`** (Pfad im Container: `/backups`).
|
||||
|
||||
---
|
||||
|
||||
## Einrichtung
|
||||
|
||||
Der `backup`-Service ist in `docker-compose.yml` und `deploy/portainer-stack.yml` enthalten.
|
||||
Nach dem nächsten Stack-Update (Portainer: Stack neu deployen) läuft er automatisch.
|
||||
|
||||
Prüfen, dass er läuft und sichert:
|
||||
```
|
||||
docker logs lageplan-backup # oder: docker compose logs backup
|
||||
docker compose exec backup ls -lh /backups
|
||||
```
|
||||
Beim ersten Start wird sofort ein Backup erstellt, danach alle 24 h.
|
||||
|
||||
### Empfehlung: Backups aus dem Container heraus auf den NAS legen (Off-Site)
|
||||
Standardmässig liegen die Backups in einem Docker-Volume. Damit sie ausserhalb von Docker
|
||||
(z. B. auf einer NAS-Freigabe, die du separat sicherst) landen, im Stack den `backup`-Service auf
|
||||
einen **Host-Pfad** umstellen:
|
||||
```yaml
|
||||
volumes:
|
||||
- /volume1/backups/lageplan:/backups # statt backups_data:/backups
|
||||
- minio_data:/minio:ro
|
||||
```
|
||||
So kannst du die Dateien einfach kopieren/auf ein anderes Medium spiegeln. **Wichtig:** Ein Backup
|
||||
auf demselben Gerät wie die Daten schützt nicht vor Geräteausfall/Diebstahl — kopiere die Backups
|
||||
regelmässig an einen zweiten Ort.
|
||||
|
||||
---
|
||||
|
||||
## Wiederherstellen (Restore)
|
||||
|
||||
> **Destruktiv.** Vorher die Web-App stoppen (`docker stop lageplan-web`) und sicherstellen, dass
|
||||
> niemand arbeitet.
|
||||
|
||||
### 1) Datenbank
|
||||
```
|
||||
# Verfügbare Dumps anzeigen
|
||||
docker compose exec backup ls -lh /backups
|
||||
|
||||
# Wiederherstellen (Dateiname einsetzen)
|
||||
docker compose exec backup pg_restore --clean --if-exists --no-owner \
|
||||
-h db -U <POSTGRES_USER> -d <POSTGRES_DB> /backups/db_2026-07-23_030000.dump
|
||||
```
|
||||
|
||||
### 2) Dateien (Uploads/MinIO)
|
||||
Da `/minio` im backup-Container schreibgeschützt ist, den Tarball in einen Container mit
|
||||
Schreibzugriff auf das MinIO-Volume entpacken:
|
||||
```
|
||||
docker run --rm -v minio_data:/data -v backups_data:/backups alpine \
|
||||
sh -c "tar xzf /backups/files_2026-07-23_030000.tar.gz -C /data"
|
||||
```
|
||||
Danach Web-App wieder starten: `docker start lageplan-web`.
|
||||
|
||||
---
|
||||
|
||||
## Restore-Test (mindestens 1× durchführen und hier notieren)
|
||||
|
||||
Ein Backup ist erst dann ein Backup, wenn ein Restore nachweislich funktioniert.
|
||||
Empfehlung: **halbjährlich** auf einer Testumgebung durchspielen.
|
||||
|
||||
| Datum | Getestet von | Ergebnis | Bemerkung |
|
||||
|-------|--------------|----------|-----------|
|
||||
| | | | |
|
||||
|
||||
---
|
||||
|
||||
## Was ist NICHT im Backup?
|
||||
- Zahlungsdaten liegen bei Stripe (nicht in der App).
|
||||
- E-Mails liegen beim SMTP-Anbieter.
|
||||
- Secrets (`NEXTAUTH_SECRET`, MinIO-Keys) — separat sicher aufbewahren (Passwort-Manager),
|
||||
sie stehen nicht in den Backups.
|
||||
|
||||
---
|
||||
|
||||
# GUI-Backup (verschlüsselt, extern: SFTP / Nextcloud)
|
||||
|
||||
Zusätzlich zum lokalen Container-Backup gibt es ein **über die Oberfläche konfigurierbares**,
|
||||
verschlüsseltes Off-Site-Backup. Als **SERVER_ADMIN**: **Administration → Backup**.
|
||||
|
||||
- **Ziele:** SFTP oder Nextcloud (WebDAV).
|
||||
- **Verschlüsselung:** AES-256-GCM mit einer **Passphrase** (im Passwort-Manager aufbewahren —
|
||||
ohne sie ist kein Restore möglich). Zugangsdaten werden verschlüsselt in der DB gespeichert.
|
||||
- **Zeitplan:** aus / täglich / wöchentlich + Aufbewahrung in Tagen (prunt alte Backups am Ziel).
|
||||
- **Ablauf:** Speichern → **Verbindung testen** → **Jetzt sichern**. Der automatische Lauf braucht
|
||||
`CRON_SECRET` (siehe `.env.example`); der interne Scheduler prüft stündlich die Fälligkeit.
|
||||
|
||||
Hochgeladen wird eine Datei `lageplan_<zeitstempel>.tar.gz.enc` (enthält `database.dump` + `files/`).
|
||||
|
||||
## Restore eines GUI-Backups
|
||||
```
|
||||
# 1) Datei vom Ziel herunterladen, dann entschlüsseln (Passphrase bereithalten):
|
||||
node scripts/decrypt-backup.js lageplan_2026-07-23-03-00-00.tar.gz.enc backup.tar.gz "DEINE-PASSPHRASE"
|
||||
|
||||
# 2) Entpacken
|
||||
tar xzf backup.tar.gz # -> database.dump + files/
|
||||
|
||||
# 3) Datenbank wiederherstellen (destruktiv, App vorher stoppen)
|
||||
docker cp database.dump lageplan-db:/tmp/database.dump
|
||||
docker exec lageplan-db pg_restore --clean --if-exists --no-owner -U <POSTGRES_USER> -d <POSTGRES_DB> /tmp/database.dump
|
||||
|
||||
# 4) Dateien zurück in MinIO (Ordner files/ in den Bucket)
|
||||
docker run --rm -v minio_data:/data -v "$PWD/files":/src alpine sh -c "cp -r /src/* /data/<MINIO_BUCKET>/ 2>/dev/null || true"
|
||||
```
|
||||
Danach App wieder starten. **Restore einmal testen** (Tabelle oben ausfüllen).
|
||||
37
docs/legal/backup-and-restore.md
Normal file
37
docs/legal/backup-and-restore.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Backup & Restore — Übersicht
|
||||
|
||||
Stand: 2026-07-24. Die ausführliche Betriebsanleitung steht in [`docs/BACKUP.md`](../BACKUP.md).
|
||||
Dieses Dokument fasst den datenschutz-/compliance-relevanten Stand zusammen.
|
||||
|
||||
## Was wird gesichert
|
||||
- **Datenbank** (PostgreSQL): Konten, Organisationen, Pläne, Journale, Zustimmungen usw.
|
||||
- **Hochgeladene Dateien** (MinIO): Pläne, Logos, Symbole.
|
||||
- **Nicht** im Backup: Zahlungsdaten (bei Stripe), E-Mails (beim SMTP-Anbieter), Secrets.
|
||||
|
||||
## Zwei Mechanismen
|
||||
1. **Lokaler Container-Backup** (`backup`-Service): täglich, im Volume `backups_data`, 30 Tage.
|
||||
Schützt vor Bedienfehlern/Korruption, **nicht** vor Geräteausfall (gleiches Gerät).
|
||||
2. **GUI-Backup, verschlüsselt & extern** (`Administration → Backup`): SFTP oder Nextcloud/WebDAV,
|
||||
AES-256-GCM mit Passphrase, Zeitplan + Aufbewahrung. **Off-site** → deckt Geräteausfall ab.
|
||||
|
||||
| Aspekt | Zustand |
|
||||
|---|---|
|
||||
| Frequenz | täglich (konfigurierbar) |
|
||||
| Verschlüsselung (extern) | ✅ AES-256-GCM (Passphrase) |
|
||||
| Verschlüsselung (lokal) | ⚠️ nein (nur lokales Sicherheitsnetz) |
|
||||
| Aufbewahrung | 30 Tage (konfigurierbar) |
|
||||
| Zugriffsschutz | Ziel-Zugangsdaten verschlüsselt in DB; Passphrase separat aufbewahren |
|
||||
| Off-site | ✅ via GUI-Backup (sofern konfiguriert) |
|
||||
|
||||
## Restore-Testplan (verbindlich)
|
||||
Ein Backup gilt erst als funktionierend, wenn ein Restore **nachweislich** geklappt hat.
|
||||
|
||||
1. Aktuelles verschlüsseltes Backup vom Ziel laden.
|
||||
2. Mit `scripts/decrypt-backup.js` + Passphrase entschlüsseln → `tar.gz`.
|
||||
3. In einer **Testumgebung** DB + Dateien wiederherstellen (siehe `docs/BACKUP.md`).
|
||||
4. Stichprobe: Login, ein Projekt, ein Journal, ein hochgeladenes Symbol prüfen.
|
||||
5. Ergebnis in der Tabelle in `docs/BACKUP.md` protokollieren.
|
||||
6. **Häufigkeit:** mindestens halbjährlich.
|
||||
|
||||
> Es wird **nicht** behauptet, dass eine Wiederherstellung funktioniert, solange kein Restore-Test
|
||||
> durchgeführt und protokolliert wurde.
|
||||
44
docs/legal/current-state-audit.md
Normal file
44
docs/legal/current-state-audit.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Bestandsaufnahme & Audit — Lageplan
|
||||
|
||||
Stand: 2026-07-24 · Prüfung des tatsächlichen Repository-Stands vor gezielten Korrekturen.
|
||||
Legende Status: ✅ umgesetzt · 🟡 teilweise · ⚠️ widersprüchlich/prüfen · ❌ fehlend · 🔒 manuell (Fachperson).
|
||||
|
||||
Dieser Audit dokumentiert, was aus früheren Runden (v1.7.0–v1.9.0) bereits vorhanden ist, damit
|
||||
**keine Doppelstrukturen** entstehen. Ergänzungen dieser Runde sind in der letzten Spalte markiert.
|
||||
|
||||
| Bereich | Fundstelle | Aktueller Zustand | Risiko | Empfohlene/umgesetzte Änderung | Umgesetzt |
|
||||
|---|---|---|---|---|---|
|
||||
| Zentrale Legal-Config | `src/config/legal.ts` | Betreiber (Perparim Ziberi, Privatperson, Villmergen), Kontakt, Recht, Subprozessoren, Dok-Versionen | — | operatorType + fksAttribution ergänzt | ✅ (erweitert) |
|
||||
| Impressum | `src/app/impressum` | Privatperson, echte Adresse; verweist auf Datenschutz | niedrig | Haftungsverweis auf NB prüfen | ✅ vorhanden |
|
||||
| Datenschutz | `src/app/datenschutz` | Neuaufbau, Subprozessoren-Tabelle (#dienstleister), IP/UA-Nachweis, Auslandbekanntgabe | niedrig | Quellen-/Lizenz-Link ergänzt | ✅ vorhanden |
|
||||
| Nutzungsbedingungen | `src/app/nutzungsbedingungen` | Abschnitte 1–14 vorhanden | 🟡 | FKS-Abschnitt ergänzt | ✅ (FKS ergänzt) |
|
||||
| Verantwortungsvolle Nutzung | `src/app/verantwortungsvolle-nutzung` | vorhanden | niedrig | — | ✅ |
|
||||
| Sicherheit (öffentlich) | `src/app/sicherheit` | TOM-Kurzfassung, 2FA | niedrig | — | ✅ |
|
||||
| Unterstützen | `src/app/unterstuetzen` + `/spenden` | „Freiwilliger Unterstützungsbeitrag", Gebührenhinweis, keine Steuerabzug-Behauptung | niedrig | „Feature"-Tiers geprüft | ✅ |
|
||||
| Org-Datenschutzvereinbarung (AVV) | `src/app/organisationen/datenschutzvereinbarung` | Entwurf, als Vereinbarung formuliert | 🟡 | Entwurfscharakter klar | ✅ |
|
||||
| Konto & Datenschutz | `src/app/konto/datenschutz` | Zustimmungen, Export-/Löschanfrage, Kontakt | niedrig | — | ✅ |
|
||||
| Admin Rechtsdokumente | `src/app/admin/legal` | Versionen, Zustimmungszähler, Suche, CSV, IP/UA/Hash | niedrig | — | ✅ |
|
||||
| Consent-Datenmodell | `LegalDocument`, `LegalAcceptance` | versioniert, contentHash, IP/UA, unique, revisionssicher | niedrig | FKS_ATTRIBUTION-Typ ergänzt | ✅ (erweitert) |
|
||||
| Registrierung | `src/app/register` + API | 2 getrennte Zustimmungen (NB akzeptieren / Datenschutz Kenntnis), keine Vorauswahl, Version+Hash+IP/UA | niedrig | — | ✅ |
|
||||
| Re-Consent | `LegalConsentGate` | blockierend, Org-Bestätigung 7 Checks, Abmelden/Docs erreichbar | niedrig | — | ✅ |
|
||||
| In-App-Hinweise | Dashboard/Messpanel/Datenschutz | Dashboard-Banner, Berechnungs-Hinweis, Geodaten-Hinweis | 🟡 | Journal-Freitext-Warnung ergänzt | ✅ (ergänzt) |
|
||||
| Export-Disclaimer | `src/hooks/use-map-export.ts` | Prüf-Hinweis PDF+PNG | 🟡 | FKS-Quellenangabe ergänzt | ✅ (ergänzt) |
|
||||
| **FKS-/FEUKOS-Quellenangabe** | Footer, `licenses-and-attributions.md` | nur Footer + interne Doku | ⚠️ unvollständig | Quellen-Seite, Symbol-Panel, NB, Export, Dok-Typ | ✅ (diese Runde) |
|
||||
| Quellen-und-Lizenzen-Seite | — | ❌ fehlte | mittel | `/quellen-und-lizenzen` erstellt | ✅ (neu) |
|
||||
| **Planstatus/Freigabe** | `prisma/schema.prisma` | ❌ keine Status-/Freigabefelder | mittel | Entwurf/Geprüft/Freigegeben + Prüfdatum | 🟡 siehe Bericht |
|
||||
| Sicherheitsheader | `next.config.js` | CSP, HSTS, nosniff, Referrer/Permissions-Policy, frame-ancestors | niedrig | CSP unsafe-inline (MapLibre) dokumentiert | ✅ |
|
||||
| MFA/WebAuthn | `src/lib/mfa.ts`, `webauthn.ts` | TOTP + Passkeys/YubiKey + Backup-Codes + Admin-Reset | niedrig | — | ✅ |
|
||||
| Rate Limiting | `src/lib/rate-limit.ts` | Login/Register/Reset/Kontakt/Share/… | 🟡 (In-Memory) | — | ✅ |
|
||||
| Mandantentrennung | `getProjectWithTenantCheck` | serverseitig erzwungen | mittel | automatisierte Tests ergänzt | 🟡 Tests (diese Runde) |
|
||||
| Backup | Container + GUI (`/admin/backup`) | lokal + verschlüsselt SFTP/WebDAV, 30 Tage | niedrig | backup-and-restore.md ergänzt | ✅ |
|
||||
| Docs (Compliance) | `docs/legal/` | inventory, subprocessors, TOM, retention, incident, licenses, claims-audit | 🟡 | +international-transfers, +privacy-risk, +manual-checklist, +this audit | ✅ (ergänzt) |
|
||||
|
||||
## Verbotene/absolute Aussagen — Nachkontrolle
|
||||
Frühere Runde entfernte 24/7, 100 %, DSG/DSGVO-konform, „keine Weitergabe", „nur Schweizer Server",
|
||||
„100 % der Spende", „Kein Unternehmen". Diese Runde prüft zusätzlich FKS-Terminologie
|
||||
(„offiziell/zertifiziert/Partner/empfohlen") → keine solchen Behauptungen zulässig.
|
||||
|
||||
## Offen / manuell (siehe manual-action-checklist.md)
|
||||
Juristische Schlussprüfung, spätere Vereinsgründung (nur als Option), Versicherungen, endgültige
|
||||
Server-/Backupstandorte externer Dienste, Aufbewahrungsfristen-Entscheide, ergänzende FKS-Bestätigung
|
||||
für Rohdatei-/API-Weitergabe, periodischer externer Sicherheitstest.
|
||||
27
docs/legal/international-data-transfers.md
Normal file
27
docs/legal/international-data-transfers.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Auslandbearbeitungen — Lageplan
|
||||
|
||||
Stand: 2026-07-24 · sorgfältiger Entwurf. Unterscheidet **Unternehmenssitz** und **Serverregion**.
|
||||
Klassifizierung: 🇨🇭 Schweiz · 🇪🇺 EU/EWR · ✅ anerkanntes Datenschutzniveau · ⚠️ zusätzliche
|
||||
vertragliche/technische Massnahmen prüfen · ❓ nicht abschliessend geklärt.
|
||||
|
||||
| Dienst | Funktion | Datenkategorien | Unternehmenssitz | Serverregion | Klassifizierung | Bemerkung |
|
||||
|---|---|---|---|---|---|---|
|
||||
| PostgreSQL (Eigenbetrieb) | Primäre Datenhaltung | Konto/Org/Plan/Journal | Schweiz (Betreiber) | Schweiz | 🇨🇭 | selbst gehostet |
|
||||
| MinIO (Eigenbetrieb) | Datei-Uploads | Pläne/Logos/Symbole | Schweiz (Betreiber) | Schweiz | 🇨🇭 | selbst gehostet |
|
||||
| OpenStreetMap Foundation | Tiles/Nominatim | Kachel/Adresse/IP | UK | UK/EU | ✅/⚠️ | clientseitig, IP-Übermittlung |
|
||||
| swisstopo | Landeskarte | Kachel/IP | Schweiz | Schweiz | 🇨🇭 | clientseitig |
|
||||
| Esri (ArcGIS) | Satellitenbilder | Kachel/IP | USA | USA/global | ⚠️ | clientseitig; Bedingungen prüfen |
|
||||
| Open-Meteo | Wind/Höhe | Koordinaten/IP | Deutschland | EU | 🇪🇺 | clientseitig |
|
||||
| Open-Elevation | Höhe (Fallback) | Koordinaten/IP | ❓ | ❓ | ❓ | Community-Dienst; Region unklar |
|
||||
| Stripe (optional) | Zahlungen | Betrag/Name/Zahlungsdaten | USA (EU-Töchter) | USA/EU | ⚠️ | nur wenn konfiguriert; DPA prüfen |
|
||||
| SMTP-Anbieter (optional) | E-Mails | Empfänger/Inhalt | abhängig | abhängig | ⚠️/❓ | vom Betreiber gewählt |
|
||||
|
||||
## Grundsätze
|
||||
- Die öffentliche Datenschutzerklärung macht **keine** pauschale „nur Schweiz"-Garantie; Auslandbearbeitung
|
||||
(v.a. Stripe, E-Mail, globale Kartendienste) wird transparent benannt.
|
||||
- Client-seitige Karten-/Geodienste erhalten die IP-Adresse des Nutzers direkt.
|
||||
|
||||
## Offen / manuell (Betreiber)
|
||||
- DPA/Standardvertragsklauseln mit Stripe und dem gewählten E-Mail-Anbieter prüfen/abschliessen.
|
||||
- Serverregion von Esri/Open-Elevation konkretisieren oder Dienst ggf. ersetzen.
|
||||
- Entscheidung, ob der Open-Elevation-Fallback (unklare Region) deaktiviert werden soll.
|
||||
@@ -1,8 +1,23 @@
|
||||
# Lizenzen und Attributionen — Lageplan
|
||||
|
||||
Stand: 2026-07-23 · sorgfältiger Entwurf. Eine Quellenangabe ersetzt NICHT automatisch eine
|
||||
Stand: 2026-07-24 · sorgfältiger Entwurf. Eine Quellenangabe ersetzt NICHT automatisch eine
|
||||
erforderliche Nutzungslizenz. Offene Freigabefragen sind markiert.
|
||||
|
||||
## Feuerwehr Koordination Schweiz FKS — schriftliche Genehmigung
|
||||
- **Organisation:** Feuerwehr Koordination Schweiz FKS
|
||||
- **Inhalte:** FEUKOS-Markierungen, -Symbole und bewilligtes Bildmaterial
|
||||
- **Datum der Freigabe:** 17. Februar 2026
|
||||
- **Quellenangabe (exakt):** „Feuerwehr Koordination Schweiz FKS"
|
||||
- **Projektbezug:** Verwendung innerhalb des beschriebenen Webprojekts lageplan.ch
|
||||
- **Archivierung:** Das Original (E-Mail) ist **ausserhalb des öffentlichen Repositorys** abgelegt
|
||||
(siehe `private-legal-evidence/`, in `.gitignore`). Es wird **nicht** committet; keine Namen/
|
||||
E-Mail-Adressen einzelner FKS-Mitarbeitender werden veröffentlicht.
|
||||
- **Kein** Anspruch auf offizielle Partnerschaft, Zertifizierung, Prüfung, behördliche Anerkennung
|
||||
oder Empfehlung von lageplan.ch durch die FKS.
|
||||
- **Offene Frage (manuell):** Falls Rohdatei-Downloads, API-Nutzung oder Weitergabe der Symbol-
|
||||
bibliothek an Dritte **nicht** Teil der ursprünglichen Projektbeschreibung waren, ist eine
|
||||
ergänzende FKS-Bestätigung einzuholen (siehe `manual-action-checklist.md`).
|
||||
|
||||
## Karten & Geodaten
|
||||
| Quelle | Nutzung | Lizenz/Bedingungen | Attribution | Offene Frage |
|
||||
|---|---|---|---|---|
|
||||
@@ -15,7 +30,7 @@ erforderliche Nutzungslizenz. Offene Freigabefragen sind markiert.
|
||||
## Symbole / Icons
|
||||
| Quelle | Nutzung | Lizenz | Attribution | Offene Frage |
|
||||
|---|---|---|---|---|
|
||||
| FKS/BABS taktische Zeichen | Einsatzsymbole | `[NUTZUNGSRECHT ZU KLÄREN]` | "Bildquelle Feuerwehr Koordination Schweiz FKS" (vorhanden) | ⚠️ **Nutzungsrecht/Freigabe FKS bestätigen** |
|
||||
| FEUKOS-/FKS-Symbole | Einsatzsymbole | Schriftliche Genehmigung FKS (17.02.2026) | "Bildquelle: Feuerwehr Koordination Schweiz FKS" | Rohdatei-/API-Weitergabe ggf. nicht abgedeckt (siehe unten) |
|
||||
| lucide-react | UI-Icons | ISC | — (im Code) | ok |
|
||||
|
||||
## Schriftarten
|
||||
|
||||
39
docs/legal/manual-action-checklist.md
Normal file
39
docs/legal/manual-action-checklist.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Manuelle Restpunkte — was Code nicht lösen kann
|
||||
|
||||
Stand: 2026-07-24. Diese Punkte sind **nicht** durch die Software erledigt und dürfen **nicht** als
|
||||
erledigt dargestellt werden. Sie erfordern Entscheidungen, Verträge oder externe Prüfungen.
|
||||
|
||||
## Rechtlich
|
||||
- [ ] **Juristische Schlussprüfung** aller Rechtsseiten (Nutzungsbedingungen, Datenschutz, AVV, FKS-
|
||||
Abschnitt) durch eine qualifizierte Schweizer Fachperson.
|
||||
- [ ] Entscheidung über eine **spätere Vereinsgründung** (aktuell nur offene Option — Betreiber bleibt
|
||||
Privatperson Perparim Ziberi). Kein Verein behaupten, solange nicht gegründet.
|
||||
- [ ] Tatsächliche **Übertragung auf einen Verein** erst nach dessen Gründung (dann Betreiberangaben,
|
||||
Impressum, AVV, Config anpassen).
|
||||
- [ ] **Steuerliche Behandlung** freiwilliger Unterstützungsbeiträge klären.
|
||||
|
||||
## FKS / Bildmaterial
|
||||
- [ ] Original der FKS-Freigabe (17.02.2026) **ausserhalb** des Repos archivieren (`private-legal-evidence/`).
|
||||
- [ ] Prüfen, ob **Rohdatei-Download, API-Nutzung oder Weitergabe** der Symbolbibliothek an Dritte von der
|
||||
ursprünglichen Projektbeschreibung gedeckt ist; falls nicht → **ergänzende FKS-Bestätigung** einholen.
|
||||
|
||||
## Versicherung
|
||||
- [ ] IT-/Vermögensschaden-Haftpflicht prüfen (Offerten einholen).
|
||||
- [ ] Cyberversicherung prüfen.
|
||||
- [ ] Bei späterem Verein: Vereins-/Rechtsschutz prüfen.
|
||||
|
||||
## Verträge & Standorte
|
||||
- [ ] Formelle Vereinbarungen mit Gemeinden/Organisationen (falls gewünscht) — die elektronische
|
||||
Organisationsbestätigung ist **kein** beidseitig unterzeichneter AVV.
|
||||
- [ ] Endgültige **Server- und Backupstandorte** der externen Dienste bestätigen (Esri, Stripe, SMTP,
|
||||
Open-Elevation) — siehe `international-data-transfers.md`.
|
||||
- [ ] **DPA/Standardvertragsklauseln** mit Stripe und dem gewählten E-Mail-Anbieter abschliessen.
|
||||
|
||||
## Aufbewahrung & Betrieb
|
||||
- [ ] Konkrete **Aufbewahrungsfristen** je Datenkategorie festlegen (siehe `retention-and-deletion.md`).
|
||||
- [ ] **Restore-Test** durchführen und in `docs/BACKUP.md` protokollieren.
|
||||
- [ ] Periodischen **externen Sicherheitstest / Pentest** beauftragen.
|
||||
|
||||
## Datenschutz
|
||||
- [ ] Prüfen lassen, ob eine **vertiefte DSFA** erforderlich ist (siehe `privacy-risk-assessment.md`).
|
||||
- [ ] `datenschutz@lageplan.ch` als **aktiv überwachtes** Postfach bestätigen.
|
||||
24
docs/legal/privacy-risk-assessment.md
Normal file
24
docs/legal/privacy-risk-assessment.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Datenschutz-Risikovorprüfung — Lageplan
|
||||
|
||||
Stand: 2026-07-24 · sorgfältiger Entwurf. Vorprüfung, **keine** abschliessende Datenschutz-
|
||||
Folgenabschätzung (DSFA). Ob eine vertiefte DSFA nötig ist, muss eine Fachperson beurteilen —
|
||||
dies wird hier **nicht** pauschal verneint.
|
||||
|
||||
Skala: Wahrscheinlichkeit/Auswirkung = niedrig / mittel / hoch.
|
||||
|
||||
| # | Risiko | Wahrsch. | Auswirkung | Bestehende Massnahmen | Restrisiko | Weitere Massnahme | Rolle |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | Offenlegung von Gebäude-/Zufahrts-/Gefahren-/Hydranten-/Schlüsselstellen-Infos | mittel | hoch | Mandantentrennung, Rollen, Zugriffskontrolle, Verbot sensibler Daten (NB) | mittel | Org-Sensibilisierung, ggf. Feldklassifizierung | Betreiber/Org |
|
||||
| 2 | Organisationsübergreifender Zugriff (IDOR) | niedrig | hoch | `getProjectWithTenantCheck`, serverseitige Prüfung, autom. Tests | niedrig | periodische Tests/Pentest | Betreiber |
|
||||
| 3 | Datenleck über Dateizugriffe | niedrig | hoch | serve-Routen mit Tenant-Check, MinIO nicht öffentlich für private Objekte | niedrig | Pentest, Bucket-Policy-Review | Betreiber |
|
||||
| 4 | Kompromittierung Adminkonto | niedrig | hoch | MFA (TOTP/WebAuthn), Rate-Limiting, bcrypt | niedrig | MFA für alle Admins erzwingen (Option) | Betreiber |
|
||||
| 5 | Auslandbearbeitung (Stripe/E-Mail/Karten) | mittel | mittel | Transparente Doku, optionale Dienste, IP nur clientseitig | mittel | DPA/SCC prüfen | Betreiber |
|
||||
| 6 | Standortdaten (GPS) der Nutzer | niedrig | mittel | nur zur Kartenfunktion, keine dauerhafte Speicherung durch App | niedrig | Doku bestätigen | Betreiber |
|
||||
| 7 | Besonders schützenswerte Daten in Freitext/Journal | mittel | hoch | NB-Verbot, In-App-Warnungen (Journal, Dashboard), Org-Bestätigung | mittel | ggf. Muster-Erkennung/Hinweis | Betreiber/Org |
|
||||
| 8 | Verlust/Nichtverfügbarkeit von Daten | niedrig | mittel | Backups (lokal + verschlüsselt off-site) | niedrig | Restore-Test durchführen | Betreiber |
|
||||
| 9 | Datenleck durch Drittanbieter-Subprozessor | niedrig | mittel | minimale Datenweitergabe, Subprozessorenliste | mittel | Anbieterprüfung dokumentieren | Betreiber |
|
||||
| 10 | Fehlende/veraltete fachliche Prüfung eines Plans | mittel | hoch | Hinweise in App/Export; Planstatus (in Umsetzung) | mittel | Freigabe-Workflow finalisieren | Org |
|
||||
|
||||
## Empfehlung
|
||||
Eine **vertiefte DSFA** sollte durch eine qualifizierte Fachperson geprüft werden — insbesondere wegen
|
||||
sicherheitsrelevanter Objekt-/Infrastrukturdaten (Risiko 1, 3, 7). Diese Vorprüfung ersetzt sie nicht.
|
||||
@@ -36,8 +36,10 @@ Legende: ✅ vorhanden · ⚠️ teilweise / zu bestätigen · ❌ offen / nicht
|
||||
- ⚠️ Secrets-Verwaltung: ENV (`NEXTAUTH_SECRET`, MinIO) + DB (`SystemSetting`: SMTP/Stripe) → **Absicherung der DB-Secrets bestätigen**.
|
||||
|
||||
## Backup & Wiederherstellung
|
||||
- ⚠️ Backups: `[VORHANDEN? FREQUENZ? AUFBEWAHRUNG?]` (siehe `docs/backup-und-seeding.md`).
|
||||
- ❌ Restore-Tests: `[DOKUMENTIEREN]`.
|
||||
- ✅ Automatisches Backup (eigener `backup`-Container): täglich Postgres-Dump + MinIO-Dateien,
|
||||
30 Tage Aufbewahrung (`docker-compose.yml` / `deploy/portainer-stack.yml`, Anleitung `docs/BACKUP.md`).
|
||||
- ⚠️ Off-Site-Kopie: empfohlen (Backups zusätzlich an einen zweiten Ort spiegeln) — siehe `docs/BACKUP.md`.
|
||||
- ⚠️ Restore-Test: mindestens 1× durchführen und in `docs/BACKUP.md` protokollieren.
|
||||
|
||||
## Logging & Monitoring
|
||||
- ✅ Server-Logs (console). ⚠️ Vermeidung sensibler Daten in Logs → **Review empfohlen**.
|
||||
|
||||
@@ -6,6 +6,9 @@ const nextConfig = {
|
||||
APP_VERSION: packageJson.version,
|
||||
},
|
||||
output: 'standalone',
|
||||
// Node-Libs für das Backup nicht bundeln, sondern zur Laufzeit aus node_modules laden
|
||||
// (ssh2 hat optionale native Module; webdav/archiver sind reine Server-Abhängigkeiten).
|
||||
serverExternalPackages: ['ssh2', 'ssh2-sftp-client', 'archiver', 'webdav'],
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
1048
package-lock.json
generated
1048
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lageplan",
|
||||
"version": "1.8.6",
|
||||
"version": "1.9.1",
|
||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -45,6 +45,7 @@
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"archiver": "^8.0.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
@@ -68,10 +69,12 @@
|
||||
"react-moveable": "^0.56.0",
|
||||
"socket.io": "^4.7.4",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"ssh2-sftp-client": "^12.1.1",
|
||||
"stripe": "^20.3.1",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^9.0.1",
|
||||
"webdav": "^5.10.0",
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
@@ -79,11 +82,13 @@
|
||||
"seed": "node prisma/seed.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^8.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/ssh2-sftp-client": "^9.0.6",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"eslint": "^8.56.0",
|
||||
|
||||
@@ -217,6 +217,7 @@ async function migrate() {
|
||||
{ type: 'ORGANIZATION_DECLARATION', version: '1.0.0', title: 'Organisationsbestätigung', url: '/verantwortungsvolle-nutzung', publishedAt: '2026-07-23' },
|
||||
{ type: 'RESPONSIBLE_USE', version: '1.0.0', title: 'Verantwortungsvolle Nutzung', url: '/verantwortungsvolle-nutzung', publishedAt: '2026-07-23' },
|
||||
{ type: 'DATA_PROCESSING_AGREEMENT', version: '1.0.0', title: 'Datenschutzvereinbarung für Organisationen', url: '/organisationen/datenschutzvereinbarung', publishedAt: '2026-07-23' },
|
||||
{ type: 'FKS_ATTRIBUTION', version: '1.0.0', title: 'FKS-Quellenangabe & Nutzungshinweis', url: '/quellen-und-lizenzen', publishedAt: '2026-07-24' },
|
||||
]
|
||||
for (const doc of LEGAL_DOCS) {
|
||||
const contentHash = crypto.createHash('sha256').update(`${doc.type}@${doc.version}`).digest('hex')
|
||||
|
||||
51
scripts/decrypt-backup.js
Normal file
51
scripts/decrypt-backup.js
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Entschlüsselt ein Lageplan-Backup (.tar.gz.enc) → .tar.gz
|
||||
*
|
||||
* Verwendung:
|
||||
* node scripts/decrypt-backup.js <input.tar.gz.enc> <output.tar.gz> [passphrase]
|
||||
* (ohne Passphrase-Argument wird sie interaktiv abgefragt bzw. aus BACKUP_PASSPHRASE gelesen)
|
||||
*
|
||||
* Danach:
|
||||
* tar xzf output.tar.gz → enthält database.dump + files/<uploads>
|
||||
* pg_restore --clean --if-exists -d <DB> database.dump
|
||||
* files/ zurück in MinIO spielen (siehe docs/BACKUP.md)
|
||||
*
|
||||
* Format der .enc-Datei: [salt(16)][iv(12)][ciphertext][tag(16)], AES-256-GCM, Key = scrypt(passphrase, salt).
|
||||
*/
|
||||
const fs = require('fs')
|
||||
const crypto = require('crypto')
|
||||
|
||||
const [inPath, outPath] = process.argv.slice(2)
|
||||
let passphrase = process.argv[4] || process.env.BACKUP_PASSPHRASE
|
||||
|
||||
if (!inPath || !outPath) {
|
||||
console.error('Verwendung: node decrypt-backup.js <input.tar.gz.enc> <output.tar.gz> [passphrase]')
|
||||
process.exit(1)
|
||||
}
|
||||
if (!passphrase) {
|
||||
console.error('Passphrase fehlt. Als 3. Argument oder via BACKUP_PASSPHRASE übergeben.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const size = fs.statSync(inPath).size
|
||||
if (size < 44) { console.error('Datei zu klein / kein gültiges Backup.'); process.exit(1) }
|
||||
|
||||
const fd = fs.openSync(inPath, 'r')
|
||||
const head = Buffer.alloc(28)
|
||||
fs.readSync(fd, head, 0, 28, 0)
|
||||
const tag = Buffer.alloc(16)
|
||||
fs.readSync(fd, tag, 0, 16, size - 16)
|
||||
fs.closeSync(fd)
|
||||
|
||||
const salt = head.subarray(0, 16)
|
||||
const iv = head.subarray(16, 28)
|
||||
const key = crypto.scryptSync(passphrase, salt, 32)
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
|
||||
decipher.setAuthTag(tag)
|
||||
|
||||
const input = fs.createReadStream(inPath, { start: 28, end: size - 17 })
|
||||
const output = fs.createWriteStream(outPath)
|
||||
input.pipe(decipher).pipe(output)
|
||||
output.on('finish', () => console.log('OK →', outPath))
|
||||
decipher.on('error', (e) => { console.error('Entschlüsselung fehlgeschlagen (falsche Passphrase?):', e.message); process.exit(1) })
|
||||
44
scripts/restore.sh
Normal file
44
scripts/restore.sh
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/bin/sh
|
||||
# =============================================================
|
||||
# Lageplan – Restore-Helfer
|
||||
# =============================================================
|
||||
# Stellt einen Datenbank-Dump (und optional die Dateien) wieder her.
|
||||
# ACHTUNG: Der DB-Restore ist DESTRUKTIV – bestehende Daten werden ersetzt.
|
||||
# Vorher die Web-App stoppen und sicherstellen, dass niemand arbeitet.
|
||||
#
|
||||
# Verwendung (auf dem Server, im Verzeichnis mit der docker-compose.yml):
|
||||
#
|
||||
# 1) Verfügbare Backups anzeigen:
|
||||
# docker compose exec backup ls -lh /backups
|
||||
#
|
||||
# 2) Datenbank wiederherstellen (Dateiname aus Schritt 1):
|
||||
# docker compose exec backup sh /scripts/restore.sh db_2026-07-23_030000.dump
|
||||
# (funktioniert nur, wenn dieses Verzeichnis in den backup-Container gemountet ist;
|
||||
# sonst den Befehl aus dem Abschnitt "Ohne Skript" in docs/BACKUP.md nutzen.)
|
||||
#
|
||||
# 3) Dateien (Uploads) wiederherstellen: siehe docs/BACKUP.md
|
||||
# =============================================================
|
||||
set -eu
|
||||
|
||||
DUMP="${1:-}"
|
||||
BACKUP_DIR="${BACKUP_DIR:-/backups}"
|
||||
PGHOST="${PGHOST:-db}"
|
||||
PGUSER="${PGUSER:-lageplan}"
|
||||
PGDATABASE="${PGDATABASE:-lageplan}"
|
||||
|
||||
if [ -z "$DUMP" ]; then
|
||||
echo "Verwendung: restore.sh <db_dumpfile>"
|
||||
echo "Verfuegbare Dumps in $BACKUP_DIR:"
|
||||
ls -1 "$BACKUP_DIR"/db_*.dump 2>/dev/null || echo " (keine gefunden)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FILE="$BACKUP_DIR/$DUMP"
|
||||
[ -f "$FILE" ] || { echo "Nicht gefunden: $FILE"; exit 1; }
|
||||
|
||||
echo "!! DESTRUKTIV: Stelle '$DUMP' in Datenbank '$PGDATABASE' auf Host '$PGHOST' wieder her."
|
||||
echo " Bestehende Daten werden ersetzt. Abbruch mit Ctrl+C, weiter mit Enter."
|
||||
read -r _
|
||||
|
||||
pg_restore --clean --if-exists --no-owner -h "$PGHOST" -U "$PGUSER" -d "$PGDATABASE" "$FILE"
|
||||
echo "[restore] Datenbank wiederhergestellt aus $DUMP"
|
||||
@@ -71,4 +71,21 @@ app.prepare().then(() => {
|
||||
httpServer.listen(port, hostname, () => {
|
||||
console.log(`> Ready on http://${hostname}:${port}`)
|
||||
})
|
||||
|
||||
// Backup-Scheduler: stündlich prüfen, ob laut Zeitplan ein Backup fällig ist.
|
||||
// Die Route /api/cron/backup entscheidet anhand der Konfiguration; hier wird nur getriggert.
|
||||
if (process.env.CRON_SECRET) {
|
||||
const checkBackup = () => {
|
||||
fetch(`http://127.0.0.1:${port}/api/cron/backup`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-cron-secret': process.env.CRON_SECRET },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => { if (d && d.skipped !== true) console.log('[backup-scheduler]', JSON.stringify(d)) })
|
||||
.catch((e) => console.warn('[backup-scheduler]', e.message))
|
||||
}
|
||||
setInterval(checkBackup, 60 * 60 * 1000) // stündlich
|
||||
setTimeout(checkBackup, 90 * 1000) // ~1.5 Min nach Start einmal
|
||||
console.log('> Backup-Scheduler aktiv (stündliche Fälligkeitsprüfung)')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useAuth } from '@/components/providers/auth-provider'
|
||||
import {
|
||||
ArrowLeft, MapPin, Shield, Map, Image as ImageIcon, Layers, BookOpen,
|
||||
Users, Settings, Building2, AlertTriangle, ClipboardList, Heart, ShieldCheck, Loader2,
|
||||
HardDriveDownload,
|
||||
} from 'lucide-react'
|
||||
import { TenantsTab } from '@/components/admin/tenants-tab'
|
||||
import { UsersTab } from '@/components/admin/users-tab'
|
||||
@@ -21,6 +22,7 @@ import { SuggestionsTab } from '@/components/admin/suggestions-tab'
|
||||
import { DictionaryTab } from '@/components/admin/dictionary-tab'
|
||||
import { SymbolManager } from '@/components/admin/symbol-manager'
|
||||
import { OrgTab } from '@/components/admin/org-tab'
|
||||
import { BackupTab } from '@/components/admin/backup-tab'
|
||||
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
|
||||
|
||||
export default function AdminPage() {
|
||||
@@ -81,7 +83,7 @@ export default function AdminPage() {
|
||||
<div className="container mx-auto py-6 px-4 max-w-7xl">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
{isServerAdmin ? (
|
||||
<TabsList className="w-full max-w-4xl h-auto flex flex-nowrap overflow-x-auto justify-start md:grid md:grid-cols-7 [&>button]:shrink-0 [&>button]:whitespace-nowrap">
|
||||
<TabsList className="w-full max-w-5xl h-auto flex flex-nowrap overflow-x-auto justify-start md:grid md:grid-cols-8 [&>button]:shrink-0 [&>button]:whitespace-nowrap">
|
||||
<TabsTrigger value="tenants" className="gap-2"><Shield className="w-4 h-4" />Mandanten</TabsTrigger>
|
||||
<TabsTrigger value="projects" className="gap-2"><Map className="w-4 h-4" />Einsätze</TabsTrigger>
|
||||
<TabsTrigger value="icons" className="gap-2"><ImageIcon className="w-4 h-4" />Symbole</TabsTrigger>
|
||||
@@ -89,6 +91,7 @@ export default function AdminPage() {
|
||||
<TabsTrigger value="dictionary" className="gap-2"><BookOpen className="w-4 h-4" />Wörterbuch</TabsTrigger>
|
||||
<TabsTrigger value="users" className="gap-2"><Users className="w-4 h-4" />Benutzer</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="gap-2"><Settings className="w-4 h-4" />System</TabsTrigger>
|
||||
<TabsTrigger value="backup" className="gap-2"><HardDriveDownload className="w-4 h-4" />Backup</TabsTrigger>
|
||||
</TabsList>
|
||||
) : (
|
||||
<TabsList className="w-full max-w-4xl h-auto flex flex-nowrap overflow-x-auto justify-start md:grid md:grid-cols-7 [&>button]:shrink-0 [&>button]:whitespace-nowrap">
|
||||
@@ -121,6 +124,10 @@ export default function AdminPage() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{isServerAdmin && (
|
||||
<TabsContent value="backup"><BackupTab /></TabsContent>
|
||||
)}
|
||||
|
||||
{/* ===== Geteilt: Benutzer ===== */}
|
||||
<TabsContent value="users"><UsersTab /></TabsContent>
|
||||
|
||||
|
||||
55
src/app/api/admin/backup/config/route.ts
Normal file
55
src/app/api/admin/backup/config/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { encryptSecret } from '@/lib/crypto-secret'
|
||||
import { loadBackupConfig, saveBackupConfig, maskBackupConfig, DEFAULT_BACKUP_CONFIG, type BackupConfig } from '@/lib/backup/config'
|
||||
|
||||
// Backup ist plattformweit → nur SERVER_ADMIN.
|
||||
async function guard() {
|
||||
const user = await getSession()
|
||||
if (!user || user.role !== 'SERVER_ADMIN') return null
|
||||
return user
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
if (!(await guard())) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
const cfg = (await loadBackupConfig()) || DEFAULT_BACKUP_CONFIG
|
||||
return NextResponse.json(maskBackupConfig(cfg))
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
if (!(await guard())) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const existing = (await loadBackupConfig()) || DEFAULT_BACKUP_CONFIG
|
||||
|
||||
const next: BackupConfig = { ...existing }
|
||||
next.enabled = !!body.enabled
|
||||
next.schedule = ['off', 'daily', 'weekly'].includes(body.schedule) ? body.schedule : 'off'
|
||||
next.retentionDays = Math.max(1, Math.min(3650, Number(body.retentionDays) || 30))
|
||||
next.destinationType = body.destinationType === 'webdav' ? 'webdav' : 'sftp'
|
||||
|
||||
// Passphrase nur ersetzen, wenn neu eingegeben
|
||||
if (typeof body.passphrase === 'string' && body.passphrase.length > 0) {
|
||||
next.encryptPassphrase = encryptSecret(body.passphrase)
|
||||
}
|
||||
|
||||
if (body.sftp) {
|
||||
next.sftp = {
|
||||
host: String(body.sftp.host || ''),
|
||||
port: Number(body.sftp.port) || 22,
|
||||
username: String(body.sftp.username || ''),
|
||||
remotePath: String(body.sftp.remotePath || '/lageplan-backups'),
|
||||
password: body.sftp.password ? encryptSecret(String(body.sftp.password)) : (existing.sftp?.password || ''),
|
||||
}
|
||||
}
|
||||
if (body.webdav) {
|
||||
next.webdav = {
|
||||
url: String(body.webdav.url || ''),
|
||||
username: String(body.webdav.username || ''),
|
||||
remotePath: String(body.webdav.remotePath || '/Lageplan-Backups'),
|
||||
password: body.webdav.password ? encryptSecret(String(body.webdav.password)) : (existing.webdav?.password || ''),
|
||||
}
|
||||
}
|
||||
|
||||
await saveBackupConfig(next)
|
||||
return NextResponse.json(maskBackupConfig(next))
|
||||
}
|
||||
17
src/app/api/admin/backup/run/route.ts
Normal file
17
src/app/api/admin/backup/run/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { runBackup } from '@/lib/backup/engine'
|
||||
|
||||
// POST: löst sofort ein Backup aus (manuell „Jetzt sichern"). Nur SERVER_ADMIN.
|
||||
export async function POST() {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user || user.role !== 'SERVER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
}
|
||||
const res = await runBackup()
|
||||
return NextResponse.json(res)
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ ok: false, message: e?.message || 'Backup fehlgeschlagen' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
23
src/app/api/admin/backup/test/route.ts
Normal file
23
src/app/api/admin/backup/test/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { loadBackupConfig } from '@/lib/backup/config'
|
||||
import { plainDestFromConfig } from '@/lib/backup/secrets'
|
||||
import { testDestination } from '@/lib/backup/destinations'
|
||||
|
||||
// POST: testet die gespeicherte Zielverbindung (Verbindung + Schreibzugriff). Nur SERVER_ADMIN.
|
||||
export async function POST() {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user || user.role !== 'SERVER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
}
|
||||
const cfg = await loadBackupConfig()
|
||||
if (!cfg) return NextResponse.json({ ok: false, message: 'Bitte zuerst konfigurieren und speichern.' }, { status: 400 })
|
||||
|
||||
const dest = plainDestFromConfig(cfg)
|
||||
const res = await testDestination(cfg.destinationType, dest)
|
||||
return NextResponse.json(res, { status: res.ok ? 200 : 400 })
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ ok: false, message: e?.message || 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
24
src/app/api/cron/backup/route.ts
Normal file
24
src/app/api/cron/backup/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { loadBackupConfig } from '@/lib/backup/config'
|
||||
import { isBackupDue, runBackup } from '@/lib/backup/engine'
|
||||
|
||||
// POST (öffentlich, aber per CRON_SECRET geschützt): vom internen Scheduler aufgerufen.
|
||||
// Führt ein Backup NUR aus, wenn es laut Zeitplan fällig ist.
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const secret = process.env.CRON_SECRET
|
||||
if (!secret) return NextResponse.json({ error: 'CRON_SECRET nicht gesetzt' }, { status: 503 })
|
||||
if (req.headers.get('x-cron-secret') !== secret) {
|
||||
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
}
|
||||
|
||||
const cfg = await loadBackupConfig()
|
||||
if (!cfg) return NextResponse.json({ skipped: true, reason: 'nicht konfiguriert' })
|
||||
if (!isBackupDue(cfg)) return NextResponse.json({ skipped: true, reason: 'nicht fällig' })
|
||||
|
||||
const res = await runBackup()
|
||||
return NextResponse.json(res)
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ ok: false, message: e?.message || 'Fehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { getSession } from '@/lib/auth'
|
||||
import { getClientIp } from '@/lib/rate-limit'
|
||||
import { recordAcceptances, getPendingAcceptances, type LegalAcceptanceContext, type LegalDocumentType } from '@/lib/legal'
|
||||
|
||||
const VALID_TYPES: LegalDocumentType[] = ['TERMS', 'PRIVACY', 'ORGANIZATION_DECLARATION', 'RESPONSIBLE_USE', 'DATA_PROCESSING_AGREEMENT']
|
||||
const VALID_CONTEXTS: LegalAcceptanceContext[] = ['REGISTRATION', 'LOGIN_RECONSENT', 'ORGANIZATION_CREATION', 'ADMIN_ROLE_ACCEPTANCE', 'DONATION']
|
||||
const VALID_TYPES: LegalDocumentType[] = ['TERMS', 'PRIVACY', 'ORGANIZATION_DECLARATION', 'RESPONSIBLE_USE', 'DATA_PROCESSING_AGREEMENT', 'FKS_ATTRIBUTION']
|
||||
const VALID_CONTEXTS: LegalAcceptanceContext[] = ['REGISTRATION', 'LOGIN_RECONSENT', 'ORGANIZATION_CREATION', 'ADMIN_ROLE_ACCEPTANCE', 'EXISTING_USER_RECONSENT', 'SUPPORT_CONTRIBUTION', 'DONATION']
|
||||
|
||||
// POST: erfasst Zustimmungen des angemeldeten Benutzers revisionssicher.
|
||||
// body: { types: LegalDocumentType[], context: LegalAcceptanceContext }
|
||||
|
||||
@@ -1031,6 +1031,8 @@ export default function AppPage() {
|
||||
}
|
||||
}}
|
||||
windLive={windLive}
|
||||
onWindChange={canEditMap ? (d) => handleWindChange(d) : undefined}
|
||||
onFetchLiveWind={canEditMap ? handleFetchLiveWind : undefined}
|
||||
isEditingByMe={isEditingByMe}
|
||||
onStopEditing={handleStopEditing}
|
||||
editingLoading={editingLoading}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import { ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react'
|
||||
import { LogoRound } from '@/components/ui/logo'
|
||||
|
||||
export default function LoginPage() {
|
||||
@@ -23,6 +23,7 @@ function LoginForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [rememberMe, setRememberMe] = useState(true)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [resendLoading, setResendLoading] = useState(false)
|
||||
const [resendSuccess, setResendSuccess] = useState(false)
|
||||
@@ -234,15 +235,27 @@ function LoginForm() {
|
||||
Passwort vergessen?
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
className={`pr-11 transition-all duration-200 ${showPassword ? '' : 'text-xl tracking-[0.25em]'}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(v => !v)}
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-md text-gray-400 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
|
||||
@@ -137,6 +137,27 @@ export default function NutzungsbedingungenPage() {
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>10a. FEUKOS-/FKS-Markierungen und -Symbole</h2>
|
||||
<p>
|
||||
Bestimmte in {legal.projectName} verwendete Markierungen und Symbole stammen von der Feuerwehr
|
||||
Koordination Schweiz FKS. Die Verwendung innerhalb von {legal.projectName} erfolgt mit schriftlicher
|
||||
Genehmigung. Bildquelle: Feuerwehr Koordination Schweiz FKS.
|
||||
</p>
|
||||
<p>
|
||||
Die Genehmigung bezieht sich auf die Verwendung innerhalb des beschriebenen Webprojekts. Sie
|
||||
begründet keine offizielle Partnerschaft, Prüfung, Zertifizierung oder Empfehlung von
|
||||
{' '}{legal.projectName} durch die FKS.
|
||||
</p>
|
||||
<p>
|
||||
Nutzer erhalten keine weitergehenden Rechte, die Symbole ausserhalb der von {legal.projectName}
|
||||
{' '}vorgesehenen Funktionen separat zu vervielfältigen, weiterzugeben, zu verkaufen oder in anderen
|
||||
Anwendungen zu verwenden. Zulässig bleibt die Verwendung in ordnungsgemäss mit {legal.projectName}
|
||||
{' '}erstellten Lageplänen und Exporten, soweit dies vom vorgesehenen Funktionsumfang und der
|
||||
erteilten Genehmigung gedeckt ist. Siehe auch <a href="/quellen-und-lizenzen">Quellen und Lizenzen</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>11. Haftung</h2>
|
||||
<p>Im gesetzlich zulässigen Umfang gilt:</p>
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function LandingPage() {
|
||||
name: 'Welche Symbole sind verfügbar?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Alle 117 offiziellen FKS/BABS-Signaturen sind integriert. Zusätzlich können eigene Symbole hochgeladen werden.',
|
||||
text: 'Alle 117 taktischen FEUKOS-Symbole der Feuerwehr Koordination Schweiz FKS sind integriert (Bildquelle: FKS). Zusätzlich können eigene Symbole hochgeladen werden.',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -352,7 +352,7 @@ export default function LandingPage() {
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{[
|
||||
{ icon: Map, title: 'Interaktive Karte', desc: 'Lagepläne auf OSM-Karten zeichnen mit Linien, Polygonen, Pfeilen und Freihandzeichnung.' },
|
||||
{ icon: Shield, title: 'FKS-Signaturen', desc: 'Alle offiziellen FKS/BABS-Signaturen per Drag & Drop platzieren. Eigene Symbole hochladen.' },
|
||||
{ icon: Shield, title: 'FEUKOS-Symbole', desc: 'Taktische FEUKOS-Symbole der FKS (Bildquelle: FKS) per Drag & Drop platzieren. Eigene Symbole hochladen.' },
|
||||
{ icon: Ruler, title: 'Messwerkzeug', desc: 'Distanzen messen mit Druckverlustberechnung und Pumpenanzahl für Schlauchleitungen.' },
|
||||
{ icon: FileText, title: 'Einsatzjournal', desc: 'Journal mit Zeitstempel, SOMA-Checkliste und Pendenzenliste — alles an einem Ort.' },
|
||||
{ icon: Smartphone, title: 'Installierbare App (PWA)', desc: 'Auf dem Homescreen installierbar — funktioniert offline wie eine native App. Desktop, Tablet & Smartphone.' },
|
||||
@@ -389,7 +389,7 @@ export default function LandingPage() {
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ status: 'done', title: 'Interaktive Karte mit Zeichenwerkzeugen', desc: 'Linien, Polygone, Pfeile, Freihand, Punkte' },
|
||||
{ status: 'done', title: 'FKS-Signaturen (117 offizielle Symbole)', desc: 'Alle taktischen Zeichen nach Schweizer Standard (Bildquelle: Feuerwehr Koordination Schweiz FKS)' },
|
||||
{ status: 'done', title: 'FEUKOS-Symbole (117 taktische Zeichen)', desc: 'Taktische Zeichen nach der Systematik der Feuerwehr Koordination Schweiz FKS (Bildquelle: Feuerwehr Koordination Schweiz FKS)' },
|
||||
{ status: 'done', title: 'Einsatzjournal mit SOMA-Checkliste', desc: 'Zeitstempel, Pendenzen, Protokollierung' },
|
||||
{ status: 'done', title: 'Messwerkzeug mit Druckverlustberechnung', desc: 'Distanzen, Höhenmeter, Pumpenanzahl' },
|
||||
{ status: 'done', title: 'Echtzeit-Zusammenarbeit (WebSocket)', desc: 'Mehrere Benutzer gleichzeitig am Lageplan' },
|
||||
@@ -459,7 +459,7 @@ export default function LandingPage() {
|
||||
},
|
||||
{
|
||||
q: 'Welche Symbole sind verfügbar?',
|
||||
a: 'Alle 117 offiziellen FKS/BABS-Signaturen sind integriert. Zusätzlich können eigene Symbole hochgeladen werden.',
|
||||
a: 'Alle 117 taktischen FEUKOS-Symbole der Feuerwehr Koordination Schweiz FKS sind integriert (Bildquelle: FKS). Zusätzlich können eigene Symbole hochgeladen werden.',
|
||||
},
|
||||
{
|
||||
q: 'Kann ich Lagepläne exportieren?',
|
||||
@@ -533,13 +533,14 @@ export default function LandingPage() {
|
||||
<Link href="/nutzungsbedingungen" className="block hover:text-gray-700 mt-1">Nutzungsbedingungen</Link>
|
||||
<Link href="/verantwortungsvolle-nutzung" className="block hover:text-gray-700 mt-1">Verantwortungsvolle Nutzung</Link>
|
||||
<Link href="/sicherheit" className="block hover:text-gray-700 mt-1">Sicherheit</Link>
|
||||
<Link href="/quellen-und-lizenzen" className="block hover:text-gray-700 mt-1">Quellen & Lizenzen</Link>
|
||||
<Link href="/organisationen/datenschutzvereinbarung" className="block hover:text-gray-700 mt-1">Datenschutzvereinbarung (Org.)</Link>
|
||||
<Link href="/unterstuetzen" className="block hover:text-gray-700 mt-1">Unterstützen</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-100 mt-8 pt-6 text-center space-y-1">
|
||||
<p className="text-sm text-gray-500">© {new Date().getFullYear()} Lageplan — Perparim Ziberi. Alle Rechte vorbehalten.</p>
|
||||
<p className="text-xs text-gray-500">Taktische Symbole: Bildquelle Feuerwehr Koordination Schweiz FKS</p>
|
||||
<p className="text-xs text-gray-500">Bildquelle: Feuerwehr Koordination Schweiz FKS · <Link href="/quellen-und-lizenzen" className="underline hover:text-gray-700">Quellen & Lizenzen</Link></p>
|
||||
<p className="text-xs text-gray-400 mt-1">v{process.env.APP_VERSION}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
63
src/app/quellen-und-lizenzen/page.tsx
Normal file
63
src/app/quellen-und-lizenzen/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { LegalPage } from '@/components/legal/legal-page'
|
||||
import { legal } from '@/config/legal'
|
||||
|
||||
export default function QuellenUndLizenzenPage() {
|
||||
const d = legal.documents.fksAttribution
|
||||
const fks = legal.fksAttribution
|
||||
return (
|
||||
<LegalPage
|
||||
title="Quellen und Lizenzen"
|
||||
version={d?.version}
|
||||
publishedAt={d?.publishedAt}
|
||||
intro={`Herkunft und Nutzungsgrundlagen der in ${legal.projectName} verwendeten Symbole, Karten- und Geodaten sowie weiterer Inhalte.`}
|
||||
>
|
||||
<section>
|
||||
<h2>Feuerwehr Koordination Schweiz FKS</h2>
|
||||
<ul>
|
||||
<li><strong>Inhalte:</strong> FEUKOS-Markierungen und -Symbole sowie bewilligtes Bildmaterial.</li>
|
||||
<li><strong>Grundlage:</strong> schriftliche Genehmigung für die Verwendung innerhalb des Webprojekts {legal.projectName}.</li>
|
||||
<li><strong>Quellenangabe:</strong> {fks.label}</li>
|
||||
</ul>
|
||||
<p>{fks.note}</p>
|
||||
<p className="text-xs">
|
||||
Hinweis: Es besteht keine offizielle Partnerschaft, Zertifizierung, Prüfung, behördliche
|
||||
Anerkennung oder Empfehlung von {legal.projectName} durch die FKS. Nutzer erhalten keine
|
||||
weitergehenden Rechte, die Symbole ausserhalb der von {legal.projectName} vorgesehenen
|
||||
Funktionen separat zu vervielfältigen, weiterzugeben, zu verkaufen oder in anderen Anwendungen
|
||||
zu verwenden.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Karten- und Geodaten</h2>
|
||||
<ul>
|
||||
<li><strong>OpenStreetMap:</strong> Basiskarte und Adresssuche (Nominatim) — © OpenStreetMap-Mitwirkende, Daten unter ODbL.</li>
|
||||
<li><strong>Esri World Imagery:</strong> Satellitenbilder — Nutzung gemäss Esri-Bedingungen.</li>
|
||||
<li><strong>swisstopo (geo.admin.ch):</strong> Schweizer Landeskarte — © swisstopo, Nutzung gemäss Bedingungen von swisstopo.</li>
|
||||
<li><strong>Open-Meteo / Open-Elevation:</strong> Wind- und Höhendaten.</li>
|
||||
</ul>
|
||||
<p className="text-xs">Karten-, Adress- und Geodaten können unvollständig, ungenau oder veraltet sein.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Schriftarten, Icons & Bibliotheken</h2>
|
||||
<ul>
|
||||
<li><strong>Barlow</strong> (SIL Open Font License 1.1), build-time selbst gehostet.</li>
|
||||
<li><strong>lucide-react</strong> (ISC) für Oberflächen-Icons.</li>
|
||||
<li>Wesentliche Open-Source-Bibliotheken: Next.js/React (MIT), MapLibre GL (BSD-3), jsPDF (MIT), u.a.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Eigene Inhalte</h2>
|
||||
<p>
|
||||
Von Benutzern hochgeladene Inhalte (Pläne, Logos, eigene Symbole) verbleiben bei den jeweiligen
|
||||
Benutzern bzw. Organisationen. Für die Rechtmässigkeit hochgeladener Inhalte sind die Benutzer
|
||||
verantwortlich (siehe <a href="/nutzungsbedingungen">Nutzungsbedingungen</a>).
|
||||
</p>
|
||||
</section>
|
||||
</LegalPage>
|
||||
)
|
||||
}
|
||||
@@ -268,7 +268,7 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
|
||||
<thead>
|
||||
<tr className="bg-gray-900 text-white">
|
||||
{(mt.columns || []).map((c: any) => (
|
||||
<th key={c.key} className={`p-1.5 font-semibold uppercase tracking-wider text-[7pt] ${c.type === 'check' ? 'text-center w-10' : c.type === 'time' ? 'text-left w-16' : 'text-left'}`}>{c.label}</th>
|
||||
<th key={c.key} className={`p-1.5 font-semibold uppercase tracking-wider text-[7pt] ${c.type === 'check' ? 'text-center w-10' : c.type === 'time' ? 'text-left w-16' : c.type === 'number' ? 'text-right w-20' : 'text-left'}`}>{c.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -276,7 +276,7 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
|
||||
{mt.rows.map((row: any, ri: number) => (
|
||||
<tr key={ri} className={ri % 2 === 1 ? 'bg-gray-50' : ''}>
|
||||
{(mt.columns || []).map((c: any) => (
|
||||
<td key={c.key} className={`p-1.5 border-b border-gray-100 ${c.type === 'check' ? 'text-center font-bold' : ''} ${c.type === 'time' ? 'font-mono text-[8pt] text-gray-500' : ''}`}>
|
||||
<td key={c.key} className={`p-1.5 border-b border-gray-100 ${c.type === 'check' ? 'text-center font-bold' : ''} ${c.type === 'time' ? 'font-mono text-[8pt] text-gray-500' : ''} ${c.type === 'number' ? 'text-right font-mono tabular-nums' : ''}`}>
|
||||
{c.type === 'check'
|
||||
? (row[c.key] ? '✓' : '—')
|
||||
: c.type === 'time'
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
{ url: `${baseUrl}/nutzungsbedingungen`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 },
|
||||
{ url: `${baseUrl}/verantwortungsvolle-nutzung`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 },
|
||||
{ url: `${baseUrl}/sicherheit`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 },
|
||||
{ url: `${baseUrl}/quellen-und-lizenzen`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 },
|
||||
{ url: `${baseUrl}/unterstuetzen`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 },
|
||||
{ url: `${baseUrl}/organisationen/datenschutzvereinbarung`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 },
|
||||
]
|
||||
|
||||
160
src/components/admin/backup-tab.tsx
Normal file
160
src/components/admin/backup-tab.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { HardDriveDownload, Loader2, ShieldCheck, PlugZap, Save } from 'lucide-react'
|
||||
|
||||
interface MaskedCfg {
|
||||
enabled: boolean
|
||||
schedule: 'off' | 'daily' | 'weekly'
|
||||
retentionDays: number
|
||||
hasPassphrase: boolean
|
||||
destinationType: 'sftp' | 'webdav'
|
||||
sftp?: { host: string; port: number; username: string; remotePath: string; hasPassword: boolean }
|
||||
webdav?: { url: string; username: string; remotePath: string; hasPassword: boolean }
|
||||
lastRun?: string | null
|
||||
lastStatus?: 'ok' | 'error' | null
|
||||
lastMessage?: string | null
|
||||
}
|
||||
|
||||
export function BackupTab() {
|
||||
const { toast } = useToast()
|
||||
const [cfg, setCfg] = useState<MaskedCfg | null>(null)
|
||||
const [busy, setBusy] = useState<'' | 'save' | 'test' | 'run'>('')
|
||||
// Neue Geheimnisse (nur senden, wenn ausgefüllt)
|
||||
const [passphrase, setPassphrase] = useState('')
|
||||
const [sftpPw, setSftpPw] = useState('')
|
||||
const [webdavPw, setWebdavPw] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const r = await fetch('/api/admin/backup/config')
|
||||
if (r.ok) setCfg(await r.json())
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (!cfg) return <div className="py-10 flex justify-center"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||
|
||||
const patch = (p: Partial<MaskedCfg>) => setCfg(c => c ? { ...c, ...p } : c)
|
||||
const patchSftp = (p: any) => setCfg(c => c ? { ...c, sftp: { ...(c.sftp || { host: '', port: 22, username: '', remotePath: '/lageplan-backups', hasPassword: false }), ...p } } : c)
|
||||
const patchWebdav = (p: any) => setCfg(c => c ? { ...c, webdav: { ...(c.webdav || { url: '', username: '', remotePath: '/Lageplan-Backups', hasPassword: false }), ...p } } : c)
|
||||
|
||||
const save = async () => {
|
||||
setBusy('save')
|
||||
try {
|
||||
const body: any = {
|
||||
enabled: cfg.enabled, schedule: cfg.schedule, retentionDays: cfg.retentionDays,
|
||||
destinationType: cfg.destinationType,
|
||||
}
|
||||
if (passphrase) body.passphrase = passphrase
|
||||
if (cfg.destinationType === 'sftp' && cfg.sftp) body.sftp = { ...cfg.sftp, password: sftpPw || undefined }
|
||||
if (cfg.destinationType === 'webdav' && cfg.webdav) body.webdav = { ...cfg.webdav, password: webdavPw || undefined }
|
||||
const r = await fetch('/api/admin/backup/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
if (r.ok) { setCfg(await r.json()); setPassphrase(''); setSftpPw(''); setWebdavPw(''); toast({ title: 'Gespeichert' }) }
|
||||
else { const d = await r.json(); toast({ title: 'Fehler', description: d.error, variant: 'destructive' }) }
|
||||
} finally { setBusy('') }
|
||||
}
|
||||
|
||||
const test = async () => {
|
||||
setBusy('test')
|
||||
try {
|
||||
const r = await fetch('/api/admin/backup/test', { method: 'POST' })
|
||||
const d = await r.json()
|
||||
toast({ title: d.ok ? 'Verbindung ok' : 'Verbindung fehlgeschlagen', description: d.message, variant: d.ok ? undefined : 'destructive' })
|
||||
} finally { setBusy('') }
|
||||
}
|
||||
|
||||
const runNow = async () => {
|
||||
if (!confirm('Jetzt ein Backup erstellen und zum Ziel hochladen?')) return
|
||||
setBusy('run')
|
||||
try {
|
||||
const r = await fetch('/api/admin/backup/run', { method: 'POST' })
|
||||
const d = await r.json()
|
||||
toast({ title: d.ok ? 'Backup erstellt' : 'Backup fehlgeschlagen', description: d.message, variant: d.ok ? undefined : 'destructive' })
|
||||
load()
|
||||
} finally { setBusy('') }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2"><HardDriveDownload className="w-5 h-5 text-primary" /> Backup (verschlüsselt, extern)</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Sichert Datenbank + hochgeladene Dateien verschlüsselt auf ein externes Ziel (SFTP oder Nextcloud/WebDAV).
|
||||
Best Practice: zusätzlich zum lokalen Backup, an einen anderen Ort.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
{cfg.lastRun && (
|
||||
<div className={`rounded-lg border p-3 text-sm ${cfg.lastStatus === 'ok' ? 'border-green-300 bg-green-50 dark:bg-green-950/30 dark:border-green-800' : 'border-red-300 bg-red-50 dark:bg-red-950/30 dark:border-red-800'}`}>
|
||||
<strong>Letztes Backup:</strong> {new Date(cfg.lastRun).toLocaleString('de-CH')} — {cfg.lastStatus === 'ok' ? '✅' : '❌'} {cfg.lastMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zeitplan */}
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<h4 className="font-semibold text-sm">Zeitplan</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">Automatisch</Label>
|
||||
<select value={cfg.schedule} onChange={e => patch({ schedule: e.target.value as any, enabled: e.target.value !== 'off' })} className="w-full h-9 rounded-md border border-input bg-background px-2 text-sm">
|
||||
<option value="off">Aus (nur manuell)</option>
|
||||
<option value="daily">Täglich</option>
|
||||
<option value="weekly">Wöchentlich</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Aufbewahrung (Tage)</Label>
|
||||
<Input type="number" min={1} max={3650} value={cfg.retentionDays} onChange={e => patch({ retentionDays: Number(e.target.value) })} className="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verschlüsselung */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h4 className="font-semibold text-sm flex items-center gap-1.5"><ShieldCheck className="w-4 h-4" /> Verschlüsselung</h4>
|
||||
<Label className="text-xs">Passphrase {cfg.hasPassphrase && <span className="text-green-600">· gesetzt</span>}</Label>
|
||||
<Input type="password" value={passphrase} onChange={e => setPassphrase(e.target.value)} placeholder={cfg.hasPassphrase ? 'Zum Beibehalten leer lassen' : 'Passphrase wählen (sicher aufbewahren!)'} className="h-9" />
|
||||
<p className="text-xs text-muted-foreground">⚠️ Ohne diese Passphrase ist kein Restore möglich — im Passwort-Manager speichern.</p>
|
||||
</div>
|
||||
|
||||
{/* Ziel */}
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="font-semibold text-sm">Ziel</h4>
|
||||
<select value={cfg.destinationType} onChange={e => patch({ destinationType: e.target.value as any })} className="h-8 rounded-md border border-input bg-background px-2 text-sm">
|
||||
<option value="sftp">SFTP</option>
|
||||
<option value="webdav">Nextcloud / WebDAV</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{cfg.destinationType === 'sftp' ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 sm:col-span-1"><Label className="text-xs">Host</Label><Input value={cfg.sftp?.host || ''} onChange={e => patchSftp({ host: e.target.value })} placeholder="nas.example.com" className="h-9" /></div>
|
||||
<div className="col-span-2 sm:col-span-1"><Label className="text-xs">Port</Label><Input type="number" value={cfg.sftp?.port ?? 22} onChange={e => patchSftp({ port: Number(e.target.value) })} className="h-9" /></div>
|
||||
<div className="col-span-2 sm:col-span-1"><Label className="text-xs">Benutzer</Label><Input value={cfg.sftp?.username || ''} onChange={e => patchSftp({ username: e.target.value })} className="h-9" /></div>
|
||||
<div className="col-span-2 sm:col-span-1"><Label className="text-xs">Passwort {cfg.sftp?.hasPassword && <span className="text-green-600">· gesetzt</span>}</Label><Input type="password" value={sftpPw} onChange={e => setSftpPw(e.target.value)} placeholder={cfg.sftp?.hasPassword ? 'leer lassen' : ''} className="h-9" /></div>
|
||||
<div className="col-span-2"><Label className="text-xs">Zielordner (Remote-Pfad)</Label><Input value={cfg.sftp?.remotePath || ''} onChange={e => patchSftp({ remotePath: e.target.value })} placeholder="/lageplan-backups" className="h-9" /></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2"><Label className="text-xs">WebDAV-URL</Label><Input value={cfg.webdav?.url || ''} onChange={e => patchWebdav({ url: e.target.value })} placeholder="https://cloud.example.com/remote.php/dav/files/USER/" className="h-9" /></div>
|
||||
<div className="col-span-2 sm:col-span-1"><Label className="text-xs">Benutzer</Label><Input value={cfg.webdav?.username || ''} onChange={e => patchWebdav({ username: e.target.value })} className="h-9" /></div>
|
||||
<div className="col-span-2 sm:col-span-1"><Label className="text-xs">App-Passwort {cfg.webdav?.hasPassword && <span className="text-green-600">· gesetzt</span>}</Label><Input type="password" value={webdavPw} onChange={e => setWebdavPw(e.target.value)} placeholder={cfg.webdav?.hasPassword ? 'leer lassen' : 'Nextcloud App-Passwort'} className="h-9" /></div>
|
||||
<div className="col-span-2"><Label className="text-xs">Zielordner</Label><Input value={cfg.webdav?.remotePath || ''} onChange={e => patchWebdav({ remotePath: e.target.value })} placeholder="/Lageplan-Backups" className="h-9" /></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={save} disabled={busy !== ''}>{busy === 'save' ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} Speichern</Button>
|
||||
<Button variant="outline" onClick={test} disabled={busy !== ''}>{busy === 'test' ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <PlugZap className="w-4 h-4 mr-1.5" />} Verbindung testen</Button>
|
||||
<Button variant="outline" onClick={runNow} disabled={busy !== ''}>{busy === 'run' ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <HardDriveDownload className="w-4 h-4 mr-1.5" />} Jetzt sichern</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Speichern → Verbindung testen → Jetzt sichern. Restore: siehe <code>docs/BACKUP.md</code> (Entschlüsseln mit der Passphrase).</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -789,6 +789,9 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1 px-0.5 print:hidden">
|
||||
Keine Patienten-, Gesundheits-, Polizei-, Passwort-, Zugangs- oder besonders schützenswerten Daten eintragen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -115,10 +115,17 @@ export function TableModule({ projectId, module, canEdit, notifyChanged }: Table
|
||||
}
|
||||
}, [projectId, module.id, notifyChanged])
|
||||
|
||||
// Spaltenbreite ableiten — respektiert optionale Breiten-Hinweise (w-16 … w-32)
|
||||
const WIDTH_MAP: Record<string, string> = { 'w-10': '40px', 'w-16': '68px', 'w-20': '92px', 'w-24': '108px', 'w-28': '124px', 'w-32': '140px' }
|
||||
const colSize = (c: ModuleColumn): string => {
|
||||
if (c.type === 'check') return '36px'
|
||||
if (c.type === 'time') return '58px'
|
||||
if (c.width && WIDTH_MAP[c.width]) return WIDTH_MAP[c.width]
|
||||
if (c.type === 'number') return '96px'
|
||||
return 'minmax(90px,1fr)'
|
||||
}
|
||||
// Grid-Template aus Spalten ableiten (+ Lösch-Spalte)
|
||||
const gridCols = columns.map(c =>
|
||||
c.type === 'check' ? '32px' : c.type === 'time' ? '52px' : c.type === 'number' ? 'minmax(0,0.6fr)' : 'minmax(0,1fr)'
|
||||
).join(' ') + (canEdit ? ' 24px' : '')
|
||||
const gridCols = columns.map(colSize).join(' ') + (canEdit ? ' 28px' : '')
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -194,21 +201,27 @@ export function TableModule({ projectId, module, canEdit, notifyChanged }: Table
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Neue Zeile */}
|
||||
{/* Neue Zeile — im selben Raster wie die Spalten, damit Eingaben unter den Titeln stehen */}
|
||||
{canEdit && (
|
||||
<div className="flex gap-1 px-1.5 py-1.5 border-t border-border/50 print:hidden">
|
||||
{textColumns.map((c, i) => (
|
||||
<div className="grid items-center gap-1 px-1.5 py-1.5 border-t border-border/50 print:hidden" style={{ gridTemplateColumns: gridCols }}>
|
||||
{columns.map(c => {
|
||||
if (c.type === 'text' || c.type === 'number') {
|
||||
return (
|
||||
<Input
|
||||
key={c.key}
|
||||
placeholder={c.label + '…'}
|
||||
placeholder={c.label}
|
||||
inputMode={c.type === 'number' ? 'numeric' : undefined}
|
||||
value={draft[c.key] || ''}
|
||||
onChange={(e) => setDraft(prev => ({ ...prev, [c.key]: e.target.value }))}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addRow()}
|
||||
className={`h-6 text-[11px] ${i === 0 && c.type !== 'number' ? 'flex-1 min-w-0' : 'w-20 shrink-0'}`}
|
||||
className="h-6 text-[11px] min-w-0 px-1.5"
|
||||
/>
|
||||
))}
|
||||
<Button size="sm" variant="ghost" onClick={addRow} disabled={!textColumns.some(c => (draft[c.key] || '').trim())} className="h-6 px-1.5 shrink-0">
|
||||
)
|
||||
}
|
||||
// Zeit/Haken: leere Zelle (wird automatisch/durch Klick gesetzt)
|
||||
return <div key={c.key} className="text-center text-[10px] text-muted-foreground/50">{c.type === 'time' ? 'auto' : ''}</div>
|
||||
})}
|
||||
<Button size="sm" variant="ghost" onClick={addRow} disabled={!textColumns.some(c => (draft[c.key] || '').trim())} className="h-6 w-6 p-0 shrink-0">
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -326,6 +326,10 @@ export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, on
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">{totalSymbols} Symbole verfügbar</p>
|
||||
<p className="text-[10px] text-muted-foreground/80 mt-0.5" title="Verwendung mit schriftlicher Genehmigung der FKS — keine offizielle Partnerschaft/Zertifizierung/Empfehlung.">
|
||||
Bildquelle: Feuerwehr Koordination Schweiz FKS ·{' '}
|
||||
<a href="/quellen-und-lizenzen" target="_blank" rel="noopener noreferrer" className="underline hover:text-foreground">Quellen & Lizenzen</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
Share2,
|
||||
Wind,
|
||||
Satellite,
|
||||
} from 'lucide-react'
|
||||
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
|
||||
import { ShareDialog } from '@/components/dialogs/share-dialog'
|
||||
@@ -56,6 +58,12 @@ import type { Project, DrawFeature, ProjectMode } from '@/types'
|
||||
import { formatDateTime } from '@/lib/utils'
|
||||
import { Logo } from '@/components/ui/logo'
|
||||
|
||||
// 8-Richtungs-Auswahl (Grad, meteorologisch „Wind aus")
|
||||
const WIND_OPTIONS: { label: string; deg: number }[] = [
|
||||
{ label: 'N', deg: 0 }, { label: 'NO', deg: 45 }, { label: 'O', deg: 90 }, { label: 'SO', deg: 135 },
|
||||
{ label: 'S', deg: 180 }, { label: 'SW', deg: 225 }, { label: 'W', deg: 270 }, { label: 'NW', deg: 315 },
|
||||
]
|
||||
|
||||
interface TopbarProps {
|
||||
project: Project | null
|
||||
onNewProject: (mode?: ProjectMode) => void
|
||||
@@ -82,6 +90,10 @@ interface TopbarProps {
|
||||
onTogglePresentationLock?: () => void
|
||||
/** Windrichtung wird live aktualisiert (für die Live-Anzeige oben) */
|
||||
windLive?: boolean
|
||||
/** Windrichtung setzen/entfernen (Grad, meteorologisch „Wind aus"; null = entfernen) */
|
||||
onWindChange?: (dir: number | null) => void
|
||||
/** Live-Wind (Open-Meteo) vom Standort holen */
|
||||
onFetchLiveWind?: () => Promise<void> | void
|
||||
/** true, wenn der aktuelle Nutzer den Bearbeitungs-Lock hält */
|
||||
isEditingByMe?: boolean
|
||||
/** Karte speichern & für andere freigeben (Lock lösen) */
|
||||
@@ -115,6 +127,8 @@ export function Topbar({
|
||||
presentationLocked,
|
||||
onTogglePresentationLock,
|
||||
windLive,
|
||||
onWindChange,
|
||||
onFetchLiveWind,
|
||||
isEditingByMe,
|
||||
onStopEditing,
|
||||
editingLoading,
|
||||
@@ -138,6 +152,8 @@ export function Topbar({
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const [windLoading, setWindLoading] = useState(false)
|
||||
|
||||
// Live clock
|
||||
const [now, setNow] = useState(new Date())
|
||||
useEffect(() => {
|
||||
@@ -214,20 +230,70 @@ export function Topbar({
|
||||
|
||||
{/* Zone 3 — Uhr + Aktionen */}
|
||||
<div className="flex items-center gap-1 md:gap-1.5 shrink-0">
|
||||
{/* Wind — taktische Live-Anzeige (Pfeil zeigt Ausbreitungsrichtung, N oben) */}
|
||||
{typeof project?.windDirection === 'number' && (
|
||||
<div
|
||||
className="hidden sm:flex items-center gap-1.5 rounded-md bg-blue-600 text-white pl-1.5 pr-2 py-1 shrink-0"
|
||||
title={`Wind aus ${degToCompass(project.windDirection)} (${project.windDirection}°) — treibt nach ${degToCompass(project.windDirection + 180)}${windLive ? ' · live' : ''}`}
|
||||
{/* Wind — klare, einzige Wind-Anzeige. Klick öffnet den Einsteller (Live vom Standort
|
||||
oder Richtung wählen). Der Pfeil zeigt die Ausbreitungsrichtung, N oben. */}
|
||||
{project && onWindChange && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{typeof project.windDirection === 'number' ? (
|
||||
<button
|
||||
className="hidden sm:flex items-center gap-1.5 rounded-md bg-blue-600 hover:bg-blue-700 text-white pl-1.5 pr-2 py-1 shrink-0"
|
||||
title={`Wind aus ${degToCompass(project.windDirection)} (${project.windDirection}°) — treibt nach ${degToCompass(project.windDirection + 180)}. Klicken zum Ändern.`}
|
||||
>
|
||||
<Wind className="w-3.5 h-3.5 shrink-0 opacity-90" />
|
||||
<svg viewBox="0 0 24 24" className="w-4 h-4 shrink-0" style={{ transform: `rotate(${project.windDirection + 180}deg)` }} aria-hidden>
|
||||
<path d="M12 3 L18 16 L12 12.5 L6 16 Z" fill="currentColor" />
|
||||
</svg>
|
||||
<span className="text-xs font-bold leading-none whitespace-nowrap">
|
||||
<span className="hidden lg:inline opacity-80 font-medium">Wind </span>aus {degToCompass(project.windDirection)}
|
||||
Wind aus {degToCompass(project.windDirection)}
|
||||
</span>
|
||||
{windLive && <span className="w-1.5 h-1.5 rounded-full bg-green-300 animate-pulse shrink-0" title="Live" />}
|
||||
</button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="hidden sm:flex h-9 md:h-10 px-2 shrink-0" title="Windrichtung setzen">
|
||||
<Wind className="w-4 h-4 lg:mr-1.5" />
|
||||
<span className="hidden lg:inline">Wind</span>
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52 p-2">
|
||||
<div className="flex items-center gap-1.5 px-1 pb-1.5 text-xs font-semibold text-muted-foreground">
|
||||
<Wind className="w-3.5 h-3.5" /> Windrichtung (Wind aus)
|
||||
</div>
|
||||
{onFetchLiveWind && (
|
||||
<button
|
||||
onClick={async () => { setWindLoading(true); try { await onFetchLiveWind() } finally { setWindLoading(false) } }}
|
||||
disabled={windLoading}
|
||||
className="w-full mb-2 flex items-center justify-center gap-1.5 rounded-md bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold py-1.5 disabled:opacity-60"
|
||||
>
|
||||
{windLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Satellite className="w-3.5 h-3.5" />}
|
||||
Live vom Standort
|
||||
</button>
|
||||
)}
|
||||
<div className="text-[10px] text-muted-foreground mb-1 px-0.5">oder Richtung wählen:</div>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{WIND_OPTIONS.map((w) => (
|
||||
<button
|
||||
key={w.deg}
|
||||
onClick={() => onWindChange(w.deg)}
|
||||
className={`h-8 rounded-md text-xs font-semibold transition-colors ${
|
||||
project.windDirection === w.deg ? 'bg-blue-600 text-white' : 'bg-muted hover:bg-accent text-foreground'
|
||||
}`}
|
||||
>
|
||||
{w.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{typeof project.windDirection === 'number' && (
|
||||
<button
|
||||
onClick={() => onWindChange(null)}
|
||||
className="mt-2 w-full text-xs text-muted-foreground hover:text-destructive py-1"
|
||||
>
|
||||
Wind entfernen
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Uhr — Ops-Rooms leben nach der Uhr */}
|
||||
@@ -248,6 +314,28 @@ export function Topbar({
|
||||
{isSaving ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
|
||||
</Button>
|
||||
|
||||
{/* Schnellzugriff: Vollbild + Präsentationsmodus (Desktop; mobil im ⋮-Menü) */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="hidden md:flex h-10 w-10 shrink-0"
|
||||
onClick={onToggleFullscreen}
|
||||
title={isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}
|
||||
>
|
||||
{isFullscreen ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
|
||||
</Button>
|
||||
{onTogglePresentationLock && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={`hidden md:flex h-10 w-10 shrink-0 ${presentationLocked ? 'border-amber-500 text-amber-600 dark:text-amber-400' : ''}`}
|
||||
onClick={onTogglePresentationLock}
|
||||
title={presentationLocked ? 'Präsentation: gesperrt (zum Entsperren tippen)' : 'Präsentationsmodus'}
|
||||
>
|
||||
{presentationLocked ? <Lock className="w-5 h-5" /> : <Unlock className="w-5 h-5" />}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Freigeben — nur sichtbar, solange ich den Bearbeitungs-Lock halte.
|
||||
Speichert und gibt die Karte für andere frei. Grün = ich bin am Zeichnen. */}
|
||||
{isEditingByMe && onStopEditing && (
|
||||
|
||||
@@ -11,6 +11,7 @@ export const LEGAL_LINKS: { href: string; label: string }[] = [
|
||||
{ href: '/nutzungsbedingungen', label: 'Nutzungsbedingungen' },
|
||||
{ href: '/verantwortungsvolle-nutzung', label: 'Verantwortungsvolle Nutzung' },
|
||||
{ href: '/sicherheit', label: 'Sicherheit' },
|
||||
{ href: '/quellen-und-lizenzen', label: 'Quellen & Lizenzen' },
|
||||
{ href: '/organisationen/datenschutzvereinbarung', label: 'Datenschutzvereinbarung (Org.)' },
|
||||
{ href: '/unterstuetzen', label: 'Unterstützen' },
|
||||
]
|
||||
|
||||
@@ -1,124 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Wind, X, Loader2, Satellite } from 'lucide-react'
|
||||
|
||||
interface MapCompassProps {
|
||||
/** Karten-Bearing in Grad (0 = Norden oben) */
|
||||
bearing: number
|
||||
/** Windrichtung meteorologisch „Wind aus" in Grad, oder null */
|
||||
windDirection: number | null | undefined
|
||||
canEdit: boolean
|
||||
onWindChange: (dir: number | null) => void
|
||||
/** Live-Wind (Open-Meteo) für den aktuellen Standort holen */
|
||||
// Die folgenden Props werden nicht mehr verwendet (Wind lebt jetzt oben in der Kopfzeile),
|
||||
// bleiben aber optional erhalten, damit bestehende Aufrufe nicht brechen.
|
||||
windDirection?: number | null
|
||||
canEdit?: boolean
|
||||
onWindChange?: (dir: number | null) => void
|
||||
onFetchLiveWind?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
const DIRS = ['N', 'NO', 'O', 'SO', 'S', 'SW', 'W', 'NW']
|
||||
|
||||
/** Grad → Kompass-Kürzel (N, NO, …). Wird auch von der Kopfzeile für die Wind-Anzeige genutzt. */
|
||||
export function degToCompass(deg: number): string {
|
||||
return DIRS[Math.round(((deg % 360) + 360) % 360 / 45) % 8]
|
||||
}
|
||||
|
||||
// 8-Richtungs-Auswahl (Grad, meteorologisch „aus")
|
||||
const WIND_OPTIONS: { label: string; deg: number }[] = [
|
||||
{ label: 'N', deg: 0 }, { label: 'NO', deg: 45 }, { label: 'O', deg: 90 }, { label: 'SO', deg: 135 },
|
||||
{ label: 'S', deg: 180 }, { label: 'SW', deg: 225 }, { label: 'W', deg: 270 }, { label: 'NW', deg: 315 },
|
||||
]
|
||||
|
||||
export function MapCompass({ bearing, windDirection, canEdit, onWindChange, onFetchLiveWind }: MapCompassProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loadingLive, setLoadingLive] = useState(false)
|
||||
const hasWind = typeof windDirection === 'number'
|
||||
|
||||
const handleLive = async () => {
|
||||
if (!onFetchLiveWind) return
|
||||
setLoadingLive(true)
|
||||
try { await onFetchLiveWind() } finally { setLoadingLive(false) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiner Nordpfeil/Kompass auf der Karte (dreht mit dem Karten-Bearing).
|
||||
* Die Windrichtung wird NICHT mehr hier angezeigt — sie lebt eindeutig in der Kopfzeile.
|
||||
*/
|
||||
export function MapCompass({ bearing }: MapCompassProps) {
|
||||
return (
|
||||
<div className="absolute top-3 left-[60px] md:left-3 z-10 flex flex-col items-center gap-1.5">
|
||||
{/* Kompass mit Nordpfeil (dreht mit dem Karten-Bearing) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canEdit && setOpen((o) => !o)}
|
||||
className="w-12 h-12 rounded-full bg-white/95 dark:bg-gray-900/95 shadow-md border border-black/10 dark:border-white/10 flex items-center justify-center relative"
|
||||
title={canEdit ? 'Wind einstellen' : 'Nordpfeil'}
|
||||
style={{ cursor: canEdit ? 'pointer' : 'default' }}
|
||||
<div className="absolute top-3 left-[60px] md:left-3 z-10">
|
||||
<div
|
||||
className="w-12 h-12 rounded-full bg-white/95 dark:bg-gray-900/95 shadow-md border border-black/10 dark:border-white/10 flex items-center justify-center"
|
||||
title="Nordpfeil"
|
||||
>
|
||||
<svg viewBox="0 0 40 40" className="w-10 h-10" style={{ transform: `rotate(${-bearing}deg)` }}>
|
||||
{/* Nordnadel (rot) */}
|
||||
<polygon points="20,5 24,21 20,18 16,21" fill="#dc2626" />
|
||||
{/* Südnadel (grau) */}
|
||||
<polygon points="20,35 24,19 20,22 16,19" fill="#9ca3af" />
|
||||
<text x="20" y="12" textAnchor="middle" fontSize="7" fontWeight="700" fill="#dc2626" transform="rotate(0 20 20)">N</text>
|
||||
<text x="20" y="12" textAnchor="middle" fontSize="7" fontWeight="700" fill="#dc2626">N</text>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Wind-Anzeige */}
|
||||
{hasWind && (
|
||||
<div className="flex items-center gap-1 rounded-full bg-blue-600 text-white text-[11px] font-semibold pl-1.5 pr-2 py-0.5 shadow">
|
||||
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5" style={{ transform: `rotate(${(windDirection as number) + 180 - bearing}deg)` }}>
|
||||
{/* Pfeil zeigt in die Drift-/Ausbreitungsrichtung (wohin es weht) */}
|
||||
<path d="M12 3 L18 15 L12 12 L6 15 Z" fill="currentColor" />
|
||||
</svg>
|
||||
<span>aus {degToCompass(windDirection as number)}</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasWind && canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-1 rounded-full bg-white/95 dark:bg-gray-900/95 text-[11px] font-medium text-muted-foreground pl-1.5 pr-2 py-0.5 shadow border border-black/10 dark:border-white/10"
|
||||
title="Windrichtung einstellen"
|
||||
>
|
||||
<Wind className="w-3.5 h-3.5" /> Wind
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Wind-Auswahl */}
|
||||
{open && canEdit && (
|
||||
<div className="absolute top-0 left-14 w-40 rounded-xl bg-white dark:bg-gray-900 shadow-xl border border-border p-2 z-20">
|
||||
<div className="flex items-center justify-between mb-1.5 px-1">
|
||||
<span className="text-xs font-semibold flex items-center gap-1"><Wind className="w-3.5 h-3.5" /> Wind aus</span>
|
||||
<button onClick={() => setOpen(false)} className="text-muted-foreground hover:text-foreground"><X className="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
{onFetchLiveWind && (
|
||||
<button
|
||||
onClick={handleLive}
|
||||
disabled={loadingLive}
|
||||
className="w-full mb-1.5 flex items-center justify-center gap-1.5 rounded-md bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold py-1.5 disabled:opacity-60"
|
||||
title="Aktuelle Windrichtung vom Standort (Open-Meteo)"
|
||||
>
|
||||
{loadingLive ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Satellite className="w-3.5 h-3.5" />}
|
||||
Live vom Standort
|
||||
</button>
|
||||
)}
|
||||
<div className="text-[10px] text-muted-foreground mb-1 px-0.5">oder Richtung wählen:</div>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{WIND_OPTIONS.map((w) => (
|
||||
<button
|
||||
key={w.deg}
|
||||
onClick={() => { onWindChange(w.deg); setOpen(false) }}
|
||||
className={`h-8 rounded-md text-xs font-semibold transition-colors ${
|
||||
windDirection === w.deg ? 'bg-blue-600 text-white' : 'bg-muted hover:bg-accent text-foreground'
|
||||
}`}
|
||||
>
|
||||
{w.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{hasWind && (
|
||||
<button
|
||||
onClick={() => { onWindChange(null); setOpen(false) }}
|
||||
className="mt-1.5 w-full text-xs text-muted-foreground hover:text-destructive py-1"
|
||||
>
|
||||
Wind entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1489,27 +1489,31 @@ export function MapView({
|
||||
const lineCoords = f.geometry.coordinates as number[][]
|
||||
if (lineCoords.length < 2) return
|
||||
|
||||
// Geographic bearing from p1 to p2 (works for short distances)
|
||||
const p1 = lineCoords[lineCoords.length - 2]
|
||||
const p2 = lineCoords[lineCoords.length - 1]
|
||||
const dLng = p2[0] - p1[0]
|
||||
const dLat = p2[1] - p1[1]
|
||||
// atan2(dLng, dLat) gives angle from north (up), clockwise — matches CSS triangle ▲ default
|
||||
const geoBearing = Math.atan2(dLng, dLat) * (180 / Math.PI)
|
||||
// Bildschirm-Winkel aus projizierten Pixeln berechnen — der geografische Winkel
|
||||
// (atan2 auf lng/lat) stimmt wegen der Mercator-Verzerrung NICHT mit der auf dem
|
||||
// Bildschirm gezeichneten Linie überein (v.a. bei nördlichen Breiten). So zeigt die
|
||||
// Spitze exakt entlang der Linie.
|
||||
const a = map.current.project(p1 as [number, number])
|
||||
const b = map.current.project(p2 as [number, number])
|
||||
// CSS-Dreieck zeigt nach oben (−y). Winkel im Uhrzeigersinn von „oben".
|
||||
const screenAngle = Math.atan2(b.x - a.x, -(b.y - a.y)) * (180 / Math.PI)
|
||||
|
||||
const color = (f.properties.color as string) || '#000000'
|
||||
const arrowEl = document.createElement('div')
|
||||
arrowEl.style.cssText = `
|
||||
width: 0; height: 0;
|
||||
border-left: 12px solid transparent;
|
||||
border-right: 12px solid transparent;
|
||||
border-bottom: 24px solid ${color};
|
||||
transform: rotate(${geoBearing}deg);
|
||||
border-left: 10px solid transparent;
|
||||
border-right: 10px solid transparent;
|
||||
border-bottom: 20px solid ${color};
|
||||
transform: rotate(${screenAngle}deg);
|
||||
transform-origin: center center;
|
||||
pointer-events: none;
|
||||
`
|
||||
|
||||
const marker = new maplibregl.Marker({ element: arrowEl, anchor: 'center', rotationAlignment: 'map' })
|
||||
// Viewport-ausgerichtet (Bildschirm), passend zum bildschirm-basierten Winkel.
|
||||
const marker = new maplibregl.Marker({ element: arrowEl, anchor: 'center' })
|
||||
.setLngLat(p2 as [number, number])
|
||||
.addTo(map.current)
|
||||
markersRef.current.push(marker)
|
||||
|
||||
@@ -18,9 +18,9 @@ describe('isPlaceholder', () => {
|
||||
})
|
||||
|
||||
describe('legal config', () => {
|
||||
it('führt genau 5 Rechtsdokumenttypen', () => {
|
||||
it('führt die erwarteten Rechtsdokumenttypen (inkl. FKS-Quellenangabe)', () => {
|
||||
expect(Object.keys(legal.documents).sort()).toEqual(
|
||||
['dataProcessingAgreement', 'organizationDeclaration', 'privacy', 'responsibleUse', 'terms']
|
||||
['dataProcessingAgreement', 'fksAttribution', 'organizationDeclaration', 'privacy', 'responsibleUse', 'terms']
|
||||
)
|
||||
})
|
||||
it('listet nur real genutzte Subprozessoren (keine erfundenen wie Google Analytics)', () => {
|
||||
|
||||
@@ -62,6 +62,8 @@ export const legal = {
|
||||
operatorName: env('LEGAL_OPERATOR_NAME', 'Perparim Ziberi'),
|
||||
/** Rechtsform */
|
||||
operatorLegalForm: env('LEGAL_OPERATOR_FORM', 'Privatperson'),
|
||||
/** Betreibertyp (Privatperson). Eine spätere Übertragung auf einen Verein ist nur eine offene Option. */
|
||||
operatorType: env('LEGAL_OPERATOR_TYPE', 'Privatperson'),
|
||||
/** Vollständige Postadresse des Betreibers */
|
||||
postalAddress: env('LEGAL_POSTAL_ADDRESS', 'Anglikerstrasse 56, 5612 Villmergen'),
|
||||
|
||||
@@ -96,18 +98,28 @@ export const legal = {
|
||||
// Nichts erfinden: nur tatsächlich im Code kontaktierte Dienste. Sitz/Region, die nicht
|
||||
// sicher bekannt ist, bleibt Platzhalter.
|
||||
processors: [
|
||||
{ name: 'OpenStreetMap (Tiles)', purpose: 'Basiskarte', data: 'Kachel-Koordinaten (Kartenausschnitt)', region: '[REGION – OSM Foundation, EU/UK]' },
|
||||
{ name: 'Esri ArcGIS World Imagery', purpose: 'Satellitenkarte', data: 'Kachel-Koordinaten', region: '[REGION]' },
|
||||
{ name: 'swisstopo (geo.admin.ch)', purpose: 'Schweizer Landeskarte', data: 'Kachel-Koordinaten', region: 'Schweiz' },
|
||||
{ name: 'Nominatim (OpenStreetMap)', purpose: 'Adress-/Reverse-Geocoding', data: 'Adress-/Ortssuche, GPS-Koordinaten', region: '[REGION – OSM Foundation]' },
|
||||
{ name: 'Open-Meteo', purpose: 'Wind- und Höhendaten', data: 'Koordinaten', region: '[REGION – EU]' },
|
||||
{ name: 'Open-Elevation', purpose: 'Höhendaten (Fallback)', data: 'Koordinaten', region: '[REGION]' },
|
||||
{ name: 'Stripe', purpose: 'Zahlungsabwicklung freiwilliger Unterstützungsbeiträge', data: 'Betrag, freiwilliger Name/Nachricht, Zahlungsdaten (direkt bei Stripe)', region: '[REGION – u.a. USA/EU; Auslandbearbeitung möglich]', optional: true },
|
||||
{ name: 'SMTP-/E-Mail-Anbieter', purpose: 'System-E-Mails (Verifizierung, Reset, Kontakt, Einladungen)', data: 'E-Mail-Adresse, Nachrichteninhalt', region: '[REGION – abhängig vom konfigurierten Anbieter]', optional: true },
|
||||
{ name: 'OpenStreetMap Foundation (Tiles)', purpose: 'Basiskarte', data: 'Kachel-Koordinaten (Kartenausschnitt), IP-Adresse', region: 'Vereinigtes Königreich / EU' },
|
||||
{ name: 'Esri ArcGIS World Imagery', purpose: 'Satellitenkarte', data: 'Kachel-Koordinaten, IP-Adresse', region: 'USA (Auslandbearbeitung möglich)' },
|
||||
{ name: 'swisstopo (geo.admin.ch)', purpose: 'Schweizer Landeskarte', data: 'Kachel-Koordinaten, IP-Adresse', region: 'Schweiz' },
|
||||
{ name: 'Nominatim (OpenStreetMap Foundation)', purpose: 'Adress-/Reverse-Geocoding', data: 'Adress-/Ortssuche, GPS-Koordinaten, IP-Adresse', region: 'Vereinigtes Königreich / EU' },
|
||||
{ name: 'Open-Meteo', purpose: 'Wind- und Höhendaten', data: 'Koordinaten, IP-Adresse', region: 'EU (Deutschland)' },
|
||||
{ name: 'Open-Elevation', purpose: 'Höhendaten (Fallback)', data: 'Koordinaten, IP-Adresse', region: 'Region nicht abschliessend geklärt (Community-Dienst)' },
|
||||
{ name: 'Stripe', purpose: 'Zahlungsabwicklung freiwilliger Unterstützungsbeiträge', data: 'Betrag, freiwilliger Name/Nachricht, Zahlungsdaten (direkt bei Stripe)', region: 'USA / EU (Auslandbearbeitung möglich)', optional: true },
|
||||
{ name: 'SMTP-/E-Mail-Anbieter', purpose: 'System-E-Mails (Verifizierung, Reset, Kontakt, Einladungen)', data: 'E-Mail-Adresse, Nachrichteninhalt', region: 'abhängig vom konfigurierten Anbieter', optional: true },
|
||||
{ name: 'MinIO (Objektspeicher, Eigenbetrieb)', purpose: 'Datei-/Bild-Uploads (Pläne, Logos, Symbole)', data: 'hochgeladene Dateien', region: 'Schweiz (beim Betreiber)' },
|
||||
{ name: 'PostgreSQL (Datenbank, Eigenbetrieb)', purpose: 'Primäre Datenhaltung', data: 'Konto-, Organisations-, Plan- und Journaldaten', region: 'Schweiz (beim Betreiber)' },
|
||||
] as ExternalProcessor[],
|
||||
|
||||
// ─── FKS/FEUKOS-Bildmaterial (schriftliche Genehmigung) ──────────
|
||||
// Die geforderte Quellenangabe lautet exakt "Feuerwehr Koordination Schweiz FKS".
|
||||
// KEINE offizielle Partnerschaft, Zertifizierung, Prüfung oder Empfehlung behaupten.
|
||||
fksAttribution: {
|
||||
label: 'Feuerwehr Koordination Schweiz FKS',
|
||||
sourceLine: 'Bildquelle: Feuerwehr Koordination Schweiz FKS',
|
||||
approvalDate: '17. Februar 2026',
|
||||
note: 'Die FEUKOS-Markierungen und -Symbole werden mit schriftlicher Genehmigung der Feuerwehr Koordination Schweiz FKS verwendet. Die Genehmigung bezieht sich auf die Verwendung des Bildmaterials innerhalb von lageplan.ch und stellt keine offizielle Partnerschaft, Zertifizierung, Prüfung oder Empfehlung von lageplan.ch durch die FKS dar.',
|
||||
},
|
||||
|
||||
// ─── Rechtsdokumente: bewusst gesetzte Versionen ──────────
|
||||
// WICHTIG: Version manuell erhöhen, wenn sich der Inhalt WESENTLICH ändert (löst Re-Consent aus).
|
||||
// Nicht automatisch aus minimalen Dateiänderungen ableiten.
|
||||
@@ -117,6 +129,7 @@ export const legal = {
|
||||
organizationDeclaration: { version: '1.0.0', publishedAt: '2026-07-23' },
|
||||
responsibleUse: { version: '1.0.0', publishedAt: '2026-07-23' },
|
||||
dataProcessingAgreement: { version: '1.0.0', publishedAt: '2026-07-23' },
|
||||
fksAttribution: { version: '1.0.0', publishedAt: '2026-07-24' },
|
||||
} as Record<string, LegalDocumentMeta>,
|
||||
|
||||
/** Dezenter Standard-Hinweis auf allen Rechtsseiten (kein Entwurfshinweis). */
|
||||
|
||||
@@ -315,6 +315,7 @@ export function useMapExport({
|
||||
if (format === 'png') {
|
||||
// Rechtlicher Prüf-Hinweis unten einbrennen
|
||||
const disclaimer = 'Mit Lageplan erstellt · Angaben vor Verwendung fachlich prüfen · Offline-/Einsatzunterlagen der Organisation bleiben massgebend'
|
||||
+ (distinctSymbols.length > 0 ? ' · Bildquelle: Feuerwehr Koordination Schweiz FKS' : '')
|
||||
const barH = Math.max(18, Math.round(exportCanvas.width * 0.014))
|
||||
ctx.save()
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.55)'
|
||||
@@ -448,6 +449,13 @@ export function useMapExport({
|
||||
|
||||
// ── Footer ──
|
||||
const footerY = pageH - m - 4
|
||||
// FKS-Quellenangabe (wenn Symbole enthalten sind)
|
||||
if (distinctSymbols.length > 0) {
|
||||
pdf.setFontSize(6)
|
||||
pdf.setFont('helvetica', 'normal')
|
||||
pdf.setTextColor(120, 120, 120)
|
||||
pdf.text('Bildquelle: Feuerwehr Koordination Schweiz FKS', m, footerY - 8.5)
|
||||
}
|
||||
// Rechtlicher Prüf-Hinweis (eine Zeile über dem Meta-Footer)
|
||||
pdf.setFontSize(6.5)
|
||||
pdf.setFont('helvetica', 'italic')
|
||||
|
||||
32
src/lib/__tests__/crypto-secret.test.ts
Normal file
32
src/lib/__tests__/crypto-secret.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
|
||||
// Server-Secret für die Schlüsselableitung setzen, bevor das Modul es liest.
|
||||
beforeAll(() => { process.env.NEXTAUTH_SECRET = 'test-secret-fuer-verschluesselung-min-16' })
|
||||
|
||||
describe('crypto-secret (Zugangsdaten-/Passphrase-Verschlüsselung)', () => {
|
||||
it('verschlüsselt und entschlüsselt korrekt (Round-Trip)', async () => {
|
||||
const { encryptSecret, decryptSecret, isEncrypted } = await import('@/lib/crypto-secret')
|
||||
const plain = 'sftp-Passwort-123!'
|
||||
const enc = encryptSecret(plain)
|
||||
expect(isEncrypted(enc)).toBe(true)
|
||||
expect(enc).not.toContain(plain)
|
||||
expect(decryptSecret(enc)).toBe(plain)
|
||||
})
|
||||
|
||||
it('erzeugt bei gleichem Klartext unterschiedliche Chiffren (zufälliges Salt/IV)', async () => {
|
||||
const { encryptSecret } = await import('@/lib/crypto-secret')
|
||||
expect(encryptSecret('gleich')).not.toBe(encryptSecret('gleich'))
|
||||
})
|
||||
|
||||
it('gibt unverschlüsselte Altwerte unverändert zurück (abwärtskompatibel)', async () => {
|
||||
const { decryptSecret, isEncrypted } = await import('@/lib/crypto-secret')
|
||||
expect(isEncrypted('klartext')).toBe(false)
|
||||
expect(decryptSecret('klartext')).toBe('klartext')
|
||||
})
|
||||
|
||||
it('leere Werte bleiben leer', async () => {
|
||||
const { encryptSecret, decryptSecret } = await import('@/lib/crypto-secret')
|
||||
expect(encryptSecret('')).toBe('')
|
||||
expect(decryptSecret('')).toBe('')
|
||||
})
|
||||
})
|
||||
87
src/lib/backup/config.ts
Normal file
87
src/lib/backup/config.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { prisma } from '@/lib/db'
|
||||
|
||||
export type BackupDestinationType = 'sftp' | 'webdav'
|
||||
export type BackupSchedule = 'off' | 'daily' | 'weekly'
|
||||
|
||||
export interface SftpConfig {
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
/** verschlüsselt (enc:v1:) */
|
||||
password: string
|
||||
remotePath: string
|
||||
}
|
||||
|
||||
export interface WebdavConfig {
|
||||
/** z.B. https://cloud.example.com/remote.php/dav/files/USER/ */
|
||||
url: string
|
||||
username: string
|
||||
/** verschlüsselt (enc:v1:) — bei Nextcloud besser ein App-Passwort */
|
||||
password: string
|
||||
remotePath: string
|
||||
}
|
||||
|
||||
export interface BackupConfig {
|
||||
enabled: boolean
|
||||
schedule: BackupSchedule
|
||||
retentionDays: number
|
||||
/** Passphrase zur Backup-Verschlüsselung, verschlüsselt (enc:v1:) */
|
||||
encryptPassphrase: string
|
||||
destinationType: BackupDestinationType
|
||||
sftp?: SftpConfig
|
||||
webdav?: WebdavConfig
|
||||
// Statusfelder (vom letzten Lauf)
|
||||
lastRun?: string | null
|
||||
lastStatus?: 'ok' | 'error' | null
|
||||
lastMessage?: string | null
|
||||
lastSizeBytes?: number | null
|
||||
}
|
||||
|
||||
const SETTING_KEY = 'backup_config'
|
||||
|
||||
export const DEFAULT_BACKUP_CONFIG: BackupConfig = {
|
||||
enabled: false,
|
||||
schedule: 'off',
|
||||
retentionDays: 30,
|
||||
encryptPassphrase: '',
|
||||
destinationType: 'sftp',
|
||||
sftp: { host: '', port: 22, username: '', password: '', remotePath: '/lageplan-backups' },
|
||||
webdav: { url: '', username: '', password: '', remotePath: '/Lageplan-Backups' },
|
||||
lastRun: null, lastStatus: null, lastMessage: null, lastSizeBytes: null,
|
||||
}
|
||||
|
||||
export async function loadBackupConfig(): Promise<BackupConfig | null> {
|
||||
try {
|
||||
const row = await (prisma as any).systemSetting.findUnique({ where: { key: SETTING_KEY } })
|
||||
if (!row?.value) return null
|
||||
return { ...DEFAULT_BACKUP_CONFIG, ...JSON.parse(row.value) }
|
||||
} catch (e) {
|
||||
console.error('[backup] loadBackupConfig:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBackupConfig(cfg: BackupConfig): Promise<void> {
|
||||
await (prisma as any).systemSetting.upsert({
|
||||
where: { key: SETTING_KEY },
|
||||
update: { value: JSON.stringify(cfg) },
|
||||
create: { key: SETTING_KEY, value: JSON.stringify(cfg), isSecret: true, category: 'backup' },
|
||||
})
|
||||
}
|
||||
|
||||
/** Konfig für die API maskieren: Geheimnisse durch Boolean-Flags ersetzen. */
|
||||
export function maskBackupConfig(cfg: BackupConfig) {
|
||||
return {
|
||||
enabled: cfg.enabled,
|
||||
schedule: cfg.schedule,
|
||||
retentionDays: cfg.retentionDays,
|
||||
hasPassphrase: !!cfg.encryptPassphrase,
|
||||
destinationType: cfg.destinationType,
|
||||
sftp: cfg.sftp ? { host: cfg.sftp.host, port: cfg.sftp.port, username: cfg.sftp.username, remotePath: cfg.sftp.remotePath, hasPassword: !!cfg.sftp.password } : undefined,
|
||||
webdav: cfg.webdav ? { url: cfg.webdav.url, username: cfg.webdav.username, remotePath: cfg.webdav.remotePath, hasPassword: !!cfg.webdav.password } : undefined,
|
||||
lastRun: cfg.lastRun ?? null,
|
||||
lastStatus: cfg.lastStatus ?? null,
|
||||
lastMessage: cfg.lastMessage ?? null,
|
||||
lastSizeBytes: cfg.lastSizeBytes ?? null,
|
||||
}
|
||||
}
|
||||
101
src/lib/backup/destinations.ts
Normal file
101
src/lib/backup/destinations.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { createReadStream, readFileSync } from 'fs'
|
||||
import type { BackupDestinationType } from './config'
|
||||
|
||||
// Entschlüsselte (Klartext-)Zugangsdaten — werden vom Aufrufer aus der Konfig entschlüsselt.
|
||||
export interface SftpPlain { host: string; port: number; username: string; password: string; remotePath: string }
|
||||
export interface WebdavPlain { url: string; username: string; password: string; remotePath: string }
|
||||
export type PlainDest = SftpPlain | WebdavPlain
|
||||
|
||||
export interface RemoteFile { name: string; size?: number; modified?: number }
|
||||
|
||||
function joinRemote(dir: string, name: string): string {
|
||||
return `${dir.replace(/\/+$/, '')}/${name}`
|
||||
}
|
||||
|
||||
// ─── SFTP ───────────────────────────────────────────────
|
||||
async function withSftp<T>(c: SftpPlain, fn: (sftp: any) => Promise<T>): Promise<T> {
|
||||
const SftpClient = (await import('ssh2-sftp-client')).default
|
||||
const sftp = new SftpClient()
|
||||
await sftp.connect({ host: c.host, port: c.port || 22, username: c.username, password: c.password, readyTimeout: 15000 })
|
||||
try {
|
||||
return await fn(sftp)
|
||||
} finally {
|
||||
try { await sftp.end() } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── WebDAV (Nextcloud) ─────────────────────────────────
|
||||
async function getWebdav(c: WebdavPlain) {
|
||||
const { createClient } = await import('webdav')
|
||||
return createClient(c.url, { username: c.username, password: c.password })
|
||||
}
|
||||
|
||||
// ─── Öffentliche, typ-übergreifende API ─────────────────
|
||||
|
||||
export async function testDestination(type: BackupDestinationType, dest: PlainDest): Promise<{ ok: boolean; message: string }> {
|
||||
try {
|
||||
const marker = Buffer.from(`lageplan-backup-test ${new Date().toISOString()}`)
|
||||
if (type === 'sftp') {
|
||||
const c = dest as SftpPlain
|
||||
await withSftp(c, async (sftp) => {
|
||||
if (!(await sftp.exists(c.remotePath))) await sftp.mkdir(c.remotePath, true)
|
||||
const p = joinRemote(c.remotePath, '.lageplan-test')
|
||||
await sftp.put(marker, p)
|
||||
await sftp.delete(p)
|
||||
})
|
||||
return { ok: true, message: 'SFTP-Verbindung und Schreibzugriff ok.' }
|
||||
}
|
||||
const c = dest as WebdavPlain
|
||||
const dav = await getWebdav(c)
|
||||
if (!(await dav.exists(c.remotePath))) await dav.createDirectory(c.remotePath, { recursive: true } as any)
|
||||
const p = joinRemote(c.remotePath, '.lageplan-test')
|
||||
await dav.putFileContents(p, marker)
|
||||
await dav.deleteFile(p)
|
||||
return { ok: true, message: 'WebDAV-Verbindung und Schreibzugriff ok.' }
|
||||
} catch (e: any) {
|
||||
return { ok: false, message: e?.message || 'Verbindung fehlgeschlagen.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadBackup(type: BackupDestinationType, dest: PlainDest, localPath: string, remoteName: string): Promise<void> {
|
||||
if (type === 'sftp') {
|
||||
const c = dest as SftpPlain
|
||||
await withSftp(c, async (sftp) => {
|
||||
if (!(await sftp.exists(c.remotePath))) await sftp.mkdir(c.remotePath, true)
|
||||
await sftp.put(createReadStream(localPath), joinRemote(c.remotePath, remoteName))
|
||||
})
|
||||
return
|
||||
}
|
||||
const c = dest as WebdavPlain
|
||||
const dav = await getWebdav(c)
|
||||
if (!(await dav.exists(c.remotePath))) await dav.createDirectory(c.remotePath, { recursive: true } as any)
|
||||
// Für WebDAV als Buffer hochladen (Backups einer kleinen Feuerwehr sind moderat gross).
|
||||
await dav.putFileContents(joinRemote(c.remotePath, remoteName), readFileSync(localPath), { overwrite: true } as any)
|
||||
}
|
||||
|
||||
export async function listBackups(type: BackupDestinationType, dest: PlainDest): Promise<RemoteFile[]> {
|
||||
if (type === 'sftp') {
|
||||
const c = dest as SftpPlain
|
||||
return withSftp(c, async (sftp) => {
|
||||
if (!(await sftp.exists(c.remotePath))) return []
|
||||
const list = await sftp.list(c.remotePath)
|
||||
return list.filter((f: any) => f.type === '-').map((f: any) => ({ name: f.name, size: f.size, modified: f.modifyTime }))
|
||||
})
|
||||
}
|
||||
const c = dest as WebdavPlain
|
||||
const dav = await getWebdav(c)
|
||||
if (!(await dav.exists(c.remotePath))) return []
|
||||
const items = (await dav.getDirectoryContents(c.remotePath)) as any[]
|
||||
return items.filter(i => i.type === 'file').map(i => ({ name: i.basename, size: i.size, modified: i.lastmod ? Date.parse(i.lastmod) : undefined }))
|
||||
}
|
||||
|
||||
export async function deleteBackup(type: BackupDestinationType, dest: PlainDest, name: string): Promise<void> {
|
||||
if (type === 'sftp') {
|
||||
const c = dest as SftpPlain
|
||||
await withSftp(c, async (sftp) => { await sftp.delete(joinRemote(c.remotePath, name)) })
|
||||
return
|
||||
}
|
||||
const c = dest as WebdavPlain
|
||||
const dav = await getWebdav(c)
|
||||
await dav.deleteFile(joinRemote(c.remotePath, name))
|
||||
}
|
||||
163
src/lib/backup/engine.ts
Normal file
163
src/lib/backup/engine.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { spawn } from 'child_process'
|
||||
import { createWriteStream, createReadStream, promises as fsp } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { randomBytes, scryptSync, createCipheriv } from 'crypto'
|
||||
// archiver nutzt `export =` (CommonJS); require umgeht die Default-Import-Inkompatibilität.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const archiver = require('archiver') as (format: string, opts?: any) => any
|
||||
import { minioClient, BUCKET } from '@/lib/minio'
|
||||
import { loadBackupConfig, saveBackupConfig, type BackupConfig } from './config'
|
||||
import { plainDestFromConfig, getPassphrase } from './secrets'
|
||||
import { uploadBackup, listBackups, deleteBackup } from './destinations'
|
||||
|
||||
const NAME_PREFIX = 'lageplan_'
|
||||
const NAME_RE = /^lageplan_(\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2})\.tar\.gz\.enc$/
|
||||
|
||||
function tsName(d = new Date()): string {
|
||||
// 2026-07-23-22-30-00 (UTC, sortierbar)
|
||||
return d.toISOString().replace('T', '-').replace(/:/g, '-').slice(0, 19)
|
||||
}
|
||||
function fmtSize(n: number): string {
|
||||
if (n > 1e9) return (n / 1e9).toFixed(1) + ' GB'
|
||||
if (n > 1e6) return (n / 1e6).toFixed(1) + ' MB'
|
||||
return (n / 1e3).toFixed(0) + ' KB'
|
||||
}
|
||||
|
||||
/** PostgreSQL-Dump (custom/komprimiert) via pg_dump. */
|
||||
function pgDump(dumpPath: string): Promise<void> {
|
||||
const url = process.env.DATABASE_URL
|
||||
if (!url) return Promise.reject(new Error('DATABASE_URL fehlt'))
|
||||
return new Promise((resolve, reject) => {
|
||||
const p = spawn('pg_dump', [url, '-Fc', '-f', dumpPath], { stdio: ['ignore', 'ignore', 'pipe'] })
|
||||
let err = ''
|
||||
p.stderr.on('data', d => { err += d.toString() })
|
||||
p.on('error', (e) => reject(new Error(`pg_dump nicht ausführbar: ${e.message} (postgresql-client im Image?)`)))
|
||||
p.on('close', code => code === 0 ? resolve() : reject(new Error('pg_dump: ' + (err || `Exit ${code}`))))
|
||||
})
|
||||
}
|
||||
|
||||
function listObjectNames(): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const out: string[] = []
|
||||
const s = minioClient.listObjects(BUCKET, '', true)
|
||||
s.on('data', (o: any) => { if (o.name) out.push(o.name) })
|
||||
s.on('error', reject)
|
||||
s.on('end', () => resolve(out))
|
||||
})
|
||||
}
|
||||
|
||||
/** tar.gz mit database.dump + files/<minio-objekte>. */
|
||||
async function buildArchive(dumpPath: string, tarPath: string): Promise<void> {
|
||||
const names = await listObjectNames()
|
||||
const output = createWriteStream(tarPath)
|
||||
const archive = archiver('tar', { gzip: true, gzipOptions: { level: 6 } })
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
output.on('close', () => resolve())
|
||||
output.on('error', reject)
|
||||
archive.on('error', reject)
|
||||
})
|
||||
archive.pipe(output)
|
||||
archive.file(dumpPath, { name: 'database.dump' })
|
||||
for (const name of names) {
|
||||
const st = await minioClient.getObject(BUCKET, name)
|
||||
archive.append(st as any, { name: 'files/' + name })
|
||||
}
|
||||
await archive.finalize()
|
||||
await done
|
||||
}
|
||||
|
||||
/** AES-256-GCM Stream-Verschlüsselung mit Passphrase. Format: [salt16][iv12][ciphertext][tag16]. */
|
||||
function encryptFile(inPath: string, outPath: string, passphrase: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const salt = randomBytes(16)
|
||||
const iv = randomBytes(12)
|
||||
const key = scryptSync(passphrase, salt, 32)
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
||||
const out = createWriteStream(outPath)
|
||||
out.on('error', reject)
|
||||
out.on('close', () => resolve())
|
||||
out.write(salt); out.write(iv)
|
||||
const input = createReadStream(inPath)
|
||||
input.on('error', reject)
|
||||
cipher.on('error', reject)
|
||||
cipher.on('data', (c) => out.write(c))
|
||||
cipher.on('end', () => { out.write(cipher.getAuthTag()); out.end() })
|
||||
input.pipe(cipher)
|
||||
})
|
||||
}
|
||||
|
||||
/** Entfernt Remote-Backups, die älter als retentionDays sind (Zeitstempel aus dem Dateinamen). */
|
||||
async function prune(cfg: BackupConfig): Promise<void> {
|
||||
try {
|
||||
const dest = plainDestFromConfig(cfg)
|
||||
const files = await listBackups(cfg.destinationType, dest)
|
||||
const cutoff = Date.now() - cfg.retentionDays * 86400_000
|
||||
for (const f of files) {
|
||||
const m = f.name.match(NAME_RE)
|
||||
if (!m) continue
|
||||
// 2026-07-23-22-30-00 → ISO
|
||||
const iso = m[1].replace(/^(\d{4}-\d{2}-\d{2})-(\d{2})-(\d{2})-(\d{2})$/, '$1T$2:$3:$4Z')
|
||||
const t = Date.parse(iso)
|
||||
if (!isNaN(t) && t < cutoff) {
|
||||
await deleteBackup(cfg.destinationType, dest, f.name)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[backup] prune:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt ein vollständiges Backup aus: DB-Dump + Dateien → tar.gz → verschlüsselt → Upload → prune.
|
||||
* Läuft unabhängig vom Zeitplan (auch für „Jetzt sichern"), solange Ziel + Passphrase gesetzt sind.
|
||||
*/
|
||||
export async function runBackup(): Promise<{ ok: boolean; message: string }> {
|
||||
const cfg = await loadBackupConfig()
|
||||
if (!cfg) throw new Error('Backup nicht konfiguriert.')
|
||||
const pass = getPassphrase(cfg)
|
||||
if (!pass) throw new Error('Backup-Passphrase fehlt.')
|
||||
const dest = plainDestFromConfig(cfg) // wirft, wenn Ziel nicht konfiguriert
|
||||
|
||||
const ts = tsName()
|
||||
const tmp = await fsp.mkdtemp(join(tmpdir(), 'lpbak-'))
|
||||
const dumpPath = join(tmp, 'db.dump')
|
||||
const tarPath = join(tmp, `${NAME_PREFIX}${ts}.tar.gz`)
|
||||
const encPath = `${tarPath}.enc`
|
||||
const remoteName = `${NAME_PREFIX}${ts}.tar.gz.enc`
|
||||
|
||||
try {
|
||||
await pgDump(dumpPath)
|
||||
await buildArchive(dumpPath, tarPath)
|
||||
await encryptFile(tarPath, encPath, pass)
|
||||
await uploadBackup(cfg.destinationType, dest, encPath, remoteName)
|
||||
const size = (await fsp.stat(encPath)).size
|
||||
await prune(cfg)
|
||||
|
||||
cfg.lastRun = new Date().toISOString()
|
||||
cfg.lastStatus = 'ok'
|
||||
cfg.lastMessage = `Gesichert: ${remoteName} (${fmtSize(size)})`
|
||||
cfg.lastSizeBytes = size
|
||||
await saveBackupConfig(cfg)
|
||||
return { ok: true, message: cfg.lastMessage }
|
||||
} catch (e: any) {
|
||||
cfg.lastRun = new Date().toISOString()
|
||||
cfg.lastStatus = 'error'
|
||||
cfg.lastMessage = e?.message || 'Unbekannter Fehler'
|
||||
await saveBackupConfig(cfg)
|
||||
throw e
|
||||
} finally {
|
||||
await fsp.rm(tmp, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Prüft, ob laut Zeitplan ein Backup fällig ist (für den Scheduler). */
|
||||
export function isBackupDue(cfg: BackupConfig, now = Date.now()): boolean {
|
||||
if (!cfg.enabled || cfg.schedule === 'off') return false
|
||||
const intervalMs = cfg.schedule === 'weekly' ? 7 * 86400_000 : 86400_000
|
||||
if (!cfg.lastRun) return true
|
||||
const last = Date.parse(cfg.lastRun)
|
||||
if (isNaN(last)) return true
|
||||
// Bei Fehlversuchen nicht dauernd neu starten: erst nach dem Intervall erneut.
|
||||
return now - last >= intervalMs
|
||||
}
|
||||
19
src/lib/backup/secrets.ts
Normal file
19
src/lib/backup/secrets.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { decryptSecret } from '@/lib/crypto-secret'
|
||||
import type { BackupConfig } from './config'
|
||||
import type { PlainDest, SftpPlain, WebdavPlain } from './destinations'
|
||||
|
||||
/** Entschlüsselt die Zugangsdaten des aktiven Ziels aus der Konfig. */
|
||||
export function plainDestFromConfig(cfg: BackupConfig): PlainDest {
|
||||
if (cfg.destinationType === 'sftp') {
|
||||
const s = cfg.sftp
|
||||
if (!s) throw new Error('SFTP nicht konfiguriert')
|
||||
return { host: s.host, port: s.port, username: s.username, password: decryptSecret(s.password), remotePath: s.remotePath } as SftpPlain
|
||||
}
|
||||
const w = cfg.webdav
|
||||
if (!w) throw new Error('WebDAV nicht konfiguriert')
|
||||
return { url: w.url, username: w.username, password: decryptSecret(w.password), remotePath: w.remotePath } as WebdavPlain
|
||||
}
|
||||
|
||||
export function getPassphrase(cfg: BackupConfig): string {
|
||||
return decryptSecret(cfg.encryptPassphrase || '')
|
||||
}
|
||||
49
src/lib/crypto-secret.ts
Normal file
49
src/lib/crypto-secret.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { randomBytes, scryptSync, createCipheriv, createDecipheriv } from 'crypto'
|
||||
|
||||
/**
|
||||
* Verschlüsselt kurze Geheimnisse (z.B. SFTP-/WebDAV-Passwörter, Backup-Passphrase) für die
|
||||
* Ablage in der Datenbank. AES-256-GCM. Der Schlüssel wird aus einem Server-Secret abgeleitet
|
||||
* (BACKUP_ENC_KEY oder NEXTAUTH_SECRET) — er liegt NICHT in der Datenbank.
|
||||
*
|
||||
* Format (base64): [salt(16)][iv(12)][tag(16)][ciphertext]
|
||||
* Ein Präfix "enc:v1:" kennzeichnet verschlüsselte Werte.
|
||||
*/
|
||||
|
||||
const PREFIX = 'enc:v1:'
|
||||
|
||||
function masterKey(): string {
|
||||
const k = process.env.BACKUP_ENC_KEY || process.env.NEXTAUTH_SECRET
|
||||
if (!k || k.length < 16) {
|
||||
throw new Error('Kein Verschlüsselungs-Secret (BACKUP_ENC_KEY oder NEXTAUTH_SECRET) gesetzt.')
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
export function isEncrypted(value: string | null | undefined): boolean {
|
||||
return typeof value === 'string' && value.startsWith(PREFIX)
|
||||
}
|
||||
|
||||
export function encryptSecret(plain: string): string {
|
||||
if (!plain) return ''
|
||||
const salt = randomBytes(16)
|
||||
const iv = randomBytes(12)
|
||||
const key = scryptSync(masterKey(), salt, 32)
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
||||
const ct = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()])
|
||||
const tag = cipher.getAuthTag()
|
||||
return PREFIX + Buffer.concat([salt, iv, tag, ct]).toString('base64')
|
||||
}
|
||||
|
||||
export function decryptSecret(value: string): string {
|
||||
if (!value) return ''
|
||||
if (!isEncrypted(value)) return value // Abwärtskompatibel: unverschlüsselte Altwerte
|
||||
const raw = Buffer.from(value.slice(PREFIX.length), 'base64')
|
||||
const salt = raw.subarray(0, 16)
|
||||
const iv = raw.subarray(16, 28)
|
||||
const tag = raw.subarray(28, 44)
|
||||
const ct = raw.subarray(44)
|
||||
const key = scryptSync(masterKey(), salt, 32)
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
||||
decipher.setAuthTag(tag)
|
||||
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8')
|
||||
}
|
||||
@@ -8,12 +8,15 @@ export type LegalDocumentType =
|
||||
| 'ORGANIZATION_DECLARATION'
|
||||
| 'RESPONSIBLE_USE'
|
||||
| 'DATA_PROCESSING_AGREEMENT'
|
||||
| 'FKS_ATTRIBUTION'
|
||||
|
||||
export type LegalAcceptanceContext =
|
||||
| 'REGISTRATION'
|
||||
| 'LOGIN_RECONSENT'
|
||||
| 'ORGANIZATION_CREATION'
|
||||
| 'ADMIN_ROLE_ACCEPTANCE'
|
||||
| 'EXISTING_USER_RECONSENT'
|
||||
| 'SUPPORT_CONTRIBUTION'
|
||||
| 'DONATION'
|
||||
|
||||
/** Für ALLE Benutzer erforderliche Dokumente. */
|
||||
|
||||
@@ -18,6 +18,7 @@ const PUBLIC_API_PREFIXES = [
|
||||
'/api/auth/resend-verification',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/mfa-login/',
|
||||
'/api/cron/',
|
||||
'/api/contact',
|
||||
'/api/demo',
|
||||
'/api/donate',
|
||||
|
||||
Reference in New Issue
Block a user