Compare commits
4
Commits
f55e5ff2fe
...
62bf878939
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62bf878939 | ||
|
|
fa58212912 | ||
|
|
a1a7f420f7 | ||
|
|
869bb61ef4 |
@@ -43,6 +43,9 @@ yarn-error.log*
|
||||
/data
|
||||
/postgres-data
|
||||
|
||||
# Sicherungen aus scripts/backup.* – enthalten alle Daten der Instanz
|
||||
/backups
|
||||
|
||||
# editors
|
||||
.idea/
|
||||
.vscode/*
|
||||
|
||||
+36
@@ -215,6 +215,42 @@ Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
|
||||
|
||||
---
|
||||
|
||||
## 4a. Export, Import und Sicherung
|
||||
|
||||
### Projekte mitnehmen
|
||||
|
||||
- **JSON-Export** einzeln oder für alle Projekte: Stammdaten, Notizen, Tags,
|
||||
Repo-Verknüpfung. **Ohne Access Tokens** — die Verbindung wird nur beschrieben, das
|
||||
Token muss beim Import neu hinterlegt werden. Auch ohne Snapshots (reproduzierbar) und
|
||||
ohne Datenbank-IDs (gelten nur in der Quellinstanz).
|
||||
- **Markdown-Export** eines Projekts: Beschreibung, Kennzahlen, Notizen und der letzte
|
||||
bekannte Git-Stand als lesbare Datei.
|
||||
- **Import mit Vorschau.** Die Datei wird streng geprüft, bevor irgendetwas passiert.
|
||||
Abgeglichen wird über den Slug; bei Treffern stehen drei Strategien bereit:
|
||||
|
||||
| Strategie | Wirkung |
|
||||
| --- | --- |
|
||||
| Überspringen | Vorhandene bleiben unverändert |
|
||||
| Zusätzlich anlegen | Kopie mit freiem Slug (`nebula` → `nebula-2`) |
|
||||
| Ersetzen | Stammdaten überschreiben, Notizen austauschen |
|
||||
|
||||
Die Vorschau zeigt je Projekt, was geschähe, und weist auf fehlende Git-Verbindungen
|
||||
hin. Erst ein zweiter Klick schreibt.
|
||||
|
||||
### Sicherung
|
||||
|
||||
- **JSON-Sicherung** im Adminbereich: Einstellungen, Konten, Verbindungen, Projekte und
|
||||
Notizen in einer Datei. Passwörter als scrypt-Hash, Git-Tokens als Chiffrat — ohne
|
||||
dasselbe `APP_SECRET` wertlos. Sitzungen und Passkeys bleiben draußen: erstere sind
|
||||
kurzlebig, letztere hängen an Gerät und rpID.
|
||||
- **Datenbank-Sicherung** über `scripts/backup.*`: `pg_dump` im Container, mit
|
||||
`--clean --if-exists`, also für sich allein einspielbar. Gegenstück ist
|
||||
`scripts/restore.*`, das vor dem Überschreiben nachfragt und sich über einen Parameter
|
||||
auf eine Wegwerf-Datenbank umlenken lässt — damit sich eine Sicherung prüfen lässt,
|
||||
ohne die echte anzufassen.
|
||||
|
||||
---
|
||||
|
||||
## 5. Oberfläche
|
||||
|
||||
- **Dark Glassmorphism**: milchige Glasflächen, driftende Nebel im Hintergrund, feines
|
||||
|
||||
@@ -199,6 +199,63 @@ Wer lieber von außen taktet, lässt den Automatismus aus und ruft
|
||||
|
||||
---
|
||||
|
||||
## Export, Import und Sicherung
|
||||
|
||||
### Projekte mitnehmen
|
||||
|
||||
| Weg | Wo | Inhalt |
|
||||
| --- | --- | --- |
|
||||
| **Alle Projekte als JSON** | Dashboard → Datenbank-Symbol | Stammdaten, Notizen, Repo-Verknüpfung |
|
||||
| **Ein Projekt als JSON** | Projektseite → Download-Symbol | dasselbe, für ein Projekt |
|
||||
| **Ein Projekt als Markdown** | Projektseite → Download-Symbol | lesbar, inklusive letztem Git-Stand |
|
||||
| **Import** | Dashboard → Datenbank-Symbol | JSON einlesen, mit Vorschau |
|
||||
|
||||
**Access Tokens sind nie enthalten.** Die Git-Verbindung wird nur beschrieben (Name,
|
||||
System, Basis-URL); nach dem Import muss das Token neu hinterlegt werden. Verweist eine
|
||||
importierte Datei auf eine hier unbekannte Verbindung, wird das Projekt ohne Verknüpfung
|
||||
angelegt und in der Vorschau angezeigt.
|
||||
|
||||
Beim Import wird über den Slug abgeglichen. Für Treffer gibt es drei Strategien:
|
||||
|
||||
- **Überspringen** — Vorhandene bleiben unverändert
|
||||
- **Zusätzlich anlegen** — Kopie mit freiem Slug (`nebula` → `nebula-2`)
|
||||
- **Ersetzen** — Stammdaten überschreiben, Notizen austauschen
|
||||
|
||||
Vor dem Schreiben zeigt eine Vorschau, was passieren würde. Erst dann wird geschrieben.
|
||||
|
||||
### Sicherung
|
||||
|
||||
Zwei Wege, mit unterschiedlichem Zweck:
|
||||
|
||||
**JSON-Sicherung** (Admin-Einstellungen → Sicherung) enthält Einstellungen, Konten,
|
||||
Verbindungen, Projekte und Notizen. Gut zum Umziehen. Passwörter liegen als scrypt-Hash
|
||||
vor, Git-Tokens als Chiffrat — ohne dasselbe `APP_SECRET` sind sie wertlos. Trotzdem
|
||||
vertraulich behandeln.
|
||||
|
||||
**Datenbank-Sicherung** über `pg_dump` — das, was man zum Wiederherstellen einer kaputten
|
||||
Instanz braucht:
|
||||
|
||||
```powershell
|
||||
.\scripts\backup.ps1 # → .\backups\nebula-<datum>.sql
|
||||
.\scripts\restore.ps1 -File .\backups\nebula-....sql
|
||||
```
|
||||
|
||||
```bash
|
||||
./scripts/backup.sh # → ./backups/nebula-<datum>.sql
|
||||
./scripts/restore.sh backups/nebula-....sql
|
||||
```
|
||||
|
||||
Die Skripte lesen Benutzer und Datenbankname aus der `.env` und führen `pg_dump` im
|
||||
Container aus — auf dem Rechner selbst muss nichts installiert sein.
|
||||
|
||||
> Eine Sicherung ist erst dann eine Sicherung, wenn du sie einmal zurückgespielt hast.
|
||||
> Das geht ohne Risiko in eine Wegwerf-Datenbank:
|
||||
>
|
||||
> ```powershell
|
||||
> docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
|
||||
> .\scripts\restore.ps1 -File .\backups\nebula-....sql -Database nebula_test -Force
|
||||
> ```
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
@@ -271,6 +328,10 @@ ausschließlich Daten des angemeldeten Kontos.
|
||||
| `GET` `POST` | `/api/admin/users` | Konten auflisten / anlegen (Admin) |
|
||||
| `PATCH` `DELETE` | `/api/admin/users/:id` | Konto ändern / löschen (Admin) |
|
||||
| `GET` `POST` | `/api/projects` | Projekte auflisten / anlegen |
|
||||
| `GET` | `/api/projects/export` | Alle eigenen Projekte als JSON |
|
||||
| `GET` | `/api/projects/:id/export` | Ein Projekt (`?format=md` für Markdown) |
|
||||
| `POST` | `/api/projects/import` | Import, mit `dryRun` für die Vorschau |
|
||||
| `GET` | `/api/admin/backup` | Vollständige Sicherung als JSON (Admin) |
|
||||
| `GET` `PATCH` `DELETE` | `/api/projects/:id` | Projekt lesen / ändern / löschen |
|
||||
| `POST` | `/api/projects/:id/sync` | Repo-Daten vom Git-System holen |
|
||||
| `GET` `POST` | `/api/projects/:id/notes` | Notizen des Projekts |
|
||||
|
||||
+9
-24
@@ -11,28 +11,7 @@ Was bereits steht, listet [FEATURES.md](FEATURES.md).
|
||||
|
||||
## 1 · Als Nächstes empfohlen
|
||||
|
||||
### 1.1 Export und Import `M`
|
||||
|
||||
Dein Vorschlag, in vier Stufen — jede für sich nutzbar:
|
||||
|
||||
| Stufe | Inhalt |
|
||||
| --- | --- |
|
||||
| **Projekt-Export** | Ein Projekt oder alle als JSON: Stammdaten, Notizen, Repo-Verknüpfung. Ein Klick auf der Projektseite, einer im Dashboard für alles. |
|
||||
| **Projekt-Import** | JSON hochladen, Vorschau, dann übernehmen. Konfliktstrategie wählbar: überspringen · umbenennen · überschreiben. |
|
||||
| **Markdown-Export** | Ein Projekt als lesbares `.md` — Beschreibung, Notizen, aktueller Git-Stand. Gut zum Archivieren oder für ein README. |
|
||||
| **Voll-Backup** | `pg_dump` aus dem Container heraus, per Knopf im Adminbereich als Datei. Plus dokumentierter Rückweg. |
|
||||
|
||||
**Offene Fragen, die wir klären sollten:**
|
||||
- **Git-Tokens mitexportieren?** Mein Vorschlag: **nein**. Die Verbindung wird
|
||||
mitexportiert (Name, System, Basis-URL), das Token muss beim Import neu
|
||||
hinterlegt werden. Ein Export-JSON landet sonst schnell irgendwo, wo es
|
||||
nicht hingehört.
|
||||
- **Reichweite:** Export der eigenen Daten für jeden — Voll-Backup nur für
|
||||
Administratoren?
|
||||
- **Snapshots mitnehmen?** Ich würde sie weglassen: sie sind reproduzierbar
|
||||
und blähen die Datei auf. Ein Sync nach dem Import holt sie zurück.
|
||||
|
||||
### 1.2 Markdown in Beschreibung und Notizen `S`
|
||||
### 1.1 Markdown in Beschreibung und Notizen `S`
|
||||
|
||||
Heute wird alles als reiner Text dargestellt. Mit Markdown werden aus
|
||||
`- [ ] Aufgabe` echte Checklisten, Codeblöcke bekommen Hervorhebung, Links
|
||||
@@ -41,13 +20,13 @@ werden klickbar.
|
||||
Zu beachten: HTML muss beim Rendern entfernt werden, sonst reißt es ein
|
||||
XSS-Loch. Mit `react-markdown` plus `rehype-sanitize` ist das sauber lösbar.
|
||||
|
||||
### 1.3 Volltextsuche über Notizen `S`
|
||||
### 1.2 Volltextsuche über Notizen `S`
|
||||
|
||||
Die Suche im Board deckt Namen, Kurzbeschreibung und Tags ab — Notizen nicht.
|
||||
Postgres kann das nativ (`tsvector` mit deutscher Konfiguration), also ohne
|
||||
zusätzlichen Dienst.
|
||||
|
||||
### 1.4 Kanban-Board mit Drag & Drop `M`
|
||||
### 1.3 Kanban-Board mit Drag & Drop `M`
|
||||
|
||||
Die gruppierte Ansicht gibt es schon, aber Statuswechsel geht nur über das
|
||||
Menü. Spalten mit Ziehen und Ablegen wären der natürlichere Weg.
|
||||
@@ -176,3 +155,9 @@ Damit klar ist, was **nicht** kommt und warum:
|
||||
| Härtung: CSRF, CSP, SSRF, Ratenbegrenzung, Datentrennung | ✓ |
|
||||
| Docker- und Standalone-Betrieb | ✓ |
|
||||
| Automatischer Abgleich mit Zeitplan | ✓ |
|
||||
| Export, Import und Sicherung | ✓ |
|
||||
|
||||
Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur
|
||||
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
|
||||
ebenfalls draußen — sie sind reproduzierbar. Der Projekt-Export steht jedem Konto für
|
||||
seine eigenen Daten offen, die Voll-Sicherung nur Administratoren.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Byteweise Sicherung der Nebula-Datenbank mit pg_dump.
|
||||
|
||||
.DESCRIPTION
|
||||
Ergaenzt den JSON-Export im Adminbereich um eine vollstaendige
|
||||
Datenbanksicherung: Schema, Daten, Sequenzen, Indizes. Genau das, was
|
||||
man zum Wiederherstellen einer kaputten Instanz braucht.
|
||||
|
||||
Laeuft die Datenbank im mitgelieferten Container, wird pg_dump dort
|
||||
ausgefuehrt - auf dem Rechner selbst muss dann nichts installiert sein.
|
||||
|
||||
.PARAMETER OutDir
|
||||
Zielverzeichnis. Vorgabe: .\backups
|
||||
|
||||
.PARAMETER Container
|
||||
Name des Postgres-Containers. Vorgabe: nebula-db
|
||||
|
||||
.EXAMPLE
|
||||
.\scripts\backup.ps1
|
||||
.\scripts\backup.ps1 -OutDir D:\Sicherungen
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$OutDir = (Join-Path $PSScriptRoot "..\backups"),
|
||||
[string]$Container = "nebula-db"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# .env einlesen, damit Benutzer und Datenbankname nicht doppelt gepflegt werden.
|
||||
$envFile = Join-Path $PSScriptRoot "..\.env"
|
||||
$settings = @{ POSTGRES_USER = "nebula"; POSTGRES_DB = "nebula" }
|
||||
if (Test-Path $envFile) {
|
||||
foreach ($line in Get-Content $envFile) {
|
||||
if ($line -match '^\s*([A-Z_]+)\s*=\s*"?([^"#]*)"?\s*$') {
|
||||
$settings[$Matches[1]] = $Matches[2].Trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$user = $settings["POSTGRES_USER"]
|
||||
$database = $settings["POSTGRES_DB"]
|
||||
|
||||
if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir | Out-Null }
|
||||
|
||||
$stamp = Get-Date -Format "yyyy-MM-dd-HHmmss"
|
||||
$target = Join-Path $OutDir "nebula-$stamp.sql"
|
||||
|
||||
$running = docker ps --filter "name=$Container" --format "{{.Names}}"
|
||||
if ($running -notcontains $Container) {
|
||||
Write-Error "Container '$Container' laeuft nicht. Zuerst 'docker compose up -d db' ausfuehren."
|
||||
}
|
||||
|
||||
Write-Host "Sichere Datenbank '$database' aus Container '$Container'..."
|
||||
|
||||
# --clean --if-exists macht die Datei fuer sich allein einspielbar.
|
||||
docker exec $Container pg_dump -U $user -d $database --clean --if-exists | Out-File -Encoding utf8 $target
|
||||
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "pg_dump ist fehlgeschlagen." }
|
||||
|
||||
$size = [math]::Round((Get-Item $target).Length / 1KB, 1)
|
||||
Write-Host "Fertig: $target ($size KB)" -ForegroundColor Green
|
||||
Write-Host "Wiederherstellen mit: .\scripts\restore.ps1 -File `"$target`""
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/bin/sh
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Byteweise Sicherung der Nebula-Datenbank mit pg_dump.
|
||||
#
|
||||
# Ergaenzt den JSON-Export im Adminbereich um eine vollstaendige
|
||||
# Datenbanksicherung: Schema, Daten, Sequenzen, Indizes.
|
||||
#
|
||||
# Aufruf: ./scripts/backup.sh [Zielverzeichnis]
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
set -eu
|
||||
|
||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
out_dir="${1:-$root/backups}"
|
||||
container="${NEBULA_DB_CONTAINER:-nebula-db}"
|
||||
|
||||
user="nebula"
|
||||
database="nebula"
|
||||
if [ -f "$root/.env" ]; then
|
||||
# Nur die beiden benoetigten Werte lesen, ohne die Datei auszufuehren.
|
||||
value_of() {
|
||||
sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*\"\{0,1\}\([^\"#]*\)\"\{0,1\}[[:space:]]*$/\1/p" \
|
||||
"$root/.env" | tail -n 1
|
||||
}
|
||||
[ -n "$(value_of POSTGRES_USER)" ] && user="$(value_of POSTGRES_USER)"
|
||||
[ -n "$(value_of POSTGRES_DB)" ] && database="$(value_of POSTGRES_DB)"
|
||||
fi
|
||||
|
||||
if ! docker ps --filter "name=$container" --format '{{.Names}}' | grep -qx "$container"; then
|
||||
echo "Container '$container' laeuft nicht. Zuerst 'docker compose up -d db' ausfuehren." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$out_dir"
|
||||
stamp="$(date +%Y-%m-%d-%H%M%S)"
|
||||
target="$out_dir/nebula-$stamp.sql"
|
||||
|
||||
echo "Sichere Datenbank '$database' aus Container '$container'..."
|
||||
# --clean --if-exists macht die Datei fuer sich allein einspielbar.
|
||||
docker exec "$container" pg_dump -U "$user" -d "$database" --clean --if-exists > "$target"
|
||||
|
||||
size="$(du -h "$target" | cut -f1)"
|
||||
echo "Fertig: $target ($size)"
|
||||
echo "Wiederherstellen mit: ./scripts/restore.sh \"$target\""
|
||||
@@ -0,0 +1,62 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Spielt eine mit backup.ps1 erzeugte Sicherung zurueck.
|
||||
|
||||
.DESCRIPTION
|
||||
ACHTUNG: Der aktuelle Inhalt der Datenbank wird dabei ersetzt. Das Skript
|
||||
fragt vorher nach, es sei denn, -Force ist gesetzt.
|
||||
|
||||
.PARAMETER Database
|
||||
Zieldatenbank. Vorgabe: der Wert aus der .env.
|
||||
|
||||
Tipp: Eine Sicherung laesst sich pruefen, ohne die echte Datenbank
|
||||
anzufassen - in eine leere Testdatenbank einspielen und nachsehen:
|
||||
|
||||
docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
|
||||
.\scripts\restore.ps1 -File .\backups\... -Database nebula_test -Force
|
||||
|
||||
.EXAMPLE
|
||||
.\scripts\restore.ps1 -File .\backups\nebula-2026-07-26-101500.sql
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$File,
|
||||
[string]$Database,
|
||||
[string]$Container = "nebula-db",
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (-not (Test-Path $File)) { Write-Error "Datei nicht gefunden: $File" }
|
||||
|
||||
$envFile = Join-Path $PSScriptRoot "..\.env"
|
||||
$settings = @{ POSTGRES_USER = "nebula"; POSTGRES_DB = "nebula" }
|
||||
if (Test-Path $envFile) {
|
||||
foreach ($line in Get-Content $envFile) {
|
||||
if ($line -match '^\s*([A-Z_]+)\s*=\s*"?([^"#]*)"?\s*$') {
|
||||
$settings[$Matches[1]] = $Matches[2].Trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$user = $settings["POSTGRES_USER"]
|
||||
$database = if ($Database) { $Database } else { $settings["POSTGRES_DB"] }
|
||||
|
||||
if (-not $Force) {
|
||||
Write-Host "Der gesamte Inhalt der Datenbank '$database' wird ersetzt." -ForegroundColor Yellow
|
||||
$answer = Read-Host "Wirklich fortfahren? (ja/nein)"
|
||||
if ($answer -ne "ja") { Write-Host "Abgebrochen."; exit 0 }
|
||||
}
|
||||
|
||||
$running = docker ps --filter "name=$Container" --format "{{.Names}}"
|
||||
if ($running -notcontains $Container) {
|
||||
Write-Error "Container '$Container' laeuft nicht."
|
||||
}
|
||||
|
||||
Write-Host "Spiele '$File' ein..."
|
||||
Get-Content $File -Raw | docker exec -i $Container psql -U $user -d $database -v ON_ERROR_STOP=1
|
||||
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "Das Einspielen ist fehlgeschlagen." }
|
||||
|
||||
Write-Host "Fertig. Anwendung neu starten, damit sie eine frische Verbindung aufbaut." -ForegroundColor Green
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/bin/sh
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Spielt eine mit backup.sh erzeugte Sicherung zurueck.
|
||||
#
|
||||
# ACHTUNG: Der aktuelle Inhalt der Datenbank wird ersetzt.
|
||||
#
|
||||
# Aufruf: ./scripts/restore.sh <datei.sql> [--force]
|
||||
#
|
||||
# Zieldatenbank ueberschreiben mit NEBULA_DB_NAME. Damit laesst sich eine
|
||||
# Sicherung pruefen, ohne die echte Datenbank anzufassen:
|
||||
#
|
||||
# docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
|
||||
# NEBULA_DB_NAME=nebula_test ./scripts/restore.sh backups/... --force
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
set -eu
|
||||
|
||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
file="${1:-}"
|
||||
force="${2:-}"
|
||||
container="${NEBULA_DB_CONTAINER:-nebula-db}"
|
||||
|
||||
if [ -z "$file" ]; then
|
||||
echo "Aufruf: ./scripts/restore.sh <datei.sql> [--force]" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "Datei nicht gefunden: $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
user="nebula"
|
||||
database="nebula"
|
||||
if [ -f "$root/.env" ]; then
|
||||
value_of() {
|
||||
sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*\"\{0,1\}\([^\"#]*\)\"\{0,1\}[[:space:]]*$/\1/p" \
|
||||
"$root/.env" | tail -n 1
|
||||
}
|
||||
[ -n "$(value_of POSTGRES_USER)" ] && user="$(value_of POSTGRES_USER)"
|
||||
[ -n "$(value_of POSTGRES_DB)" ] && database="$(value_of POSTGRES_DB)"
|
||||
fi
|
||||
database="${NEBULA_DB_NAME:-$database}"
|
||||
|
||||
if [ "$force" != "--force" ]; then
|
||||
printf "Der gesamte Inhalt der Datenbank '%s' wird ersetzt.\nWirklich fortfahren? (ja/nein) " "$database"
|
||||
read -r answer
|
||||
[ "$answer" = "ja" ] || { echo "Abgebrochen."; exit 0; }
|
||||
fi
|
||||
|
||||
if ! docker ps --filter "name=$container" --format '{{.Names}}' | grep -qx "$container"; then
|
||||
echo "Container '$container' laeuft nicht." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Spiele '$file' ein..."
|
||||
docker exec -i "$container" psql -U "$user" -d "$database" -v ON_ERROR_STOP=1 < "$file"
|
||||
|
||||
echo "Fertig. Anwendung neu starten, damit sie eine frische Verbindung aufbaut."
|
||||
@@ -0,0 +1,88 @@
|
||||
import { requireAdminApi } from "@/lib/auth/guard";
|
||||
import { APP_NAME } from "@/lib/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { exportFileName } from "@/lib/export/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Vollstaendige Sicherung der Instanz als JSON.
|
||||
*
|
||||
* Anders als der Projekt-Export ist das eine echte Sicherung und enthaelt
|
||||
* deshalb auch Konten und Verbindungen. Was drinsteht:
|
||||
*
|
||||
* - Passwoerter nur als scrypt-Hash, nicht im Klartext
|
||||
* - Git-Tokens nur als Chiffrat; ohne dasselbe APP_SECRET sind sie wertlos
|
||||
* - Sitzungen und Passkeys bewusst nicht: Sitzungen sind kurzlebig, und
|
||||
* Passkeys haengen ohnehin am Geraet und an der rpID
|
||||
*
|
||||
* Die Datei ist damit vertraulich zu behandeln. Fuer eine byteweise Sicherung
|
||||
* der Datenbank siehe scripts/backup.* mit pg_dump.
|
||||
*/
|
||||
export async function GET() {
|
||||
const auth = await requireAdminApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const [settings, users, connections, projects] = await Promise.all([
|
||||
prisma.appSettings.findUnique({ where: { id: "singleton" } }),
|
||||
prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
email: true,
|
||||
passwordHash: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
},
|
||||
orderBy: { username: "asc" },
|
||||
}),
|
||||
prisma.gitConnection.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
ownerId: true,
|
||||
name: true,
|
||||
provider: true,
|
||||
baseUrl: true,
|
||||
username: true,
|
||||
tokenCipher: true,
|
||||
tokenHint: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.project.findMany({
|
||||
include: {
|
||||
notes: { orderBy: { createdAt: "asc" } },
|
||||
// Snapshots bleiben draussen: reproduzierbar und gross.
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const payload = {
|
||||
kind: "nebula.backup",
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
app: APP_NAME,
|
||||
note:
|
||||
"Enthaelt Passwort-Hashes und verschluesselte Git-Tokens. " +
|
||||
"Die Tokens sind nur mit demselben APP_SECRET wieder lesbar. Vertraulich behandeln.",
|
||||
settings,
|
||||
users,
|
||||
connections,
|
||||
projects,
|
||||
};
|
||||
|
||||
const filename = exportFileName(`${APP_NAME}-sicherung`, new Date(), "json");
|
||||
|
||||
return new Response(JSON.stringify(payload, null, 2), {
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { exportFileName } from "@/lib/export/format";
|
||||
import { buildExportFile, toMarkdown } from "@/lib/export/project";
|
||||
import { jsonError } from "@/lib/security/request";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
/**
|
||||
* Ein einzelnes Projekt exportieren.
|
||||
*
|
||||
* ?format=json (Vorgabe) – zum Wiedereinlesen
|
||||
* ?format=md – lesbar, zum Archivieren
|
||||
*/
|
||||
export async function GET(request: Request, { params }: Params) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const { id } = await params;
|
||||
const format = new URL(request.url).searchParams.get("format") ?? "json";
|
||||
|
||||
const project = await prisma.project.findFirst({
|
||||
where: { id, ownerId: auth.user.id },
|
||||
include: {
|
||||
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
|
||||
connection: { select: { name: true, provider: true, baseUrl: true } },
|
||||
snapshot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) return jsonError("Projekt nicht gefunden", 404);
|
||||
|
||||
if (format === "md" || format === "markdown") {
|
||||
const filename = exportFileName(project.slug, new Date(), "md");
|
||||
return new Response(toMarkdown(project), {
|
||||
headers: {
|
||||
"Content-Type": "text/markdown; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const payload = buildExportFile([project]);
|
||||
const filename = exportFileName(project.slug, new Date(), "json");
|
||||
|
||||
return new Response(JSON.stringify(payload, null, 2), {
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { APP_NAME } from "@/lib/config";
|
||||
import { exportFileName } from "@/lib/export/format";
|
||||
import { buildExportFile, loadProjectsForExport } from "@/lib/export/project";
|
||||
import { jsonError } from "@/lib/security/request";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Alle eigenen Projekte als JSON zum Herunterladen. */
|
||||
export async function GET() {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const projects = await loadProjectsForExport(auth.user.id);
|
||||
if (projects.length === 0) {
|
||||
return jsonError("Es gibt noch keine Projekte zum Exportieren.", 404);
|
||||
}
|
||||
|
||||
const payload = buildExportFile(projects);
|
||||
const filename = exportFileName(`${APP_NAME}-projekte`, new Date(), "json");
|
||||
|
||||
return new Response(JSON.stringify(payload, null, 2), {
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { exportFileSchema, type ExportedProject } from "@/lib/export/format";
|
||||
import { uniqueSlug } from "@/lib/projects";
|
||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||
import {
|
||||
IMPORT_MAX_BODY_BYTES,
|
||||
clientIp,
|
||||
jsonError,
|
||||
readJsonBody,
|
||||
tooManyRequests,
|
||||
} from "@/lib/security/request";
|
||||
import { slugify } from "@/lib/utils";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const requestSchema = z.object({
|
||||
data: z.unknown(),
|
||||
/**
|
||||
* skip – vorhandene Projekte unveraendert lassen
|
||||
* rename – zusaetzlich anlegen, mit freiem Slug
|
||||
* overwrite – vorhandenes Projekt ersetzen (Notizen werden ausgetauscht)
|
||||
*/
|
||||
strategy: z.enum(["skip", "rename", "overwrite"]).default("skip"),
|
||||
/** Nur pruefen und berichten, nichts schreiben. */
|
||||
dryRun: z.boolean().default(true),
|
||||
});
|
||||
|
||||
type Action = "create" | "rename" | "overwrite" | "skip" | "error";
|
||||
|
||||
interface ItemResult {
|
||||
name: string;
|
||||
slug: string;
|
||||
action: Action;
|
||||
/** Bei "rename" der tatsaechlich vergebene Slug. */
|
||||
newSlug?: string;
|
||||
notes: number;
|
||||
/** matched: Verbindung gefunden · missing: beschrieben, aber nicht vorhanden */
|
||||
connection: "matched" | "missing" | "none";
|
||||
connectionName?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const limited = rateLimit(
|
||||
`import:${clientIp(request)}`,
|
||||
LIMITS.mutation.limit,
|
||||
LIMITS.mutation.windowMs,
|
||||
);
|
||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||
|
||||
const body = await readJsonBody(request, IMPORT_MAX_BODY_BYTES);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const outer = requestSchema.safeParse(body.data);
|
||||
if (!outer.success) return jsonError("Ungueltige Anfrage.", 422);
|
||||
|
||||
// Die hochgeladene Datei ist Fremdinhalt – strikt pruefen, bevor irgendetwas
|
||||
// damit passiert.
|
||||
const parsed = exportFileSchema.safeParse(outer.data.data);
|
||||
if (!parsed.success) {
|
||||
const first = parsed.error.issues[0];
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Die Datei passt nicht zum erwarteten Format. " +
|
||||
(first ? `${first.path.join(".") || "Datei"}: ${first.message}` : ""),
|
||||
},
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
const { strategy, dryRun } = outer.data;
|
||||
const file = parsed.data;
|
||||
|
||||
// Verbindungen des Kontos einmal laden, um sie den Projekten zuzuordnen.
|
||||
const connections = await prisma.gitConnection.findMany({
|
||||
where: { ownerId: auth.user.id },
|
||||
select: { id: true, name: true, provider: true, baseUrl: true },
|
||||
});
|
||||
|
||||
function matchConnection(described: ExportedProject["connection"]) {
|
||||
if (!described) return null;
|
||||
const byName = connections.find(
|
||||
(candidate) => candidate.name.toLowerCase() === described.name.toLowerCase(),
|
||||
);
|
||||
if (byName) return byName;
|
||||
return (
|
||||
connections.find(
|
||||
(candidate) =>
|
||||
candidate.provider === described.provider &&
|
||||
candidate.baseUrl.replace(/\/+$/, "").toLowerCase() ===
|
||||
described.baseUrl.replace(/\/+$/, "").toLowerCase(),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
const results: ItemResult[] = [];
|
||||
|
||||
for (const incoming of file.projects) {
|
||||
const slug = incoming.slug || slugify(incoming.name) || "projekt";
|
||||
const connection = matchConnection(incoming.connection);
|
||||
const connectionState: ItemResult["connection"] = incoming.connection
|
||||
? connection
|
||||
? "matched"
|
||||
: "missing"
|
||||
: "none";
|
||||
|
||||
const existing = await prisma.project.findFirst({
|
||||
where: { slug, ownerId: auth.user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const base: ItemResult = {
|
||||
name: incoming.name,
|
||||
slug,
|
||||
action: "create",
|
||||
notes: incoming.notes.length,
|
||||
connection: connectionState,
|
||||
connectionName: incoming.connection?.name,
|
||||
};
|
||||
|
||||
if (existing && strategy === "skip") {
|
||||
results.push({ ...base, action: "skip", message: "Slug bereits vergeben." });
|
||||
continue;
|
||||
}
|
||||
|
||||
const action: Action = !existing ? "create" : strategy === "rename" ? "rename" : "overwrite";
|
||||
|
||||
if (dryRun) {
|
||||
results.push({
|
||||
...base,
|
||||
action,
|
||||
...(action === "rename" ? { newSlug: `${slug}-…` } : {}),
|
||||
...(action === "overwrite"
|
||||
? { message: "Ersetzt Stammdaten und tauscht die Notizen aus." }
|
||||
: {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = {
|
||||
name: incoming.name,
|
||||
summary: incoming.summary ?? null,
|
||||
description: incoming.description ?? null,
|
||||
status: incoming.status,
|
||||
priority: incoming.priority,
|
||||
progress: incoming.progress,
|
||||
accent: incoming.accent,
|
||||
tags: incoming.tags,
|
||||
favorite: incoming.favorite,
|
||||
autoSync: incoming.autoSync,
|
||||
repoUrl: incoming.repoUrl ?? null,
|
||||
repoOwner: incoming.repoOwner ?? null,
|
||||
repoName: incoming.repoName ?? null,
|
||||
connectionId: connection?.id ?? null,
|
||||
};
|
||||
|
||||
const notes = incoming.notes.map((note) => ({
|
||||
title: note.title ?? null,
|
||||
content: note.content,
|
||||
pinned: note.pinned ?? false,
|
||||
...(note.createdAt && !Number.isNaN(Date.parse(note.createdAt))
|
||||
? { createdAt: new Date(note.createdAt) }
|
||||
: {}),
|
||||
}));
|
||||
|
||||
try {
|
||||
if (existing && action === "overwrite") {
|
||||
// Notizen austauschen statt zusammenfuehren: vorhersagbar, und ein
|
||||
// erneuter Import erzeugt keine Dubletten.
|
||||
await prisma.$transaction([
|
||||
prisma.note.deleteMany({ where: { projectId: existing.id } }),
|
||||
prisma.project.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...data, slug, notes: { create: notes } },
|
||||
}),
|
||||
]);
|
||||
results.push({ ...base, action: "overwrite" });
|
||||
} else {
|
||||
const finalSlug = existing ? await uniqueSlug(incoming.name, auth.user.id) : slug;
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
...data,
|
||||
ownerId: auth.user.id,
|
||||
slug: finalSlug,
|
||||
notes: { create: notes },
|
||||
},
|
||||
});
|
||||
results.push({
|
||||
...base,
|
||||
action: existing ? "rename" : "create",
|
||||
...(existing ? { newSlug: finalSlug } : {}),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
results.push({
|
||||
...base,
|
||||
action: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
total: results.length,
|
||||
created: results.filter((entry) => entry.action === "create").length,
|
||||
renamed: results.filter((entry) => entry.action === "rename").length,
|
||||
overwritten: results.filter((entry) => entry.action === "overwrite").length,
|
||||
skipped: results.filter((entry) => entry.action === "skip").length,
|
||||
errors: results.filter((entry) => entry.action === "error").length,
|
||||
missingConnections: results.filter((entry) => entry.connection === "missing").length,
|
||||
};
|
||||
|
||||
return NextResponse.json({ dryRun, strategy, summary, results });
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
FileJson,
|
||||
Loader2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Strategy = "skip" | "rename" | "overwrite";
|
||||
|
||||
interface ItemResult {
|
||||
name: string;
|
||||
slug: string;
|
||||
action: "create" | "rename" | "overwrite" | "skip" | "error";
|
||||
newSlug?: string;
|
||||
notes: number;
|
||||
connection: "matched" | "missing" | "none";
|
||||
connectionName?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface ImportResponse {
|
||||
dryRun: boolean;
|
||||
strategy: Strategy;
|
||||
summary: {
|
||||
total: number;
|
||||
created: number;
|
||||
renamed: number;
|
||||
overwritten: number;
|
||||
skipped: number;
|
||||
errors: number;
|
||||
missingConnections: number;
|
||||
};
|
||||
results: ItemResult[];
|
||||
}
|
||||
|
||||
const STRATEGIES: Array<{ value: Strategy; label: string; hint: string }> = [
|
||||
{ value: "skip", label: "Ueberspringen", hint: "Vorhandene bleiben unveraendert." },
|
||||
{ value: "rename", label: "Zusaetzlich anlegen", hint: "Doppelte bekommen einen freien Slug." },
|
||||
{ value: "overwrite", label: "Ersetzen", hint: "Stammdaten und Notizen werden ueberschrieben." },
|
||||
];
|
||||
|
||||
const ACTION_LABEL: Record<ItemResult["action"], { text: string; className: string }> = {
|
||||
create: { text: "neu", className: "bg-emerald-500/12 text-emerald-200" },
|
||||
rename: { text: "zusaetzlich", className: "bg-cyan-500/12 text-cyan-200" },
|
||||
overwrite: { text: "ersetzt", className: "bg-amber-500/12 text-amber-200" },
|
||||
skip: { text: "uebersprungen", className: "bg-slate-500/12 text-slate-300" },
|
||||
error: { text: "Fehler", className: "bg-rose-500/12 text-rose-200" },
|
||||
};
|
||||
|
||||
export function ImportDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const router = useRouter();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [payload, setPayload] = useState<unknown>(null);
|
||||
const [strategy, setStrategy] = useState<Strategy>("skip");
|
||||
const [preview, setPreview] = useState<ImportResponse | null>(null);
|
||||
const [done, setDone] = useState<ImportResponse | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function reset() {
|
||||
setFileName(null);
|
||||
setPayload(null);
|
||||
setPreview(null);
|
||||
setDone(null);
|
||||
setError(null);
|
||||
setStrategy("skip");
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
|
||||
function close() {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function pickFile(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setError(null);
|
||||
setPreview(null);
|
||||
setDone(null);
|
||||
setFileName(file.name);
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text);
|
||||
setPayload(parsed);
|
||||
await run(parsed, strategy, true);
|
||||
} catch {
|
||||
setPayload(null);
|
||||
setError("Die Datei enthaelt kein gueltiges JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
async function run(data: unknown, chosen: Strategy, dryRun: boolean) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/projects/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ data, strategy: chosen, dryRun }),
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(body.error ?? "Import fehlgeschlagen.");
|
||||
|
||||
if (dryRun) setPreview(body as ImportResponse);
|
||||
else {
|
||||
setDone(body as ImportResponse);
|
||||
router.refresh();
|
||||
}
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : String(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeStrategy(next: Strategy) {
|
||||
setStrategy(next);
|
||||
if (payload) await run(payload, next, true);
|
||||
}
|
||||
|
||||
const summary = done?.summary ?? preview?.summary;
|
||||
const results = done?.results ?? preview?.results ?? [];
|
||||
|
||||
// Wuerde der Import ueberhaupt etwas veraendern? Sonst waere der Knopf ein
|
||||
// Angebot, das ins Leere laeuft.
|
||||
const wouldChange = preview
|
||||
? preview.summary.created + preview.summary.renamed + preview.summary.overwritten > 0
|
||||
: false;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={close}
|
||||
width="xl"
|
||||
title="Projekte importieren"
|
||||
description="Eine zuvor exportierte JSON-Datei einlesen. Vor dem Schreiben gibt es eine Vorschau."
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* Datei */}
|
||||
<div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={pickFile}
|
||||
className="hidden"
|
||||
id="import-file"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost w-full justify-start py-3"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={busy}
|
||||
>
|
||||
<FileJson className="h-4 w-4 text-violet-300" />
|
||||
{fileName ?? "JSON-Datei auswaehlen…"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Strategie */}
|
||||
{payload ? (
|
||||
<div>
|
||||
<span className="field-label">Bei gleichem Slug</span>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{STRATEGIES.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={busy || Boolean(done)}
|
||||
onClick={() => changeStrategy(option.value)}
|
||||
className={cn(
|
||||
"rounded-xl border px-3 py-2.5 text-left transition-all disabled:opacity-60",
|
||||
strategy === option.value
|
||||
? "border-violet-400/60 bg-violet-500/12"
|
||||
: "border-white/9 hover:border-white/20",
|
||||
)}
|
||||
>
|
||||
<span className="block text-sm text-slate-100">{option.label}</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-slate-500">
|
||||
{option.hint}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Vorschau oder Ergebnis */}
|
||||
{summary ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="text-slate-400">
|
||||
{done ? "Ergebnis:" : "Vorschau:"} {summary.total} Projekt(e)
|
||||
</span>
|
||||
{summary.created > 0 ? <Chip tone="emerald">{summary.created} neu</Chip> : null}
|
||||
{summary.renamed > 0 ? <Chip tone="cyan">{summary.renamed} zusaetzlich</Chip> : null}
|
||||
{summary.overwritten > 0 ? (
|
||||
<Chip tone="amber">{summary.overwritten} ersetzt</Chip>
|
||||
) : null}
|
||||
{summary.skipped > 0 ? <Chip tone="slate">{summary.skipped} uebersprungen</Chip> : null}
|
||||
{summary.errors > 0 ? <Chip tone="rose">{summary.errors} Fehler</Chip> : null}
|
||||
</div>
|
||||
|
||||
{summary.missingConnections > 0 ? (
|
||||
<p className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
{summary.missingConnections} Projekt(e) verweisen auf eine Git-Verbindung,
|
||||
die hier nicht existiert. Sie werden ohne Verknuepfung angelegt – lege die
|
||||
Verbindung an und waehle sie im Projekt aus. Access Tokens sind in
|
||||
Exportdateien grundsaetzlich nicht enthalten.
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="max-h-64 overflow-y-auto rounded-xl border border-white/8">
|
||||
{results.map((item) => {
|
||||
const label = ACTION_LABEL[item.action];
|
||||
return (
|
||||
<div
|
||||
key={`${item.slug}-${item.name}`}
|
||||
className="flex items-center justify-between gap-3 border-b border-white/5 px-3 py-2 last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm text-slate-200">{item.name}</p>
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 font-mono text-[11px] text-slate-500">
|
||||
<span>{item.slug}</span>
|
||||
{item.newSlug ? (
|
||||
<>
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
<span className="text-cyan-300">{item.newSlug}</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="text-slate-600">· {item.notes} Notizen</span>
|
||||
{item.connection === "missing" ? (
|
||||
<span className="text-amber-300/80">
|
||||
· Verbindung „{item.connectionName}“ fehlt
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{item.message ? (
|
||||
<p className="mt-0.5 text-[11px] text-slate-600">{item.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded px-1.5 py-0.5 text-[10px] uppercase tracking-wider",
|
||||
label.className,
|
||||
)}
|
||||
>
|
||||
{label.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{done ? (
|
||||
<p className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Import abgeschlossen.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-ghost" onClick={close} disabled={busy}>
|
||||
{done ? "Schliessen" : "Abbrechen"}
|
||||
</button>
|
||||
{!done ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy || !wouldChange}
|
||||
title={
|
||||
preview && !wouldChange
|
||||
? "Mit dieser Strategie wuerde sich nichts aendern."
|
||||
: undefined
|
||||
}
|
||||
onClick={() => payload && run(payload, strategy, false)}
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
Import ausfuehren
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone: "emerald" | "cyan" | "amber" | "slate" | "rose";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const tones = {
|
||||
emerald: "bg-emerald-500/12 text-emerald-200",
|
||||
cyan: "bg-cyan-500/12 text-cyan-200",
|
||||
amber: "bg-amber-500/12 text-amber-200",
|
||||
slate: "bg-slate-500/12 text-slate-300",
|
||||
rose: "bg-rose-500/12 text-rose-200",
|
||||
};
|
||||
return <span className={cn("rounded px-1.5 py-0.5", tones[tone])}>{children}</span>;
|
||||
}
|
||||
@@ -1,10 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { LayoutGrid, ListFilter, Plus, Rows3, Search, X } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Database,
|
||||
Download,
|
||||
LayoutGrid,
|
||||
ListFilter,
|
||||
Plus,
|
||||
Rows3,
|
||||
Search,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { ProjectStatus } from "@prisma/client";
|
||||
import { ImportDialog } from "@/components/ImportDialog";
|
||||
import { ProjectCard } from "@/components/ProjectCard";
|
||||
import { ProjectDialog } from "@/components/ProjectDialog";
|
||||
import { Popover } from "@/components/ui/Popover";
|
||||
import { STATUS_META, STATUS_ORDER } from "@/lib/status";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ConnectionSafe, ProjectListItem } from "@/types";
|
||||
@@ -33,6 +45,9 @@ export function ProjectBoard({
|
||||
const [view, setView] = useState<ViewMode>("grid");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [dataMenuOpen, setDataMenuOpen] = useState(false);
|
||||
const dataButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const map = new Map<ProjectStatus, number>();
|
||||
@@ -165,6 +180,19 @@ export function ProjectBoard({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
ref={dataButtonRef}
|
||||
type="button"
|
||||
onClick={() => setDataMenuOpen((value) => !value)}
|
||||
className="rounded-xl border border-white/9 p-2 text-slate-500 transition-colors hover:text-white"
|
||||
aria-label="Daten exportieren oder importieren"
|
||||
title="Export und Import"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={dataMenuOpen}
|
||||
>
|
||||
<Database className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button type="button" className="btn btn-primary" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Neues Projekt</span>
|
||||
@@ -267,11 +295,54 @@ export function ProjectBoard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Popover
|
||||
anchorRef={dataButtonRef}
|
||||
open={dataMenuOpen}
|
||||
onClose={() => setDataMenuOpen(false)}
|
||||
width={260}
|
||||
>
|
||||
<a
|
||||
href="/api/projects/export"
|
||||
download
|
||||
role="menuitem"
|
||||
onClick={() => setDataMenuOpen(false)}
|
||||
className="flex items-start gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||||
>
|
||||
<Download className="mt-0.5 h-4 w-4 shrink-0 text-violet-300" />
|
||||
<span>
|
||||
Alle Projekte exportieren
|
||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
||||
JSON mit Notizen, ohne Access Tokens
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setDataMenuOpen(false);
|
||||
setImportOpen(true);
|
||||
}}
|
||||
className="flex w-full items-start gap-2.5 rounded-lg px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||||
>
|
||||
<Upload className="mt-0.5 h-4 w-4 shrink-0 text-cyan-300" />
|
||||
<span>
|
||||
Projekte importieren
|
||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
||||
Mit Vorschau vor dem Schreiben
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</Popover>
|
||||
|
||||
<ProjectDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
connections={connections}
|
||||
/>
|
||||
|
||||
<ImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarDays,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FileJson,
|
||||
FileText,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Star,
|
||||
@@ -14,6 +17,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { ProjectDialog } from "@/components/ProjectDialog";
|
||||
import { StatusSelect } from "@/components/StatusSelect";
|
||||
import { Popover } from "@/components/ui/Popover";
|
||||
import { PRIORITY_META, accentOf } from "@/lib/status";
|
||||
import { cn, formatDate } from "@/lib/utils";
|
||||
import type { ConnectionSafe, ProjectDetail } from "@/types";
|
||||
@@ -30,6 +34,8 @@ export function ProjectHeader({
|
||||
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const exportButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [favorite, setFavorite] = useState(project.favorite);
|
||||
const [progress, setProgress] = useState(project.progress);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
@@ -162,6 +168,17 @@ export function ProjectHeader({
|
||||
<div className="flex flex-col items-end gap-3">
|
||||
<StatusSelect projectId={project.id} status={project.status} />
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
ref={exportButtonRef}
|
||||
type="button"
|
||||
className="btn btn-ghost px-2.5 py-1.5 text-xs"
|
||||
onClick={() => setExportOpen((value) => !value)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={exportOpen}
|
||||
title="Exportieren"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost px-3 py-1.5 text-xs"
|
||||
@@ -211,6 +228,44 @@ export function ProjectHeader({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
anchorRef={exportButtonRef}
|
||||
open={exportOpen}
|
||||
onClose={() => setExportOpen(false)}
|
||||
width={250}
|
||||
>
|
||||
<a
|
||||
href={`/api/projects/${project.id}/export`}
|
||||
download
|
||||
role="menuitem"
|
||||
onClick={() => setExportOpen(false)}
|
||||
className="flex items-start gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||||
>
|
||||
<FileJson className="mt-0.5 h-4 w-4 shrink-0 text-violet-300" />
|
||||
<span>
|
||||
Als JSON
|
||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
||||
Zum Wiedereinlesen, mit Notizen
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href={`/api/projects/${project.id}/export?format=md`}
|
||||
download
|
||||
role="menuitem"
|
||||
onClick={() => setExportOpen(false)}
|
||||
className="flex items-start gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||||
>
|
||||
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-cyan-300" />
|
||||
<span>
|
||||
Als Markdown
|
||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
||||
Lesbar, inklusive Git-Stand
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</Popover>
|
||||
|
||||
<ProjectDialog
|
||||
open={editOpen}
|
||||
onClose={() => setEditOpen(false)}
|
||||
|
||||
@@ -5,12 +5,15 @@ import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
Download,
|
||||
Fingerprint,
|
||||
FolderGit2,
|
||||
Loader2,
|
||||
Lock,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
User,
|
||||
@@ -226,6 +229,40 @@ export function AdminManager({ data }: { data: AdminData }) {
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* ── Sicherung ───────────────────────────────────── */}
|
||||
<section className="glass p-5">
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-amber-500/12 text-amber-300">
|
||||
<Database className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
||||
Sicherung
|
||||
</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm text-slate-400">
|
||||
Vollstaendige Sicherung der Instanz als JSON: Einstellungen, Konten,
|
||||
Verbindungen, Projekte und Notizen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/api/admin/backup" download className="btn btn-ghost">
|
||||
<Download className="h-4 w-4" />
|
||||
Sicherung herunterladen
|
||||
</a>
|
||||
|
||||
<p className="mt-3 flex items-start gap-2 rounded-lg border border-amber-500/25 bg-amber-500/8 px-3 py-2 text-[11px] leading-snug text-amber-200/90">
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Die Datei enthaelt Passwort-Hashes und verschluesselte Git-Tokens. Die Tokens
|
||||
sind nur mit demselben <code>APP_SECRET</code> wieder lesbar, Passwoerter
|
||||
liegen als scrypt-Hash vor – trotzdem vertraulich behandeln. Fuer eine
|
||||
byteweise Sicherung der Datenbank gibt es <code>scripts/backup.ps1</code>{" "}
|
||||
beziehungsweise <code>scripts/backup.sh</code> mit <code>pg_dump</code>.
|
||||
</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ── Benutzer ────────────────────────────────────── */}
|
||||
<section className="glass p-5">
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Austauschformat fuer Projekte.
|
||||
*
|
||||
* Bewusst schlank und ohne Geheimnisse:
|
||||
* - keine Access Tokens. Die Verbindung wird nur beschrieben (Name, System,
|
||||
* Basis-URL), das Token muss beim Import neu hinterlegt werden. Eine
|
||||
* Exportdatei landet sonst schnell in einem Download-Ordner oder einem
|
||||
* Cloud-Abgleich.
|
||||
* - keine Repo-Snapshots. Die sind reproduzierbar und blaehen die Datei auf;
|
||||
* ein Abgleich nach dem Import holt sie zurueck.
|
||||
* - keine Datenbank-IDs. Die gelten nur in der Instanz, aus der exportiert
|
||||
* wurde.
|
||||
*/
|
||||
|
||||
export const EXPORT_KIND = "nebula.projects";
|
||||
export const EXPORT_VERSION = 1;
|
||||
|
||||
const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const;
|
||||
const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const;
|
||||
|
||||
export const exportedNoteSchema = z.object({
|
||||
title: z.string().max(160).nullable().optional(),
|
||||
content: z.string().max(20000),
|
||||
pinned: z.boolean().optional().default(false),
|
||||
createdAt: z.string().optional(),
|
||||
});
|
||||
|
||||
export const exportedConnectionSchema = z.object({
|
||||
name: z.string().max(80),
|
||||
provider: z.enum(PROVIDERS),
|
||||
baseUrl: z.string().max(300),
|
||||
});
|
||||
|
||||
export const exportedProjectSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
slug: z.string().max(60).optional(),
|
||||
summary: z.string().max(280).nullable().optional(),
|
||||
description: z.string().max(20000).nullable().optional(),
|
||||
status: z.enum(STATUS).optional().default("IDEA"),
|
||||
priority: z.number().int().min(1).max(4).optional().default(2),
|
||||
progress: z.number().int().min(0).max(100).optional().default(0),
|
||||
accent: z.string().max(20).optional().default("violet"),
|
||||
tags: z.array(z.string().max(30)).max(12).optional().default([]),
|
||||
favorite: z.boolean().optional().default(false),
|
||||
autoSync: z.boolean().optional().default(true),
|
||||
repoUrl: z.string().max(500).nullable().optional(),
|
||||
repoOwner: z.string().max(200).nullable().optional(),
|
||||
repoName: z.string().max(200).nullable().optional(),
|
||||
/** Nur zur Wiedererkennung – ohne Token. */
|
||||
connection: exportedConnectionSchema.nullable().optional(),
|
||||
notes: z.array(exportedNoteSchema).max(500).optional().default([]),
|
||||
createdAt: z.string().optional(),
|
||||
});
|
||||
|
||||
export const exportFileSchema = z.object({
|
||||
kind: z.literal(EXPORT_KIND),
|
||||
version: z.number().int().min(1).max(EXPORT_VERSION),
|
||||
exportedAt: z.string().optional(),
|
||||
app: z.string().optional(),
|
||||
projects: z.array(exportedProjectSchema).min(1).max(1000),
|
||||
});
|
||||
|
||||
export type ExportedProject = z.infer<typeof exportedProjectSchema>;
|
||||
export type ExportFile = z.infer<typeof exportFileSchema>;
|
||||
|
||||
/** Dateiname ohne Sonderzeichen, mit Datumsstempel. */
|
||||
export function exportFileName(prefix: string, date: Date, extension: string): string {
|
||||
const stamp = date.toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
||||
const safe = prefix
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
return `${safe || "nebula"}-${stamp}.${extension}`;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { GitConnection, Note, Project, RepoSnapshot } from "@prisma/client";
|
||||
import { APP_NAME } from "@/lib/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PRIORITY_META, statusOf } from "@/lib/status";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import { EXPORT_KIND, EXPORT_VERSION, type ExportFile, type ExportedProject } from "./format";
|
||||
|
||||
type ProjectForExport = Project & {
|
||||
notes: Note[];
|
||||
connection: Pick<GitConnection, "name" | "provider" | "baseUrl"> | null;
|
||||
};
|
||||
|
||||
/** Formt einen Datensatz in das Austauschformat um – ohne Geheimnisse. */
|
||||
export function toExportedProject(project: ProjectForExport): ExportedProject {
|
||||
return {
|
||||
name: project.name,
|
||||
slug: project.slug,
|
||||
summary: project.summary,
|
||||
description: project.description,
|
||||
status: project.status,
|
||||
priority: project.priority,
|
||||
progress: project.progress,
|
||||
accent: project.accent,
|
||||
tags: project.tags,
|
||||
favorite: project.favorite,
|
||||
autoSync: project.autoSync,
|
||||
repoUrl: project.repoUrl,
|
||||
repoOwner: project.repoOwner,
|
||||
repoName: project.repoName,
|
||||
connection: project.connection
|
||||
? {
|
||||
name: project.connection.name,
|
||||
provider: project.connection.provider,
|
||||
baseUrl: project.connection.baseUrl,
|
||||
}
|
||||
: null,
|
||||
notes: project.notes.map((note) => ({
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
pinned: note.pinned,
|
||||
createdAt: note.createdAt.toISOString(),
|
||||
})),
|
||||
createdAt: project.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildExportFile(projects: ProjectForExport[]): ExportFile {
|
||||
return {
|
||||
kind: EXPORT_KIND,
|
||||
version: EXPORT_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
app: APP_NAME,
|
||||
projects: projects.map(toExportedProject),
|
||||
};
|
||||
}
|
||||
|
||||
/** Laedt die Projekte eines Kontos im fuer den Export noetigen Umfang. */
|
||||
export async function loadProjectsForExport(
|
||||
ownerId: string,
|
||||
projectId?: string,
|
||||
): Promise<ProjectForExport[]> {
|
||||
return prisma.project.findMany({
|
||||
where: { ownerId, ...(projectId ? { id: projectId } : {}) },
|
||||
include: {
|
||||
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
|
||||
connection: { select: { name: true, provider: true, baseUrl: true } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Markdown ─────────────────────────────────────────────────
|
||||
|
||||
const PROVIDER_LABEL: Record<string, string> = {
|
||||
GITHUB: "GitHub",
|
||||
GITLAB: "GitLab",
|
||||
GITEA: "Gitea",
|
||||
};
|
||||
|
||||
/**
|
||||
* Ein Projekt als lesbares Markdown – zum Archivieren oder als Grundlage
|
||||
* fuer ein README. Der Git-Stand kommt aus dem letzten Snapshot, damit die
|
||||
* Datei auch ohne Netz vollstaendig ist.
|
||||
*/
|
||||
export function toMarkdown(
|
||||
project: ProjectForExport & { snapshot: RepoSnapshot | null },
|
||||
): string {
|
||||
const status = statusOf(project.status);
|
||||
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`# ${project.name}`, "");
|
||||
if (project.summary) lines.push(`> ${project.summary}`, "");
|
||||
|
||||
lines.push("| | |", "| --- | --- |");
|
||||
lines.push(`| Status | ${status.label} |`);
|
||||
lines.push(`| Fortschritt | ${project.progress} % |`);
|
||||
lines.push(`| Prioritaet | ${priority.label} |`);
|
||||
if (project.tags.length) {
|
||||
lines.push(`| Tags | ${project.tags.map((tag) => `\`${tag}\``).join(", ")} |`);
|
||||
}
|
||||
lines.push(`| Angelegt | ${formatDateTime(project.createdAt)} |`);
|
||||
if (project.repoUrl) lines.push(`| Repository | ${project.repoUrl} |`);
|
||||
if (project.connection) {
|
||||
const label = PROVIDER_LABEL[project.connection.provider] ?? project.connection.provider;
|
||||
lines.push(`| Git-System | ${label} (${project.connection.baseUrl}) |`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
if (project.description) {
|
||||
lines.push("## Beschreibung", "", project.description, "");
|
||||
}
|
||||
|
||||
const snapshot = project.snapshot;
|
||||
if (snapshot) {
|
||||
lines.push("## Stand des Repositories", "");
|
||||
lines.push(`Abgerufen am ${formatDateTime(snapshot.fetchedAt)}.`, "");
|
||||
lines.push("| | |", "| --- | --- |");
|
||||
if (snapshot.defaultBranch) lines.push(`| Default-Branch | \`${snapshot.defaultBranch}\` |`);
|
||||
if (snapshot.latestTag) lines.push(`| Neuester Tag | \`${snapshot.latestTag}\` |`);
|
||||
lines.push(`| Branches | ${snapshot.branchCount} |`);
|
||||
lines.push(`| Tags | ${snapshot.tagCount} |`);
|
||||
lines.push(`| Offene PRs/MRs | ${snapshot.openPullRequests} |`);
|
||||
lines.push(`| Offene Issues | ${snapshot.openIssues} |`);
|
||||
if (snapshot.lastCommitSha) {
|
||||
lines.push(
|
||||
`| Letzter Commit | \`${snapshot.lastCommitSha.slice(0, 7)}\` – ` +
|
||||
`${snapshot.lastCommitMessage ?? ""} |`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (project.notes.length) {
|
||||
lines.push("## Notizen", "");
|
||||
for (const note of project.notes) {
|
||||
const pin = note.pinned ? " 📌" : "";
|
||||
lines.push(`### ${note.title ?? "Ohne Titel"}${pin}`, "");
|
||||
lines.push(note.content, "");
|
||||
lines.push(`<sub>${formatDateTime(note.createdAt)}</sub>`, "");
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("---", "");
|
||||
lines.push(`<sub>Exportiert aus ${APP_NAME} am ${formatDateTime(new Date())}.</sub>`, "");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/** Groesste akzeptierte JSON-Nutzlast. */
|
||||
/** Groesste akzeptierte JSON-Nutzlast im Normalfall. */
|
||||
const MAX_BODY_BYTES = 256 * 1024;
|
||||
|
||||
/**
|
||||
* Groessere Obergrenze fuer den Import: eine Exportdatei mit vielen Notizen
|
||||
* sprengt 256 KB schnell.
|
||||
*/
|
||||
export const IMPORT_MAX_BODY_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
export function jsonError(
|
||||
message: string,
|
||||
status: number,
|
||||
@@ -34,6 +40,7 @@ export type BodyResult<T> =
|
||||
*/
|
||||
export async function readJsonBody<T = unknown>(
|
||||
request: Request,
|
||||
maxBytes: number = MAX_BODY_BYTES,
|
||||
): Promise<BodyResult<T>> {
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (!contentType.toLowerCase().includes("application/json")) {
|
||||
@@ -44,7 +51,7 @@ export async function readJsonBody<T = unknown>(
|
||||
}
|
||||
|
||||
const declaredLength = Number(request.headers.get("content-length") ?? "0");
|
||||
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||
return { ok: false, response: jsonError("Anfrage zu gross.", 413) };
|
||||
}
|
||||
|
||||
@@ -55,7 +62,7 @@ export async function readJsonBody<T = unknown>(
|
||||
return { ok: false, response: jsonError("Anfrage konnte nicht gelesen werden.", 400) };
|
||||
}
|
||||
|
||||
if (raw.length > MAX_BODY_BYTES) {
|
||||
if (raw.length > maxBytes) {
|
||||
return { ok: false, response: jsonError("Anfrage zu gross.", 413) };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user