feat(aether): initial commit – self-hosted Cloud, Phasen 1–7

Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
# Node — muss im Image via `npm ci` frisch gebaut werden,
# sonst überschreibt der Host-Stand die Builder-Stage (tsc: Permission denied)
frontend/node_modules/
frontend/dist/
# Go
backend/aether
backend/*.test
backend/vendor/
# Secrets — Werte kommen zur Laufzeit über env_file, nie ins Image
deploy/.env
.env
# Daten
data/
backend/data/
# Git / IDE / OS
.git/
.gitignore
.idea/
.vscode/
*.swp
.DS_Store
Thumbs.db
+28
View File
@@ -0,0 +1,28 @@
# Secrets
deploy/.env
.env
# Go
backend/aether
backend/*.test
backend/vendor/
# Node
frontend/node_modules/
frontend/dist/
# Data (never commit real data)
data/
backend/data/
# OS
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
# Von vite.config.ts aus node_modules kopiert (PDF.js-Standardschriften)
frontend/public/pdfjs/
+165
View File
@@ -0,0 +1,165 @@
# Aether – Projektkonventionen für Claude Code
## Projekt-Überblick
Self-hosted Cloud-Alternative zu Nextcloud. Go-Backend + React-Frontend, modularer Monolith.
Projektplan: `cloud-projekt-plan.md`
## Monorepo-Struktur
```
aether/
├── backend/ # Go – modularer Monolith
│ ├── cmd/aether/ # main.go – Einstiegspunkt
│ ├── internal/ # Domänen-Pakete (nie von außen importieren)
│ │ ├── auth/ # Login, Sessions, CSRF, Argon2id, OIDC-User-Anlage
│ │ ├── storage/ # Storage-Interface + LocalFS (atomar, SHA-256)
│ │ ├── files/ # Datei-/Ordner-CRUD, Trash, Versionen, Favoriten
│ │ ├── sharing/ # Öffentliche Share-Links
│ │ ├── admin/ # Admin-Panel: User, Gruppen(-mitgliedschaft), Settings/Theming
│ │ ├── oidc/ # OIDC-Login (optional, via .env)
│ │ ├── webdav/ # WebDAV-Endpoint /dav/ (Basic Auth – nur App-Passwörter)
│ │ ├── acl/ # Interne Freigaben an User/Gruppen (Rechteauflösung)
│ │ ├── audit/ # Audit-Log (Schreibpfad; Endpunkte liegen in admin/)
│ │ ├── gc/ # Blob-GC: Platte gegen DB abgleichen
│ │ ├── thumbnails/ # Vorschaubild-Generierung
│ │ ├── groups/ # LEERER STUB – Gruppenordner liegen in files/groupfolder.go
│ │ ├── documents/ # Volltext: OCR-Warteschlange + Arbeiter (Phase 6, Teil 1)
│ │ ├── tags/ # Ordnungsmerkmale einer Datei: Etiketten, Belegdatum, Absender
│ │ └── office/ # WOPI-Host: Collabora/OnlyOffice, ein Adapter fuer beide
│ ├── db/migrations/ # SQL-Migrations – Spiegelkopie (siehe Migrations-Regeln!)
│ └── pkg/ # Hilfspakete: config, middleware, response, db (embed migrations)
├── frontend/ # React + TS + Vite + Tailwind
│ └── src/
│ ├── components/
│ ├── pages/
│ ├── api/ # Typed API-Client
│ └── hooks/
├── deploy/ # Docker Compose, Dockerfile, .env.example
└── CLAUDE.md
```
## Backend-Konventionen (Go)
- **Module-Pfad**: `aether`
- **Go-Version**: 1.25 (siehe `go.mod`)
- **Router**: `github.com/go-chi/chi/v5`
- **DB-Treiber**: `github.com/jackc/pgx/v5` (pgxpool für Connection-Pool)
- **Migrations**: `github.com/golang-migrate/migrate/v4`
- **Logging**: `github.com/rs/zerolog` – strukturiert, immer mit Kontext (`log.Ctx(ctx)`)
- **Hashing**: `golang.org/x/crypto/argon2` (Argon2id) – App-Passwörter dagegen SHA-256 (zufälliges Token, jeder WebDAV-Request authentifiziert einzeln)
- **Sessions**: `github.com/gorilla/sessions` mit securecookie
### Paket-Regeln
- Alle Domänen-Pakete unter `internal/` – kein Import von außen
- Keine zirkulären Abhängigkeiten; Dependency-Richtung: `cmd → internal/* → pkg/*`
- Handler in `internal/<domain>/handler.go`, Business-Logic in `internal/<domain>/service.go`, DB in `internal/<domain>/store.go`
- Interfaces für alle externen Abhängigkeiten (Storage, DB) → leicht mockbar
### Code-Stil
- `gofmt` + `goimports` – immer formatiert
- Fehler nie ignorieren (`_`), immer wrappen mit `fmt.Errorf("context: %w", err)`
- Keine Magic-Numbers – Konstanten mit sprechenden Namen
- HTTP-Handler signieren: `func (h *Handler) MethodName(w http.ResponseWriter, r *http.Request)`
- Middleware-Chain: Auth → CSRF → RateLimit → Handler
### Tests
- Testdatei neben der Quelldatei: `auth_test.go` neben `auth.go`
- Integrationstests mit echtem Postgres via `testcontainers-go`
- Pflicht-Tests: Path-Traversal, Upload-Abbruch, Checksum-Verifikation, Soft-Delete-Recovery
- `go test ./...` muss grün sein vor jedem Commit
## Frontend-Konventionen (React + TS)
- **Bundler**: Vite
- **Styling**: Tailwind CSS + CSS-Variablen für Design-Tokens
- **Testing**: Vitest + Testing Library; E2E: Playwright
- **API-Client**: typed fetch-Wrapper unter `src/api/`
### Design-Tokens (CSS-Variablen)
Alle Farben laufen über CSS-Custom-Properties:
```css
:root {
--color-primary: ...;
--color-accent: ...;
--color-background: ...;
--color-surface: ...;
--color-text: ...;
}
```
Admin setzt globale Tokens; User-Override nur für eigene Palette (Scope: `[data-user-theme]`).
### Komponenten-Regeln
- Komponenten in `PascalCase.tsx`
- Kein `dangerouslySetInnerHTML` mit User-Input (XSS)
- API-Calls nur über `src/api/`-Funktionen, nie direkt `fetch` in Komponenten
## Sicherheits-Pflichten
- Path-Traversal: jeden Dateipfad kanonisieren und gegen Storage-Root prüfen
- CSRF: Double-Submit-Cookie oder synchronizer-Token
- Rate-Limiting: Login-Endpunkt + Admin-Login strenger
- Auth-Check: jede Route serverseitig prüfen – nie Frontend vertrauen
- SQL: ausschließlich parametrisierte Queries (pgx unterstützt das nativ)
- Uploads: außerhalb Webroot, keine ausführbaren Rechte, MIME-Prüfung
- WebDAV: **nur App-Passwörter**, nie das Kontopasswort – es ist zugleich der Admin-Zugang
- Kein Zugriff auf fremde IDs → `404`, nicht `403`: sonst verrät die Antwort die Existenz
- Listen und Suche schneiden den erreichbaren Bereich **in der Abfrage** zu, nie nachträglich im Handler
- Hochgeladene Dateien nur für eine Positivliste harmloser Typen `inline` ausliefern (sonst läuft fremdes HTML im eigenen Ursprung)
## Datenintegrität (nicht verhandelbar)
1. **Atomares Schreiben**: Temp-Datei → `fsync` → `rename`. Niemals in-place.
2. **SHA-256**: beim Upload berechnen + in DB; beim Download verifizieren.
3. **Soft-Delete only**: kein hartes Löschen. Bytes erst per GC entfernen.
4. **DB-Commit nach Blob-Write**: Metadaten erst committen, wenn Blob erfolgreich.
## Migrations-Regeln
- Dateien: `000001_init.up.sql` / `000001_init.down.sql`
- **Jede Migration in BEIDE Verzeichnisse legen:** `backend/pkg/db/migrations/` (wird ins Binary embedded und beim Start ausgeführt) UND `backend/db/migrations/` (Spiegelkopie). Beide müssen identisch sein.
- Keine destruktiven Änderungen ohne explizites Down-Migration
- Migration beim App-Start automatisch ausführen (via `migrate.Up()`)
- Aktueller Stand: bis `000018_tag_icons` (Emoji an Etiketten und Absendern)
## Commit-Regeln
Format: `<typ>(<scope>): <kurze Beschreibung>`
Typen: `feat`, `fix`, `test`, `refactor`, `docs`, `chore`, `security`
Beispiele:
- `feat(auth): add argon2id password hashing`
- `test(storage): add checksum verification test`
- `security(admin): enforce server-side admin check on all routes`
Kein Commit ohne grüne Tests.
## Reverse Proxy & Proxy-Awareness
- **Kein Proxy-Container** in docker-compose.yml – TLS und Domain gehören zum Wirt,
nicht ins Projekt. Nichts hier soll einen mitgelieferten Proxy voraussetzen.
- App lauscht auf reinem HTTP (`APP_LISTEN_ADDR`). TLS = externer Proxy.
- `X-Forwarded-For` / `X-Forwarded-Proto` werden **nur** von `TRUSTED_PROXIES` (IPs/CIDRs) akzeptiert.
- Echte Client-IP immer über `middleware.RealIP(r)` abrufen – niemals `r.RemoteAddr` direkt.
- `Secure`-Cookie-Flag: über `middleware.IsHTTPS(r)` setzen (berücksichtigt Forwarded-Proto).
- `PUBLIC_URL` für OIDC-Redirects, WOPI-Callbacks und Share-Links verwenden.
- HSTS gehört auf den Edge-Proxy, **nicht** in die App-Header.
## Docker & Secrets
- **Backend immer via Docker starten/laufen lassen (`docker compose ...` in `deploy/`), nie `go run`.** Der Host hat kein Go-Toolchain installiert; Builds/Compile-Checks laufen über das `golang:1.25-alpine`-Image. Migrations + DB-Verbindung sind auf die Compose-Umgebung ausgelegt.
- Secrets **nur** über `.env` / Compose-Env – nie im Image oder Repo
- Multi-Stage-Build: Builder-Stage (Go-Compiler) → Alpine final
- `.env` ist in `.gitignore`; `.env.example` zeigt alle Keys ohne Werte
## Phasen-Stopp
Nach Phase 1: **STOPP** – Zusammenfassung + Phase-2-Vorschlag, erst nach OK weitermachen.
+417
View File
@@ -0,0 +1,417 @@
# Aether
Self-hosted cloud storage — schnelle, schlanke Alternative zu Nextcloud.
**Stack:** Go + chi · React + TS + Vite · PostgreSQL · Docker Compose
---
## Reverse Proxy (Pflicht)
Aether lauscht intern auf reinem HTTP. TLS wird von deinem eigenen Reverse Proxy terminiert.
**nginx, Traefik, Cloudflare Tunnel** – alles funktioniert. Dieses Projekt
liefert bewusst **keinen** Proxy mit: TLS, Zertifikate und Domain gehören in
deine Hand, nicht in ein Compose-File, das du ohnehin überschreiben müsstest.
Der Proxy muss folgende Headers setzen:
```
X-Forwarded-For: <echte Client-IP>
X-Forwarded-Proto: https
```
Die IP deines Proxys muss in `TRUSTED_PROXIES` stehen – sonst ignoriert Aether diese Header
(Sicherheitsmaßnahme gegen IP-Spoofing).
Der Proxy muss folgendes durchreichen (keine eigene Verarbeitung):
- Alle HTTP-Methoden inkl. `PROPFIND`, `MKCOL`, `MOVE`, `COPY` (für WebDAV)
- Große Uploads (Timeout und Body-Size-Limit großzügig setzen)
- WebSocket-Verbindungen (für Office-Co-Editing, Phase 6)
- HSTS-Header setzen (z. B. `Strict-Transport-Security: max-age=31536000; includeSubDomains`)
## Schnellstart (lokal)
### Voraussetzungen
- Docker + Docker Compose
- Go 1.25+ und Node 20+ **nur**, wenn du aus dem Quelltext bauen willst –
zum Betreiben genügt das fertige Abbild (siehe Schritt 3)
### 1. Repository klonen
```bash
git clone <repo-url> aether
cd aether
```
### 2. `.env` anlegen
```bash
cp deploy/.env.example deploy/.env
```
Dann in `deploy/.env` ausfüllen:
```env
SESSION_SECRET=$(openssl rand -hex 32)
DB_PASSWORD=<sicheres_passwort>
PUBLIC_URL=https://cloud.example.com
TRUSTED_PROXIES=<IP-deines-Proxys>
ADMIN_USERNAME=admin
ADMIN_PASSWORD=<sicheres_admin_passwort>
```
### 3. Starten
```bash
cd deploy
docker login gitea.honeyy.duckdns.org # nur beim ersten Mal
docker compose up -d
```
Das Compose zieht ein **fertiges Abbild** aus der eigenen Registry
(`gitea.honeyy.duckdns.org/honey/aether`). Für eine neue Umgebung reichen damit
drei Dateien – `docker-compose.yml`, `.env` und das Datenverzeichnis. Backend
und Frontend müssen dort nicht liegen und nicht gebaut werden.
Wer aus dem Quelltext bauen will, kommentiert in `docker-compose.yml` `image:`
aus und den `build:`-Block ein, dann:
```bash
docker compose build app && docker compose up -d
```
Aktualisieren:
```bash
docker compose pull app && docker compose up -d app
```
Beim ersten Start:
- Postgres-Migrations laufen automatisch
- Der erste Admin-Account wird aus `ADMIN_USERNAME` / `ADMIN_PASSWORD` angelegt
- Nach dem Start: `ADMIN_PASSWORD` aus `.env` entfernen oder leer lassen
### 4. Öffnen
Die App ist über deinen Reverse Proxy erreichbar (z. B. `https://cloud.example.com`).
Direkt (ohne Proxy): http://localhost:8080/health
---
## Abbild bauen und veröffentlichen
```bash
# im Wurzelverzeichnis des Projekts, nicht in deploy/
IMG=gitea.honeyy.duckdns.org/honey/aether
docker build -f deploy/Dockerfile -t $IMG:$(date +%Y.%m.%d) -t $IMG:latest .
docker push $IMG:$(date +%Y.%m.%d)
docker push $IMG:latest
```
**Zwei Tags, mit Absicht.** `latest` ist der bewegliche Name zum Aufbauen, der
datierte der feste zum Zurückrollen. Wer nur `latest` fährt, hat im Fehlerfall
nichts, worauf er zurück kann – der Name zeigt dann schon auf die Fassung, die
gerade Ärger macht.
Das Abbild wiegt rund **540 MB**. Den Löwenanteil machen `ocrmypdf`,
`tesseract` samt Sprachmodellen und `poppler-utils` aus – ohne sie wären es
etwa 210 MB, aber auch keine Volltextsuche in Scans. Wer das nicht braucht,
streicht die zweite `apk`-Zeile im Dockerfile und setzt `OCR_ENABLED=false`;
der Server prüft beim Start selbst, ob die Programme da sind, und läuft sonst
ohne Volltextsuche weiter.
Gebaut wird für die Architektur des Wirts. Für ein anderes Ziel (etwa ein
ARM-Gerät) braucht es `docker buildx build --platform linux/arm64`.
---
## Lokale Entwicklung
### Backend (immer via Docker)
Das Backend wird **ausschließlich über Docker Compose** gestartet – nie mit `go run`.
Postgres-Migrations und DB-Verbindung sind auf die Compose-Umgebung ausgelegt.
```bash
cd deploy
docker compose up -d --build # Backend + Postgres starten
docker compose logs -f app # Logs verfolgen
docker compose down # stoppen
```
Code-Änderungen am Backend: `docker compose up -d --build` neu ausführen (Image neu bauen).
**Compile-Check ohne lokales Go-Toolchain** (der Host hat kein Go installiert):
```bash
docker run --rm -v "$PWD/../backend":/src -w /src golang:1.25-alpine go build ./...
```
### Frontend
```bash
cd frontend
npm install
npm run dev # http://localhost:5173 (Vite dev server, spricht Backend auf :8080 an)
npm run build # Production-Build (wird vom Backend-Image mitgebaut)
```
### Tests
```bash
# Backend (im Go-Image, da Host kein Go hat)
docker run --rm -v "$PWD/backend":/src -w /src golang:1.25-alpine go test ./...
# Frontend
cd frontend && npm test
```
---
## Struktur
```
aether/
├── backend/
│ ├── cmd/aether/ # Einstiegspunkt
│ ├── internal/ # Domänen-Pakete (auth, storage, files, …)
│ ├── db/migrations/ # SQL-Migrations
│ └── pkg/ # Shared utils (config, middleware, response)
├── frontend/
│ └── src/
│ ├── api/ # Typed API-Client
│ ├── components/
│ ├── hooks/
│ └── pages/
├── deploy/
│ ├── docker-compose.yml # app + postgres (kein Proxy-Container)
│ ├── Dockerfile
│ └── .env.example
└── CLAUDE.md # Projektkonventionen
```
---
## Wo die Dateien liegen
Nicht in einem Docker-Volume, sondern in einem gewöhnlichen Verzeichnis auf dem
Wirt – `DATA_PATH` in der `.env`. Der Aufbau darin ist derselbe wie in der
Oberfläche:
```
<DATA_PATH>/
├── users/<benutzername>/home/… ← die eigenen Dateien, echte Namen
│ /.trash/… ← Papierkorb
│ /.versions/… ← frühere Fassungen
├── groups/<gruppen-id>/home/… ← Gruppenordner
├── thumbs/ ← Vorschaubilder
├── branding/ ← Logo und App-Symbol
└── tmp/ ← Zwischendateien, wird selbst geräumt
```
**Warum das so ist:** Ein Archiv ist nur dann eines, wenn man ohne die
Anwendung an seine Unterlagen kommt. Hier reicht dafür `scp -r` oder ein Blick
in den Dateimanager – keine Datenbank, kein Export, kein laufender Container
nötig. Bei einem benannten Volume läge alles unter
`/var/lib/docker/volumes/…` und gehörte `root`.
Damit die Dateien **dir** gehören und nicht einem Container-Nutzer, läuft der
Container unter deiner Kennung (`APP_UID`/`APP_GID`, siehe `.env`). Damit
funktioniert SFTP direkt:
```bash
sftp dein-nutzer@dein-server
get -r /pfad/zu/aether-data/users/deinname/home
```
> **Nur lesen.** Wer von Hand eine Datei hineinlegt, hat sie auf der Platte,
> aber nicht in der Datenbank: Die Cloud sieht sie nicht, und die
> Speicherbereinigung räumt sie irgendwann weg. Zum Hineinlegen ist WebDAV der
> Weg (siehe unten).
### Von einem benannten Volume umsteigen
Ältere Installationen hatten ein Volume `deploy_data`. Umzug mit Prüfung:
```bash
cd deploy
docker compose stop app
mkdir -p /pfad/zu/aether-data
docker run --rm -v deploy_data:/src:ro -v /pfad/zu/aether-data:/dst alpine:3.20 \
sh -c 'cp -a /src/. /dst/ && chown -R $(id -u):$(id -g) /dst'
# Prüfen, bevor umgeschaltet wird: beide Summen müssen gleich sein
docker run --rm -v deploy_data:/src:ro alpine:3.20 \
sh -c 'cd /src && find . -type f -exec sha256sum {} \; | LC_ALL=C sort | sha256sum'
(cd /pfad/zu/aether-data && find . -type f -exec sha256sum {} \; | LC_ALL=C sort | sha256sum)
# erst dann DATA_PATH in die .env eintragen und starten
docker compose up -d app
```
Das alte Volume **nicht sofort löschen**. Es ist der Rückfall, solange nicht
alles geprüft ist; `docker volume rm deploy_data` kann warten, bis du sicher
bist.
---
## WebDAV
Adresse: `https://<deine-domain>/dav/`
Angemeldet wird sich mit dem **Benutzernamen** und einem **App-Passwort**
(Profil → App passwords). Das Kontopasswort wird an `/dav/` **abgelehnt** – es
ist zugleich der Admin-Zugang und hat auf Geräten nichts verloren. Ein
App-Passwort gilt nur für WebDAV, nie fürs Web-Login, und lässt sich einzeln
widerrufen, ohne die anderen Geräte auszusperren.
Der Aufbau entspricht dem der Weboberfläche: eigene Ordner und Gruppenordner
liegen nebeneinander in der Wurzel, Freigaben von Personen unter
`/dav/Shared/<Person>/<Freigabe>/`. Trägt ein Gruppenordner denselben Namen wie
ein eigener, behält der eigene ihn und der Gruppenordner bekommt den
Gruppennamen angehängt (`Projekt (Buero)`).
Nach zehn Fehlanmeldungen in 15 Minuten sperrt die IP für den Rest des Fensters
(`429`). Normale Anfragen zählen nicht mit – Sync-Clients senden legitim
hunderte pro Minute.
---
## Office (Collabora / OnlyOffice)
Aether ist **WOPI-Host**. Das ist die Schnittstelle, über die Office-Server
sich eine Datei holen und wieder zurückschreiben – **Collabora und OnlyOffice
sprechen beide dieselbe.** Deshalb wird hier *ein* Adapter gebaut und der
Anbieter per `.env` umgeschaltet, statt zweimal dasselbe zu integrieren.
### Was dazukommt
| | |
|---|---|
| `OFFICE_PROVIDER` | `collabora`, `onlyoffice` oder leer (aus) |
| `OFFICE_URL` | wo das **Backend** den Office-Server erreicht |
| `OFFICE_PUBLIC_URL` | wo der **Browser** ihn erreicht (leer = wie oben) |
| `OFFICE_WOPI_URL` | wo der **Office-Server** Aether erreicht (leer = `PUBLIC_URL`) |
| `OFFICE_ALLOWED_HOSTS` | Ziele, die das Backend anfragen darf – gegen SSRF |
**Drei Adressen für drei Richtungen.** Das wirkt übertrieben, bis man es einmal
aufgebaut hat: Im Compose-Netz heißt der Office-Server `collabora`, im Browser
muss dort ein auflösbarer Name stehen, und der Office-Server wiederum erreicht
Aether unter `app:8080` – „localhost" ist aus seinem Container heraus er selbst.
Der Container startet nur mit dem Profil, sonst kostet er Arbeitsspeicher, ohne
gebraucht zu werden:
```bash
docker compose --profile office up -d
```
### Welche Dateien, und wie gut
Bearbeitet wird nur, was auf der **Positivliste** steht:
`odt docx doc rtf txt · ods xlsx xls csv · odp pptx ppt · odg`.
PDF steht bewusst **nicht** darauf – es hat seinen eigenen Betrachter, und ein
Office-Server würde beim Speichern ein anderes Dokument daraus machen.
Collabora Online *ist* LibreOffice, mit derselben Datei-Unterstützung. Für die
Microsoft-Formate heißt das:
| | |
|---|---|
| `docx`, `xlsx`, `pptx` | öffnen und speichern zuverlässig, Format bleibt erhalten |
| Alte `doc`, `xls`, `ppt` | gehen, sind aber Rückwärtskompatibilität – kein Ziel der Entwicklung |
| Komplexes Layout | Seitenumbrüche und Schriftabstände können um Kleinigkeiten abweichen |
| Sehr große Tabellen, Pivot, seltene Formeln | die häufigste Stelle für echte Abweichungen |
| Makros (VBA) | laufen nicht – und das ist Absicht, nicht Mangel |
Fehlende **Schriften** sind der häufigste Grund für „sieht anders aus": Der
Container bringt Calibri und Cambria nicht mit. Wer Dokumente von außen
bearbeitet, legt die Schriften nach.
Für ein Belegarchiv ist das unkritisch – da wird gelesen und selten geändert.
Für den täglichen Umgang mit fremden Word-Dateien gilt: **Rundlauf prüfen**,
nicht darauf vertrauen.
### Wie das zusammenspielt
Der Office-Server ist **kein Client deines Browsers allein** – er holt die
Datei selbst beim Backend ab:
```
Browser ──────────► Office-Server (Bedienoberfläche im <iframe>)
Office-Server ────► Aether /wopi/... (Datei holen, Datei zurückschreiben)
```
Beide Pfeile müssen funktionieren. Das ist der Punkt, an dem solche
Installationen scheitern: Der Office-Server muss **Aether unter seiner
öffentlichen Adresse erreichen** (`PUBLIC_URL`), nicht nur unter
`localhost`. Ein Zugriffstoken mit kurzer Laufzeit hängt an jedem Aufruf; ohne
gültiges Token liefert der WOPI-Endpunkt nichts.
### Was beim Aufbau schiefgeht
Alles hier ist beim Testen tatsächlich passiert, in dieser Reihenfolge:
| Symptom | Ursache |
|---|---|
| Rahmen bleibt leer, Konsole: *„Framing … violates default-src 'self'"* | Unsere eigene Sicherheitsrichtlinie. `frame-src` fällt auf `default-src` zurück; der Office-Ursprung muss ausdrücklich erlaubt sein. Passiert automatisch, sobald `OFFICE_PROVIDER` gesetzt ist. |
| Editor lädt, Dokument bleibt weiß | `ssl.termination=true` ohne HTTPS davor. Collabora sagt dem Browser dann `wss://`, das es nicht gibt. Im HTTP-Test `OFFICE_SSL_TERMINATION=false`. |
| Log: *„CheckFileInfo returned 0 … ECONNREFUSED"* | `OFFICE_WOPI_URL` zeigt auf `127.0.0.1:8090`. Der Port ist auf loopback gebunden – aus einem Container nicht erreichbar. Im Compose den Dienstnamen nehmen. |
| Log: *„WOPISrc must be URI-encoded"* | Behoben; die Adresse wird vollständig kodiert. |
| Log: *„Failed to exec coolmount … needs CAP_SYS_ADMIN"* | Gelöst über `--o:mount_jail_tree=false` im Compose. |
| Fehlende Symbole (404 auf `.svg`) | Lücken im Collabora-Image selbst. Kosmetisch. |
### Sicherheit
- **SSRF:** Das Backend ruft den Office-Server auf. Ausgehende Ziele werden auf
`OFFICE_ALLOWED_HOSTS` beschränkt – sonst wird aus einer Adresse in der
Konfiguration ein Weg ins interne Netz.
- **Tokens sind dateibezogen und kurzlebig.** Ein Token für Datei A öffnet
Datei B nicht, auch nicht mit derselben Sitzung.
- **Schreiben folgt derselben Regel wie ein Upload:** atomar schreiben,
SHA-256 neu berechnen, Version anlegen, wenn Versionierung an ist. Ein
Office-Server ist kein Sonderweg an der Datenintegrität vorbei.
### Speicherbedarf – vor der Entscheidung lesen
Der Office-Server ist der mit Abstand hungrigste Teil dieser Anlage:
| | Bedarf | Bemerkung |
|---|---|---|
| **Collabora (CODE)** | ~1 GB im Leerlauf, +100–200 MB je offenem Dokument | LibreOffice-Kern, startet pro Dokument einen Prozess |
| **OnlyOffice Docs** | 2 GB Minimum, **4 GB empfohlen**, dazu Swap | bringt eigenes Postgres, RabbitMQ und Node mit |
Dazu kommt, was schon läuft: Postgres, Aether und – nicht unterschätzen – die
**Texterkennung**. ocrmypdf und ghostscript nehmen sich bei einem großen Scan
kurzzeitig mehrere hundert Megabyte.
### Welchen nehmen?
- **Collabora**, wenn der Speicher knapp ist oder Herkunft und ODF zählen.
LibreOffice-Kern, europäisch, deutlich genügsamer.
- **OnlyOffice**, wenn `.docx`/`.xlsx` zwischen Desktop-Office und Web hin und
her wandern. Beste Treue bei OOXML, weil es nativ damit arbeitet.
- **EuroOffice** ist ein junger OnlyOffice-Fork und nutzt **denselben
WOPI-Weg**. Eine Entscheidung für OnlyOffice heute verbaut ihn nicht: Es wäre
ein Wechsel des Containers und eine Zeile in der `.env`.
---
## Phasen-Roadmap
| Phase | Inhalt | Status |
|-------|--------|--------|
| 0 | Fundament (Monorepo, Docker, Migrations, Backend-Skeleton) | ✅ |
| 1 | Auth (Argon2id, Sessions), Storage (atomar + SHA-256), Datei-CRUD, Frontend | ✅ |
| 2 | Uploads mit Fortschritt, Papierkorb, Versionen, Share-Links, Thumbnails | ✅ |
| 2.5 | Sidebar-Favoriten, Ordner verschieben/hochladen, „Verschieben nach…" | ✅ |
| 3 | Admin-Panel, Gruppen(-mitgliedschaft), Theming, User-Profil | ✅ |
| 4 | WebDAV (`/dav/`), OIDC (optional) | ✅ (im Test) |
| 4.5 | Härtung (Quota, Audit-Log, App-Passwörter), interne Shares, Gruppenordner, Logo/App-Symbol | ✅ (im Test) |
| 5 | UI (Suche, Vorschau, Sortierung, Vor/Zurück) – Design-Rest offen | 🔄 |
| 6 | Dokumente: OCR + Volltextsuche ✅, Etiketten ✅, Absender ✅, PDF-Betrachter ✅ – Inbox vertagt | 🔄 |
| 7 | Office (WOPI-Host, Collabora / OnlyOffice) – [Anleitung](#office-collabora--onlyoffice) | ✅ (im Test) |
| 8 | Härtung, 2FA, Observability, Extension-Points | 🔲 |
> Manuelle Test-Checkliste für die fertigen Phasen: siehe [`TESTING.md`](TESTING.md).
+1079
View File
File diff suppressed because it is too large. Load diff
+622
View File
@@ -0,0 +1,622 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/signal"
"path"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/httprate"
"github.com/google/uuid"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"aether/internal/acl"
"aether/internal/admin"
"aether/internal/audit"
"aether/internal/auth"
"aether/internal/documents"
"aether/internal/files"
"aether/internal/gc"
"aether/internal/office"
appoidc "aether/internal/oidc"
"aether/internal/sharing"
"aether/internal/storage"
"aether/internal/tags"
"aether/internal/thumbnails"
appwebdav "aether/internal/webdav"
"aether/pkg/config"
appdb "aether/pkg/db"
"aether/pkg/middleware"
"aether/pkg/response"
)
// spaHandler liefert statische Dateien aus und fällt für alles andere auf
// index.html zurück.
//
// Nötig, weil das Frontend seine Adressen selbst verwaltet: `/s/<token>`,
// `/admin` und `/profile` existieren als Dateien nicht. Ohne Rückfalloption
// beantwortet ein reiner FileServer sie mit 404 – ein frisch geteilter Link
// landete beim Empfänger also auf einer Fehlerseite. Dass es bisher trotzdem
// meist funktionierte, lag allein am Service Worker, der die Navigation
// abfing; wer die Seite zum ersten Mal öffnet, hat den noch nicht.
func spaHandler(root string) http.Handler {
fs := http.FileServer(http.Dir(root))
index := filepath.Join(root, "index.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upath := path.Clean("/" + r.URL.Path)
// Serverseitige Bereiche nie überdecken – dort ist ein 404 die richtige
// Antwort und kein Grund, die Anwendung auszuliefern.
for _, reserved := range []string{"/api/", "/dav/", "/share/", "/branding/", "/wopi/"} {
if strings.HasPrefix(upath+"/", reserved) {
http.NotFound(w, r)
return
}
}
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(upath))); err == nil {
fs.ServeHTTP(w, r)
return
}
// Nur echte Seitenaufrufe bekommen die Anwendung. Eine fehlende
// JavaScript- oder Bilddatei soll als 404 sichtbar bleiben, statt
// stillschweigend HTML zu liefern – das erzeugt sonst rätselhafte
// Syntaxfehler in der Konsole statt einer klaren Meldung.
if r.Method != http.MethodGet || !strings.Contains(r.Header.Get("Accept"), "text/html") {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, index)
})
}
func main() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "config error: %v\n", err)
os.Exit(1)
}
if cfg.IsDev() {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
}
zerolog.SetGlobalLevel(zerolog.InfoLevel)
log.Info().Str("env", cfg.AppEnv).Str("addr", cfg.ListenAddr).Str("public_url", cfg.PublicURL).Msg("Aether starting")
ctx := context.Background()
if err := appdb.Migrate(cfg.DSN()); err != nil {
log.Fatal().Err(err).Msg("migrations failed")
}
log.Info().Msg("migrations up to date")
pool, err := appdb.Connect(ctx, cfg.DSN())
if err != nil {
log.Fatal().Err(err).Msg("database connection failed")
}
defer pool.Close()
// ── Storage ───────────────────────────────────────────────────────────────
// Thumbnails are stored in the main blob store under the "thumbs/" prefix.
blobStore, err := storage.NewLocalFS(cfg.StoragePath, cfg.TmpPath)
if err != nil {
log.Fatal().Err(err).Msg("blob storage init failed")
}
// ── Packages ──────────────────────────────────────────────────────────────
auditor := audit.NewLogger(pool)
aclStore := acl.NewStore(pool)
aclHandler := acl.NewHandler(aclStore, auditor)
authStore := auth.NewStore(pool)
sessions := auth.NewSessionStore(cfg.SessionSecret, cfg.SessionMaxAge)
fileStore := files.NewStore(pool)
authHandler := auth.NewHandler(authStore, sessions, fileStore, auditor)
tagStore := tags.NewStore(pool)
tagHandler := tags.NewHandler(tagStore, aclStore)
// ── Texterkennung ─────────────────────────────────────────────────────────
// Bleibt nil, wenn abgeschaltet oder die Programme fehlen. Alles, was
// docStore benutzt, prüft deshalb auf nil – eine Cloud ohne OCR ist eine
// vollständige Cloud, nur ohne Volltextsuche.
var docStore *documents.Store
if cfg.OCREnabled {
if ok, missing := documents.Available(); !ok {
log.Warn().Strs("missing", missing).
Msg("Texterkennung abgeschaltet: Programme fehlen im Image")
} else {
docStore = documents.NewStore(pool)
worker := documents.NewWorker(docStore, blobStore, &documents.Extractor{
Languages: cfg.OCRLanguages,
MaxPages: cfg.OCRMaxPages,
Timeout: cfg.OCRTimeout,
TmpDir: cfg.TmpPath,
}, log.Logger)
go worker.Run(ctx)
// Bestand nachtragen: Beim ersten Start nach dem Einbau hat noch
// keine einzige Datei einen Auftrag. Ohne das bliebe alles
// unauffindbar, was vorher hochgeladen wurde – und das ist am
// Anfang der gesamte Bestand.
go func() {
n, err := docStore.EnqueueMissing(ctx, 5000)
if err != nil {
log.Warn().Err(err).Msg("Bestand nachtragen fehlgeschlagen")
return
}
if n > 0 {
log.Info().Int("files", n).Msg("Bestandsdateien zur Texterkennung eingeplant")
}
// Einträge nach älterer Regel erneut erkennen. Siehe
// documents.ExtractVersion – die Seitenmarke im Text kam
// nachträglich dazu, und ohne sie kann der Betrachter bei
// einem Scan die Fundstelle nicht auf einer Seite verorten.
m, err := docStore.EnqueueOutdated(ctx, 5000)
if err != nil {
log.Warn().Err(err).Msg("veraltete Texteintraege nachtragen fehlgeschlagen")
return
}
if m > 0 {
log.Info().Int("files", m).Int("version", documents.ExtractVersion).
Msg("veraltete Texteintraege zur erneuten Erkennung eingeplant")
}
}()
}
}
// fileHandler bekommt docStore mit: Der PDF-Betrachter holt sich darueber
// den erkannten Text eines Scans, in Seiten zerlegt.
fileHandler := files.NewHandler(fileStore, blobStore, auditor, aclStore, tagStore, docStore)
shareStore := sharing.NewStore(pool)
shareHandler := sharing.NewHandler(shareStore, fileStore, blobStore, auditor)
collector := gc.New(pool, blobStore)
adminStore := admin.NewStore(pool)
adminHandler := admin.NewHandler(adminStore, blobStore, auditor, collector, fileStore)
// docHandler ist auch bei abgeschaltetem OCR gültig – er meldet dann
// schlicht enabled:false. Eine Methode auf einem nil-Zeiger geht in Go
// gut, solange sie ihn nicht dereferenziert; Status prüft genau darauf.
docHandler := documents.NewHandler(docStore,
func(r *http.Request) bool {
u := auth.UserFromContext(r.Context())
return u != nil && u.IsAdmin
},
func(r *http.Request) uuid.UUID {
if u := auth.UserFromContext(r.Context()); u != nil {
return u.ID
}
return uuid.Nil
})
// ── Office ────────────────────────────────────────────────────────────────
// Bleibt nil, wenn OFFICE_PROVIDER leer ist. Ein nil-Handler meldet der
// Oberfläche schlicht „aus"; die Anwendung ist ohne Office vollständig.
var officeHandler *office.Handler
if cfg.OfficeEnabled() {
client, err := office.NewClient(cfg.OfficeProvider, cfg.OfficeURL, cfg.OfficePublicURL,
cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS)
if err != nil {
// Kein Fatal: Eine falsche Office-Adresse soll die Cloud nicht am
// Starten hindern. Dateien bleiben erreichbar, nur das Bearbeiten
// fehlt – und der Log sagt, warum.
log.Warn().Err(err).Msg("Office abgeschaltet: Konfiguration fehlerhaft")
} else {
officeHandler = office.NewHandler(client, office.NewSigner(cfg.SessionSecret),
fileStore, blobStore, aclStore, cfg.PublicURL, cfg.OfficeWopiURL)
log.Info().Str("provider", cfg.OfficeProvider).Str("url", cfg.OfficeURL).
Msg("Office-Bearbeitung aktiv")
}
}
// Der Ursprung, den die Sicherheitsrichtlinie als Rahmen zulassen muss.
// Leer, wenn kein Office-Server eingerichtet ist – dann bleibt die
// Richtlinie so eng wie zuvor.
officeFrameSrc := ""
if officeHandler.Enabled() {
officeFrameSrc = cfg.OfficePublicURL
if officeFrameSrc == "" {
officeFrameSrc = cfg.OfficeURL
}
}
// WebDAV
davHandler := appwebdav.NewHandler(authStore, fileStore, aclStore, blobStore)
// OIDC (optional – disabled if OIDC_ISSUER not set)
oidcHandler, err := appoidc.New(ctx, appoidc.Config{
Issuer: cfg.OIDCIssuer,
ClientID: cfg.OIDCClientID,
ClientSecret: cfg.OIDCClientSecret,
RedirectURL: cfg.OIDCRedirectURL,
LinkByUsername: cfg.OIDCLinkByUsername,
}, authStore, sessions)
if err != nil {
log.Fatal().Err(err).Msg("OIDC init failed")
}
// ── Admin seed ────────────────────────────────────────────────────────────
if cfg.AdminUsername != "" && cfg.AdminPassword != "" {
if err := seedAdmin(ctx, authStore, cfg.AdminUsername, cfg.AdminPassword); err != nil {
log.Warn().Err(err).Msg("admin seed skipped")
} else {
log.Info().Str("username", cfg.AdminUsername).Msg("admin account ready")
}
}
// ── Blob-GC ───────────────────────────────────────────────────────────────
// Räumt Blobs weg, auf die keine Datenbankzeile mehr zeigt. Läuft im
// Hintergrund; jeder Lauf, der etwas entfernt, landet im Audit-Log.
collector.Start(ctx, func(res gc.Result) {
auditor.Log(ctx, audit.Event{
ActorName: "system",
Action: audit.ActionGarbageCollect,
Detail: fmt.Sprintf("%d orphaned blobs removed, %d bytes freed",
res.Deleted, res.FreedBytes),
})
})
// ── Router ────────────────────────────────────────────────────────────────
// chi lehnt Methoden ab, die es nicht kennt – ohne diese Registrierung
// beantwortet der Router jedes PROPFIND & Co. mit 405, bevor der
// WebDAV-Handler (und damit dessen Auth) überhaupt erreicht wird.
for _, m := range []string{"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK"} {
chi.RegisterMethod(m)
}
r := chi.NewRouter()
r.Use(chimiddleware.RequestID)
r.Use(middleware.TrustedProxy(cfg))
r.Use(middleware.Logger)
r.Use(middleware.SecurityHeaders(officeFrameSrc))
r.Use(chimiddleware.Recoverer)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
response.JSON(w, http.StatusOK, map[string]string{"status": "ok", "version": "0.2.0"})
})
// Logo ohne Anmeldung – die Login-Seite zeigt es, bevor jemand angemeldet ist.
r.Get("/branding/logo", adminHandler.ServeLogo)
r.Get("/branding/icon/{size}", adminHandler.ServeAppIcon)
// Das Manifest beschreibt die installierbare App. Es kommt aus der
// Datenbank, damit Name, Farbe und Symbol dem Admin-Bereich folgen.
r.Get("/manifest.webmanifest", adminHandler.ServeManifest)
// WOPI – **bewusst ausserhalb der Sitzungs- und CSRF-Kette.** Diese Aufrufe
// kommen vom Office-Server, nicht aus einem Browser; ihre Berechtigung ist
// allein das kurzlebige Token, das an genau eine Datei gebunden ist.
if officeHandler.Enabled() {
r.Get("/wopi/files/{id}", officeHandler.CheckFileInfo)
r.Get("/wopi/files/{id}/contents", officeHandler.GetFile)
r.Post("/wopi/files/{id}/contents", officeHandler.PutFile)
}
// Public share API – completely outside /api/v1 to avoid auth middleware conflict
r.Get("/share/{token}", shareHandler.GetPublicInfo)
r.Post("/share/{token}/download", shareHandler.Download)
// WebDAV endpoint – own auth (Basic Auth), outside /api/v1.
// Kein StripPrefix: der xwebdav.Handler entfernt "/dav" selbst über sein
// Prefix-Feld. Beides zusammen strippt doppelt und liefert nur noch 404.
r.Mount("/dav", davHandler)
// Public config (no auth) – used by login page to discover OIDC availability
r.Get("/api/v1/public/config", func(w http.ResponseWriter, r *http.Request) {
cloudName := "Aether"
// Farben gehören hierher (statt hinter die Auth), damit schon die
// Login-Seite im eingestellten Farbschema erscheint.
var primaryColor, accentColor, logoURL, faviconURL, appleTouchURL string
if s, err := adminStore.GetAllSettings(r.Context()); err == nil {
if n, ok := s["cloud_name"]; ok && n != "" {
cloudName = n
}
primaryColor = s["primary_color"]
accentColor = s["accent_color"]
logoURL = admin.LogoURL(s["logo_storage_key"])
// Dasselbe Symbol in zwei Größen: klein für den Tab, groß für den
// Startbildschirm von iOS.
faviconURL = admin.AppIconURL(s["app_icon_storage_key"], "64")
appleTouchURL = admin.AppIconURL(s["app_icon_storage_key"], "192")
}
apps := cfg.ExternalApps
if apps == nil {
apps = []config.ExternalApp{} // nie null – das Frontend erwartet eine Liste
}
response.JSON(w, http.StatusOK, map[string]any{
"oidc_enabled": oidcHandler.IsEnabled(),
"cloud_name": cloudName,
"primary_color": primaryColor,
"accent_color": accentColor,
"logo_url": logoURL,
"favicon_url": faviconURL,
"apple_touch_icon_url": appleTouchURL,
"external_apps": apps,
// Damit die Oberflaeche den Menuepunkt „Edit in Office" gar nicht
// erst anbietet, wenn es keinen Office-Server gibt.
"office_enabled": officeHandler.Enabled(),
})
})
// Auth routes – login/logout strictly rate-limited, /me is lenient
r.Route("/api/v1/auth", func(r chi.Router) {
r.With(httprate.LimitByIP(20, time.Minute)).Post("/login", authHandler.Login)
r.With(httprate.LimitByIP(20, time.Minute)).Post("/logout", authHandler.Logout)
r.With(auth.RequireAuth(sessions, authStore)).Get("/me", authHandler.Me)
r.With(auth.RequireAuth(sessions, authStore), auth.CSRF).Patch("/profile", authHandler.UpdateProfile)
r.With(auth.RequireAuth(sessions, authStore), auth.CSRF).Post("/change-password", authHandler.ChangePassword)
// OIDC routes (only wired if oidcHandler != nil)
if oidcHandler != nil {
r.With(httprate.LimitByIP(20, time.Minute)).Get("/oidc/login", oidcHandler.Login)
r.Get("/oidc/callback", oidcHandler.Callback)
}
})
// Protected API
r.Route("/api/v1", func(r chi.Router) {
r.Use(auth.RequireAuth(sessions, authStore))
r.Use(auth.CSRF)
r.Use(httprate.LimitByIP(2000, time.Minute))
// Files
// Sammel-Download als Archiv. POST, weil eine Auswahl beliebig lang sein
// kann – als Adresse würde sie an der Längengrenze scheitern.
r.Post("/download/zip", fileHandler.DownloadZip)
r.Post("/files", afterUpload(fileHandler, fileStore, blobStore, docStore))
r.Get("/files", fileHandler.List)
// Suche über Namen von Ordnern und Dateien, begrenzt auf das, was der
// Anfragende erreichen darf.
r.Get("/search", fileHandler.Search)
r.Get("/files/{id}/download", fileHandler.Download)
r.Get("/files/{id}/thumbnail", fileHandler.GetThumbnail)
// Erkannter Text, in Seiten zerlegt – fuer die Suche im PDF-Betrachter.
r.Get("/files/{id}/text", fileHandler.TextPages)
// Sitzung fuer den Office-Rahmen: Hier entsteht das WOPI-Token, im
// angemeldeten Zusammenhang.
r.Get("/office/session/{id}", officeHandler.Session)
r.Patch("/files/{id}/versioning", fileHandler.SetVersioning)
r.Get("/files/{id}/versions", fileHandler.ListVersions)
r.Get("/files/{id}/versions/{versionId}/download", fileHandler.DownloadVersion)
r.Post("/files/{id}/versions/{versionId}/restore", fileHandler.RestoreVersion)
r.Delete("/files/{id}", fileHandler.Delete)
r.Patch("/files/{id}/rename", fileHandler.Rename)
r.Patch("/files/{id}/move", fileHandler.Move)
r.Post("/files/{id}/share", shareHandler.Create)
// Folders
r.Post("/folders", fileHandler.CreateFolder)
r.Patch("/folders/{id}/rename", fileHandler.RenameFolder)
r.Patch("/folders/{id}/move", fileHandler.MoveFolder)
r.Delete("/folders/{id}", fileHandler.DeleteFolder)
// Favorites
r.Get("/favorites", fileHandler.ListFavorites)
r.Post("/favorites", fileHandler.AddFavorite)
r.Delete("/favorites/{folderId}", fileHandler.RemoveFavorite)
r.Patch("/favorites/{folderId}/expand", fileHandler.SetFavoriteExpanded)
r.Patch("/favorites/reorder", fileHandler.ReorderFavorites)
// Trash
// Etiketten – siehe internal/tags. Die Rechteprüfung steckt in den
// Abfragen des Stores, nicht in einer Middleware davor.
r.Get("/tags", tagHandler.List)
r.Post("/tags", tagHandler.Create)
r.Patch("/tags/{id}", tagHandler.Update)
r.Delete("/tags/{id}", tagHandler.Delete)
r.Get("/tags/{id}/files", tagHandler.Files)
r.Put("/files/{id}/tags", tagHandler.SetForFile)
// Absender: dieselbe Form wie die Etiketten, aber genau einer je Datei.
// Gesetzt wird er ueber /files/{id}/tags mit – ein Dialog, ein Vorgang.
r.Get("/correspondents", tagHandler.ListCorrespondents)
r.Post("/correspondents", tagHandler.CreateCorrespondent)
r.Patch("/correspondents/{id}", tagHandler.UpdateCorrespondent)
r.Delete("/correspondents/{id}", tagHandler.DeleteCorrespondent)
r.Get("/correspondents/{id}/files", tagHandler.CorrespondentFiles)
r.Get("/trash", fileHandler.ListTrash)
r.Post("/trash/{id}/restore", fileHandler.RestoreFile)
r.Delete("/trash", fileHandler.EmptyTrash)
r.Delete("/trash/{id}", fileHandler.HardDelete)
// Shares
r.Get("/shares", shareHandler.List)
r.Delete("/shares/{id}", shareHandler.Delete)
// Interne Freigaben
r.Get("/shared-with-me", aclHandler.SharedWithMe)
r.Get("/share-recipients", aclHandler.Recipients)
r.Get("/folders/{id}/acl", aclHandler.ListFolderACL)
r.Post("/folders/{id}/acl", aclHandler.GrantFolderACL)
r.Get("/files/{id}/acl", aclHandler.ListFileACL)
r.Post("/files/{id}/acl", aclHandler.GrantFileACL)
r.Delete("/acl/{id}", aclHandler.RevokeACL)
// App passwords (WebDAV/sync clients)
r.Get("/app-passwords", authHandler.ListAppPasswords)
r.Post("/app-passwords", authHandler.CreateAppPassword)
r.Delete("/app-passwords/{id}", authHandler.DeleteAppPassword)
// Settings (readable by all authenticated users for theming)
r.Get("/admin/settings", adminHandler.GetSettings)
// Admin-only routes (server-side admin check inside each handler)
r.Patch("/admin/settings", adminHandler.UpdateSettings)
r.Get("/admin/users", adminHandler.ListUsers)
r.Post("/admin/users", adminHandler.CreateUser)
r.Patch("/admin/users/{id}", adminHandler.UpdateUser)
r.Post("/admin/users/{id}/reset-password", adminHandler.ResetPassword)
r.Delete("/admin/users/{id}", adminHandler.DeleteUser)
r.Get("/admin/audit", adminHandler.ListAudit)
r.Get("/admin/audit/actions", adminHandler.ListAuditActions)
r.Post("/admin/gc", adminHandler.RunGC)
// Immer erreichbar, auch wenn OCR aus ist – der Handler antwortet dann
// mit enabled:false. Eine Route, die je nach Konfiguration 404 liefert,
// wäre für die Oberfläche nicht von einem Fehler zu unterscheiden.
r.Get("/admin/ocr", docHandler.Status)
// Derselbe Stand, aber nur für die eigenen Dateien – ohne Adminrechte.
r.Get("/ocr", docHandler.MyStatus)
r.Post("/admin/branding/logo", adminHandler.UploadLogo)
r.Delete("/admin/branding/logo", adminHandler.DeleteLogo)
r.Post("/admin/branding/icon", adminHandler.UploadAppIcon)
r.Delete("/admin/branding/icon", adminHandler.DeleteAppIcon)
r.Get("/admin/group-folders", adminHandler.ListGroupFolders)
r.Post("/admin/group-folders", adminHandler.CreateGroupFolder)
r.Delete("/admin/group-folders/{id}", adminHandler.DeleteGroupFolder)
r.Get("/admin/groups", adminHandler.ListGroups)
r.Post("/admin/groups", adminHandler.CreateGroup)
r.Patch("/admin/groups/{id}", adminHandler.UpdateGroup)
r.Delete("/admin/groups/{id}", adminHandler.DeleteGroup)
r.Get("/admin/groups/{id}/members", adminHandler.ListGroupMembers)
r.Post("/admin/groups/{id}/members", adminHandler.AddGroupMember)
r.Delete("/admin/groups/{id}/members/{userId}", adminHandler.RemoveGroupMember)
})
// Serve built frontend
r.Handle("/*", spaHandler("./static"))
srv := &http.Server{
Addr: cfg.ListenAddr,
Handler: r,
ReadHeaderTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Minute, // großzügig für Multi-GB Up-/Downloads
IdleTimeout: 60 * time.Second,
}
done := make(chan struct{})
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-quit
log.Info().Msg("shutting down...")
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutCtx)
close(done)
}()
log.Info().Str("addr", srv.Addr).Msg("listening")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal().Err(err).Msg("server error")
}
<-done
log.Info().Msg("stopped")
}
// afterUpload hängt sich hinter den Upload und stößt an, was danach im
// Hintergrund passieren soll: das Vorschaubild und – bei Dokumenten – die
// Texterkennung.
//
// Der Unterschied zwischen beiden ist Absicht: Das Vorschaubild entsteht hier
// direkt in einer Goroutine, die Texterkennung wird nur in die Warteschlange
// eingetragen. Ein verlorenes Vorschaubild fällt sofort auf und ist in
// Millisekunden neu gemacht; verlorener Text fällt nie auf – man sucht später
// vergeblich und weiß nicht, warum. Deshalb ist er die Datenbankzeile wert.
//
// docStore darf nil sein (OCR abgeschaltet oder Programme fehlen).
func afterUpload(fh *files.Handler, fileStore *files.Store, st storage.Storage, docStore *documents.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
crw := &captureWriter{ResponseWriter: w}
fh.Upload(crw, r)
if crw.status != http.StatusCreated {
return
}
// Parse file ID and MIME type from captured JSON response (wrapped in {"data":{...}}).
var wrapper struct {
Data struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
MimeType string `json:"mime_type"`
} `json:"data"`
}
if err := json.Unmarshal(crw.body.Bytes(), &wrapper); err != nil || wrapper.Data.ID == uuid.Nil {
return
}
created := wrapper.Data
if docStore != nil && documents.Supported(created.MimeType, created.Name) {
// Eigener Kontext: Der Anfrage-Kontext endet, sobald die Antwort
// draußen ist – der Eintrag ginge dann ins Leere.
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second)
if err := docStore.Enqueue(ctx, created.ID); err != nil {
log.Warn().Err(err).Str("file_id", created.ID.String()).
Msg("Texterkennung konnte nicht eingeplant werden")
}
cancel()
}
if !thumbnails.SupportedMIME(created.MimeType) {
return
}
// Generate thumbnail in background – non-fatal if it fails.
go func() {
ctx := context.Background()
f, err := fileStore.GetFileByID(ctx, created.ID)
if err != nil {
return
}
rc, err := st.Get(ctx, f.StorageKey)
if err != nil {
return
}
defer rc.Close()
if err := thumbnails.Generate(ctx, st, created.ID.String(), created.MimeType, rc); err != nil {
log.Warn().Err(err).Str("file_id", created.ID.String()).Msg("thumbnail generation failed")
return
}
_ = fileStore.UpdateThumbnail(ctx, created.ID, true)
}()
}
}
func seedAdmin(ctx context.Context, store *auth.Store, username, password string) error {
exists, err := store.AdminExists(ctx)
if err != nil {
return fmt.Errorf("check admin: %w", err)
}
if exists {
return fmt.Errorf("admin already exists, skipping seed")
}
hash, err := auth.HashPassword(password)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
_, err = store.Create(ctx, username, hash, true)
return err
}
// captureWriter buffers the response body while forwarding to the real ResponseWriter.
type captureWriter struct {
http.ResponseWriter
status int
body bytes.Buffer
}
func (cw *captureWriter) WriteHeader(status int) {
cw.status = status
cw.ResponseWriter.WriteHeader(status)
}
func (cw *captureWriter) Write(b []byte) (int, error) {
cw.body.Write(b)
return cw.ResponseWriter.Write(b)
}
@@ -0,0 +1,12 @@
DROP TRIGGER IF EXISTS trg_files_updated_at ON files;
DROP TRIGGER IF EXISTS trg_folders_updated_at ON folders;
DROP TRIGGER IF EXISTS trg_users_updated_at ON users;
DROP FUNCTION IF EXISTS update_updated_at();
DROP TABLE IF EXISTS file_versions;
DROP TABLE IF EXISTS files;
DROP TABLE IF EXISTS folders;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS users;
DROP EXTENSION IF EXISTS "pgcrypto";
+103
View File
@@ -0,0 +1,103 @@
-- Aether – Initial schema
-- Phase 0: users, sessions, folders, files (no email)
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ── Users ─────────────────────────────────────────────────────────────────────
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL, -- Argon2id hash
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
quota_bytes BIGINT NOT NULL DEFAULT 10737418240, -- 10 GB default
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── Sessions ──────────────────────────────────────────────────────────────────
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
data BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
-- ── Folders ───────────────────────────────────────────────────────────────────
CREATE TABLE folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (owner_id, parent_id, name)
);
CREATE INDEX idx_folders_owner ON folders(owner_id);
CREATE INDEX idx_folders_parent ON folders(parent_id);
-- ── Files ─────────────────────────────────────────────────────────────────────
CREATE TABLE files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
folder_id UUID REFERENCES folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size BIGINT NOT NULL DEFAULT 0,
checksum TEXT NOT NULL, -- SHA-256 hex
storage_key TEXT NOT NULL, -- path within blob storage
version INTEGER NOT NULL DEFAULT 1,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_files_owner ON files(owner_id);
CREATE INDEX idx_files_folder ON files(folder_id);
CREATE INDEX idx_files_deleted ON files(is_deleted);
CREATE INDEX idx_files_storage ON files(storage_key);
-- Partial unique index: no two non-deleted files can share name+folder for the same owner.
CREATE UNIQUE INDEX idx_files_unique_name
ON files(owner_id, folder_id, name)
WHERE NOT is_deleted;
-- ── File versions ─────────────────────────────────────────────────────────────
-- Previous versions of files (kept for Soft-Delete + versioning).
CREATE TABLE file_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
size BIGINT NOT NULL,
checksum TEXT NOT NULL,
storage_key TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_file_versions_file ON file_versions(file_id);
-- ── Updated_at trigger ────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_folders_updated_at
BEFORE UPDATE ON folders
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_files_updated_at
BEFORE UPDATE ON files
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS uploads;
DROP TABLE IF EXISTS share_links;
ALTER TABLE files DROP COLUMN IF EXISTS has_thumbnail;
@@ -0,0 +1,35 @@
-- Phase 2: share links, resumable uploads, thumbnails
ALTER TABLE files ADD COLUMN has_thumbnail BOOLEAN NOT NULL DEFAULT FALSE;
-- ── Share links ───────────────────────────────────────────────────────────────
CREATE TABLE share_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
password_hash TEXT, -- Argon2id, NULL = no password
expires_at TIMESTAMPTZ, -- NULL = never
download_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_share_links_token ON share_links(token);
CREATE INDEX idx_share_links_owner ON share_links(owner_id);
CREATE INDEX idx_share_links_file ON share_links(file_id);
-- ── Resumable uploads ────────────────────────────────────────────────────────
CREATE TABLE uploads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
total_size BIGINT NOT NULL,
uploaded BIGINT NOT NULL DEFAULT 0,
storage_key TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours'
);
CREATE INDEX idx_uploads_owner ON uploads(owner_id);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS favorites;
@@ -0,0 +1,11 @@
CREATE TABLE favorites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
folder_id UUID NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
is_expanded BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, folder_id)
);
CREATE INDEX idx_favorites_user ON favorites(user_id);
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS group_members;
DROP TABLE IF EXISTS groups;
DELETE FROM settings WHERE key IN ('cloud_name','primary_color','accent_color','logo_storage_key');
ALTER TABLE users DROP COLUMN IF EXISTS display_name;
@@ -0,0 +1,35 @@
-- Phase 3: Admin panel – groups, settings, user display names
-- ── Users: display name ───────────────────────────────────────────────────────
ALTER TABLE users ADD COLUMN IF NOT EXISTS display_name TEXT NOT NULL DEFAULT '';
-- ── Groups ───────────────────────────────────────────────────────────────────
CREATE TABLE groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE group_members (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (group_id, user_id)
);
CREATE INDEX idx_group_members_user ON group_members(user_id);
-- ── Settings (key-value, admin-managed) ───────────────────────────────────────
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Default settings
INSERT INTO settings (key, value) VALUES
('cloud_name', 'Aether'),
('primary_color', '#6366f1'),
('accent_color', '#8b5cf6'),
('logo_storage_key', '');
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN IF EXISTS oidc_sub;
+3
View File
@@ -0,0 +1,3 @@
-- OIDC subject for linking OIDC provider accounts to local users.
-- NULL means the user only has a local password login.
ALTER TABLE users ADD COLUMN oidc_sub TEXT UNIQUE;
@@ -0,0 +1 @@
ALTER TABLE files DROP COLUMN IF EXISTS versioning_enabled;
@@ -0,0 +1,11 @@
-- Versionierung ist ab hier eine Opt-in-Einstellung pro Datei.
-- Standard: aus. Ohne den Schalter würde jede Überschreibung eine weitere
-- Fassung aufheben und den Speicher unbemerkt volllaufen lassen.
ALTER TABLE files
ADD COLUMN versioning_enabled BOOLEAN NOT NULL DEFAULT false;
-- Dateien, die bereits Fassungen haben, behalten ihr bisheriges Verhalten –
-- sonst würde ein bestehender Verlauf stillschweigend eingefroren.
UPDATE files f
SET versioning_enabled = true
WHERE EXISTS (SELECT 1 FROM file_versions v WHERE v.file_id = f.id);
@@ -0,0 +1,3 @@
ALTER TABLE users
DROP COLUMN IF EXISTS primary_color,
DROP COLUMN IF EXISTS accent_color;
@@ -0,0 +1,9 @@
-- Persönliche Farbpalette pro Nutzer. NULL bedeutet: es gilt die globale
-- Einstellung des Administrators.
--
-- Bis hierher zeigte die Profilseite zwar Farbwähler, schrieb damit aber in die
-- globalen Einstellungen – für normale Nutzer scheiterte das an der
-- Admin-Prüfung, beim Administrator färbte es die ganze Cloud um.
ALTER TABLE users
ADD COLUMN primary_color TEXT,
ADD COLUMN accent_color TEXT;
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN IF EXISTS use_custom_theme;
@@ -0,0 +1,13 @@
-- Schalter zwischen persönlicher und globaler Palette.
--
-- Bisher bedeutete "keine eigene Farbe" = globale Einstellung. Zum Zurückschalten
-- musste man die eigenen Werte löschen und war sie damit los. Der Schalter trennt
-- beides: die Farben bleiben gespeichert, nur ihre Anwendung wird umgelegt.
ALTER TABLE users
ADD COLUMN use_custom_theme BOOLEAN NOT NULL DEFAULT false;
-- Wer bereits eine eigene Farbe gesetzt hat, hat sie sichtbar verwendet –
-- das Verhalten soll durch die Migration nicht kippen.
UPDATE users
SET use_custom_theme = true
WHERE primary_color IS NOT NULL OR accent_color IS NOT NULL;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS app_passwords;
@@ -0,0 +1,15 @@
-- App-Passwörter: widerrufbare Zugangsdaten für WebDAV-/Sync-Clients, damit das
-- Hauptpasswort nicht auf Geräten liegt, die man verlieren kann.
CREATE TABLE app_passwords (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
-- SHA-256 des Tokens. Kein Argon2: das Token ist serverseitig zufällig und
-- hochentropisch, also nicht ratbar – und WebDAV authentifiziert jeden
-- einzelnen Request, wo Argon2 pro Aufruf zu teuer wäre.
token_hash TEXT NOT NULL UNIQUE,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_app_passwords_user ON app_passwords(user_id);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS audit_log;
@@ -0,0 +1,27 @@
-- Audit-Log: wer hat was wann getan. Zweck ist die Nachvollziehbarkeit nach
-- einem Vorfall – deshalb muss ein Eintrag auch dann noch lesbar sein, wenn der
-- handelnde oder betroffene Account längst gelöscht ist.
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- ON DELETE SET NULL statt CASCADE: sonst räumte das Löschen eines Users
-- genau die Spuren weg, die man danach untersuchen will.
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
-- Denormalisiert, damit der Name den gelöschten Account überlebt.
actor_name TEXT NOT NULL,
action TEXT NOT NULL,
target_type TEXT,
target_id TEXT,
target_name TEXT,
ip TEXT,
detail TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Die Admin-Ansicht liest immer neueste zuerst, oft gefiltert nach Aktion oder
-- handelndem Konto.
CREATE INDEX idx_audit_log_created ON audit_log (created_at DESC);
CREATE INDEX idx_audit_log_actor ON audit_log (actor_id, created_at DESC);
CREATE INDEX idx_audit_log_action ON audit_log (action, created_at DESC);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS acl_entries;
@@ -0,0 +1,51 @@
-- Interne Freigaben: Ordner oder Datei an einen anderen Nutzer oder eine Gruppe
-- geben, ohne dass die Bytes umziehen. Der Empfänger sieht sie virtuell unter
-- „Geteilt mit mir"; auf der Platte bleibt alles beim Eigentümer.
--
-- Abweichung vom Projektplan: dort war eine polymorphe Tabelle skizziert
-- (subject_type + subject_id als Text/UUID ohne Fremdschlüssel). Hier stattdessen
-- vier nullbare Fremdschlüssel mit CHECK-Bedingung. Grund: polymorph kann die
-- Datenbank nichts erzwingen – ein gelöschter Nutzer oder Ordner hinterließe
-- Einträge, die auf nichts mehr zeigen, und aus so einer Leiche später eine
-- Freigabe für einen neu vergebenen Namen zu machen wäre ein Sicherheitsloch.
-- Mit echten Fremdschlüsseln räumt ON DELETE CASCADE das von selbst auf.
CREATE TABLE acl_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Empfänger: genau eines von beiden.
subject_user_id UUID REFERENCES users(id) ON DELETE CASCADE,
subject_group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
-- Freigegebene Ressource: genau eines von beiden.
resource_folder_id UUID REFERENCES folders(id) ON DELETE CASCADE,
resource_file_id UUID REFERENCES files(id) ON DELETE CASCADE,
permission TEXT NOT NULL CHECK (permission IN ('read', 'write')),
-- Wer die Freigabe erteilt hat. SET NULL statt CASCADE: die Freigabe soll
-- nicht verschwinden, nur weil das Konto des Erteilers gelöscht wurde.
granted_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT acl_one_subject CHECK (num_nonnulls(subject_user_id, subject_group_id) = 1),
CONSTRAINT acl_one_resource CHECK (num_nonnulls(resource_folder_id, resource_file_id) = 1)
);
-- Zwei Freigaben derselben Ressource an denselben Empfänger wären mehrdeutig:
-- welches Recht gilt dann? Partielle Unique-Indizes, weil NULL in einem
-- gewöhnlichen UNIQUE nicht mit sich selbst kollidiert.
CREATE UNIQUE INDEX idx_acl_user_folder ON acl_entries (subject_user_id, resource_folder_id)
WHERE subject_user_id IS NOT NULL AND resource_folder_id IS NOT NULL;
CREATE UNIQUE INDEX idx_acl_user_file ON acl_entries (subject_user_id, resource_file_id)
WHERE subject_user_id IS NOT NULL AND resource_file_id IS NOT NULL;
CREATE UNIQUE INDEX idx_acl_group_folder ON acl_entries (subject_group_id, resource_folder_id)
WHERE subject_group_id IS NOT NULL AND resource_folder_id IS NOT NULL;
CREATE UNIQUE INDEX idx_acl_group_file ON acl_entries (subject_group_id, resource_file_id)
WHERE subject_group_id IS NOT NULL AND resource_file_id IS NOT NULL;
-- Die Zugriffsprüfung läuft bei jedem Request; sie fragt nach Ressource.
CREATE INDEX idx_acl_folder ON acl_entries (resource_folder_id) WHERE resource_folder_id IS NOT NULL;
CREATE INDEX idx_acl_file ON acl_entries (resource_file_id) WHERE resource_file_id IS NOT NULL;
-- „Geteilt mit mir" fragt nach Empfänger.
CREATE INDEX idx_acl_subject_user ON acl_entries (subject_user_id) WHERE subject_user_id IS NOT NULL;
CREATE INDEX idx_acl_subject_group ON acl_entries (subject_group_id) WHERE subject_group_id IS NOT NULL;
@@ -0,0 +1,18 @@
DROP INDEX IF EXISTS idx_files_group;
DROP INDEX IF EXISTS idx_folders_group;
DROP INDEX IF EXISTS idx_folders_group_root_name;
DROP INDEX IF EXISTS idx_folders_group_name;
ALTER TABLE groups DROP COLUMN IF EXISTS quota_bytes;
ALTER TABLE files DROP CONSTRAINT IF EXISTS files_one_principal;
ALTER TABLE folders DROP CONSTRAINT IF EXISTS folders_one_principal;
-- Erst die Gruppendaten entfernen, sonst scheitert das NOT NULL unten.
DELETE FROM files WHERE group_id IS NOT NULL;
DELETE FROM folders WHERE group_id IS NOT NULL;
ALTER TABLE files DROP COLUMN IF EXISTS group_id;
ALTER TABLE folders DROP COLUMN IF EXISTS group_id;
ALTER TABLE files ALTER COLUMN owner_id SET NOT NULL;
ALTER TABLE folders ALTER COLUMN owner_id SET NOT NULL;
@@ -0,0 +1,35 @@
-- Gruppenordner: ein Ordner, der keinem einzelnen Konto gehört, sondern einer
-- Gruppe. Wer Mitglied ist, sieht ihn; wer die Gruppe verlässt, nicht mehr.
--
-- Ordner und Dateien gehören ab jetzt entweder einem Nutzer ODER einer Gruppe,
-- nie beidem und nie keinem. Deshalb wird owner_id nullbar und bekommt eine
-- CHECK-Bedingung als Gegengewicht – ohne die könnte ein Datensatz herrenlos
-- werden und wäre über keine Abfrage mehr erreichbar.
ALTER TABLE folders ALTER COLUMN owner_id DROP NOT NULL;
ALTER TABLE files ALTER COLUMN owner_id DROP NOT NULL;
ALTER TABLE folders ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE;
ALTER TABLE files ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE;
ALTER TABLE folders ADD CONSTRAINT folders_one_principal
CHECK (num_nonnulls(owner_id, group_id) = 1);
ALTER TABLE files ADD CONSTRAINT files_one_principal
CHECK (num_nonnulls(owner_id, group_id) = 1);
-- Gruppen bekommen ein eigenes Kontingent. Die Alternative – „zählt beim
-- Ersteller" – wäre für die Mitglieder nicht nachvollziehbar: Der Ordner wäre
-- plötzlich voll, weil jemand anders privat etwas hochgeladen hat.
ALTER TABLE groups ADD COLUMN quota_bytes BIGINT NOT NULL DEFAULT 10737418240;
-- Die bestehende UNIQUE (owner_id, parent_id, name) greift für Gruppenordner
-- nicht: In SQL kollidiert NULL nicht mit NULL, zwei Gruppenordner gleichen
-- Namens rutschten also durch.
CREATE UNIQUE INDEX idx_folders_group_name ON folders (group_id, parent_id, name)
WHERE group_id IS NOT NULL AND parent_id IS NOT NULL;
-- Auf oberster Ebene ist parent_id NULL, das braucht einen eigenen Index.
CREATE UNIQUE INDEX idx_folders_group_root_name ON folders (group_id, name)
WHERE group_id IS NOT NULL AND parent_id IS NULL;
CREATE INDEX idx_folders_group ON folders (group_id) WHERE group_id IS NOT NULL;
CREATE INDEX idx_files_group ON files (group_id) WHERE group_id IS NOT NULL;
@@ -0,0 +1,5 @@
DROP INDEX IF EXISTS idx_folders_name_trgm;
DROP INDEX IF EXISTS idx_files_name_trgm;
-- Die Erweiterung bleibt bewusst stehen: Sie wieder zu entfernen würde alles
-- mitreißen, was sonst noch auf ihr aufbaut, und sie kostet nichts.
@@ -0,0 +1,12 @@
-- Namenssuche über Dateien und Ordner.
--
-- pg_trgm statt einer Volltextsuche: Gesucht wird nach Namensteilen
-- ("rechn" findet "Rechnung_2026.pdf"), und eine Volltextsuche zerlegt in
-- Wörter, findet also gerade Teilwörter nicht. Volltext über den *Inhalt*
-- kommt in Phase 6 dazu – das ist eine andere Aufgabe.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- GIN mit Trigrammen macht ILIKE '%…%' indizierbar. Ohne den Index läuft jede
-- Suche als vollständiger Tabellendurchlauf.
CREATE INDEX idx_files_name_trgm ON files USING gin (name gin_trgm_ops);
CREATE INDEX idx_folders_name_trgm ON folders USING gin (name gin_trgm_ops);
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS ocr_jobs;
DROP TABLE IF EXISTS file_text;
@@ -0,0 +1,63 @@
-- Volltext über den *Inhalt* – die zweite Hälfte der Suche.
--
-- Migration 000013 machte Datei*namen* durchsuchbar. Das reicht, solange
-- Dateien sprechende Namen haben. Ein Scanner nennt seine Ablage aber
-- "scan_0042.pdf", und genau die Belege sollen auffindbar werden.
-- ── Der erkannte Text ─────────────────────────────────────────────────────────
-- Eigene Tabelle statt einer Spalte an files: Der Text ist um Größenordnungen
-- länger als der Rest der Zeile, und files wird bei jedem Auflisten gelesen.
-- Postgres lagert lange Werte zwar aus (TOAST), aber die tsvector-Spalte und
-- ihr GIN-Index gehörten dann trotzdem an eine Tabelle, die ständig in
-- Bewegung ist.
CREATE TABLE file_text (
file_id UUID PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
content TEXT NOT NULL,
-- Woher der Text stammt. 'layer' = das PDF hatte bereits eine Textebene
-- (pdftotext, verlustfrei); 'ocr' = aus dem Bild erkannt, kann Fehler
-- enthalten. Der Unterschied ist für die Fehlersuche wichtig: Findet die
-- Suche ein Wort nicht, sagt die Herkunft, ob OCR es verlesen hat.
source TEXT NOT NULL CHECK (source IN ('layer', 'ocr')),
-- Die Sprachen, mit denen erkannt wurde ("deu+eng"). Bei 'layer' leer.
lang TEXT NOT NULL DEFAULT '',
-- Zeichenzahl des Originals vor dem Kürzen – siehe maxTextBytes im Worker.
chars INTEGER NOT NULL DEFAULT 0,
pages INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Berechnete Spalte statt eines Triggers: Sie kann nie mit dem Inhalt
-- auseinanderlaufen, weil Postgres sie bei jedem Schreiben selbst füllt.
--
-- 'german' fest verdrahtet, nicht 'simple': Ohne Stammformen fände
-- "Rechnung" die "Rechnungen" nicht. Deutsch ist die Sprache der Belege,
-- um die es hier geht; englische Wörter darin verlieren nur ihre
-- Stammformbildung, sie bleiben wörtlich auffindbar. Eine Änderung wäre
-- eine Migration – der Preis dafür, dass der Ausdruck IMMUTABLE sein muss.
tsv tsvector GENERATED ALWAYS AS (to_tsvector('german', content)) STORED
);
CREATE INDEX idx_file_text_tsv ON file_text USING gin (tsv);
-- ── Die Warteschlange ─────────────────────────────────────────────────────────
-- Warum eine Tabelle und nicht einfach ein Goroutine-Aufruf wie bei den
-- Vorschaubildern: OCR dauert Sekunden bis Minuten. Startet der Server in
-- dieser Zeit neu – Update, Absturz, Neustart des Wirts –, ist ein Auftrag im
-- Speicher verloren, und die Datei bleibt für immer unauffindbar, ohne dass es
-- jemand merkt. Bei einem Vorschaubild fällt das auf; bei fehlendem Text nicht.
CREATE TABLE ocr_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Ein Auftrag je Datei. Wird dieselbe Datei erneut hochgeladen, ersetzt der
-- neue Auftrag den alten (ON CONFLICT), statt sich daneben zu stapeln.
file_id UUID NOT NULL UNIQUE REFERENCES files(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'running', 'done', 'failed')),
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Der Arbeiter fragt nur nach offenen Aufträgen. Ein Teilindex bleibt klein,
-- auch wenn sich zehntausend erledigte Zeilen ansammeln.
CREATE INDEX idx_ocr_jobs_pending ON ocr_jobs(created_at)
WHERE status IN ('pending', 'running');
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_files_document_date;
ALTER TABLE files DROP COLUMN IF EXISTS document_date;
DROP TABLE IF EXISTS file_tags;
DROP TABLE IF EXISTS tags;
+75
View File
@@ -0,0 +1,75 @@
-- Etiketten für Dateien.
--
-- Warum neben Ordnern und Volltext noch ein drittes Ordnungsmittel: Ordner
-- beantworten „wann" (Rechnungen/2026), der Volltext beantwortet „wo steht
-- dieses Wort". Beides beantwortet **nicht**, wofür ein Beleg gebraucht wird –
-- „absetzbar" steht in keiner Rechnung, das ist eine Einschätzung des Menschen.
--
-- Und der Fall, an dem Ordner scheitern: Der Kaufbeleg eines Notebooks ist
-- zugleich Garantienachweis *und* absetzbar. In einen Ordner passt er nur
-- einmal; Etiketten trägt dieselbe Datei beliebig viele.
-- ── Die Etiketten selbst ─────────────────────────────────────────────────────
-- Prinzipal-Modell wie bei files und folders: Ein Etikett gehört entweder einem
-- Konto oder einer Gruppe, nie beidem und nie keinem.
--
-- Der Grund für die Gruppenvariante: In einem Gruppenordner arbeiten mehrere an
-- denselben Dokumenten. Hätte dort jeder seine eigenen Etiketten, sortierte
-- jeder dieselben Dateien für sich neu ein und niemand sähe die Arbeit des
-- anderen – der Sinn eines geteilten Ordners wäre dahin.
CREATE TABLE tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID REFERENCES users(id) ON DELETE CASCADE,
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
name TEXT NOT NULL,
-- Hex-Farbe für den Aufkleber. Leer heißt: die Oberfläche wählt eine aus
-- dem Namen, damit ein neues Etikett nicht erst eingefärbt werden muss.
color TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT tags_one_principal CHECK (
(owner_id IS NOT NULL AND group_id IS NULL) OR
(owner_id IS NULL AND group_id IS NOT NULL)
),
CONSTRAINT tags_name_not_empty CHECK (length(btrim(name)) > 0)
);
-- Namen sind je Prinzipal eindeutig, und zwar **ohne Rücksicht auf
-- Groß-/Kleinschreibung**. Sonst stehen nach drei Monaten "Stadtwerke",
-- "stadtwerke" und "STADTWERKE" nebeneinander – genau der Zerfall, den
-- Etiketten gegenüber freien Ordnernamen verhindern sollen.
CREATE UNIQUE INDEX idx_tags_owner_name ON tags (owner_id, lower(btrim(name)))
WHERE owner_id IS NOT NULL;
CREATE UNIQUE INDEX idx_tags_group_name ON tags (group_id, lower(btrim(name)))
WHERE group_id IS NOT NULL;
-- ── Die Zuordnung ────────────────────────────────────────────────────────────
-- Eine reine Verknüpfungstabelle. Nichts davon wird in die Datei geschrieben:
-- Das Dokument bleibt Byte für Byte, wie es hochgeladen wurde – dieselbe Regel
-- wie beim erkannten Text (siehe internal/documents).
CREATE TABLE file_tags (
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
-- Wer es angehängt hat. In einem Gruppenordner sonst nicht nachvollziehbar.
tagged_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (file_id, tag_id)
);
-- Der Primärschlüssel deckt die Richtung Datei → Etiketten ab. Die andere
-- Richtung – „zeig mir alles mit steuer-2026" – ist genau die Abfrage, für die
-- Etiketten gebaut werden, und braucht ihren eigenen Index.
CREATE INDEX idx_file_tags_tag ON file_tags (tag_id);
-- ── Belegdatum ───────────────────────────────────────────────────────────────
-- Das Datum, das auf dem Dokument steht – nicht das des Uploads.
--
-- Für beide Zwecke entscheidend: Die Garantiefrist läuft ab dem **Kauf**, und in
-- die Steuererklärung 2026 gehört, was 2026 gekauft wurde – auch wenn der Beleg
-- erst im März 2027 eingescannt wird. created_at wäre für beides das falsche
-- Datum, und zwar unauffällig falsch.
--
-- NULL heißt schlicht „nicht gesetzt": Bei einem Urlaubsfoto gibt es kein
-- Belegdatum, und ein Vorgabewert wäre eine Behauptung.
ALTER TABLE files ADD COLUMN document_date DATE;
CREATE INDEX idx_files_document_date ON files (document_date) WHERE document_date IS NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE file_text DROP COLUMN IF EXISTS extract_version;
@@ -0,0 +1,13 @@
-- Seitengrenzen im erkannten Text.
--
-- Bis hierher ersetzte clean() den Seitenumbruch (\f) durch einen Zeilenumbruch:
-- Für die Volltextsuche ist er bedeutungslos, ein tsvector kennt keine Seiten.
-- Für den PDF-Betrachter ist er das Gegenteil von bedeutungslos – bei einem
-- Scan steht der Text *nur* hier, im PDF selbst liegt bloß ein Bild. Ohne die
-- Marke lässt sich nicht sagen, auf welcher Seite ein Suchwort steht, und der
-- Betrachter beginnt bei einem Beleg-Scan wieder stumpf auf Seite 1.
--
-- extract_version sagt, nach welcher Regel ein Eintrag entstanden ist. Ältere
-- Einträge werden beim Start erneut in die Warteschlange gelegt, statt still
-- ohne Seitenmarken liegen zu bleiben.
ALTER TABLE file_text ADD COLUMN IF NOT EXISTS extract_version smallint NOT NULL DEFAULT 0;
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_files_correspondent;
ALTER TABLE files DROP COLUMN IF EXISTS correspondent_id;
DROP TABLE IF EXISTS correspondents;
@@ -0,0 +1,52 @@
-- Absender („Korrespondent") an Dateien.
--
-- Warum neben Etiketten noch ein Feld: Es sind **zwei verschiedene Fragen**.
-- Ein Etikett sagt, *wofür* ein Dokument gebraucht wird („absetzbar",
-- „garantie") – davon trägt eine Datei beliebig viele. Der Absender sagt, *von
-- wem* es kommt („Stadtwerke", „Finanzamt") – davon hat eine Datei genau einen.
--
-- Als Etikett gelöst stünden beide in derselben Liste: „Stadtwerke" neben
-- „absetzbar", Absender und Verwendungszweck durcheinander. Bei zwanzig
-- Etiketten fällt das nicht auf, bei achtzig ist die Liste unbenutzbar. Und
-- „alle Rechnungen von X" ließe sich nicht von „alles Absetzbare" trennen –
-- genau die Verknüpfung, für die es gebaut wird.
--
-- Die Eins-Regel ist dabei kein Mangel, sondern der Zweck: Sie erzwingt eine
-- Entscheidung und hält die Liste kurz.
CREATE TABLE correspondents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Prinzipal-Modell wie bei tags: entweder ein Konto oder eine Gruppe.
-- In einem Gruppenordner müssen alle dieselben Absender sehen, sonst
-- sortiert jeder dieselben Dokumente für sich neu ein.
owner_id UUID REFERENCES users(id) ON DELETE CASCADE,
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT correspondents_one_principal CHECK (
(owner_id IS NOT NULL AND group_id IS NULL) OR
(owner_id IS NULL AND group_id IS NOT NULL)
),
CONSTRAINT correspondents_name_not_empty CHECK (length(btrim(name)) > 0)
);
-- Eindeutig je Prinzipal, ohne Rücksicht auf Groß-/Kleinschreibung – dieselbe
-- Regel wie bei Etiketten. Hier wiegt sie schwerer: „Amazon" und „amazon"
-- nebeneinander hieße, dass „alle Rechnungen von Amazon" nur die Hälfte
-- findet, und zwar unauffällig.
CREATE UNIQUE INDEX idx_correspondents_owner_name
ON correspondents (owner_id, lower(btrim(name))) WHERE owner_id IS NOT NULL;
CREATE UNIQUE INDEX idx_correspondents_group_name
ON correspondents (group_id, lower(btrim(name))) WHERE group_id IS NOT NULL;
-- Am Datei-Datensatz, nicht in einer Verknüpfungstabelle: Genau einer je
-- Datei – das steht damit im Schema und nicht bloß in einer Zusicherung des
-- Codes.
--
-- ON DELETE SET NULL statt CASCADE: Wer einen Absender löscht, will ihn aus
-- der Liste haben – nicht seine Rechnungen.
ALTER TABLE files ADD COLUMN correspondent_id UUID REFERENCES correspondents(id) ON DELETE SET NULL;
-- „Alles von Stadtwerke" ist die Abfrage, für die das Feld gebaut wird.
CREATE INDEX idx_files_correspondent ON files (correspondent_id) WHERE correspondent_id IS NOT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE tags DROP COLUMN IF EXISTS icon;
ALTER TABLE correspondents DROP COLUMN IF EXISTS icon;
@@ -0,0 +1,15 @@
-- Ein Symbol je Etikett und je Absender.
--
-- Die Farbe allein trägt nicht weit genug: Sie unterscheidet zuverlässig etwa
-- fünf Einträge. Danach fragt man sich, ob Blau nun „Uni" oder „Versicherung"
-- war. Ein Emoji ist sofort erkennbar, braucht keine Legende und ist in jeder
-- Schrift schon da – kein Symbolsatz, keine Grafikdateien, keine zusätzliche
-- Ladezeit.
--
-- Leer heißt „keins": Die Farbe bleibt der Rückfall, niemand muss ein Symbol
-- wählen. Ein Vorgabe-Emoji wäre eine Behauptung über den Inhalt.
--
-- TEXT statt eines einzelnen Zeichens: Ein Emoji ist selten ein Codepunkt.
-- 👨‍🎓 sind fünf, 🏳️‍🌈 vier – ein CHAR(1) hätte sie zerschnitten.
ALTER TABLE tags ADD COLUMN IF NOT EXISTS icon TEXT NOT NULL DEFAULT '';
ALTER TABLE correspondents ADD COLUMN IF NOT EXISTS icon TEXT NOT NULL DEFAULT '';
+34
View File
@@ -0,0 +1,34 @@
module aether
go 1.25.0
require (
github.com/coreos/go-oidc/v3 v3.19.0
github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/httprate v0.15.0
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/gorilla/sessions v1.4.0
github.com/jackc/pgx/v5 v5.10.0
github.com/rs/zerolog v1.33.0
golang.org/x/crypto v0.53.0
golang.org/x/image v0.42.0
golang.org/x/net v0.56.0
golang.org/x/oauth2 v0.36.0
)
require (
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
)
+124
View File
@@ -0,0 +1,124 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+351
View File
@@ -0,0 +1,351 @@
// Package acl regelt interne Freigaben: Ordner und Dateien an andere Nutzer
// oder Gruppen geben, ohne dass Daten umziehen.
//
// Das Zugriffsmodell in einem Satz: Zugriff hat, wem die Ressource gehört –
// oder wer einen Freigabeeintrag darauf besitzt, direkt oder über eine Gruppe,
// auf der Ressource selbst oder auf einem Ordner darüber.
//
// Die Vererbung nach unten ist der Grund, warum das nicht mit einer einfachen
// Abfrage geht: Wer einen Ordner freigibt, meint dessen ganzen Inhalt – sonst
// müsste man jede Datei einzeln freigeben, und jede neu hinzugefügte gleich
// mit. Deshalb läuft die Prüfung den Ordnerbaum nach oben, bis sie einen
// Eintrag findet.
package acl
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
// ErrNoAccess: weder Eigentümer noch Freigabe. Handler antworten damit
// bewusst „nicht gefunden" statt „verboten" – sonst verriete die Antwort,
// dass die ID überhaupt existiert.
ErrNoAccess = errors.New("no access to this resource")
// ErrReadOnly: Zugriff ja, Schreiben nein.
ErrReadOnly = errors.New("read-only access")
ErrNotFound = errors.New("resource not found")
)
// Permission ist absichtlich klein gehalten. Feinere Rechte (löschen vs.
// ändern) brächten Fragen mit sich, die für eine persönliche Cloud niemand
// stellt.
type Permission string
const (
PermRead Permission = "read"
PermWrite Permission = "write"
)
func (p Permission) valid() bool { return p == PermRead || p == PermWrite }
// Access beschreibt das Ergebnis einer Zugriffsprüfung.
type Access struct {
// OwnerID ist der *Prinzipal* der Ressource – ein Konto oder eine Gruppe,
// nicht zwingend der Anfragende. Alle Speicher- und Datenbankoperationen
// laufen weiter über ihn, denn dort liegen die Daten.
//
// Ein Prinzipal statt eines Eigentümers, weil Gruppenordner niemandem
// persönlich gehören. Die Abfragen prüfen deshalb `owner_id = $1 OR
// group_id = $1`; UUIDs sind global eindeutig, eine Gruppen-ID kann also
// nie versehentlich auf ein Konto passen.
OwnerID uuid.UUID
// IsGroup unterscheidet die beiden Fälle dort, wo es darauf ankommt:
// beim Anlegen (welche Spalte wird gesetzt) und beim Speicherpfad.
IsGroup bool
// OwnerUsername, weil Speicherpfade daraus gebaut werden
// (`users/{name}/home/…`). Wer in einem geliehenen Ordner arbeitet, schreibt
// in den Bereich des Eigentümers, nicht in seinen eigenen.
OwnerUsername string
// OwnerQuota ist das Kontingent des Eigentümers. Wer in einen geliehenen
// Ordner hochlädt, verbraucht dessen Platz – die Bytes liegen ja in dessen
// Bereich. Alles andere wäre ein Weg, fremde Kontingente zu umgehen.
OwnerQuota int64
// Permission ist das, was der Anfragende darf.
Permission Permission
// Shared unterscheidet eigenen Besitz von geliehenem Zugriff.
Shared bool
}
func (a Access) CanWrite() bool { return a.Permission == PermWrite }
// OwnerRef und GroupRef liefern die Werte für die Spalten owner_id / group_id.
// Genau einer ist gesetzt – so verlangt es die CHECK-Bedingung.
func (a Access) OwnerRef() *uuid.UUID {
if a.IsGroup {
return nil
}
id := a.OwnerID
return &id
}
func (a Access) GroupRef() *uuid.UUID {
if !a.IsGroup {
return nil
}
id := a.OwnerID
return &id
}
// StorageRoot ist das Wurzelsegment für Speicherpfade. Gruppendaten liegen
// bewusst nicht unter `users/…`: Auf der Platte soll man ansehen können, dass
// ein Ordner keinem Konto gehört – sonst sucht man die Daten einer Gruppe im
// Verzeichnis eines Nutzers, der zufällig ihr Ersteller war.
func (a Access) StorageRoot() string {
if a.IsGroup {
return "groups/" + a.OwnerID.String()
}
return "users/" + a.OwnerUsername
}
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) *Store {
return &Store{db: db}
}
// ── Zugriffsauflösung ────────────────────────────────────────────────────────
// ResolveFolder klärt, was userID mit diesem Ordner darf.
func (s *Store) ResolveFolder(ctx context.Context, userID, folderID uuid.UUID) (Access, error) {
// Gruppenordner haben keinen Eigentümer, deshalb beide Fälle in einer
// Abfrage. COALESCE macht die Gruppen-ID zum Prinzipal, wenn owner_id leer
// ist – genau die ID, mit der die Store-Abfragen weiterarbeiten.
var principal uuid.UUID
var isGroup bool
var ownerName string
var quota int64
err := s.db.QueryRow(ctx,
`SELECT COALESCE(f.owner_id, f.group_id),
f.group_id IS NOT NULL,
COALESCE(u.username, ''),
COALESCE(u.quota_bytes, g.quota_bytes, 0)
FROM folders f
LEFT JOIN users u ON u.id = f.owner_id
LEFT JOIN groups g ON g.id = f.group_id
WHERE f.id = $1`, folderID).Scan(&principal, &isGroup, &ownerName, &quota)
if errors.Is(err, pgx.ErrNoRows) {
return Access{}, ErrNotFound
}
if err != nil {
return Access{}, fmt.Errorf("load folder principal: %w", err)
}
if isGroup {
// Mitgliedschaft ersetzt hier die Freigabe: Wer in der Gruppe ist, darf
// schreiben; wer sie verlässt, verliert den Zugriff im selben Moment.
member, err := s.isGroupMember(ctx, userID, principal)
if err != nil {
return Access{}, err
}
if !member {
return Access{}, ErrNoAccess
}
return Access{
OwnerID: principal, IsGroup: true, OwnerQuota: quota,
Permission: PermWrite, Shared: true,
}, nil
}
if principal == userID {
return Access{OwnerID: principal, OwnerUsername: ownerName, OwnerQuota: quota, Permission: PermWrite}, nil
}
ownerID, ownerQuota := principal, quota
perm, err := s.bestPermissionForFolder(ctx, userID, folderID)
if err != nil {
return Access{}, err
}
if perm == "" {
return Access{}, ErrNoAccess
}
return Access{OwnerID: ownerID, OwnerUsername: ownerName, OwnerQuota: ownerQuota, Permission: perm, Shared: true}, nil
}
// ResolveFile klärt, was userID mit dieser Datei darf. Geerbt wird über den
// Ordner, in dem sie liegt.
func (s *Store) ResolveFile(ctx context.Context, userID, fileID uuid.UUID) (Access, error) {
var principal uuid.UUID
var isGroup bool
var ownerName string
var quota int64
var folderID *uuid.UUID
err := s.db.QueryRow(ctx,
`SELECT COALESCE(x.owner_id, x.group_id),
x.group_id IS NOT NULL,
COALESCE(u.username, ''),
COALESCE(u.quota_bytes, g.quota_bytes, 0),
x.folder_id
FROM files x
LEFT JOIN users u ON u.id = x.owner_id
LEFT JOIN groups g ON g.id = x.group_id
WHERE x.id = $1`, fileID).Scan(&principal, &isGroup, &ownerName, &quota, &folderID)
if errors.Is(err, pgx.ErrNoRows) {
return Access{}, ErrNotFound
}
if err != nil {
return Access{}, fmt.Errorf("load file principal: %w", err)
}
if isGroup {
member, err := s.isGroupMember(ctx, userID, principal)
if err != nil {
return Access{}, err
}
if !member {
return Access{}, ErrNoAccess
}
return Access{
OwnerID: principal, IsGroup: true, OwnerQuota: quota,
Permission: PermWrite, Shared: true,
}, nil
}
if principal == userID {
return Access{OwnerID: principal, OwnerUsername: ownerName, OwnerQuota: quota, Permission: PermWrite}, nil
}
ownerID, ownerQuota := principal, quota
// Direkte Freigabe auf der Datei.
perm, err := s.directFilePermission(ctx, userID, fileID)
if err != nil {
return Access{}, err
}
// Geerbt aus dem Ordner darüber. Das stärkere Recht gewinnt: eine
// Schreibfreigabe auf dem Ordner soll nicht dadurch entwertet werden, dass
// jemand zusätzlich eine Lesefreigabe auf einer einzelnen Datei erteilt hat.
if folderID != nil && perm != PermWrite {
inherited, err := s.bestPermissionForFolder(ctx, userID, *folderID)
if err != nil {
return Access{}, err
}
perm = stronger(perm, inherited)
}
if perm == "" {
return Access{}, ErrNoAccess
}
return Access{OwnerID: ownerID, OwnerUsername: ownerName, OwnerQuota: ownerQuota, Permission: perm, Shared: true}, nil
}
// RequireFolderWrite / RequireFileWrite sind die bequeme Form für Handler, die
// ändern wollen.
func (s *Store) RequireFolderWrite(ctx context.Context, userID, folderID uuid.UUID) (Access, error) {
a, err := s.ResolveFolder(ctx, userID, folderID)
if err != nil {
return a, err
}
if !a.CanWrite() {
return a, ErrReadOnly
}
return a, nil
}
func (s *Store) RequireFileWrite(ctx context.Context, userID, fileID uuid.UUID) (Access, error) {
a, err := s.ResolveFile(ctx, userID, fileID)
if err != nil {
return a, err
}
if !a.CanWrite() {
return a, ErrReadOnly
}
return a, nil
}
// IsGroupMember ist die öffentliche Fassung von isGroupMember – gebraucht von
// internal/tags, um zu prüfen, ob jemand einer Gruppe ein Etikett anlegen darf.
func (s *Store) IsGroupMember(ctx context.Context, userID, groupID uuid.UUID) (bool, error) {
return s.isGroupMember(ctx, userID, groupID)
}
func (s *Store) isGroupMember(ctx context.Context, userID, groupID uuid.UUID) (bool, error) {
var exists bool
err := s.db.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM group_members WHERE user_id = $1 AND group_id = $2)`,
userID, groupID).Scan(&exists)
if err != nil {
return false, fmt.Errorf("check group membership: %w", err)
}
return exists, nil
}
// bestPermissionForFolder läuft vom Ordner aus nach oben und liefert das
// stärkste Recht, das userID auf dem Weg findet – direkt oder über eine Gruppe.
// Leerer Rückgabewert heißt: keinerlei Zugriff.
func (s *Store) bestPermissionForFolder(ctx context.Context, userID, folderID uuid.UUID) (Permission, error) {
// Rekursiv nach oben statt nach unten: Der Pfad zur Wurzel ist ein paar
// Ebenen lang, der Unterbaum kann Tausende Einträge haben.
const q = `
WITH RECURSIVE ancestry AS (
SELECT id, parent_id FROM folders WHERE id = $1
UNION ALL
SELECT f.id, f.parent_id
FROM folders f
JOIN ancestry a ON f.id = a.parent_id
)
SELECT e.permission
FROM acl_entries e
JOIN ancestry a ON a.id = e.resource_folder_id
WHERE e.subject_user_id = $2
OR e.subject_group_id IN (
SELECT group_id FROM group_members WHERE user_id = $2
)`
rows, err := s.db.Query(ctx, q, folderID, userID)
if err != nil {
return "", fmt.Errorf("resolve folder permission: %w", err)
}
defer rows.Close()
var best Permission
for rows.Next() {
var p Permission
if err := rows.Scan(&p); err != nil {
return "", fmt.Errorf("scan permission: %w", err)
}
best = stronger(best, p)
}
return best, rows.Err()
}
func (s *Store) directFilePermission(ctx context.Context, userID, fileID uuid.UUID) (Permission, error) {
rows, err := s.db.Query(ctx,
`SELECT permission FROM acl_entries
WHERE resource_file_id = $1
AND (subject_user_id = $2
OR subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $2))`,
fileID, userID)
if err != nil {
return "", fmt.Errorf("resolve file permission: %w", err)
}
defer rows.Close()
var best Permission
for rows.Next() {
var p Permission
if err := rows.Scan(&p); err != nil {
return "", fmt.Errorf("scan permission: %w", err)
}
best = stronger(best, p)
}
return best, rows.Err()
}
// stronger: Mehrere Wege zur selben Ressource (direkt und über zwei Gruppen)
// sind normal. Das großzügigste Recht gilt – alles andere wäre für den Nutzer
// nicht nachvollziehbar.
func stronger(a, b Permission) Permission {
if a == PermWrite || b == PermWrite {
return PermWrite
}
if a == PermRead || b == PermRead {
return PermRead
}
return ""
}
+60
View File
@@ -0,0 +1,60 @@
package acl
import "testing"
// stronger entscheidet, welches Recht gilt, wenn jemand eine Ressource über
// mehrere Wege erreicht – direkt und zusätzlich über zwei Gruppen etwa. Kippt
// diese Funktion, bekommt jemand entweder zu viel oder unerwartet zu wenig.
func TestStrongerPermissionWins(t *testing.T) {
tests := []struct {
name string
a, b Permission
want Permission
}{
{"nichts und nichts", "", "", ""},
{"nur lesen", PermRead, "", PermRead},
{"lesen und schreiben", PermRead, PermWrite, PermWrite},
{"schreiben und lesen", PermWrite, PermRead, PermWrite},
{"zweimal schreiben", PermWrite, PermWrite, PermWrite},
{"zweimal lesen", PermRead, PermRead, PermRead},
{"schreiben aus dem Nichts", "", PermWrite, PermWrite},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := stronger(tt.a, tt.b); got != tt.want {
t.Errorf("stronger(%q, %q) = %q, want %q", tt.a, tt.b, got, tt.want)
}
})
}
}
// Ein unbekanntes Recht darf nicht durchrutschen: Die Datenbank hat zwar einen
// CHECK, aber die Prüfung soll vorher greifen und eine klare Fehlermeldung
// liefern statt eines Constraint-Fehlers.
func TestPermissionValidation(t *testing.T) {
for _, p := range []Permission{PermRead, PermWrite} {
if !p.valid() {
t.Errorf("%q sollte gültig sein", p)
}
}
for _, p := range []Permission{"", "admin", "owner", "READ", "write "} {
if p.valid() {
t.Errorf("%q sollte ungültig sein", p)
}
}
}
// Access.CanWrite ist die Weiche vor jeder Änderung.
func TestAccessCanWrite(t *testing.T) {
if (Access{Permission: PermRead}).CanWrite() {
t.Error("Lesezugriff darf nicht schreiben dürfen")
}
if !(Access{Permission: PermWrite}).CanWrite() {
t.Error("Schreibzugriff muss schreiben dürfen")
}
// Der Nullwert entsteht, wenn eine Auflösung fehlschlägt – der darf
// keinesfalls Schreibrechte bedeuten.
if (Access{}).CanWrite() {
t.Error("leerer Zugriff darf nicht schreiben dürfen")
}
}
+263
View File
@@ -0,0 +1,263 @@
package acl
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"aether/internal/audit"
"aether/internal/auth"
"aether/pkg/middleware"
"aether/pkg/response"
)
// Handler bedient den Freigabedialog. Die Zugriffsprüfung selbst passiert im
// files-Paket; hier geht es nur ums Erteilen, Ansehen und Widerrufen.
//
// Freigeben darf ausschließlich der Eigentümer. Wer selbst nur eine
// Schreibfreigabe hat, kann sie nicht weiterreichen – sonst verlöre der
// Eigentümer die Kontrolle darüber, wer seine Daten sieht.
type Handler struct {
store *Store
auditor *audit.Logger
}
func NewHandler(store *Store, auditor *audit.Logger) *Handler {
return &Handler{store: store, auditor: auditor}
}
// logShare hält fest, wer wem Zugriff auf was gegeben oder genommen hat. Bei
// Freigaben ist das die interessanteste Spur überhaupt: Sie erklärt, wie jemand
// an Daten kam, die ihm nicht gehören.
func (h *Handler) logShare(r *http.Request, action, resourceType, resourceID, detail string) {
user := auth.UserFromContext(r.Context())
e := audit.Event{
Action: action,
TargetType: resourceType,
TargetID: resourceID,
Detail: detail,
IP: middleware.RealIP(r),
}
if user != nil {
e.ActorID = &user.ID
e.ActorName = user.Username
}
h.auditor.Log(r.Context(), e)
}
// ownedFolder / ownedFile liefern die Ressourcen-ID und den Anfragenden –
// aber nur, wenn ihm die Ressource auch gehört. Alles andere ist bereits
// beantwortet.
func (h *Handler) ownedFolder(w http.ResponseWriter, r *http.Request) (uuid.UUID, uuid.UUID, bool) {
user := auth.UserFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid folder id")
return uuid.Nil, uuid.Nil, false
}
a, err := h.store.ResolveFolder(r.Context(), user.ID, id)
if err != nil {
response.Error(w, http.StatusNotFound, "folder not found")
return uuid.Nil, uuid.Nil, false
}
if a.OwnerID != user.ID {
response.Error(w, http.StatusForbidden, "only the owner can manage sharing")
return uuid.Nil, uuid.Nil, false
}
return id, user.ID, true
}
func (h *Handler) ownedFile(w http.ResponseWriter, r *http.Request) (uuid.UUID, uuid.UUID, bool) {
user := auth.UserFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid file id")
return uuid.Nil, uuid.Nil, false
}
a, err := h.store.ResolveFile(r.Context(), user.ID, id)
if err != nil {
response.Error(w, http.StatusNotFound, "file not found")
return uuid.Nil, uuid.Nil, false
}
if a.OwnerID != user.ID {
response.Error(w, http.StatusForbidden, "only the owner can manage sharing")
return uuid.Nil, uuid.Nil, false
}
return id, user.ID, true
}
func userFrom(r *http.Request) uuid.UUID {
if u := auth.UserFromContext(r.Context()); u != nil {
return u.ID
}
return uuid.Nil
}
type grantRequest struct {
SubjectID string `json:"subject_id"`
SubjectType string `json:"subject_type"` // "user" | "group"
Permission Permission `json:"permission"`
}
// GET /api/v1/folders/{id}/acl
func (h *Handler) ListFolderACL(w http.ResponseWriter, r *http.Request) {
id, _, ok := h.ownedFolder(w, r)
if !ok {
return
}
entries, err := h.store.ListForFolder(r.Context(), id)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load shares")
return
}
response.JSON(w, http.StatusOK, entries)
}
// POST /api/v1/folders/{id}/acl
func (h *Handler) GrantFolderACL(w http.ResponseWriter, r *http.Request) {
id, owner, ok := h.ownedFolder(w, r)
if !ok {
return
}
req, ok := decodeGrant(w, r)
if !ok {
return
}
entry, err := h.store.GrantFolder(r.Context(), id, req.subjectID, req.isGroup, req.perm, owner)
if err == nil {
h.logShare(r, audit.ActionShareGrant, "folder", id.String(),
string(req.perm)+" for "+req.subjectType()+" "+req.subjectID.String())
}
writeGrantResult(w, entry, err)
}
// GET /api/v1/files/{id}/acl
func (h *Handler) ListFileACL(w http.ResponseWriter, r *http.Request) {
id, _, ok := h.ownedFile(w, r)
if !ok {
return
}
entries, err := h.store.ListForFile(r.Context(), id)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load shares")
return
}
response.JSON(w, http.StatusOK, entries)
}
// POST /api/v1/files/{id}/acl
func (h *Handler) GrantFileACL(w http.ResponseWriter, r *http.Request) {
id, owner, ok := h.ownedFile(w, r)
if !ok {
return
}
req, ok := decodeGrant(w, r)
if !ok {
return
}
entry, err := h.store.GrantFile(r.Context(), id, req.subjectID, req.isGroup, req.perm, owner)
if err == nil {
h.logShare(r, audit.ActionShareGrant, "file", id.String(),
string(req.perm)+" for "+req.subjectType()+" "+req.subjectID.String())
}
writeGrantResult(w, entry, err)
}
// DELETE /api/v1/acl/{id}
func (h *Handler) RevokeACL(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
if user == uuid.Nil {
response.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
entryID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := h.store.Revoke(r.Context(), entryID, user); errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "share not found")
return
} else if err != nil {
response.Error(w, http.StatusInternalServerError, "could not revoke share")
return
}
h.logShare(r, audit.ActionShareRevoke, "acl", entryID.String(), "")
w.WriteHeader(http.StatusNoContent)
}
// GET /api/v1/shared-with-me
func (h *Handler) SharedWithMe(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
if user == uuid.Nil {
response.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
items, err := h.store.ListSharedWith(r.Context(), user)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load shared items")
return
}
response.JSON(w, http.StatusOK, items)
}
// GET /api/v1/share-recipients
func (h *Handler) Recipients(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
if user == uuid.Nil {
response.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
list, err := h.store.Recipients(r.Context(), user)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load recipients")
return
}
response.JSON(w, http.StatusOK, list)
}
// ── Hilfen ───────────────────────────────────────────────────────────────────
type parsedGrant struct {
subjectID uuid.UUID
isGroup bool
perm Permission
}
func (g parsedGrant) subjectType() string { return subjectTypeName(g.isGroup) }
func decodeGrant(w http.ResponseWriter, r *http.Request) (parsedGrant, bool) {
var body grantRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid request body")
return parsedGrant{}, false
}
id, err := uuid.Parse(body.SubjectID)
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid subject_id")
return parsedGrant{}, false
}
if body.SubjectType != "user" && body.SubjectType != "group" {
response.Error(w, http.StatusBadRequest, `subject_type must be "user" or "group"`)
return parsedGrant{}, false
}
if !body.Permission.valid() {
response.Error(w, http.StatusBadRequest, `permission must be "read" or "write"`)
return parsedGrant{}, false
}
return parsedGrant{subjectID: id, isGroup: body.SubjectType == "group", perm: body.Permission}, true
}
func writeGrantResult(w http.ResponseWriter, entry *Entry, err error) {
switch {
case errors.Is(err, ErrSelfShare):
response.Error(w, http.StatusBadRequest, "you already have access to this")
case err != nil:
response.Error(w, http.StatusInternalServerError, "could not share")
default:
response.JSON(w, http.StatusCreated, entry)
}
}
+288
View File
@@ -0,0 +1,288 @@
package acl
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
var ErrSelfShare = errors.New("cannot share with yourself")
// Entry ist eine Freigabe, wie sie der Eigentümer im Dialog sieht.
type Entry struct {
ID uuid.UUID `json:"id"`
SubjectType string `json:"subject_type"` // "user" | "group"
SubjectID uuid.UUID `json:"subject_id"`
SubjectName string `json:"subject_name"`
Permission Permission `json:"permission"`
CreatedAt time.Time `json:"created_at"`
}
// SharedResource ist ein Eintrag in „Geteilt mit mir".
type SharedResource struct {
Type string `json:"type"` // "folder" | "file"
ID uuid.UUID `json:"id"`
Name string `json:"name"`
OwnerID uuid.UUID `json:"owner_id"`
OwnerName string `json:"owner_name"`
Permission Permission `json:"permission"`
Size int64 `json:"size"`
MimeType string `json:"mime_type"`
SharedAt time.Time `json:"shared_at"`
// ViaGroup nennt die Gruppe, über die der Zugriff kommt – sonst wundert
// man sich, wo die Freigabe herkommt, die man nie persönlich bekommen hat.
ViaGroup string `json:"via_group"`
}
// ── Freigaben verwalten ──────────────────────────────────────────────────────
// GrantFolder gibt einen Ordner frei. subjectIsGroup entscheidet, ob subjectID
// ein Nutzer oder eine Gruppe ist.
func (s *Store) GrantFolder(ctx context.Context, folderID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
return s.grant(ctx, &folderID, nil, subjectID, subjectIsGroup, perm, grantedBy)
}
func (s *Store) GrantFile(ctx context.Context, fileID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
return s.grant(ctx, nil, &fileID, subjectID, subjectIsGroup, perm, grantedBy)
}
func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
if !perm.valid() {
return nil, fmt.Errorf("invalid permission %q", perm)
}
// Sich selbst freigeben ist sinnlos und würde in „Geteilt mit mir" die
// eigenen Ordner doppelt anzeigen.
if !subjectIsGroup && subjectID == grantedBy {
return nil, ErrSelfShare
}
var subjUser, subjGroup *uuid.UUID
if subjectIsGroup {
subjGroup = &subjectID
} else {
subjUser = &subjectID
}
var e Entry
err := s.db.QueryRow(ctx,
`INSERT INTO acl_entries
(subject_user_id, subject_group_id, resource_folder_id, resource_file_id, permission, granted_by)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, permission, created_at`,
subjUser, subjGroup, folderID, fileID, perm, grantedBy,
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
// Eine zweite Freigabe an denselben Empfänger ist keine neue, sondern eine
// Änderung des Rechts – sonst müsste man erst widerrufen, um von „lesen"
// auf „schreiben" zu wechseln. Die vier partiellen Unique-Indizes lassen
// sich nicht in einer einzigen ON-CONFLICT-Klausel abdecken, deshalb hier
// über den Fehlercode.
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return s.updateExisting(ctx, folderID, fileID, subjUser, subjGroup, perm)
}
if err != nil {
return nil, fmt.Errorf("grant access: %w", err)
}
e.SubjectID = subjectID
e.SubjectType = subjectTypeName(subjectIsGroup)
return &e, nil
}
func (s *Store) updateExisting(ctx context.Context, folderID, fileID, subjUser, subjGroup *uuid.UUID, perm Permission) (*Entry, error) {
var e Entry
err := s.db.QueryRow(ctx,
`UPDATE acl_entries SET permission = $1
WHERE resource_folder_id IS NOT DISTINCT FROM $2
AND resource_file_id IS NOT DISTINCT FROM $3
AND subject_user_id IS NOT DISTINCT FROM $4
AND subject_group_id IS NOT DISTINCT FROM $5
RETURNING id, permission, created_at`,
perm, folderID, fileID, subjUser, subjGroup,
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("update access: %w", err)
}
if subjGroup != nil {
e.SubjectID, e.SubjectType = *subjGroup, "group"
} else if subjUser != nil {
e.SubjectID, e.SubjectType = *subjUser, "user"
}
return &e, nil
}
// Revoke entfernt eine Freigabe. ownerID stellt sicher, dass nur der
// Eigentümer der Ressource widerrufen kann.
func (s *Store) Revoke(ctx context.Context, entryID, ownerID uuid.UUID) error {
tag, err := s.db.Exec(ctx,
`DELETE FROM acl_entries e
WHERE e.id = $1
AND ( EXISTS (SELECT 1 FROM folders f WHERE f.id = e.resource_folder_id AND f.owner_id = $2)
OR EXISTS (SELECT 1 FROM files x WHERE x.id = e.resource_file_id AND x.owner_id = $2) )`,
entryID, ownerID)
if err != nil {
return fmt.Errorf("revoke access: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ListForFolder / ListForFile zeigen dem Eigentümer, wer aktuell Zugriff hat.
func (s *Store) ListForFolder(ctx context.Context, folderID uuid.UUID) ([]Entry, error) {
return s.list(ctx, `e.resource_folder_id = $1`, folderID)
}
func (s *Store) ListForFile(ctx context.Context, fileID uuid.UUID) ([]Entry, error) {
return s.list(ctx, `e.resource_file_id = $1`, fileID)
}
func (s *Store) list(ctx context.Context, where string, resourceID uuid.UUID) ([]Entry, error) {
rows, err := s.db.Query(ctx, `
SELECT e.id,
CASE WHEN e.subject_group_id IS NOT NULL THEN 'group' ELSE 'user' END,
COALESCE(e.subject_group_id, e.subject_user_id),
COALESCE(g.name, NULLIF(u.display_name, ''), u.username, ''),
e.permission, e.created_at
FROM acl_entries e
LEFT JOIN users u ON u.id = e.subject_user_id
LEFT JOIN groups g ON g.id = e.subject_group_id
WHERE `+where+`
ORDER BY e.created_at`, resourceID)
if err != nil {
return nil, fmt.Errorf("list access entries: %w", err)
}
defer rows.Close()
out := []Entry{}
for rows.Next() {
var e Entry
if err := rows.Scan(&e.ID, &e.SubjectType, &e.SubjectID, &e.SubjectName,
&e.Permission, &e.CreatedAt); err != nil {
return nil, fmt.Errorf("scan access entry: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
// ListSharedWith liefert alles, was userID freigegeben bekommen hat – direkt
// oder über eine Gruppe. Das ist der Inhalt von „Geteilt mit mir".
//
// Nur die obersten freigegebenen Knoten erscheinen: Wer einen Ordner und darin
// eine Datei freigegeben bekommt, soll den Ordner sehen und die Datei darin,
// nicht beides nebeneinander.
func (s *Store) ListSharedWith(ctx context.Context, userID uuid.UUID) ([]SharedResource, error) {
const q = `
SELECT 'folder', f.id, f.name, f.owner_id,
COALESCE(NULLIF(o.display_name, ''), o.username),
e.permission, 0::bigint, '', e.created_at,
COALESCE(g.name, '')
FROM acl_entries e
JOIN folders f ON f.id = e.resource_folder_id
JOIN users o ON o.id = f.owner_id
LEFT JOIN groups g ON g.id = e.subject_group_id
WHERE (e.subject_user_id = $1
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
-- Ordner weglassen, deren Elternteil ohnehin schon freigegeben ist.
AND NOT EXISTS (
SELECT 1
FROM acl_entries pe
JOIN folders pf ON pf.id = pe.resource_folder_id
WHERE pf.id = f.parent_id
AND (pe.subject_user_id = $1
OR pe.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
)
UNION ALL
SELECT 'file', x.id, x.name, x.owner_id,
COALESCE(NULLIF(o.display_name, ''), o.username),
e.permission, x.size, x.mime_type, e.created_at,
COALESCE(g.name, '')
FROM acl_entries e
JOIN files x ON x.id = e.resource_file_id
JOIN users o ON o.id = x.owner_id
LEFT JOIN groups g ON g.id = e.subject_group_id
WHERE x.is_deleted = false
AND (e.subject_user_id = $1
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
-- Datei weglassen, wenn ihr Ordner bereits freigegeben ist.
AND NOT EXISTS (
SELECT 1
FROM acl_entries fe
WHERE fe.resource_folder_id = x.folder_id
AND (fe.subject_user_id = $1
OR fe.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
)
ORDER BY 1, 3`
rows, err := s.db.Query(ctx, q, userID)
if err != nil {
return nil, fmt.Errorf("list shared resources: %w", err)
}
defer rows.Close()
out := []SharedResource{}
for rows.Next() {
var r SharedResource
if err := rows.Scan(&r.Type, &r.ID, &r.Name, &r.OwnerID, &r.OwnerName,
&r.Permission, &r.Size, &r.MimeType, &r.SharedAt, &r.ViaGroup); err != nil {
return nil, fmt.Errorf("scan shared resource: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// ── Verzeichnis für den Freigabedialog ───────────────────────────────────────
type Recipient struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "user" | "group"
}
// Recipients listet mögliche Empfänger. Bewusst nur ID, Anzeigename und Art –
// Quota, Admin-Status und E-Mail gehen einen normalen Nutzer nichts an, auch
// wenn er teilen darf.
func (s *Store) Recipients(ctx context.Context, exclude uuid.UUID) ([]Recipient, error) {
rows, err := s.db.Query(ctx, `
SELECT id, COALESCE(NULLIF(display_name, ''), username), 'user'
FROM users WHERE id <> $1
UNION ALL
SELECT id, name, 'group' FROM groups
ORDER BY 3 DESC, 2`, exclude)
if err != nil {
return nil, fmt.Errorf("list recipients: %w", err)
}
defer rows.Close()
out := []Recipient{}
for rows.Next() {
var r Recipient
if err := rows.Scan(&r.ID, &r.Name, &r.Type); err != nil {
return nil, fmt.Errorf("scan recipient: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
func subjectTypeName(isGroup bool) string {
if isGroup {
return "group"
}
return "user"
}
+142
View File
@@ -0,0 +1,142 @@
package admin
import (
"bytes"
"fmt"
"image"
"image/color"
_ "image/gif"
_ "image/jpeg"
"image/png"
"io"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp" // nur der Dekodierer – x/image ist schon da
)
// Aus einem einzigen hochgeladenen Bild entstehen alle Größen, die Browser und
// Android brauchen. Zwei Bilder zu pflegen (Tab-Symbol und App-Symbol) war der
// Grund, es zusammenzulegen.
const (
// Chrome verweigert die Installation, wenn im Manifest kein Symbol von
// mindestens dieser Kantenlänge steht.
minIconSide = 192
// Der maskable-Bereich, den Android garantiert nicht wegschneidet: 80 % in
// der Mitte. Der Rest verschwindet je nach Gerät im Kreis oder Squircle.
maskableSafeZone = 0.8
)
// iconVariant beschreibt eine der erzeugten Dateien.
type iconVariant struct {
// name ist der Dateiname im Symbol-Verzeichnis, ohne Endung.
name string
side int
// maskable legt das Motiv verkleinert auf einen farbigen Grund.
maskable bool
}
// renderAppIcon erzeugt aus der Vorlage die PNG-Dateien des Symbol-Satzes.
// Der Schlüssel der Rückgabe ist der Dateiname inklusive Endung.
//
// Hochskaliert wird nie: Aus einer 256er-Vorlage ein 512er zu rechnen ergibt
// ein matschiges Symbol, das schlechter aussieht als gar keins. Fehlt eine
// Größe, nennt das Manifest sie einfach nicht.
func renderAppIcon(src io.Reader, bg color.Color) (map[string][]byte, error) {
img, _, err := image.Decode(src)
if err != nil {
return nil, fmt.Errorf("decode image: %w", err)
}
// Nicht quadratische Vorlagen werden gerahmt, nicht beschnitten – ein
// Zuschnitt würde ein Logo anschneiden, und das fällt erst auf, wenn das
// Symbol schon überall klebt.
square := squareCanvas(img)
side := square.Bounds().Dx()
if side < minIconSide {
return nil, fmt.Errorf("image must be at least %dx%d pixels, got %dx%d",
minIconSide, minIconSide, img.Bounds().Dx(), img.Bounds().Dy())
}
variants := []iconVariant{
// 64 statt 32 fürs Tab: Auf feinen Bildschirmen wird das Symbol mit
// doppelter Pixeldichte gezeichnet.
{name: "64", side: 64},
{name: "192", side: 192},
}
if side >= 512 {
variants = append(variants, iconVariant{name: "512", side: 512})
}
maskSide := 192
if side >= 512 {
maskSide = 512
}
variants = append(variants, iconVariant{name: "maskable", side: maskSide, maskable: true})
out := make(map[string][]byte, len(variants))
for _, v := range variants {
buf, err := encodeVariant(square, v, bg)
if err != nil {
return nil, fmt.Errorf("render %s: %w", v.name, err)
}
out[v.name+".png"] = buf
}
return out, nil
}
func encodeVariant(square image.Image, v iconVariant, bg color.Color) ([]byte, error) {
dst := image.NewRGBA(image.Rect(0, 0, v.side, v.side))
target := dst.Bounds()
if v.maskable {
// Durchsichtig geht hier nicht: Android legt sonst eine weiße Kachel
// unter das Symbol, die im dunklen App-Menü leuchtet.
draw.Draw(dst, dst.Bounds(), image.NewUniform(bg), image.Point{}, draw.Src)
inset := int(float64(v.side) * (1 - maskableSafeZone) / 2)
target = image.Rect(inset, inset, v.side-inset, v.side-inset)
}
// CatmullRom statt des BiLinear aus internal/thumbnails: Vorschaubilder
// dürfen weich sein, ein Symbol soll scharfe Kanten behalten.
draw.CatmullRom.Scale(dst, target, square, square.Bounds(), draw.Over, nil)
var buf bytes.Buffer
// PNG, damit Transparenz erhalten bleibt – ein Symbol mit weißem Kasten
// drumherum sieht auf jedem farbigen Hintergrund falsch aus.
if err := png.Encode(&buf, dst); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// squareCanvas legt das Bild mittig auf eine quadratische, durchsichtige
// Fläche. Ist es schon quadratisch, bleibt es unverändert.
func squareCanvas(img image.Image) image.Image {
b := img.Bounds()
if b.Dx() == b.Dy() {
return img
}
side := b.Dx()
if b.Dy() > side {
side = b.Dy()
}
dst := image.NewRGBA(image.Rect(0, 0, side, side))
draw.Draw(dst, image.Rect(
(side-b.Dx())/2, (side-b.Dy())/2,
(side-b.Dx())/2+b.Dx(), (side-b.Dy())/2+b.Dy(),
), img, b.Min, draw.Src)
return dst
}
// parseHexColor liest "#rrggbb". Bei allem anderen kommt die Ersatzfarbe
// zurück – eine kaputte Einstellung soll keinen Upload scheitern lassen.
func parseHexColor(s string, fallback color.RGBA) color.RGBA {
if len(s) != 7 || s[0] != '#' {
return fallback
}
var r, g, b uint8
if _, err := fmt.Sscanf(s[1:], "%02x%02x%02x", &r, &g, &b); err != nil {
return fallback
}
return color.RGBA{R: r, G: g, B: b, A: 255}
}
+122
View File
@@ -0,0 +1,122 @@
package admin
import (
"bytes"
"image"
"image/color"
"image/png"
"testing"
)
func testImage(w, h int) []byte {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
img.Set(x, y, color.RGBA{R: 20, G: 180, B: 120, A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
panic(err)
}
return buf.Bytes()
}
func decodeSize(t *testing.T, blob []byte) (int, int) {
t.Helper()
img, err := png.Decode(bytes.NewReader(blob))
if err != nil {
t.Fatalf("decode result: %v", err)
}
return img.Bounds().Dx(), img.Bounds().Dy()
}
var testBG = color.RGBA{R: 31, G: 107, B: 92, A: 255}
func TestRenderAppIconSizes(t *testing.T) {
out, err := renderAppIcon(bytes.NewReader(testImage(1000, 1000)), testBG)
if err != nil {
t.Fatalf("render: %v", err)
}
for name, want := range map[string]int{
"64.png": 64, "192.png": 192, "512.png": 512, "maskable.png": 512,
} {
blob, ok := out[name]
if !ok {
t.Fatalf("%s missing", name)
}
if w, h := decodeSize(t, blob); w != want || h != want {
t.Errorf("%s: got %dx%d, want %dx%d", name, w, h, want, want)
}
}
}
// Aus einer kleinen Vorlage darf kein 512er entstehen – hochskaliert sähe es
// schlechter aus als gar keins, und das Manifest nennt dann nur, was es gibt.
func TestRenderAppIconNeverUpscales(t *testing.T) {
out, err := renderAppIcon(bytes.NewReader(testImage(256, 256)), testBG)
if err != nil {
t.Fatalf("render: %v", err)
}
if _, ok := out["512.png"]; ok {
t.Error("512.png was generated from a 256px source")
}
if w, _ := decodeSize(t, out["maskable.png"]); w != 192 {
t.Errorf("maskable: got %d, want 192", w)
}
}
func TestRenderAppIconRejectsSmall(t *testing.T) {
if _, err := renderAppIcon(bytes.NewReader(testImage(100, 100)), testBG); err == nil {
t.Fatal("expected an error for a 100px source")
}
}
// Nicht quadratische Vorlagen werden gerahmt, nicht beschnitten: Ein Zuschnitt
// würde ein Logo anschneiden, und das fällt erst auf, wenn das Symbol klebt.
func TestRenderAppIconFramesInsteadOfCropping(t *testing.T) {
out, err := renderAppIcon(bytes.NewReader(testImage(400, 150)), testBG)
if err != nil {
t.Fatalf("render: %v", err)
}
img, err := png.Decode(bytes.NewReader(out["192.png"]))
if err != nil {
t.Fatalf("decode: %v", err)
}
if _, _, _, a := img.At(96, 2).RGBA(); a != 0 {
t.Error("top edge should be transparent padding, not image content")
}
if _, g, _, a := img.At(96, 96).RGBA(); a == 0 || g>>8 < 150 {
t.Error("centre should carry the motif")
}
}
// Der maskable-Grund ist die Primärfarbe: Durchsichtig füllt Android mit Weiß,
// und Weiß leuchtet im dunklen App-Menü.
func TestRenderAppIconMaskableHasBackground(t *testing.T) {
out, err := renderAppIcon(bytes.NewReader(testImage(512, 512)), testBG)
if err != nil {
t.Fatalf("render: %v", err)
}
img, err := png.Decode(bytes.NewReader(out["maskable.png"]))
if err != nil {
t.Fatalf("decode: %v", err)
}
r, g, b, a := img.At(2, 2).RGBA()
if a>>8 != 255 || r>>8 != 31 || g>>8 != 107 || b>>8 != 92 {
t.Errorf("corner: got %d,%d,%d,%d – want the background colour", r>>8, g>>8, b>>8, a>>8)
}
}
func TestParseHexColor(t *testing.T) {
fallback := color.RGBA{R: 1, G: 2, B: 3, A: 255}
if got := parseHexColor("#1f6b5c", fallback); got != (color.RGBA{R: 31, G: 107, B: 92, A: 255}) {
t.Errorf("valid colour: got %v", got)
}
// Eine kaputte Einstellung darf keinen Upload scheitern lassen.
for _, bad := range []string{"", "1f6b5c", "#xyz", "#1f6b5cff"} {
if got := parseHexColor(bad, fallback); got != fallback {
t.Errorf("%q: got %v, want the fallback", bad, got)
}
}
}
+99
View File
@@ -0,0 +1,99 @@
package admin
import (
"fmt"
"net/http"
"strconv"
"github.com/google/uuid"
"aether/internal/audit"
"aether/pkg/response"
)
// Die Audit-Endpunkte liegen im admin-Paket, nicht in audit: `auth` schreibt
// selbst Einträge und importiert deshalb `audit` – läge der Handler dort und
// bräuchte `auth` für die Admin-Prüfung, wäre der Import-Zyklus fertig.
// GET /api/v1/admin/audit?action=&actor_id=&limit=&offset=
func (h *Handler) ListAudit(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
f := audit.Filter{Action: r.URL.Query().Get("action")}
if raw := r.URL.Query().Get("actor_id"); raw != "" {
id, err := uuid.Parse(raw)
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid actor_id")
return
}
f.ActorID = &id
}
if raw := r.URL.Query().Get("limit"); raw != "" {
f.Limit, _ = strconv.Atoi(raw)
}
if raw := r.URL.Query().Get("offset"); raw != "" {
f.Offset, _ = strconv.Atoi(raw)
}
entries, total, err := h.auditor.List(r.Context(), f)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load audit log")
return
}
response.JSON(w, http.StatusOK, map[string]any{
"entries": entries,
"total": total,
})
}
// GET /api/v1/admin/audit/actions – füttert das Filter-Dropdown.
func (h *Handler) ListAuditActions(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
actions, err := h.auditor.Actions(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load actions")
return
}
response.JSON(w, http.StatusOK, actions)
}
// POST /api/v1/admin/gc – Bereinigung von Hand anstoßen.
//
// Der GC läuft ohnehin im Hintergrund; dieser Endpunkt macht ihn beobachtbar.
// Ein Aufräumjob, dessen Wirkung man nicht nachsehen kann, ist schwer zu
// vertrauen – und genau darum geht es hier, weil er Dateien löscht.
func (h *Handler) RunGC(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
if h.gc == nil {
response.Error(w, http.StatusServiceUnavailable, "cleanup not available")
return
}
res, err := h.gc.Run(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "cleanup failed")
return
}
h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "",
fmt.Sprintf("manual run: %d blobs scanned, %d removed, %d bytes freed",
res.Scanned, res.Deleted, res.FreedBytes))
response.JSON(w, http.StatusOK, map[string]any{
"scanned": res.Scanned,
"orphans": res.Orphans,
"deleted": res.Deleted,
"freed_bytes": res.FreedBytes,
"skipped_young": res.SkippedYoung,
})
}
+416
View File
@@ -0,0 +1,416 @@
package admin
import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"image/color"
"io"
"net/http"
"path"
"strings"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
"aether/internal/audit"
"aether/internal/storage"
"aether/pkg/response"
)
// Bild-Upload für den Admin-Bereich: Logo und App-Symbol.
//
// Das Logo ist ein einzelnes Blob. Das App-Symbol ist ein Verzeichnis mit
// mehreren Größen, die beim Hochladen aus einer Vorlage entstehen – ein Upload
// deckt damit Tab-Symbol und installierte App zugleich ab.
const (
logoSettingKey = "logo_storage_key"
appIconSettingKey = "app_icon_storage_key"
// Genug Bytes, damit http.DetectContentType sicher entscheidet.
sniffLen = 512
// 2 MiB – ein Logo, das größer ist, ist kein Logo. Fürs App-Symbol gilt
// dieselbe Grenze: Die Vorlage darf großzügig sein, ausgeliefert werden
// ohnehin nur die gerechneten Größen.
maxBrandingBytes = 2 << 20
humanMaxSize = "2 MB"
humanTypes = "a PNG, JPEG, WebP or GIF image"
)
// Erlaubte Bildformate. SVG fehlt bewusst: Eine SVG-Datei darf Skripte
// enthalten, und beide Bilder werden ohne Anmeldung ausgeliefert – also genau
// dort, wo noch niemand angemeldet ist. Ein Rasterbild kann das nicht.
//
// ICO fehlt ebenfalls: Go bringt keinen Dekodierer mit, und ohne Dekodieren
// lassen sich keine Größen rechnen.
var allowedImageTypes = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/gif": ".gif",
}
// Die Dateien, aus denen ein Symbol-Satz besteht. renderAppIcon legt sie an –
// welche davon wirklich entstehen, hängt von der Größe der Vorlage ab.
var iconSizes = []string{"64", "192", "512", "maskable"}
// ── Logo ─────────────────────────────────────────────────────────────────────
// POST /api/v1/admin/branding/logo (multipart, Feld "file")
func (h *Handler) UploadLogo(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
data, ext, ok := readUpload(w, r, "logo")
if !ok {
return
}
suffix := randomSuffix()
if suffix == "" {
response.Error(w, http.StatusInternalServerError, "could not store the logo")
return
}
newKey := "branding/logo-" + suffix + ext
settings, _ := h.store.GetAllSettings(r.Context())
oldKey := settings[logoSettingKey]
if _, _, err := h.storage.Put(r.Context(), newKey, bytes.NewReader(data)); err != nil {
response.Error(w, http.StatusInternalServerError, "could not store the logo")
return
}
if err := h.store.SetSetting(r.Context(), logoSettingKey, newKey); err != nil {
// Ohne Eintrag zeigt niemand auf das Blob – wieder wegräumen.
_ = h.storage.Delete(r.Context(), newKey)
response.Error(w, http.StatusInternalServerError, "database error")
return
}
// Erst jetzt das alte Logo entfernen: Wäre es vorher weg und der neue
// Eintrag scheiterte, stünde man ganz ohne da.
if oldKey != "" && oldKey != newKey {
if err := h.storage.Delete(r.Context(), oldKey); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Str("key", oldKey).
Msg("previous logo could not be removed")
}
}
h.logAdmin(r, audit.ActionSettingsUpdate, "settings", "", "", "logo uploaded")
response.JSON(w, http.StatusOK, map[string]string{"url": LogoURL(newKey)})
}
// DELETE /api/v1/admin/branding/logo
func (h *Handler) DeleteLogo(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
settings, _ := h.store.GetAllSettings(r.Context())
key := settings[logoSettingKey]
if key == "" {
w.WriteHeader(http.StatusNoContent)
return
}
if err := h.store.SetSetting(r.Context(), logoSettingKey, ""); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if err := h.storage.Delete(r.Context(), key); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Str("key", key).Msg("logo blob not removed")
}
h.logAdmin(r, audit.ActionSettingsUpdate, "settings", "", "", "logo removed")
w.WriteHeader(http.StatusNoContent)
}
// ServeLogo liefert das Logo ohne Anmeldung aus – die Anmeldeseite braucht es,
// und dort ist noch niemand angemeldet.
func (h *Handler) ServeLogo(w http.ResponseWriter, r *http.Request) {
settings, err := h.store.GetAllSettings(r.Context())
if err != nil {
http.NotFound(w, r)
return
}
key := settings[logoSettingKey]
if key == "" {
http.NotFound(w, r)
return
}
mime := "application/octet-stream"
for m, ext := range allowedImageTypes {
if path.Ext(key) == ext {
mime = m
break
}
}
h.serveBlob(w, r, key, mime)
}
// ── App-Symbol ───────────────────────────────────────────────────────────────
// POST /api/v1/admin/branding/icon (multipart, Feld "file")
//
// Aus der Vorlage entstehen alle Größen auf einmal. Ein zweites Feld für das
// Tab-Symbol gibt es bewusst nicht – zwei Bilder zu pflegen, die dasselbe
// zeigen sollen, laufen mit der Zeit auseinander.
func (h *Handler) UploadAppIcon(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
data, _, ok := readUpload(w, r, "app icon")
if !ok {
return
}
settings, _ := h.store.GetAllSettings(r.Context())
// Der maskable-Grund nimmt die eingestellte Primärfarbe: Weiß leuchtet im
// dunklen App-Menü, und durchsichtig füllt Android selbst mit Weiß.
bg := parseHexColor(settings["primary_color"], color.RGBA{R: 0x63, G: 0x66, B: 0xf1, A: 255})
variants, err := renderAppIcon(bytes.NewReader(data), bg)
if err != nil {
// Zu klein oder unlesbar – beides ist eine Sache des Absenders, keine
// des Servers, und die Meldung sagt welche.
response.Error(w, http.StatusUnprocessableEntity, err.Error())
return
}
suffix := randomSuffix()
if suffix == "" {
response.Error(w, http.StatusInternalServerError, "could not store the app icon")
return
}
newDir := "branding/icon-" + suffix + "/"
oldDir := settings[appIconSettingKey]
for name, blob := range variants {
if _, _, err := h.storage.Put(r.Context(), newDir+name, bytes.NewReader(blob)); err != nil {
// Halbe Sätze sind wertlos: was schon liegt, wieder wegräumen.
h.removeIconDir(r, newDir)
response.Error(w, http.StatusInternalServerError, "could not store the app icon")
return
}
}
if err := h.store.SetSetting(r.Context(), appIconSettingKey, newDir); err != nil {
h.removeIconDir(r, newDir)
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if oldDir != "" && oldDir != newDir {
h.removeIconDir(r, oldDir)
}
h.logAdmin(r, audit.ActionSettingsUpdate, "settings", "", "",
fmt.Sprintf("app icon uploaded (%d sizes)", len(variants)))
response.JSON(w, http.StatusOK, map[string]string{"url": AppIconURL(newDir, "192")})
}
// DELETE /api/v1/admin/branding/icon
func (h *Handler) DeleteAppIcon(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
settings, _ := h.store.GetAllSettings(r.Context())
dir := settings[appIconSettingKey]
if dir == "" {
w.WriteHeader(http.StatusNoContent)
return
}
if err := h.store.SetSetting(r.Context(), appIconSettingKey, ""); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.removeIconDir(r, dir)
h.logAdmin(r, audit.ActionSettingsUpdate, "settings", "", "", "app icon removed")
w.WriteHeader(http.StatusNoContent)
}
// ServeAppIcon liefert eine Größe des Symbol-Satzes aus – ohne Anmeldung, der
// Browser fragt das Tab-Symbol vor dem Anmelden ab.
func (h *Handler) ServeAppIcon(w http.ResponseWriter, r *http.Request) {
size := chi.URLParam(r, "size")
if !validIconSize(size) {
http.NotFound(w, r)
return
}
settings, err := h.store.GetAllSettings(r.Context())
if err != nil {
http.NotFound(w, r)
return
}
dir := settings[appIconSettingKey]
if dir == "" {
http.NotFound(w, r)
return
}
h.serveBlob(w, r, dir+size+".png", "image/png")
}
// removeIconDir räumt ein Symbol-Verzeichnis samt Verzeichnis selbst weg.
// Einzelne Dateien zu löschen reichte nicht: Der leere Ordner bliebe stehen,
// bei jedem Austausch einer mehr – und der GC sähe ihn nie, weil er nur
// Dateien betrachtet.
//
// Fehler werden nur vermerkt: Ein Rest auf der Platte ist unschön, aber kein
// Grund, dem Admin einen erfolgreichen Upload als fehlgeschlagen zu melden.
func (h *Handler) removeIconDir(r *http.Request, dir string) {
if dir == "" || !strings.HasPrefix(dir, "branding/icon-") {
// Ein Präfix, das nicht danach aussieht, wird nicht angefasst –
// DeletePrefix löscht rekursiv, da will man sich nicht vertippen.
log.Ctx(r.Context()).Warn().Str("dir", dir).Msg("refusing to remove unexpected icon dir")
return
}
if err := h.storage.DeletePrefix(r.Context(), dir); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Str("dir", dir).Msg("app icon dir not removed")
}
}
// ── Gemeinsames ──────────────────────────────────────────────────────────────
// readUpload liest das hochgeladene Bild vollständig ein und prüft Größe und
// Format. Vollständig deshalb, weil das App-Symbol ohnehin dekodiert werden
// muss – bei höchstens 2 MiB ist das unproblematisch.
func readUpload(w http.ResponseWriter, r *http.Request, what string) ([]byte, string, bool) {
r.Body = http.MaxBytesReader(w, r.Body, maxBrandingBytes+1024)
file, _, err := r.FormFile("file")
if err != nil {
// MaxBytesReader schlägt schon beim Zerlegen des Formulars zu. Ohne
// diese Unterscheidung bekäme man bei einer zu großen Datei die Meldung
// „kein Bild gefunden" und suchte den Fehler an der falschen Stelle.
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
response.Error(w, http.StatusRequestEntityTooLarge,
fmt.Sprintf("%s must be %s or smaller", what, humanMaxSize))
return nil, "", false
}
response.Error(w, http.StatusBadRequest, "expected an image in the 'file' field")
return nil, "", false
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, maxBrandingBytes+1))
if err != nil {
response.Error(w, http.StatusBadRequest, "could not read the image")
return nil, "", false
}
if int64(len(data)) > maxBrandingBytes {
response.Error(w, http.StatusRequestEntityTooLarge,
fmt.Sprintf("%s must be %s or smaller", what, humanMaxSize))
return nil, "", false
}
// Den Typ aus dem Inhalt bestimmen, nicht aus dem Dateinamen oder dem
// mitgeschickten Content-Type – beides kann der Client frei behaupten.
head := data
if len(head) > sniffLen {
head = head[:sniffLen]
}
mime := strings.Split(http.DetectContentType(head), ";")[0]
ext, ok := allowedImageTypes[mime]
if !ok {
response.Error(w, http.StatusUnsupportedMediaType,
fmt.Sprintf("%s must be %s", what, humanTypes))
return nil, "", false
}
return data, ext, true
}
// serveBlob liefert ein Branding-Blob aus.
func (h *Handler) serveBlob(w http.ResponseWriter, r *http.Request, key, mime string) {
// Nur unterhalb von branding/ – der Schlüssel kommt zwar aus den
// Einstellungen, aber die Prüfung kostet nichts und schließt aus, dass eine
// verbogene Zeile eine fremde Datei ausliefert.
if !strings.HasPrefix(key, "branding/") {
http.NotFound(w, r)
return
}
rc, err := h.storage.Get(r.Context(), key)
if err != nil {
http.NotFound(w, r)
return
}
defer rc.Close()
w.Header().Set("Content-Type", mime)
// Kein Erraten des Typs im Browser – zusammen mit dem SVG-Verbot schließt
// das aus, dass ein hochgeladenes Bild als etwas Ausführbares gedeutet wird.
w.Header().Set("X-Content-Type-Options", "nosniff")
// Der Pfad enthält einen Zufallsanteil, der sich bei jedem Austausch
// ändert – deshalb darf lange zwischengespeichert werden.
w.Header().Set("Cache-Control", "public, max-age=604800")
_, _ = io.Copy(w, rc)
}
func randomSuffix() string {
b := make([]byte, 6)
if _, err := rand.Read(b); err != nil {
return ""
}
return hex.EncodeToString(b)
}
func validIconSize(size string) bool {
for _, s := range iconSizes {
if s == size {
return true
}
}
return false
}
// AvailableIconSizes nennt die Größen, die für den hinterlegten Symbol-Satz
// wirklich auf der Platte liegen. Aus einer kleinen Vorlage entsteht kein
// 512er, und das Manifest darf nichts nennen, was es nicht gibt.
func (h *Handler) AvailableIconSizes(r *http.Request, dir string) []string {
if dir == "" {
return nil
}
found := map[string]bool{}
err := h.storage.Walk(r.Context(), dir, func(key string, _ storage.FileInfo) error {
found[strings.TrimSuffix(path.Base(key), ".png")] = true
return nil
})
if err != nil {
return nil
}
// Reihenfolge aus iconSizes, nicht die der Platte – im Manifest sollen die
// Größen aufsteigend stehen.
var sizes []string
for _, s := range iconSizes {
if found[s] {
sizes = append(sizes, s)
}
}
return sizes
}
// LogoURL baut die öffentliche Adresse des Logos.
func LogoURL(storageKey string) string {
if storageKey == "" {
return ""
}
return "/branding/logo?v=" + version(storageKey)
}
// AppIconURL baut die öffentliche Adresse einer Symbol-Größe.
func AppIconURL(dir, size string) string {
if dir == "" {
return ""
}
return "/branding/icon/" + size + "?v=" + version(dir)
}
// version ist der Zufallsanteil aus dem Speicherschlüssel. Er hängt an jeder
// Adresse, damit ein Austausch sofort sichtbar wird – Browser halten gerade
// Tab-Symbole sonst hartnäckig fest.
func version(storageKey string) string {
base := path.Base(strings.TrimSuffix(storageKey, "/"))
return strings.TrimSuffix(base, path.Ext(base))
}
@@ -0,0 +1,115 @@
package admin
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"aether/internal/audit"
"aether/internal/files"
"aether/pkg/response"
)
// Gruppenordner werden ausschließlich hier verwaltet. Bewusst nicht in der
// Dateiansicht: Ein Gruppenordner gehört keinem der Mitglieder, also soll ihn
// auch keines von ihnen entfernen können.
// GET /api/v1/admin/group-folders
func (h *Handler) ListGroupFolders(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
list, err := h.files.ListGroupFolders(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load group folders")
return
}
response.JSON(w, http.StatusOK, list)
}
// POST /api/v1/admin/group-folders
func (h *Handler) CreateGroupFolder(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
var body struct {
GroupID string `json:"group_id"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
groupID, err := uuid.Parse(body.GroupID)
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid group_id")
return
}
// Gleiche Namensregeln wie überall – der Name wird Teil des Ablagepfads.
name := files.SanitizeFilename(strings.TrimSpace(body.Name))
if name == "" {
response.Error(w, http.StatusBadRequest, "name required")
return
}
folder, err := h.files.CreateGroupFolder(r.Context(), groupID, name)
if errors.Is(err, files.ErrGroupFolderExists) {
response.Error(w, http.StatusConflict, "this group already has a folder")
return
}
if errors.Is(err, files.ErrNameTaken) {
response.Error(w, http.StatusConflict, "a folder with that name already exists")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not create group folder")
return
}
h.logAdmin(r, audit.ActionGroupFolderCreate, "folder", folder.ID.String(), folder.Name,
"group "+groupID.String())
response.JSON(w, http.StatusCreated, folder)
}
// DELETE /api/v1/admin/group-folders/{id}
func (h *Handler) DeleteGroupFolder(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid folder id")
return
}
groupID, err := h.files.DeleteGroupFolderTree(r.Context(), id)
if errors.Is(err, files.ErrNotFound) {
response.Error(w, http.StatusNotFound, "group folder not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not delete group folder")
return
}
// Speicher erst nach dem Datenbank-Commit, und am Stück statt Blob für
// Blob: Da eine Gruppe genau einen Ordner hat, ist `groups/<id>` exakt ihr
// Bereich – inklusive Papierkorb und Versionen. Einzeln gelöscht blieben
// leere Verzeichnisse zurück.
if err := h.storage.DeletePrefix(r.Context(), "groups/"+groupID.String()); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Str("group_id", groupID.String()).
Msg("group folder deleted but storage remains")
}
h.logAdmin(r, audit.ActionGroupFolderDelete, "folder", id.String(), "",
"group "+groupID.String()+" and all its files")
w.WriteHeader(http.StatusNoContent)
}
+490
View File
@@ -0,0 +1,490 @@
package admin
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"aether/internal/audit"
"aether/internal/auth"
"aether/internal/files"
"aether/internal/gc"
"aether/internal/storage"
"aether/pkg/middleware"
"aether/pkg/response"
)
type Handler struct {
store *Store
storage storage.Storage
auditor *audit.Logger
gc *gc.Collector
files *files.Store
}
func NewHandler(store *Store, st storage.Storage, auditor *audit.Logger, collector *gc.Collector, fileStore *files.Store) *Handler {
return &Handler{store: store, storage: st, auditor: auditor, gc: collector, files: fileStore}
}
// logAdmin schreibt einen Audit-Eintrag für eine Admin-Aktion. Bündelt das
// Wiederkehrende (Handelnder, IP), damit die Aufrufstellen kurz bleiben.
func (h *Handler) logAdmin(r *http.Request, action, targetType, targetID, targetName, detail string) {
actor := auth.UserFromContext(r.Context())
e := audit.Event{
Action: action,
TargetType: targetType,
TargetID: targetID,
TargetName: targetName,
Detail: detail,
IP: middleware.RealIP(r),
}
if actor != nil {
e.ActorID = &actor.ID
e.ActorName = actor.Username
}
h.auditor.Log(r.Context(), e)
}
// requireAdmin is called at the start of every handler — server-side check.
func requireAdmin(r *http.Request) (*auth.User, bool) {
user := auth.UserFromContext(r.Context())
return user, user != nil && user.IsAdmin
}
// ── Users ─────────────────────────────────────────────────────────────────────
// GET /api/v1/admin/users
func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
users, err := h.store.ListUsers(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.JSON(w, http.StatusOK, users)
}
// POST /api/v1/admin/users
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
var body struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Password string `json:"password"`
IsAdmin bool `json:"is_admin"`
QuotaBytes int64 `json:"quota_bytes"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
if strings.TrimSpace(body.Username) == "" || strings.TrimSpace(body.Password) == "" {
response.Error(w, http.StatusBadRequest, "username and password required")
return
}
if body.QuotaBytes <= 0 {
body.QuotaBytes = 10 << 30 // 10 GB default
}
hash, err := auth.HashPassword(body.Password)
if err != nil {
response.Error(w, http.StatusInternalServerError, "hash error")
return
}
user, err := h.store.CreateUser(r.Context(), strings.TrimSpace(body.Username), body.DisplayName, hash, body.IsAdmin, body.QuotaBytes)
if errors.Is(err, ErrNameTaken) {
response.Error(w, http.StatusConflict, "username already taken")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionUserCreate, "user", user.ID.String(), user.Username,
fmt.Sprintf("admin=%t, quota=%d", body.IsAdmin, body.QuotaBytes))
response.JSON(w, http.StatusCreated, user)
}
// PATCH /api/v1/admin/users/{id}
func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) {
caller, ok := requireAdmin(r)
if !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid user id")
return
}
var body struct {
DisplayName string `json:"display_name"`
IsAdmin bool `json:"is_admin"`
QuotaBytes int64 `json:"quota_bytes"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
if body.QuotaBytes <= 0 {
body.QuotaBytes = 10 << 30
}
user, err := h.store.UpdateUser(r.Context(), id, body.DisplayName, body.IsAdmin, body.QuotaBytes, caller.ID)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "user not found")
return
}
if errors.Is(err, ErrSelfDemote) {
response.Error(w, http.StatusBadRequest, "cannot remove your own admin status")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionUserUpdate, "user", user.ID.String(), user.Username,
fmt.Sprintf("admin=%t, quota=%d", body.IsAdmin, body.QuotaBytes))
response.JSON(w, http.StatusOK, user)
}
// POST /api/v1/admin/users/{id}/reset-password
func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid user id")
return
}
// Accept explicit password or generate a random one.
var body struct {
Password string `json:"password"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
newPassword := strings.TrimSpace(body.Password)
if newPassword == "" {
newPassword = randomPassword()
}
hash, err := auth.HashPassword(newPassword)
if err != nil {
response.Error(w, http.StatusInternalServerError, "hash error")
return
}
if err := h.store.ResetPassword(r.Context(), id, hash); errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "user not found")
return
} else if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
// Protokolliert wird, DASS zurückgesetzt wurde – niemals das Passwort selbst.
h.logAdmin(r, audit.ActionUserPasswordReset, "user", id.String(), "", "")
// Return the new plain-text password to the admin (no email in this system).
response.JSON(w, http.StatusOK, map[string]string{"new_password": newPassword})
}
// DELETE /api/v1/admin/users/{id}
func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) {
caller, ok := requireAdmin(r)
if !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid user id")
return
}
// Den Namen vor dem Löschen holen: danach lässt sich der Speicherbereich
// des Kontos nicht mehr ermitteln.
victim, err := h.store.GetUser(r.Context(), id)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "user not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if err := h.store.DeleteUser(r.Context(), id, caller.ID); errors.Is(err, ErrSelfDemote) {
response.Error(w, http.StatusBadRequest, "cannot delete your own account")
return
} else if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "user not found")
return
} else if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
// Erst die Datenbank, dann die Dateien: Schlägt das Aufräumen fehl, bleiben
// höchstens Dateileichen zurück – umgekehrt hätte ein Konto ohne seine
// Dateien überlebt. Die Datenbankzeilen sind per Fremdschlüssel-Kaskade weg,
// es gibt also keinen Verweis mehr auf diese Blobs.
if victim.Username != "" {
if err := h.storage.DeletePrefix(r.Context(), "users/"+victim.Username); err != nil {
log.Ctx(r.Context()).Error().Err(err).
Str("username", victim.Username).
Msg("user deleted but their files could not be removed")
}
}
h.logAdmin(r, audit.ActionUserDelete, "user", id.String(), victim.Username,
"account and all files removed")
w.WriteHeader(http.StatusNoContent)
}
// ── Groups ────────────────────────────────────────────────────────────────────
// GET /api/v1/admin/groups
func (h *Handler) ListGroups(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
groups, err := h.store.ListGroups(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.JSON(w, http.StatusOK, groups)
}
// POST /api/v1/admin/groups
func (h *Handler) CreateGroup(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
var body struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
response.Error(w, http.StatusBadRequest, "name required")
return
}
group, err := h.store.CreateGroup(r.Context(), strings.TrimSpace(body.Name), body.Description)
if errors.Is(err, ErrNameTaken) {
response.Error(w, http.StatusConflict, "group name already taken")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionGroupCreate, "group", group.ID.String(), group.Name, "")
response.JSON(w, http.StatusCreated, group)
}
// PATCH /api/v1/admin/groups/{id}
func (h *Handler) UpdateGroup(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid group id")
return
}
var body struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
response.Error(w, http.StatusBadRequest, "name required")
return
}
group, err := h.store.UpdateGroup(r.Context(), id, strings.TrimSpace(body.Name), body.Description)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "group not found")
return
}
if errors.Is(err, ErrNameTaken) {
response.Error(w, http.StatusConflict, "group name already taken")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionGroupUpdate, "group", group.ID.String(), group.Name, "")
response.JSON(w, http.StatusOK, group)
}
// DELETE /api/v1/admin/groups/{id}
func (h *Handler) DeleteGroup(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid group id")
return
}
if err := h.store.DeleteGroup(r.Context(), id); errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "group not found")
return
} else if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionGroupDelete, "group", id.String(), "", "")
w.WriteHeader(http.StatusNoContent)
}
// GET /api/v1/admin/groups/{id}/members
func (h *Handler) ListGroupMembers(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid group id")
return
}
members, err := h.store.ListGroupMembers(r.Context(), id)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.JSON(w, http.StatusOK, members)
}
// POST /api/v1/admin/groups/{id}/members
func (h *Handler) AddGroupMember(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
groupID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid group id")
return
}
var body struct {
UserID uuid.UUID `json:"user_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.UserID == uuid.Nil {
response.Error(w, http.StatusBadRequest, "user_id required")
return
}
if err := h.store.AddGroupMember(r.Context(), groupID, body.UserID); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionGroupMemberAdd, "group", groupID.String(), "",
"user "+body.UserID.String())
w.WriteHeader(http.StatusNoContent)
}
// DELETE /api/v1/admin/groups/{id}/members/{userId}
func (h *Handler) RemoveGroupMember(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
groupID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid group id")
return
}
userID, err := uuid.Parse(chi.URLParam(r, "userId"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid user id")
return
}
if err := h.store.RemoveGroupMember(r.Context(), groupID, userID); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logAdmin(r, audit.ActionGroupMemberRemove, "group", groupID.String(), "",
"user "+userID.String())
w.WriteHeader(http.StatusNoContent)
}
// ── Settings ──────────────────────────────────────────────────────────────────
// GET /api/v1/admin/settings
func (h *Handler) GetSettings(w http.ResponseWriter, r *http.Request) {
// Settings are readable by all authenticated users (needed for theming).
if auth.UserFromContext(r.Context()) == nil {
response.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
settings, err := h.store.GetAllSettings(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.JSON(w, http.StatusOK, settings)
}
// PATCH /api/v1/admin/settings
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
allowed := map[string]bool{
"cloud_name": true, "primary_color": true, "accent_color": true,
}
changed := []string{}
for k, v := range body {
if !allowed[k] {
continue
}
if err := h.store.SetSetting(r.Context(), k, v); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
changed = append(changed, k+"="+v)
}
if len(changed) > 0 {
sort.Strings(changed) // Map-Reihenfolge ist zufällig – sonst wäre das Protokoll unruhig.
h.logAdmin(r, audit.ActionSettingsUpdate, "settings", "", "", strings.Join(changed, ", "))
}
settings, _ := h.store.GetAllSettings(r.Context())
response.JSON(w, http.StatusOK, settings)
}
// ── helpers ───────────────────────────────────────────────────────────────────
func randomPassword() string {
b := make([]byte, 12)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
@@ -0,0 +1,95 @@
package admin
import (
"encoding/json"
"net/http"
)
// Das Web-App-Manifest kommt aus der Datenbank statt aus dem Build. Vorher
// stand es fest in vite.config.ts und zeigte auf drei mitgelieferte Dateien –
// ein hochgeladenes Symbol konnte die installierte App also nie erreichen.
// Die mitgelieferten Symbole aus frontend/public/, wenn keins hinterlegt ist.
var builtinIcons = []manifestIcon{
{Src: "/icon-192.png", Sizes: "192x192", Type: "image/png"},
{Src: "/icon-512.png", Sizes: "512x512", Type: "image/png"},
{Src: "/icon-maskable-512.png", Sizes: "512x512", Type: "image/png", Purpose: "maskable"},
}
type manifestIcon struct {
Src string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
// Purpose bleibt bei "any" leer – das ist die Vorgabe, und ein leeres Feld
// ist im Manifest weniger missverständlich als ein ausgeschriebenes "any".
Purpose string `json:"purpose,omitempty"`
}
// ServeManifest liefert /manifest.webmanifest. Ohne Anmeldung: Der Browser
// holt es, bevor irgendjemand angemeldet ist.
func (h *Handler) ServeManifest(w http.ResponseWriter, r *http.Request) {
name := "Aether"
themeColor := "#6366f1"
var iconDir string
if s, err := h.store.GetAllSettings(r.Context()); err == nil {
if n := s["cloud_name"]; n != "" {
name = n
}
if c := s["primary_color"]; c != "" {
themeColor = c
}
iconDir = s[appIconSettingKey]
}
icons := builtinIcons
if sizes := h.AvailableIconSizes(r, iconDir); len(sizes) > 0 {
icons = nil
for _, size := range sizes {
// Das 64er ist das Tab-Symbol. Android will mindestens 192 und
// wertet kleinere Einträge nur als Ballast.
if size == "64" {
continue
}
icon := manifestIcon{Src: AppIconURL(iconDir, size), Type: "image/png"}
if size == "maskable" {
icon.Purpose = "maskable"
icon.Sizes = maskableSizeOf(sizes)
} else {
icon.Sizes = size + "x" + size
}
icons = append(icons, icon)
}
}
manifest := map[string]any{
"name": name,
"short_name": name,
"description": "Your self-hosted cloud",
"theme_color": themeColor,
"background_color": "#0f172a",
"display": "standalone",
"start_url": "/",
"scope": "/",
"lang": "de",
"icons": icons,
}
w.Header().Set("Content-Type", "application/manifest+json")
// Die Symbole darin dürfen lange zwischengespeichert werden, die Liste
// selbst nicht: Sonst bekäme der Browser einen Symbolwechsel tagelang nicht
// mit, und genau darauf wartet die installierte App.
w.Header().Set("Cache-Control", "no-cache")
_ = json.NewEncoder(w).Encode(manifest)
}
// maskableSizeOf nennt die Kantenlänge der maskable-Fassung. Sie entsteht in
// 512, sobald die Vorlage das hergibt – sonst in 192.
func maskableSizeOf(sizes []string) string {
for _, s := range sizes {
if s == "512" {
return "512x512"
}
}
return "192x192"
}
+41
View File
@@ -0,0 +1,41 @@
package admin
import (
"time"
"github.com/google/uuid"
)
type User struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
IsAdmin bool `json:"is_admin"`
QuotaBytes int64 `json:"quota_bytes"`
UsedBytes int64 `json:"used_bytes"`
// Groups sind die Namen der Gruppen, in denen der Nutzer Mitglied ist –
// damit die Benutzerliste die Zugehörigkeit zeigt, ohne pro Zeile
// nachladen zu müssen.
Groups []string `json:"groups"`
CreatedAt time.Time `json:"created_at"`
}
type Group struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
MemberCount int `json:"member_count"`
CreatedAt time.Time `json:"created_at"`
}
type GroupMember struct {
UserID uuid.UUID `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
JoinedAt time.Time `json:"joined_at"`
}
type Setting struct {
Key string `json:"key"`
Value string `json:"value"`
}
+297
View File
@@ -0,0 +1,297 @@
package admin
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("not found")
var ErrNameTaken = errors.New("name already taken")
var ErrSelfDemote = errors.New("cannot remove own admin status")
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) *Store {
return &Store{db: db}
}
// ── Users ─────────────────────────────────────────────────────────────────────
func (s *Store) ListUsers(ctx context.Context) ([]*User, error) {
rows, err := s.db.Query(ctx,
// Die Gruppen kommen als Unterabfrage, nicht als weiterer JOIN: ein JOIN
// würde die Zeilen vervielfachen und damit die Summe der Dateigrößen
// verfälschen.
`SELECT u.id, u.username, u.display_name, u.is_admin, u.quota_bytes,
COALESCE(SUM(f.size) FILTER (WHERE f.is_deleted = false), 0) AS used_bytes,
COALESCE((SELECT array_agg(g.name ORDER BY g.name)
FROM group_members gm
JOIN groups g ON g.id = gm.group_id
WHERE gm.user_id = u.id), '{}') AS groups,
u.created_at
FROM users u
LEFT JOIN files f ON f.owner_id = u.id
GROUP BY u.id
ORDER BY u.created_at ASC`,
)
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
defer rows.Close()
users := make([]*User, 0)
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, &u.UsedBytes, &u.Groups, &u.CreatedAt); err != nil {
return nil, err
}
users = append(users, &u)
}
return users, rows.Err()
}
func (s *Store) GetUser(ctx context.Context, id uuid.UUID) (*User, error) {
var u User
err := s.db.QueryRow(ctx,
`SELECT u.id, u.username, u.display_name, u.is_admin, u.quota_bytes,
COALESCE(SUM(f.size) FILTER (WHERE f.is_deleted = false), 0) AS used_bytes,
u.created_at
FROM users u
LEFT JOIN files f ON f.owner_id = u.id
WHERE u.id = $1
GROUP BY u.id`,
id,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, &u.UsedBytes, &u.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &u, err
}
func (s *Store) CreateUser(ctx context.Context, username, displayName, passwordHash string, isAdmin bool, quotaBytes int64) (*User, error) {
var u User
err := s.db.QueryRow(ctx,
`INSERT INTO users (username, display_name, password, is_admin, quota_bytes)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, display_name, is_admin, quota_bytes, 0, created_at`,
username, displayName, passwordHash, isAdmin, quotaBytes,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, &u.UsedBytes, &u.CreatedAt)
if err != nil {
if isUniqueViolation(err) {
return nil, ErrNameTaken
}
return nil, fmt.Errorf("create user: %w", err)
}
return &u, nil
}
func (s *Store) UpdateUser(ctx context.Context, id uuid.UUID, displayName string, isAdmin bool, quotaBytes int64, callerID uuid.UUID) (*User, error) {
// Prevent admin from removing their own admin flag.
if id == callerID && !isAdmin {
return nil, ErrSelfDemote
}
var u User
err := s.db.QueryRow(ctx,
`UPDATE users SET display_name=$2, is_admin=$3, quota_bytes=$4
WHERE id=$1
RETURNING id, username, display_name, is_admin, quota_bytes, created_at`,
id, displayName, isAdmin, quotaBytes,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, &u.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &u, err
}
func (s *Store) ResetPassword(ctx context.Context, id uuid.UUID, newHash string) error {
tag, err := s.db.Exec(ctx,
`UPDATE users SET password=$2 WHERE id=$1`, id, newHash)
if err != nil {
return fmt.Errorf("reset password: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) DeleteUser(ctx context.Context, id, callerID uuid.UUID) error {
if id == callerID {
return ErrSelfDemote
}
tag, err := s.db.Exec(ctx, `DELETE FROM users WHERE id=$1`, id)
if err != nil {
return fmt.Errorf("delete user: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ── Groups ────────────────────────────────────────────────────────────────────
func (s *Store) ListGroups(ctx context.Context) ([]*Group, error) {
rows, err := s.db.Query(ctx,
`SELECT g.id, g.name, g.description,
COUNT(gm.user_id) AS member_count,
g.created_at
FROM groups g
LEFT JOIN group_members gm ON gm.group_id = g.id
GROUP BY g.id
ORDER BY g.name`,
)
if err != nil {
return nil, fmt.Errorf("list groups: %w", err)
}
defer rows.Close()
groups := make([]*Group, 0)
for rows.Next() {
var g Group
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.MemberCount, &g.CreatedAt); err != nil {
return nil, err
}
groups = append(groups, &g)
}
return groups, rows.Err()
}
func (s *Store) CreateGroup(ctx context.Context, name, description string) (*Group, error) {
var g Group
err := s.db.QueryRow(ctx,
`INSERT INTO groups (name, description)
VALUES ($1, $2)
RETURNING id, name, description, 0, created_at`,
name, description,
).Scan(&g.ID, &g.Name, &g.Description, &g.MemberCount, &g.CreatedAt)
if err != nil {
if isUniqueViolation(err) {
return nil, ErrNameTaken
}
return nil, fmt.Errorf("create group: %w", err)
}
return &g, nil
}
// UpdateGroup ändert Name und Beschreibung einer Gruppe.
func (s *Store) UpdateGroup(ctx context.Context, id uuid.UUID, name, description string) (*Group, error) {
var g Group
err := s.db.QueryRow(ctx,
`UPDATE groups SET name=$2, description=$3
WHERE id=$1
RETURNING id, name, description,
(SELECT COUNT(*) FROM group_members WHERE group_id = groups.id),
created_at`,
id, name, description,
).Scan(&g.ID, &g.Name, &g.Description, &g.MemberCount, &g.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
if isUniqueViolation(err) {
return nil, ErrNameTaken
}
return nil, fmt.Errorf("update group: %w", err)
}
return &g, nil
}
func (s *Store) DeleteGroup(ctx context.Context, id uuid.UUID) error {
tag, err := s.db.Exec(ctx, `DELETE FROM groups WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) ListGroupMembers(ctx context.Context, groupID uuid.UUID) ([]*GroupMember, error) {
rows, err := s.db.Query(ctx,
`SELECT u.id, u.username, u.display_name, gm.joined_at
FROM group_members gm
JOIN users u ON u.id = gm.user_id
WHERE gm.group_id = $1
ORDER BY u.username`,
groupID,
)
if err != nil {
return nil, fmt.Errorf("list members: %w", err)
}
defer rows.Close()
members := make([]*GroupMember, 0)
for rows.Next() {
var m GroupMember
if err := rows.Scan(&m.UserID, &m.Username, &m.DisplayName, &m.JoinedAt); err != nil {
return nil, err
}
members = append(members, &m)
}
return members, rows.Err()
}
func (s *Store) AddGroupMember(ctx context.Context, groupID, userID uuid.UUID) error {
_, err := s.db.Exec(ctx,
`INSERT INTO group_members (group_id, user_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING`,
groupID, userID,
)
return err
}
func (s *Store) RemoveGroupMember(ctx context.Context, groupID, userID uuid.UUID) error {
_, err := s.db.Exec(ctx,
`DELETE FROM group_members WHERE group_id=$1 AND user_id=$2`,
groupID, userID,
)
return err
}
// ── Settings ──────────────────────────────────────────────────────────────────
func (s *Store) GetAllSettings(ctx context.Context) (map[string]string, error) {
rows, err := s.db.Query(ctx, `SELECT key, value FROM settings`)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]string)
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
result[k] = v
}
return result, rows.Err()
}
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
_, err := s.db.Exec(ctx,
`INSERT INTO settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=NOW()`,
key, value,
)
return err
}
// ── helpers ───────────────────────────────────────────────────────────────────
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
type sqlStater interface{ SQLState() string }
var se sqlStater
if errors.As(err, &se) {
return se.SQLState() == "23505"
}
return false
}
+207
View File
@@ -0,0 +1,207 @@
// Package audit protokolliert sicherheitsrelevante Vorgänge.
//
// Leitgedanke: Ein Audit-Eintrag ist nur so viel wert, wie er nach einem
// Vorfall noch aussagt. Deshalb werden Namen mitgeschrieben statt nur IDs, und
// das Protokollieren darf die eigentliche Aktion niemals scheitern lassen –
// ein kaputtes Log ist ärgerlich, eine deswegen abgebrochene Anmeldung wäre
// schlimmer.
package audit
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog/log"
)
// Actions – als Konstanten, damit Filter und Schreiber nicht auseinanderlaufen.
const (
ActionLogin = "auth.login"
ActionLoginFailed = "auth.login_failed"
ActionLogout = "auth.logout"
ActionPasswordChange = "auth.password_change"
ActionAppPasswordCreate = "app_password.create"
ActionAppPasswordRevoke = "app_password.revoke"
ActionUserCreate = "admin.user_create"
ActionUserUpdate = "admin.user_update"
ActionUserDelete = "admin.user_delete"
ActionUserPasswordReset = "admin.user_password_reset"
ActionGroupCreate = "admin.group_create"
ActionGroupUpdate = "admin.group_update"
ActionGroupDelete = "admin.group_delete"
ActionGroupMemberAdd = "admin.group_member_add"
ActionGroupMemberRemove = "admin.group_member_remove"
ActionSettingsUpdate = "admin.settings_update"
ActionGroupFolderCreate = "admin.group_folder_create"
ActionGroupFolderDelete = "admin.group_folder_delete"
ActionShareCreate = "share.create"
ActionShareDelete = "share.delete"
ActionShareGrant = "share.grant"
ActionShareRevoke = "share.revoke"
ActionFileDelete = "file.delete"
ActionTrashEmpty = "file.trash_empty"
ActionHardDelete = "file.hard_delete"
ActionFileRestore = "file.restore"
ActionGarbageCollect = "system.gc"
)
// Entry ist ein Eintrag, wie ihn die Admin-Ansicht liest.
type Entry struct {
ID uuid.UUID `json:"id"`
ActorID *uuid.UUID `json:"actor_id"`
ActorName string `json:"actor_name"`
Action string `json:"action"`
TargetType *string `json:"target_type"`
TargetID *string `json:"target_id"`
TargetName *string `json:"target_name"`
IP *string `json:"ip"`
Detail *string `json:"detail"`
CreatedAt time.Time `json:"created_at"`
}
// Event beschreibt einen zu protokollierenden Vorgang.
type Event struct {
ActorID *uuid.UUID
ActorName string
Action string
TargetType string
TargetID string
TargetName string
IP string
Detail string
}
type Logger struct {
db *pgxpool.Pool
}
func NewLogger(db *pgxpool.Pool) *Logger {
return &Logger{db: db}
}
// Log schreibt einen Eintrag. Fehler werden geloggt, nicht zurückgegeben: der
// Aufrufer soll seine Aktion nicht abbrechen, nur weil das Protokoll klemmt.
//
// Bewusst mit eigenem Kontext: bei einem abgebrochenen Request (Client weg,
// Timeout) ist der Vorgang trotzdem passiert und gehört ins Protokoll.
func (l *Logger) Log(ctx context.Context, e Event) {
if l == nil || l.db == nil {
return
}
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
_, err := l.db.Exec(writeCtx,
`INSERT INTO audit_log
(actor_id, actor_name, action, target_type, target_id, target_name, ip, detail)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
e.ActorID, e.ActorName, e.Action,
nullIfEmpty(e.TargetType), nullIfEmpty(e.TargetID), nullIfEmpty(e.TargetName),
nullIfEmpty(e.IP), nullIfEmpty(e.Detail),
)
if err != nil {
log.Ctx(ctx).Error().Err(err).
Str("action", e.Action).
Str("actor", e.ActorName).
Msg("audit: could not write entry")
}
}
// Filter für die Admin-Ansicht.
type Filter struct {
Action string
ActorID *uuid.UUID
Limit int
Offset int
}
const (
defaultLimit = 100
maxLimit = 500
)
// List liefert Einträge, neueste zuerst, plus die Gesamtzahl für die
// Blätterfunktion.
func (l *Logger) List(ctx context.Context, f Filter) ([]Entry, int, error) {
limit := f.Limit
if limit <= 0 {
limit = defaultLimit
}
if limit > maxLimit {
limit = maxLimit
}
var total int
if err := l.db.QueryRow(ctx,
`SELECT count(*) FROM audit_log
WHERE ($1 = '' OR action = $1)
AND ($2::uuid IS NULL OR actor_id = $2)`,
f.Action, f.ActorID,
).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count audit entries: %w", err)
}
rows, err := l.db.Query(ctx,
`SELECT id, actor_id, actor_name, action, target_type, target_id,
target_name, ip, detail, created_at
FROM audit_log
WHERE ($1 = '' OR action = $1)
AND ($2::uuid IS NULL OR actor_id = $2)
ORDER BY created_at DESC
LIMIT $3 OFFSET $4`,
f.Action, f.ActorID, limit, f.Offset)
if err != nil {
return nil, 0, fmt.Errorf("query audit entries: %w", err)
}
defer rows.Close()
entries := []Entry{}
for rows.Next() {
var e Entry
if err := rows.Scan(&e.ID, &e.ActorID, &e.ActorName, &e.Action,
&e.TargetType, &e.TargetID, &e.TargetName, &e.IP, &e.Detail, &e.CreatedAt); err != nil {
return nil, 0, fmt.Errorf("scan audit entry: %w", err)
}
entries = append(entries, e)
}
return entries, total, rows.Err()
}
// Actions listet die tatsächlich vorkommenden Aktionen – füttert das
// Filter-Dropdown, damit dort nichts steht, was es im Protokoll gar nicht gibt.
func (l *Logger) Actions(ctx context.Context) ([]string, error) {
rows, err := l.db.Query(ctx, `SELECT DISTINCT action FROM audit_log ORDER BY action`)
if err != nil {
return nil, fmt.Errorf("query audit actions: %w", err)
}
defer rows.Close()
out := []string{}
for rows.Next() {
var a string
if err := rows.Scan(&a); err != nil {
return nil, fmt.Errorf("scan audit action: %w", err)
}
out = append(out, a)
}
return out, rows.Err()
}
func nullIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
+264
View File
@@ -0,0 +1,264 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"aether/internal/audit"
"aether/pkg/middleware"
"aether/pkg/response"
)
// App-Passwörter ersetzen das Hauptpasswort in WebDAV-Clients. Ein verlorenes
// Handy kostet dann ein Token, nicht das Konto – und der Widerruf trifft nur
// dieses eine Gerät.
const (
appPasswordGroups = 5
appPasswordGroupLen = 5
// Ohne 0/1/l/o/i – auf einem Handy-Keyboard abgetippt sind die zu leicht
// zu verwechseln.
appPasswordAlphabet = "abcdefghjkmnpqrstuvwxyz23456789"
maxAppPasswords = 20
// Schreiblast bremsen: WebDAV authentifiziert jeden Request einzeln, ein
// UPDATE pro Aufruf wäre reine Verschwendung.
lastUsedResolution = 5 * time.Minute
)
var ErrTooManyAppPasswords = errors.New("too many app passwords")
type AppPassword struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
LastUsedAt *time.Time `json:"last_used_at"`
CreatedAt time.Time `json:"created_at"`
}
// GenerateAppPassword liefert ein Token in Fünfergruppen (aaaaa-bbbbb-…),
// damit es sich am Handy noch abtippen lässt.
func GenerateAppPassword() (string, error) {
buf := make([]byte, appPasswordGroups*appPasswordGroupLen)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("read random: %w", err)
}
var sb strings.Builder
for i, b := range buf {
if i > 0 && i%appPasswordGroupLen == 0 {
sb.WriteByte('-')
}
sb.WriteByte(appPasswordAlphabet[int(b)%len(appPasswordAlphabet)])
}
return sb.String(), nil
}
// HashAppPassword: SHA-256 genügt, weil das Token zufällig und hochentropisch
// ist. Argon2 wäre hier nur langsam, nicht sicherer – und liefe bei WebDAV bei
// jedem einzelnen Request.
func HashAppPassword(token string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
return hex.EncodeToString(sum[:])
}
// ── Store ────────────────────────────────────────────────────────────────────
func (s *Store) ListAppPasswords(ctx context.Context, userID uuid.UUID) ([]AppPassword, error) {
rows, err := s.db.Query(ctx,
`SELECT id, name, last_used_at, created_at
FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC`,
userID)
if err != nil {
return nil, fmt.Errorf("query app passwords: %w", err)
}
defer rows.Close()
out := []AppPassword{}
for rows.Next() {
var a AppPassword
if err := rows.Scan(&a.ID, &a.Name, &a.LastUsedAt, &a.CreatedAt); err != nil {
return nil, fmt.Errorf("scan app password: %w", err)
}
out = append(out, a)
}
return out, rows.Err()
}
func (s *Store) CreateAppPassword(ctx context.Context, userID uuid.UUID, name, token string) (*AppPassword, error) {
var count int
if err := s.db.QueryRow(ctx,
`SELECT count(*) FROM app_passwords WHERE user_id = $1`, userID).Scan(&count); err != nil {
return nil, fmt.Errorf("count app passwords: %w", err)
}
if count >= maxAppPasswords {
return nil, ErrTooManyAppPasswords
}
var a AppPassword
err := s.db.QueryRow(ctx,
`INSERT INTO app_passwords (user_id, name, token_hash)
VALUES ($1, $2, $3)
RETURNING id, name, last_used_at, created_at`,
userID, name, HashAppPassword(token),
).Scan(&a.ID, &a.Name, &a.LastUsedAt, &a.CreatedAt)
if err != nil {
return nil, fmt.Errorf("insert app password: %w", err)
}
return &a, nil
}
func (s *Store) DeleteAppPassword(ctx context.Context, userID, id uuid.UUID) error {
tag, err := s.db.Exec(ctx,
`DELETE FROM app_passwords WHERE id = $1 AND user_id = $2`, id, userID)
if err != nil {
return fmt.Errorf("delete app password: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// VerifyAppPassword sucht das Token über seinen Hash und gibt den zugehörigen
// User zurück. Der Username muss passen, damit ein Token nicht für ein fremdes
// Konto gilt.
func (s *Store) VerifyAppPassword(ctx context.Context, username, token string) (*User, error) {
hash := HashAppPassword(token)
var u User
var storedHash string
var pwID uuid.UUID
var lastUsed *time.Time
err := s.db.QueryRow(ctx,
`SELECT ap.id, ap.token_hash, ap.last_used_at,
u.id, u.username, u.display_name, u.is_admin, u.quota_bytes, u.created_at
FROM app_passwords ap
JOIN users u ON u.id = ap.user_id
WHERE ap.token_hash = $1 AND u.username = $2`,
hash, username,
).Scan(&pwID, &storedHash, &lastUsed,
&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, &u.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("query app password: %w", err)
}
// Gürtel und Hosenträger – der Vergleich lief schon in der Datenbank.
if subtle.ConstantTimeCompare([]byte(storedHash), []byte(hash)) != 1 {
return nil, ErrNotFound
}
if lastUsed == nil || time.Since(*lastUsed) > lastUsedResolution {
if _, err := s.db.Exec(ctx,
`UPDATE app_passwords SET last_used_at = now() WHERE id = $1`, pwID); err != nil {
return nil, fmt.Errorf("touch app password: %w", err)
}
}
return &u, nil
}
// ── Handlers ─────────────────────────────────────────────────────────────────
// GET /api/v1/app-passwords
func (h *Handler) ListAppPasswords(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
list, err := h.store.ListAppPasswords(r.Context(), user.ID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load app passwords")
return
}
response.JSON(w, http.StatusOK, list)
}
// POST /api/v1/app-passwords
func (h *Handler) CreateAppPassword(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid request body")
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
response.Error(w, http.StatusBadRequest, "name required")
return
}
if len(name) > 60 {
name = name[:60]
}
token, err := GenerateAppPassword()
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not generate app password")
return
}
created, err := h.store.CreateAppPassword(r.Context(), user.ID, name, token)
if errors.Is(err, ErrTooManyAppPasswords) {
response.Error(w, http.StatusConflict, "app password limit reached")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not create app password")
return
}
h.auditor.Log(r.Context(), audit.Event{
ActorID: &user.ID,
ActorName: user.Username,
Action: audit.ActionAppPasswordCreate,
TargetType: "app_password",
TargetID: created.ID.String(),
TargetName: created.Name,
IP: middleware.RealIP(r),
})
// Einziger Moment, in dem das Klartext-Token existiert – danach steht nur
// noch der Hash in der Datenbank.
response.JSON(w, http.StatusCreated, struct {
AppPassword
Password string `json:"password"`
}{AppPassword: *created, Password: token})
}
// DELETE /api/v1/app-passwords/{id}
func (h *Handler) DeleteAppPassword(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := h.store.DeleteAppPassword(r.Context(), user.ID, id); errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "app password not found")
return
} else if err != nil {
response.Error(w, http.StatusInternalServerError, "could not revoke app password")
return
}
h.auditor.Log(r.Context(), audit.Event{
ActorID: &user.ID,
ActorName: user.Username,
Action: audit.ActionAppPasswordRevoke,
TargetType: "app_password",
TargetID: id.String(),
IP: middleware.RealIP(r),
})
w.WriteHeader(http.StatusNoContent)
}
+66
View File
@@ -0,0 +1,66 @@
package auth
import (
"strings"
"testing"
)
func TestGenerateAppPasswordFormat(t *testing.T) {
token, err := GenerateAppPassword()
if err != nil {
t.Fatalf("GenerateAppPassword: %v", err)
}
groups := strings.Split(token, "-")
if len(groups) != appPasswordGroups {
t.Fatalf("got %d groups, want %d (%q)", len(groups), appPasswordGroups, token)
}
for _, g := range groups {
if len(g) != appPasswordGroupLen {
t.Fatalf("group %q has length %d, want %d", g, len(g), appPasswordGroupLen)
}
for _, c := range g {
if !strings.ContainsRune(appPasswordAlphabet, c) {
t.Fatalf("character %q is outside the alphabet – hard to type on a phone", c)
}
}
}
}
func TestGenerateAppPasswordIsRandom(t *testing.T) {
seen := make(map[string]bool, 100)
for i := 0; i < 100; i++ {
token, err := GenerateAppPassword()
if err != nil {
t.Fatalf("GenerateAppPassword: %v", err)
}
if seen[token] {
t.Fatalf("duplicate token after %d draws: %q", i, token)
}
seen[token] = true
}
}
// Der Klartext darf nie gespeichert werden – gehasht wird deterministisch,
// damit der Login ein reiner Index-Lookup bleibt.
func TestHashAppPassword(t *testing.T) {
const token = "abcde-fghij-klmno-pqrst-uvwxy"
h := HashAppPassword(token)
if h == token || strings.Contains(h, token) {
t.Fatal("hash leaks the plaintext token")
}
if len(h) != 64 {
t.Fatalf("hash length = %d, want 64 hex chars", len(h))
}
if h != HashAppPassword(token) {
t.Fatal("hash is not deterministic")
}
if h == HashAppPassword("abcde-fghij-klmno-pqrst-uvwxz") {
t.Fatal("different tokens hash to the same value")
}
// Clients hängen beim Kopieren gern Whitespace an.
if h != HashAppPassword(" "+token+"\n") {
t.Fatal("surrounding whitespace changes the hash")
}
}
+266
View File
@@ -0,0 +1,266 @@
package auth
import (
"encoding/json"
"net/http"
"strings"
"aether/internal/audit"
"aether/pkg/middleware"
"aether/pkg/response"
)
type Handler struct {
store *Store
sessions *SessionStore
usage UsageQuerier
auditor *audit.Logger
}
func NewHandler(store *Store, sessions *SessionStore, usage UsageQuerier, auditor *audit.Logger) *Handler {
return &Handler{store: store, sessions: sessions, usage: usage, auditor: auditor}
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// POST /api/v1/auth/login
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Username == "" || req.Password == "" {
response.Error(w, http.StatusBadRequest, "username and password required")
return
}
user, hash, err := h.store.GetByUsername(r.Context(), req.Username)
if err != nil {
// constant-time: always hash even if user not found to prevent timing attacks
_, _ = VerifyPassword(req.Password, "$argon2id$v=19$m=65536,t=1,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
h.auditor.Log(r.Context(), audit.Event{
ActorName: req.Username,
Action: audit.ActionLoginFailed,
IP: middleware.RealIP(r),
Detail: "unknown account",
})
response.Error(w, http.StatusUnauthorized, "invalid credentials")
return
}
ok, err := VerifyPassword(req.Password, hash)
if err != nil || !ok {
h.auditor.Log(r.Context(), audit.Event{
ActorID: &user.ID,
ActorName: user.Username,
Action: audit.ActionLoginFailed,
IP: middleware.RealIP(r),
Detail: "wrong password",
})
response.Error(w, http.StatusUnauthorized, "invalid credentials")
return
}
if err := h.sessions.SetUser(w, r, user.ID); err != nil {
response.Error(w, http.StatusInternalServerError, "session error")
return
}
csrfToken, err := SetCSRFCookie(w, r)
if err != nil {
response.Error(w, http.StatusInternalServerError, "csrf error")
return
}
h.auditor.Log(r.Context(), audit.Event{
ActorID: &user.ID,
ActorName: user.Username,
Action: audit.ActionLogin,
IP: middleware.RealIP(r),
})
// Denselben Wert wie /me mitgeben: Ohne ihn stand der Speicherbalken
// direkt nach der Anmeldung auf 0 B und sprang erst beim nächsten
// Nutzer-Abgleich auf den echten Stand.
if used, err := h.usage.UsedBytes(r.Context(), user.ID); err == nil {
user.UsedBytes = used
}
response.JSON(w, http.StatusOK, map[string]any{
"user": user,
"csrf_token": csrfToken,
})
}
// POST /api/v1/auth/logout
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
// Die Route liegt bewusst nicht hinter RequireAuth – abmelden soll auch
// mit abgelaufener Sitzung klappen und nie mit 401 scheitern. Dadurch
// steht aber kein Nutzer im Kontext, und der Eintrag im Protokoll blieb
// bisher aus. Also hier selbst aus der Sitzung nachschlagen.
if userID, ok := h.sessions.GetUserID(r); ok {
name := ""
if u, err := h.store.GetByID(r.Context(), userID); err == nil {
name = u.Username
}
h.auditor.Log(r.Context(), audit.Event{
ActorID: &userID,
ActorName: name,
Action: audit.ActionLogout,
IP: middleware.RealIP(r),
})
}
_ = h.sessions.Clear(w, r)
// clear CSRF cookie
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: "",
Path: "/",
MaxAge: -1,
})
response.NoContent(w)
}
// PATCH /api/v1/auth/profile – update own display_name
// isHexColor lässt leere Werte zu – leer bedeutet "globale Farbe verwenden".
func isHexColor(v string) bool {
if v == "" {
return true
}
if len(v) != 7 || v[0] != '#' {
return false
}
for _, c := range v[1:] {
if !strings.ContainsRune("0123456789abcdefABCDEF", c) {
return false
}
}
return true
}
func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
// Zeiger, damit sich "Feld nicht mitgeschickt" von "auf leer setzen"
// unterscheiden lässt: Der Profilname und die Farben werden getrennt
// gespeichert, sollen sich aber nicht gegenseitig überschreiben.
var body struct {
DisplayName *string `json:"display_name"`
PrimaryColor *string `json:"primary_color"`
AccentColor *string `json:"accent_color"`
UseCustomTheme *bool `json:"use_custom_theme"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
if body.DisplayName != nil {
name := strings.TrimSpace(*body.DisplayName)
if err := h.store.UpdateDisplayName(r.Context(), user.ID, name); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
user.DisplayName = name
}
if body.PrimaryColor != nil || body.AccentColor != nil {
primary, accent := user.PrimaryColor, user.AccentColor
if body.PrimaryColor != nil {
primary = strings.TrimSpace(*body.PrimaryColor)
}
if body.AccentColor != nil {
accent = strings.TrimSpace(*body.AccentColor)
}
if !isHexColor(primary) || !isHexColor(accent) {
response.Error(w, http.StatusBadRequest, "colors must be hex values like #4f46e5")
return
}
if err := h.store.UpdateColors(r.Context(), user.ID, primary, accent); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
user.PrimaryColor, user.AccentColor = primary, accent
}
if body.UseCustomTheme != nil {
if err := h.store.SetUseCustomTheme(r.Context(), user.ID, *body.UseCustomTheme); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
user.UseCustomTheme = *body.UseCustomTheme
}
response.JSON(w, http.StatusOK, user)
}
// POST /api/v1/auth/change-password
func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
var body struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.CurrentPassword == "" || body.NewPassword == "" {
response.Error(w, http.StatusBadRequest, "current_password and new_password required")
return
}
// Konten, die über den Identitätsanbieter kommen, haben hier kein Passwort.
// Der Aufruf scheitert ohnehin – '' ist kein gültiger Argon2-Hash –, aber
// mit „current password incorrect" statt mit dem wahren Grund. Diese
// Prüfung sagt, was los ist; die Oberfläche blendet das Formular
// zusätzlich aus.
if user.IsOIDC {
response.Error(w, http.StatusBadRequest,
"this account signs in through your identity provider — change the password there")
return
}
_, hash, err := h.store.GetByUsername(r.Context(), user.Username)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
ok, err := VerifyPassword(body.CurrentPassword, hash)
if err != nil || !ok {
response.Error(w, http.StatusUnauthorized, "current password incorrect")
return
}
newHash, err := HashPassword(body.NewPassword)
if err != nil {
response.Error(w, http.StatusInternalServerError, "hash error")
return
}
if err := h.store.UpdatePassword(r.Context(), user.ID, newHash); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.auditor.Log(r.Context(), audit.Event{
ActorID: &user.ID,
ActorName: user.Username,
Action: audit.ActionPasswordChange,
IP: middleware.RealIP(r),
})
response.NoContent(w)
}
// GET /api/v1/auth/me
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
if used, err := h.usage.UsedBytes(r.Context(), user.ID); err == nil {
user.UsedBytes = used
}
// Refresh CSRF token on each /me call so frontend always has a valid one.
csrfToken, err := SetCSRFCookie(w, r)
if err != nil {
response.Error(w, http.StatusInternalServerError, "csrf error")
return
}
response.JSON(w, http.StatusOK, map[string]any{
"user": user,
"csrf_token": csrfToken,
})
}
+67
View File
@@ -0,0 +1,67 @@
package auth
import (
"context"
"net/http"
"aether/pkg/response"
)
type contextKey int
const keyUser contextKey = iota
// RequireAuth loads the session user and injects it into context.
// Returns 401 if not authenticated.
func RequireAuth(ss *SessionStore, store *Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, ok := ss.GetUserID(r)
if !ok {
response.Error(w, http.StatusUnauthorized, "authentication required")
return
}
user, err := store.GetByID(r.Context(), id)
if err != nil {
response.Error(w, http.StatusUnauthorized, "authentication required")
return
}
ctx := context.WithValue(r.Context(), keyUser, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// RequireAdmin additionally checks that the user is an admin.
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
if user == nil || !user.IsAdmin {
response.Error(w, http.StatusForbidden, "admin access required")
return
}
next.ServeHTTP(w, r)
})
}
// CSRF validates the double-submit CSRF token for state-changing requests.
func CSRF(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
if !ValidateCSRF(r) {
response.Error(w, http.StatusForbidden, "invalid CSRF token")
return
}
next.ServeHTTP(w, r)
})
}
// UserFromContext retrieves the authenticated user from context (may be nil).
func UserFromContext(ctx context.Context) *User {
u, _ := ctx.Value(keyUser).(*User)
return u
}
+37
View File
@@ -0,0 +1,37 @@
package auth
import (
"context"
"time"
"github.com/google/uuid"
)
type User struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
IsAdmin bool `json:"is_admin"`
QuotaBytes int64 `json:"quota_bytes"`
UsedBytes int64 `json:"used_bytes"`
// Persönliche Palette. Sie bleibt gespeichert, auch wenn UseCustomTheme
// aus ist – so kann man jederzeit umschalten, ohne sie neu zu wählen.
PrimaryColor string `json:"primary_color"`
AccentColor string `json:"accent_color"`
UseCustomTheme bool `json:"use_custom_theme"`
// IsOIDC heißt: Dieses Konto meldet sich über den Identitätsanbieter an
// und hat hier gar kein Passwort (`password` steht auf ''). Die Oberfläche
// blendet die Passwortänderung dann aus – ein Formular, das nur
// fehlschlagen kann, ist schlimmer als keines.
//
// Nicht sicherheitsrelevant, sondern eine Auskunft: Anmeldung und
// Passwortänderung scheitern für solche Konten ohnehin schon daran, dass
// '' kein gültiger Argon2-Hash ist.
IsOIDC bool `json:"is_oidc"`
CreatedAt time.Time `json:"created_at"`
}
// UsageQuerier is satisfied by files.Store – keeps auth ↔ files decoupled.
type UsageQuerier interface {
UsedBytes(ctx context.Context, userID uuid.UUID) (int64, error)
}
+90
View File
@@ -0,0 +1,90 @@
package auth
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
// Prüft den Fall, der beim Einschalten von OIDC über eine bestehende
// Installation entscheidet: Wird das vorhandene Konto verbunden – oder entsteht
// daneben ein zweites, leeres?
//
// Läuft nur mit TEST_DSN gegen eine echte Datenbank; ohne wird übersprungen,
// damit `go test ./...` überall grün bleibt.
func TestGetOrCreateByOIDC_Linking(t *testing.T) {
dsn := os.Getenv("TEST_DSN")
if dsn == "" {
t.Skip("TEST_DSN nicht gesetzt")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("verbinden: %v", err)
}
defer pool.Close()
s := NewStore(pool)
// Aufräumen, auch wenn der Test scheitert.
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE username LIKE 'linkprobe%'`)
}
cleanup()
defer cleanup()
hash, _ := HashPassword("irrelevant-aber-gueltig")
if _, err := pool.Exec(ctx,
`INSERT INTO users (username, display_name, password, is_admin)
VALUES ('linkprobe', 'Link Probe', $1, true)`, hash); err != nil {
t.Fatalf("konto anlegen: %v", err)
}
t.Run("ohne Verknüpfung entsteht ein zweites Konto", func(t *testing.T) {
u, err := s.GetOrCreateByOIDC(ctx, "sub-A", "linkprobe", "Link Probe", false)
if err != nil {
t.Fatalf("aufruf: %v", err)
}
if u.Username == "linkprobe" {
t.Fatal("erwartet: ein neues Konto mit abgewandeltem Namen")
}
if u.IsAdmin {
t.Error("ein neu angelegtes Konto darf keine Adminrechte erben")
}
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE oidc_sub = 'sub-A'`)
})
t.Run("mit Verknüpfung bleibt das bestehende Konto", func(t *testing.T) {
u, err := s.GetOrCreateByOIDC(ctx, "sub-B", "linkprobe", "Link Probe", true)
if err != nil {
t.Fatalf("aufruf: %v", err)
}
if u.Username != "linkprobe" {
t.Fatalf("erwartet linkprobe, bekam %q", u.Username)
}
if !u.IsAdmin {
t.Error("Adminrecht des bestehenden Kontos ging verloren")
}
if !u.IsOIDC {
t.Error("Konto sollte jetzt als OIDC-Konto gelten")
}
})
t.Run("erneutes Anmelden findet dasselbe Konto", func(t *testing.T) {
u, err := s.GetOrCreateByOIDC(ctx, "sub-B", "linkprobe", "Link Probe", true)
if err != nil || u.Username != "linkprobe" {
t.Fatalf("wiederholte Anmeldung: %v / %+v", err, u)
}
})
t.Run("fremde Identität übernimmt ein gebundenes Konto nicht", func(t *testing.T) {
u, err := s.GetOrCreateByOIDC(ctx, "sub-FREMD", "linkprobe", "Angreifer", true)
if err != nil {
t.Fatalf("aufruf: %v", err)
}
if u.Username == "linkprobe" {
t.Fatal("ein bereits gebundenes Konto wurde übernommen – das darf nie passieren")
}
})
}
+49
View File
@@ -0,0 +1,49 @@
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
const (
argonTime = 1
argonMemory = 64 * 1024
argonThreads = 4
argonKeyLen = 32
saltLen = 16
)
func HashPassword(password string) (string, error) {
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate salt: %w", err)
}
hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
), nil
}
func VerifyPassword(password, encoded string) (bool, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, fmt.Errorf("invalid hash format")
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, fmt.Errorf("decode salt: %w", err)
}
expected, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, fmt.Errorf("decode hash: %w", err)
}
check := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, uint32(len(expected)))
return subtle.ConstantTimeCompare(expected, check) == 1, nil
}
+39
View File
@@ -0,0 +1,39 @@
package auth
import (
"testing"
)
func TestHashAndVerify(t *testing.T) {
hash, err := HashPassword("correct-horse-battery-staple")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if hash == "" {
t.Fatal("expected non-empty hash")
}
ok, err := VerifyPassword("correct-horse-battery-staple", hash)
if err != nil {
t.Fatalf("VerifyPassword: %v", err)
}
if !ok {
t.Error("expected password to match")
}
ok, err = VerifyPassword("wrong-password", hash)
if err != nil {
t.Fatalf("VerifyPassword wrong: %v", err)
}
if ok {
t.Error("expected wrong password to not match")
}
}
func TestHashUniqueness(t *testing.T) {
h1, _ := HashPassword("same-password")
h2, _ := HashPassword("same-password")
if h1 == h2 {
t.Error("two hashes of the same password should differ (different salts)")
}
}
+118
View File
@@ -0,0 +1,118 @@
package auth
import (
"crypto/rand"
"encoding/base64"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/gorilla/sessions"
"aether/pkg/middleware"
)
const (
sessionName = "aether_session"
sessionKeyUser = "user_id"
csrfCookieName = "aether_csrf"
csrfHeaderName = "X-CSRF-Token"
)
type SessionStore struct {
store *sessions.CookieStore
maxAge int
}
func NewSessionStore(secret string, maxAge int) *SessionStore {
store := sessions.NewCookieStore([]byte(secret))
store.Options = &sessions.Options{
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
return &SessionStore{store: store, maxAge: maxAge}
}
func (ss *SessionStore) SetUser(w http.ResponseWriter, r *http.Request, userID uuid.UUID) error {
sess, err := ss.store.Get(r, sessionName)
if err != nil {
sess, _ = ss.store.New(r, sessionName)
}
sess.Values[sessionKeyUser] = userID.String()
sess.Options.Secure = middleware.IsHTTPS(r)
return ss.store.Save(r, w, sess)
}
func (ss *SessionStore) GetUserID(r *http.Request) (uuid.UUID, bool) {
sess, err := ss.store.Get(r, sessionName)
if err != nil {
return uuid.Nil, false
}
raw, ok := sess.Values[sessionKeyUser].(string)
if !ok {
return uuid.Nil, false
}
id, err := uuid.Parse(raw)
if err != nil {
return uuid.Nil, false
}
return id, true
}
func (ss *SessionStore) Clear(w http.ResponseWriter, r *http.Request) error {
sess, err := ss.store.Get(r, sessionName)
if err != nil {
return nil
}
sess.Options.MaxAge = -1
return ss.store.Save(r, w, sess)
}
// SetCSRFCookie sets a CSRF token as a readable (non-httpOnly) cookie.
func SetCSRFCookie(w http.ResponseWriter, r *http.Request) (string, error) {
token := make([]byte, 32)
if _, err := rand.Read(token); err != nil {
return "", err
}
encoded := base64.RawURLEncoding.EncodeToString(token)
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: encoded,
Path: "/",
HttpOnly: false, // must be readable by JS
Secure: middleware.IsHTTPS(r),
SameSite: http.SameSiteStrictMode,
})
return encoded, nil
}
// ValidateCSRF prüft, dass das mitgeschickte Token zum CSRF-Cookie passt
// (Double-Submit).
//
// Neben dem Header wird auch ein Formularfeld akzeptiert. Grund: Ein Download
// muss aus einer **Navigation** kommen, damit der Browser ihn als Datei
// behandelt statt ihn in den Speicher zu laden – und ein abgeschicktes
// Formular kann keine Header setzen. Am Schutz ändert das nichts: Verglichen
// wird weiterhin gegen das Cookie, an das eine fremde Seite nicht herankommt.
func ValidateCSRF(r *http.Request) bool {
cookie, err := r.Cookie(csrfCookieName)
if err != nil || cookie.Value == "" {
return false
}
if token := r.Header.Get(csrfHeaderName); token != "" {
return token == cookie.Value
}
// Nur bei Formularen nachsehen: Bei anderen Inhaltstypen würde ParseForm
// den Rumpf lesen, den der eigentliche Handler danach noch braucht.
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "application/x-www-form-urlencoded") {
if err := r.ParseForm(); err != nil {
return false
}
token := r.PostFormValue("csrf_token")
return token != "" && token == cookie.Value
}
return false
}
+211
View File
@@ -0,0 +1,211 @@
package auth
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// userCols ist die Spaltenliste jeder Nutzerabfrage – einmal, weil vier
// Abfragen mit derselben Reihenfolge scannen und eine übersehene Ergänzung
// sonst erst zur Laufzeit auffällt.
const userCols = `id, username, display_name, is_admin, quota_bytes,
COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme,
oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at`
var ErrNotFound = errors.New("user not found")
var ErrUsernameTaken = errors.New("username already taken")
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) *Store {
return &Store{db: db}
}
func (s *Store) GetByUsername(ctx context.Context, username string) (*User, string, error) {
var u User
var passwordHash string
err := s.db.QueryRow(ctx,
`SELECT id, username, display_name, password, is_admin, quota_bytes, created_at
FROM users WHERE username = $1`,
username,
).Scan(&u.ID, &u.Username, &u.DisplayName, &passwordHash, &u.IsAdmin, &u.QuotaBytes, &u.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", ErrNotFound
}
if err != nil {
return nil, "", fmt.Errorf("query user: %w", err)
}
return &u, passwordHash, nil
}
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (*User, error) {
var u User
err := s.db.QueryRow(ctx,
`SELECT id, username, display_name, is_admin, quota_bytes,
COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at
FROM users WHERE id = $1`,
id,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("query user: %w", err)
}
return &u, nil
}
func (s *Store) Create(ctx context.Context, username, passwordHash string, isAdmin bool) (*User, error) {
var u User
err := s.db.QueryRow(ctx,
`INSERT INTO users (username, password, is_admin)
VALUES ($1, $2, $3)
RETURNING id, username, display_name, is_admin, quota_bytes,
COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at`,
username, passwordHash, isAdmin,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt)
if err != nil {
if isUniqueViolation(err) {
return nil, ErrUsernameTaken
}
return nil, fmt.Errorf("create user: %w", err)
}
return &u, nil
}
// GetOrCreateByOIDC findet den Nutzer über sein OIDC-Subject – oder legt ihn an.
//
// linkByUsername entscheidet über den heiklen Fall: Es gibt bereits ein Konto
// mit diesem Benutzernamen, aber ohne Bindung an eine OIDC-Identität.
//
// - **aus (Voreinstellung):** Es entsteht ein *zweites* Konto. Da der Name
// schon vergeben ist, heißt es „name2" – leer, ohne Adminrechte, ohne die
// bisherigen Dateien. Wer seine Cloud bisher mit Passwort benutzt hat und
// dann OIDC einschaltet, landet also in einer fremden, leeren Cloud.
// - **an:** Das bestehende Konto wird mit der Identität verbunden. Dateien,
// Adminrecht und Freigaben bleiben, künftig meldet man sich darüber an.
//
// **Warum das nicht die Voreinstellung ist:** Verbinden heißt, dem
// Identitätsanbieter zu glauben, dass „marc" bei ihm derselbe „marc" ist wie
// hier. Bei einem eigenen Anbieter, dessen Konten man selbst anlegt, stimmt
// das. Bei einem Anbieter, bei dem sich Fremde registrieren und ihren
// Benutzernamen frei wählen können, wäre es die Übernahme fremder Konten.
// Deshalb eine bewusste Entscheidung in der .env statt einer stillen Annahme.
//
// Verbunden wird nur, was **noch keine** Bindung hat: Ein Konto, das bereits zu
// einer anderen Identität gehört, wird nie überschrieben.
func (s *Store) GetOrCreateByOIDC(ctx context.Context, sub, username, displayName string, linkByUsername bool) (*User, error) {
// First: try to find by oidc_sub.
var u User
err := s.db.QueryRow(ctx,
`SELECT id, username, display_name, is_admin, quota_bytes,
COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at
FROM users WHERE oidc_sub=$1`,
sub,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt)
if err == nil {
return &u, nil
}
// Bestehendes Konto übernehmen, falls gewünscht und noch ungebunden.
if linkByUsername {
err = s.db.QueryRow(ctx,
`UPDATE users SET oidc_sub = $1
WHERE username = $2 AND (oidc_sub IS NULL OR oidc_sub = '')
RETURNING `+userCols,
sub, username,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt)
if err == nil {
return &u, nil
}
// Kein Treffer heißt: kein solches Konto oder schon anderweitig
// gebunden. Beides führt weiter unten zum Anlegen eines neuen.
}
// Not found: create a new user. Ensure username uniqueness.
candidate := username
for i := 2; i <= 99; i++ {
err = s.db.QueryRow(ctx,
`INSERT INTO users (username, display_name, password, oidc_sub)
VALUES ($1, $2, '', $3)
RETURNING id, username, display_name, is_admin, quota_bytes,
COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at`,
candidate, displayName, sub,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt)
if err == nil {
return &u, nil
}
if isUniqueViolation(err) {
candidate = fmt.Sprintf("%s%d", username, i)
continue
}
return nil, fmt.Errorf("create oidc user: %w", err)
}
return nil, fmt.Errorf("could not find unique username for %q", username)
}
func (s *Store) UpdateDisplayName(ctx context.Context, id uuid.UUID, displayName string) error {
_, err := s.db.Exec(ctx, `UPDATE users SET display_name=$2 WHERE id=$1`, id, displayName)
return err
}
// SetUseCustomTheme legt um, ob die persönliche Palette angewendet wird.
// Die gespeicherten Farben bleiben dabei unangetastet.
func (s *Store) SetUseCustomTheme(ctx context.Context, id uuid.UUID, enabled bool) error {
_, err := s.db.Exec(ctx, `UPDATE users SET use_custom_theme=$2 WHERE id=$1`, id, enabled)
return err
}
// UpdateColors setzt die persönliche Palette. Leere Werte werden als NULL
// gespeichert – dann gilt wieder die globale Einstellung des Administrators.
func (s *Store) UpdateColors(ctx context.Context, id uuid.UUID, primary, accent string) error {
var p, a *string
if primary != "" {
p = &primary
}
if accent != "" {
a = &accent
}
_, err := s.db.Exec(ctx,
`UPDATE users SET primary_color=$2, accent_color=$3 WHERE id=$1`, id, p, a)
return err
}
func (s *Store) UpdatePassword(ctx context.Context, id uuid.UUID, newHash string) error {
_, err := s.db.Exec(ctx, `UPDATE users SET password=$2 WHERE id=$1`, id, newHash)
return err
}
func (s *Store) AdminExists(ctx context.Context) (bool, error) {
var count int
err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE is_admin = true`).Scan(&count)
return count > 0, err
}
func isUniqueViolation(err error) bool {
return err != nil && (containsCode(err, "23505"))
}
func containsCode(err error, code string) bool {
return err != nil && len(err.Error()) > 0 &&
(func() bool {
type pgErr interface{ SQLState() string }
var pe pgErr
if errors.As(err, &pe) {
return pe.SQLState() == code
}
return false
}())
}
+66
View File
@@ -0,0 +1,66 @@
package documents
import (
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
)
// minFreeBytes ist die Untergrenze, unter der die Texterkennung pausiert.
//
// OCR ist das einzige Stück dieser Anwendung, das von sich aus und ohne
// Zutun eines Menschen Platz verbraucht: Es arbeitet eine Warteschlange ab,
// solange etwas darin liegt. Eine Cloud, die ihre eigene Platte vollschreibt,
// nimmt Uploads nicht mehr an – und das wäre dann kein OCR-Problem mehr,
// sondern ein Ausfall.
//
// 2 GiB ist mit Absicht großzügig: Der Sidecar einer Erkennung ist kaum größer
// als ein Megabyte, tesseract und ghostscript brauchen währenddessen aber
// Arbeitsdateien, deren Größe von der Auflösung der Vorlage abhängt. Lieber
// eine Weile nichts erkennen als eine volle Platte.
const minFreeBytes = 2 << 30 // 2 GiB
// freeBytes meldet den freien Platz auf dem Dateisystem, auf dem path liegt.
func freeBytes(path string) (uint64, error) {
var st syscall.Statfs_t
if err := syscall.Statfs(path, &st); err != nil {
return 0, fmt.Errorf("statfs %s: %w", path, err)
}
// Bavail, nicht Bfree: Bfree zählt auch die Reserve mit, die nur root
// benutzen darf – der Server läuft aber als unprivilegierter Nutzer und
// käme daran nie heran.
return st.Bavail * uint64(st.Bsize), nil
}
// sweepTemp räumt Zwischendateien früherer Läufe weg.
//
// Im Normalfall räumt der Arbeiter selbst auf. Wird der Container aber hart
// beendet – `docker kill`, ein Absturz, Stromausfall –, läuft kein defer mehr,
// und die angefangene Datei bleibt liegen. Ohne diesen Durchgang sammelt sich
// das über Monate an, ausgerechnet auf der Platte, die auch die Dateien trägt.
//
// Es wird nur weggeräumt, was diese Anwendung selbst angelegt hat: das Präfix
// "ocr-" im Zwischenverzeichnis. Fremde Dateien bleiben unangetastet.
func sweepTemp(dir string) (int, int64) {
entries, err := os.ReadDir(dir)
if err != nil {
return 0, 0
}
var count int
var bytes int64
for _, e := range entries {
if e.IsDir() || !strings.HasPrefix(e.Name(), "ocr-") {
continue
}
full := filepath.Join(dir, e.Name())
if info, err := e.Info(); err == nil {
bytes += info.Size()
}
if err := os.Remove(full); err == nil {
count++
}
}
return count, bytes
}
+302
View File
@@ -0,0 +1,302 @@
// Package documents macht den Inhalt von Dokumenten durchsuchbar.
//
// Die Namenssuche (internal/files/search.go) findet "Rechnung_Stadtwerke.pdf".
// Ein Scanner nennt seine Ablage aber "scan_0042.pdf" – der Name sagt dann
// nichts, und der Beleg ist praktisch verloren. Dieses Paket holt den Text aus
// der Datei und legt ihn nach file_text, wo die Suche ihn findet.
package documents
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"unicode/utf8"
)
// Herkunft des Textes. Der Unterschied zählt bei der Fehlersuche: Findet die
// Suche ein Wort nicht, sagt die Herkunft, ob OCR es verlesen haben kann.
const (
SourceLayer = "layer" // aus der Textebene des PDFs – verlustfrei
SourceOCR = "ocr" // aus dem Bild erkannt – kann Fehler enthalten
)
// minCharsPerPage entscheidet, ob ein PDF schon Text mitbringt.
//
// Nicht "> 0 Zeichen": Fast jeder Scan trägt eine Kopfzeile, eine Seitenzahl
// oder ein Wasserzeichen als echten Text – oft nur zehn Zeichen. Ohne diese
// Schwelle gälte er als lesbar, und die eigentliche Rechnung bliebe
// unauffindbar. Eine getippte Seite hat mehrere tausend Zeichen; 120 trennt die
// beiden Fälle mit reichlich Abstand in beide Richtungen.
const minCharsPerPage = 120
// maxTextBytes deckelt, was in die Datenbank geht. Ein 400-seitiges Handbuch
// erzeugt sonst mehrere Megabyte Text pro Datei, und der GIN-Index wächst mit.
// Für einen Beleg ist das weit jenseits von allem; abgeschnitten wird am Ende,
// wo der Inhalt ohnehin am wenigsten aussagt.
const maxTextBytes = 1 << 20 // 1 MiB
// Extractor holt Text aus einer Datei. Er ruft externe Programme auf –
// pdftotext, ocrmypdf, tesseract –, die im Container mitgeliefert werden.
type Extractor struct {
// Languages sind die Sprachmodelle für tesseract, z. B. "deu+eng".
Languages string
// MaxPages begrenzt, wie viele Seiten erkannt werden. OCR kostet Sekunden
// je Seite; ohne Grenze blockiert ein einzelnes dickes PDF die
// Warteschlange für alle anderen.
MaxPages int
// Timeout je Datei. Greift zusätzlich zu MaxPages, weil auch eine einzelne
// Seite entgleisen kann.
Timeout time.Duration
// TmpDir nimmt die Zwischendateien auf. Leer heißt: das System entscheidet.
TmpDir string
}
// Result ist der gefundene Text mitsamt seiner Herkunft.
type Result struct {
Text string
Source string
// Lang sind die verwendeten Sprachmodelle – bei SourceLayer leer, weil
// dort nichts erkannt, sondern nur ausgelesen wurde.
Lang string
Pages int
// Chars ist die Länge *vor* dem Kürzen auf maxTextBytes.
Chars int
}
// Supported sagt, ob aus dieser Datei überhaupt Text zu holen ist.
//
// Bewusst eng: PDFs und Bilder. Word- und Excel-Dateien bräuchten je einen
// eigenen Umwandler; sie tragen ihren Text ohnehin meist schon im Namen und
// sind selten das, was eingescannt wird.
func Supported(mime, name string) bool {
m := strings.ToLower(mime)
if strings.Contains(m, "pdf") {
return true
}
if strings.HasPrefix(m, "image/") {
// SVG ist Text, kein Bild – tesseract kann damit nichts anfangen.
return !strings.Contains(m, "svg")
}
switch strings.ToLower(filepath.Ext(name)) {
case ".pdf", ".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp":
return true
}
return false
}
// Available prüft, ob die Programme da sind. Fehlen sie, läuft die Cloud
// weiter – nur ohne Volltext. Ein fehlendes Paket soll den Start nicht
// verhindern.
func Available() (bool, []string) {
var missing []string
for _, bin := range []string{"pdftotext", "ocrmypdf", "tesseract"} {
if _, err := exec.LookPath(bin); err != nil {
missing = append(missing, bin)
}
}
return len(missing) == 0, missing
}
// Extract liest die Datei unter path. Das Original wird dabei **nicht**
// angefasst.
//
// Das ist die wichtigste Eigenschaft hier: ocrmypdf schreibt normalerweise ein
// neues PDF mit eingebetteter Textebene. Täten wir das, änderten sich Größe und
// Prüfsumme der Datei – die gespeicherte Prüfsumme passte nicht mehr, die
// Versionshistorie bekäme einen Eintrag, den niemand ausgelöst hat, und
// WebDAV-Clients hielten die Datei für fremdverändert und lüden sie erneut. Für
// ein Archiv ist eine unangetastete Datei mehr wert als ein durchsuchbares PDF.
// Der Text landet deshalb daneben in der Datenbank, das PDF bleibt Byte für
// Byte, wie es hochgeladen wurde.
func (e *Extractor) Extract(ctx context.Context, path, mime, name string) (Result, error) {
if e.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, e.Timeout)
defer cancel()
}
isPDF := strings.Contains(strings.ToLower(mime), "pdf") ||
strings.EqualFold(filepath.Ext(name), ".pdf")
if isPDF {
return e.extractPDF(ctx, path)
}
return e.extractImage(ctx, path)
}
func (e *Extractor) extractPDF(ctx context.Context, path string) (Result, error) {
pages := e.pageCount(ctx, path)
// Erst der billige Weg: Hat das PDF eine Textebene, ist pdftotext in
// Millisekunden fertig und liefert den Text exakt so, wie er gesetzt wurde.
// OCR wäre hier nicht nur langsamer, sondern schlechter – es würde
// fehlerfreien Text durch geratenen ersetzen.
limit := pages
if e.MaxPages > 0 && (limit == 0 || limit > e.MaxPages) {
limit = e.MaxPages
}
args := []string{"-q"}
if limit > 0 {
args = append(args, "-l", fmt.Sprint(limit))
}
args = append(args, path, "-")
layer, err := e.run(ctx, "pdftotext", args...)
if err == nil {
text := clean(layer)
perPage := len(text)
if pages > 1 {
perPage = len(text) / pages
}
if perPage >= minCharsPerPage {
return finish(text, SourceLayer, "", pages), nil
}
}
// Zu wenig Text – also ein Scan. Jetzt OCR.
sidecar, err := os.CreateTemp(e.TmpDir, "ocr-*.txt")
if err != nil {
return Result{}, fmt.Errorf("sidecar anlegen: %w", err)
}
sidecarPath := sidecar.Name()
sidecar.Close()
defer os.Remove(sidecarPath)
ocrArgs := []string{
"--quiet",
"-l", e.Languages,
// Der eigentliche Zweck des Aufrufs: --sidecar schreibt den erkannten
// Text als reine Textdatei daneben.
"--sidecar", sidecarPath,
// Wir landen hier nur, wenn kaum Text da ist. --force-ocr rastert die
// Seite und erkennt sie neu, statt an den paar vorhandenen Zeichen zu
// scheitern (ocrmypdf bricht sonst mit PriorOcrFoundError ab).
"--force-ocr",
// Kein Ergebnis-PDF erzeugen. Uns interessiert allein der Sidecar; das
// Schreiben (und Optimieren) einer Datei, die sofort wieder
// verschwindet, wäre reine Rechenzeit und reiner Plattenplatz.
"--output-type", "none",
"--optimize", "0",
}
if e.MaxPages > 0 && pages > e.MaxPages {
ocrArgs = append(ocrArgs, "--pages", fmt.Sprintf("1-%d", e.MaxPages))
}
// Ausgabe nach stdout, und dank --output-type none kommt dort nichts an:
// gemessen 0 Bytes, Sidecar trotzdem vollständig. Damit entsteht gar kein
// Ergebnis-PDF – wichtig auf einer Platte, die knapp werden kann, denn ein
// force-OCR rastert jede Seite und das Ergebnis kann ein Vielfaches der
// Vorlage wiegen.
//
// /dev/null als Ausgabe geht übrigens **nicht** – ocrmypdf bricht damit
// mit Exit 3 ab. Der Bindestrich ist der Weg.
ocrArgs = append(ocrArgs, path, "-")
if _, err := e.run(ctx, "ocrmypdf", ocrArgs...); err != nil {
return Result{}, fmt.Errorf("ocrmypdf: %w", err)
}
raw, err := os.ReadFile(sidecarPath)
if err != nil {
return Result{}, fmt.Errorf("sidecar lesen: %w", err)
}
return finish(clean(string(raw)), SourceOCR, e.Languages, pages), nil
}
func (e *Extractor) extractImage(ctx context.Context, path string) (Result, error) {
out, err := e.run(ctx, "tesseract", path, "stdout", "-l", e.Languages)
if err != nil {
return Result{}, fmt.Errorf("tesseract: %w", err)
}
return finish(clean(out), SourceOCR, e.Languages, 1), nil
}
// pageCount fragt pdfinfo nach der Seitenzahl. Schlägt das fehl, arbeiten wir
// mit 0 weiter – die Seitenzahl steuert nur Grenzen, sie ist nicht kritisch.
func (e *Extractor) pageCount(ctx context.Context, path string) int {
out, err := e.run(ctx, "pdfinfo", path)
if err != nil {
return 0
}
for _, line := range strings.Split(out, "\n") {
if !strings.HasPrefix(line, "Pages:") {
continue
}
var n int
if _, err := fmt.Sscanf(strings.TrimSpace(line), "Pages: %d", &n); err == nil {
return n
}
}
return 0
}
func (e *Extractor) run(ctx context.Context, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
var out, errOut bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errOut
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(errOut.String())
if len(msg) > 300 {
msg = msg[:300] + "…"
}
if msg != "" {
return "", fmt.Errorf("%s: %w (%s)", name, err, msg)
}
return "", fmt.Errorf("%s: %w", name, err)
}
return out.String(), nil
}
// clean räumt auf, was aus den Werkzeugen kommt.
//
// **Der Seitenumbruch (\f) bleibt stehen.** Für die Suche ist er bedeutungslos
// – ein tsvector kennt keine Seiten –, für den Betrachter ist er das Einzige,
// woran sich ablesen lässt, *wo* ein Wort steht. Bei einem Scan liegt im PDF
// nur ein Bild; der erkannte Text steht ausschließlich hier. Ohne die Marke
// beginnt der Betrachter bei einem zwölfseitigen Beleg wieder auf Seite 1.
//
// Ein Wust aus Leerzeilen bläht den Text dagegen nur auf. Ungültiges UTF-8
// muss raus, weil Postgres es sonst zurückweist und der ganze Auftrag
// scheitert; OCR liefert es bei verrauschten Vorlagen durchaus.
func clean(s string) string {
s = strings.ReplaceAll(s, "\x00", "")
if !utf8.ValidString(s) {
s = strings.ToValidUTF8(s, "")
}
var b strings.Builder
b.Grow(len(s))
blank := 0
for _, line := range strings.Split(s, "\n") {
line = strings.TrimRight(line, " \t\r")
// Die Seitenmarke steht bei pdftotext am Ende der letzten Zeile einer
// Seite. Sie überlebt das Trimmen und zählt nicht als leer.
if strings.TrimSpace(strings.ReplaceAll(line, "\f", "")) == "" && !strings.Contains(line, "\f") {
blank++
if blank > 1 {
continue
}
} else {
blank = 0
}
b.WriteString(line)
b.WriteByte('\n')
}
return strings.TrimSpace(b.String())
}
// finish kürzt auf maxTextBytes und merkt sich die ursprüngliche Länge.
func finish(text, source, lang string, pages int) Result {
chars := utf8.RuneCountInString(text)
if len(text) > maxTextBytes {
text = text[:maxTextBytes]
// Nicht mitten in einem Zeichen abschneiden – das ergäbe ungültiges
// UTF-8, und Postgres lehnte den ganzen Text ab.
for len(text) > 0 && !utf8.ValidString(text) {
text = text[:len(text)-1]
}
}
return Result{Text: text, Source: source, Lang: lang, Pages: pages, Chars: chars}
}
+86
View File
@@ -0,0 +1,86 @@
package documents
import (
"net/http"
"github.com/google/uuid"
"aether/pkg/response"
)
// Handler zeigt den Stand der Texterkennung.
//
// Ohne diese Auskunft ist nicht zu erkennen, ob der Volltext funktioniert:
// Eine Suche, die nichts findet, sieht genauso aus wie eine Suche in einem
// Bestand, der noch gar nicht erkannt wurde. Nach dem ersten Start läuft der
// Arbeiter je nach Menge Stunden – ohne Zähler weiß niemand, ob er
// vorankommt.
type Handler struct {
store *Store
// isAdmin und userID trennen die Rechteprüfung von diesem Paket.
// documents kennt weder Sessions noch Rollen, und soll es auch nicht.
isAdmin func(r *http.Request) bool
userID func(r *http.Request) uuid.UUID
}
func NewHandler(store *Store, isAdmin func(r *http.Request) bool, userID func(r *http.Request) uuid.UUID) *Handler {
return &Handler{store: store, isAdmin: isAdmin, userID: userID}
}
// Status beantwortet GET /api/v1/admin/ocr – der Gesamtstand des Servers.
func (h *Handler) Status(w http.ResponseWriter, r *http.Request) {
if !h.isAdmin(r) {
response.Error(w, http.StatusForbidden, "admin required")
return
}
// h.store nil heißt: OCR ist abgeschaltet oder die Programme fehlen. Das
// ist eine Auskunft, kein Fehler – die Oberfläche soll „aus" anzeigen
// können, statt in einen Fehlerzustand zu laufen.
if h == nil || h.store == nil {
response.JSON(w, http.StatusOK, map[string]any{"enabled": false})
return
}
stats, err := h.store.Stats(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "ocr status failed")
return
}
response.JSON(w, http.StatusOK, map[string]any{
"enabled": true,
"pending": stats.Pending,
"running": stats.Running,
"done": stats.Done,
"failed": stats.Failed,
"indexed": stats.Indexed,
})
}
// MyStatus beantwortet GET /api/v1/ocr – der Stand der **eigenen** Dateien.
//
// Ohne diesen Endpunkt wüsste nur der Administrator, ob ein Dokument schon
// gelesen wurde. Das ist die falsche Person: Wissen muss es, wer die Rechnung
// hochgeladen hat und sie gerade nicht findet.
func (h *Handler) MyStatus(w http.ResponseWriter, r *http.Request) {
uid := h.userID(r)
if uid == uuid.Nil {
response.Error(w, http.StatusUnauthorized, "not signed in")
return
}
if h == nil || h.store == nil {
response.JSON(w, http.StatusOK, map[string]any{"enabled": false})
return
}
stats, err := h.store.StatsForUser(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "ocr status failed")
return
}
response.JSON(w, http.StatusOK, map[string]any{
"enabled": true,
"pending": stats.Pending,
"running": stats.Running,
"done": stats.Done,
"failed": stats.Failed,
"indexed": stats.Indexed,
})
}
+315
View File
@@ -0,0 +1,315 @@
package documents
import (
"context"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ExtractVersion ist die Fassung der Textaufbereitung.
//
// Sie steht an jedem Eintrag in file_text. Wird die Aufbereitung geändert, so
// dass alte Einträge nicht mehr taugen, wird die Zahl erhöht – beim Start
// werden sie dann erneut erkannt, statt still nach der alten Regel liegen zu
// bleiben. Ohne so eine Marke fällt der Unterschied nie auf: Der Text ist ja
// da, er kann nur weniger.
//
// 1 – Seitenumbruch (\f) bleibt im Text stehen (23.08.2026)
const ExtractVersion = 1
// ErrNoJob heißt: gerade nichts zu tun. Kein Fehler, sondern der Normalfall.
var ErrNoJob = errors.New("no pending job")
// maxAttempts begrenzt, wie oft ein Auftrag wiederholt wird.
//
// Zwei Wiederholungen fangen das ab, was vorübergehend war – ein Neustart
// mitten im Lauf, kurzzeitig fehlender Speicherplatz. Was dreimal scheitert,
// scheitert an der Datei selbst (kaputtes PDF, passwortgeschützt), und ein
// vierter Versuch verbrennt nur Rechenzeit. Der Auftrag bleibt dann als
// 'failed' mit Fehlertext stehen, statt still zu verschwinden.
const maxAttempts = 3
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) *Store {
return &Store{db: db}
}
// Job ist ein Auftrag samt allem, was der Arbeiter über die Datei braucht.
type Job struct {
ID uuid.UUID
FileID uuid.UUID
Attempts int
Name string
MimeType string
StorageKey string
Size int64
}
// Enqueue trägt eine Datei zur Texterkennung ein.
//
// ON CONFLICT statt eines zweiten Eintrags: Wird dieselbe Datei erneut
// hochgeladen, ist der alte Text hinfällig – der Auftrag wird auf 'pending'
// zurückgesetzt, statt sich daneben zu stapeln. Der Zähler geht dabei auf 0,
// weil eine neue Fassung eine neue Chance verdient: Vielleicht war es genau
// der kaputte Scan, der ersetzt wurde.
func (s *Store) Enqueue(ctx context.Context, fileID uuid.UUID) error {
_, err := s.db.Exec(ctx, `
INSERT INTO ocr_jobs (file_id, status)
VALUES ($1, 'pending')
ON CONFLICT (file_id) DO UPDATE
SET status = 'pending', attempts = 0, last_error = '', updated_at = NOW()`,
fileID)
if err != nil {
return fmt.Errorf("enqueue ocr job: %w", err)
}
return nil
}
// Claim holt den ältesten offenen Auftrag und markiert ihn als laufend.
//
// FOR UPDATE SKIP LOCKED ist der Kern: Zwei Arbeiter greifen damit nie
// denselben Auftrag – der zweite überspringt die gesperrte Zeile, statt darauf
// zu warten. Heute läuft nur einer, aber die Sperre kostet nichts und erspart
// die Suche nach doppelt erkannten Dateien, sobald es zwei werden.
//
// 'running' wird mit übernommen, nicht nur 'pending': Ein Auftrag, der beim
// Neustart mittendrin war, steht sonst für immer auf 'running' und wird nie
// wieder angefasst. Der Zähler sorgt dafür, dass daraus keine Endlosschleife
// wird, falls die Datei den Server reproduzierbar umbringt.
func (s *Store) Claim(ctx context.Context) (*Job, error) {
var j Job
err := s.db.QueryRow(ctx, `
UPDATE ocr_jobs
SET status = 'running', attempts = attempts + 1, updated_at = NOW()
WHERE id = (
SELECT j.id
FROM ocr_jobs j
JOIN files f ON f.id = j.file_id AND f.is_deleted = false
WHERE j.status IN ('pending', 'running')
AND j.attempts < $1
ORDER BY j.created_at
FOR UPDATE SKIP LOCKED
LIMIT 1)
RETURNING id, file_id, attempts,
(SELECT name FROM files WHERE id = file_id),
(SELECT mime_type FROM files WHERE id = file_id),
(SELECT storage_key FROM files WHERE id = file_id),
(SELECT size FROM files WHERE id = file_id)`,
maxAttempts,
).Scan(&j.ID, &j.FileID, &j.Attempts, &j.Name, &j.MimeType, &j.StorageKey, &j.Size)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNoJob
}
if err != nil {
return nil, fmt.Errorf("claim ocr job: %w", err)
}
return &j, nil
}
// Complete speichert den Text und schließt den Auftrag ab. Beides in einer
// Transaktion: Ein gespeicherter Text ohne erledigten Auftrag würde beim
// nächsten Durchgang erneut erkannt, ein erledigter Auftrag ohne Text ließe die
// Datei unauffindbar zurück.
func (s *Store) Complete(ctx context.Context, job *Job, res Result) error {
tx, err := s.db.Begin(ctx)
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `
INSERT INTO file_text (file_id, content, source, lang, chars, pages, extract_version)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (file_id) DO UPDATE
SET content = EXCLUDED.content, source = EXCLUDED.source,
lang = EXCLUDED.lang, chars = EXCLUDED.chars,
pages = EXCLUDED.pages, extract_version = EXCLUDED.extract_version,
created_at = NOW()`,
job.FileID, res.Text, res.Source, res.Lang, res.Chars, res.Pages, ExtractVersion,
); err != nil {
return fmt.Errorf("save text: %w", err)
}
if _, err := tx.Exec(ctx,
`UPDATE ocr_jobs SET status = 'done', last_error = '', updated_at = NOW() WHERE id = $1`,
job.ID,
); err != nil {
return fmt.Errorf("finish job: %w", err)
}
return tx.Commit(ctx)
}
// Fail vermerkt den Fehler. Ist der Zähler ausgeschöpft, bleibt der Auftrag als
// 'failed' liegen; sonst geht er zurück in die Schlange.
func (s *Store) Fail(ctx context.Context, job *Job, cause error) error {
msg := cause.Error()
if len(msg) > 500 {
msg = msg[:500] + "…"
}
status := "pending"
if job.Attempts >= maxAttempts {
status = "failed"
}
_, err := s.db.Exec(ctx,
`UPDATE ocr_jobs SET status = $2, last_error = $3, updated_at = NOW() WHERE id = $1`,
job.ID, status, msg)
return err
}
// Skip schließt einen Auftrag ohne Text ab – etwa wenn die Datei inzwischen
// gelöscht wurde. 'done' statt 'failed': Es ist nichts schiefgegangen.
func (s *Store) Skip(ctx context.Context, jobID uuid.UUID, reason string) error {
_, err := s.db.Exec(ctx,
`UPDATE ocr_jobs SET status = 'done', last_error = $2, updated_at = NOW() WHERE id = $1`,
jobID, reason)
return err
}
// Stats zählt die Aufträge je Zustand – für die Anzeige im Admin-Bereich und
// für die Prüfung, ob der Arbeiter überhaupt vorankommt.
type Stats struct {
Pending int `json:"pending"`
Running int `json:"running"`
Done int `json:"done"`
Failed int `json:"failed"`
// Indexed ist die Zahl der Dateien mit gespeichertem Text.
Indexed int `json:"indexed"`
}
func (s *Store) Stats(ctx context.Context) (Stats, error) {
var st Stats
err := s.db.QueryRow(ctx, `
SELECT COUNT(*) FILTER (WHERE status = 'pending'),
COUNT(*) FILTER (WHERE status = 'running'),
COUNT(*) FILTER (WHERE status = 'done'),
COUNT(*) FILTER (WHERE status = 'failed'),
(SELECT COUNT(*) FROM file_text)
FROM ocr_jobs`,
).Scan(&st.Pending, &st.Running, &st.Done, &st.Failed, &st.Indexed)
if err != nil {
return st, fmt.Errorf("ocr stats: %w", err)
}
return st, nil
}
// StatsForUser zählt nur, was diesem Nutzer gehört.
//
// Warum es das getrennt von Stats gibt: Den Gesamtstand darf nur der
// Administrator sehen – die Zahl aller Dokumente auf dem Server verrät etwas
// über fremde Bestände. Wissen muss es aber genau der, der die Rechnung
// hochgeladen hat: Findet seine Suche sie nicht, ist die Frage „ist sie schon
// gelesen?" die erste, die er stellt.
//
// Gezählt wird über owner_id, also die eigenen Dateien. Dateien in
// Gruppenordnern bleiben außen vor – sie gehören der Gruppe, und ihr Stand
// wäre für den Einzelnen keine Auskunft über den eigenen Bestand.
func (s *Store) StatsForUser(ctx context.Context, userID uuid.UUID) (Stats, error) {
var st Stats
err := s.db.QueryRow(ctx, `
SELECT COUNT(*) FILTER (WHERE j.status = 'pending'),
COUNT(*) FILTER (WHERE j.status = 'running'),
COUNT(*) FILTER (WHERE j.status = 'done'),
COUNT(*) FILTER (WHERE j.status = 'failed'),
COUNT(*) FILTER (WHERE t.file_id IS NOT NULL)
FROM files f
JOIN ocr_jobs j ON j.file_id = f.id
LEFT JOIN file_text t ON t.file_id = f.id
WHERE f.owner_id = $1 AND f.is_deleted = false`,
userID,
).Scan(&st.Pending, &st.Running, &st.Done, &st.Failed, &st.Indexed)
if err != nil {
return st, fmt.Errorf("ocr stats for user: %w", err)
}
return st, nil
}
// EnqueueMissing trägt alle Bestandsdateien nach, die noch keinen Auftrag
// haben. Ohne das bliebe alles unauffindbar, was vor dem Einbau hochgeladen
// wurde – und das ist beim ersten Start genau der gesamte Bestand.
//
// Die Auswahl der Typen passiert hier in SQL statt über Supported(): Bei
// zehntausend Dateien wäre es Unfug, jede einzelne nach Go zu holen, nur um
// ihren MIME-Typ anzusehen.
func (s *Store) EnqueueMissing(ctx context.Context, limit int) (int, error) {
tag, err := s.db.Exec(ctx, `
INSERT INTO ocr_jobs (file_id, status)
SELECT f.id, 'pending'
FROM files f
WHERE f.is_deleted = false
AND (f.mime_type ILIKE '%pdf%'
OR (f.mime_type ILIKE 'image/%' AND f.mime_type NOT ILIKE '%svg%')
OR f.name ~* '\.(pdf|png|jpe?g|tiff?|bmp|webp)$')
AND NOT EXISTS (SELECT 1 FROM ocr_jobs j WHERE j.file_id = f.id)
ORDER BY f.created_at DESC
LIMIT $1
ON CONFLICT (file_id) DO NOTHING`, limit)
if err != nil {
return 0, fmt.Errorf("enqueue missing: %w", err)
}
return int(tag.RowsAffected()), nil
}
// EnqueueOutdated legt Einträge erneut in die Warteschlange, die nach einer
// älteren Regel entstanden sind.
//
// Anlass war die Seitenmarke: Bis Fassung 1 wurde der Seitenumbruch aus dem
// Text entfernt. Wer damit indiziert wurde, ist zwar auffindbar – aber der
// Betrachter kann bei einem Scan nicht sagen, auf welcher Seite das Wort
// steht. Ein stiller Rest, der sich nur durch erneutes Erkennen auflöst.
//
// Bewusst mit derselben Obergrenze wie EnqueueMissing: Bei einem großen
// Bestand soll ein Neustart nicht die ganze Platte an OCR verheizen, sondern
// den Rückstand über mehrere Starte abtragen.
func (s *Store) EnqueueOutdated(ctx context.Context, limit int) (int, error) {
tag, err := s.db.Exec(ctx, `
INSERT INTO ocr_jobs (file_id, status)
SELECT t.file_id, 'pending'
FROM file_text t
JOIN files f ON f.id = t.file_id AND f.is_deleted = false
WHERE t.extract_version < $2
LIMIT $1
ON CONFLICT (file_id) DO UPDATE
SET status = 'pending', attempts = 0, last_error = '', updated_at = NOW()`,
limit, ExtractVersion)
if err != nil {
return 0, fmt.Errorf("enqueue outdated: %w", err)
}
return int(tag.RowsAffected()), nil
}
// TextPages liefert den erkannten Text einer Datei, in Seiten zerlegt.
//
// Für den PDF-Betrachter: Bei einem Scan steht im PDF selbst kein Wort, nur
// ein Bild. Ohne diesen Weg fände die Suche im Dokument nichts – ausgerechnet
// bei dem Dateityp, für den der erkannte Text überhaupt gebaut wurde.
//
// Die Zugriffsprüfung passiert im Aufrufer; hier liegt nur die Abfrage.
func (s *Store) TextPages(ctx context.Context, fileID uuid.UUID) ([]string, string, error) {
var content, source string
err := s.db.QueryRow(ctx,
`SELECT content, source FROM file_text WHERE file_id = $1`, fileID,
).Scan(&content, &source)
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", nil
}
if err != nil {
return nil, "", fmt.Errorf("text pages: %w", err)
}
return strings.Split(content, "\f"), source, nil
}
// DeleteText entfernt den Text einer Datei. Wird beim endgültigen Löschen
// gebraucht; solange die Datei nur im Papierkorb liegt, bleibt er stehen –
// beim Wiederherstellen wäre er sonst weg.
func (s *Store) DeleteText(ctx context.Context, fileID uuid.UUID) error {
_, err := s.db.Exec(ctx, `DELETE FROM file_text WHERE file_id = $1`, fileID)
return err
}
+239
View File
@@ -0,0 +1,239 @@
package documents
import (
"context"
"errors"
"fmt"
"io"
"os"
"time"
"github.com/rs/zerolog"
"aether/internal/storage"
)
// idleInterval ist die Pause, wenn nichts zu tun ist.
//
// Abfragen statt Benachrichtigen (LISTEN/NOTIFY) ist hier die richtige Wahl:
// Eine Abfrage auf einen Teilindex kostet nichts, und der Arbeiter muss ohnehin
// regelmäßig nachsehen – ein Auftrag kann auch dadurch entstehen, dass der
// Server beim letzten Mal mittendrin ausgefallen ist. Fünf Sekunden Verzug
// spielen bei einer Aufgabe, die selbst Sekunden bis Minuten dauert, keine
// Rolle.
const idleInterval = 5 * time.Second
// busyInterval: Wurde gerade etwas erledigt, sofort weitermachen. Eine kurze
// Pause bleibt trotzdem, damit ein Stapel von hundert Scans nicht die ganze
// Maschine belegt und die Cloud daneben zäh wird.
const busyInterval = 200 * time.Millisecond
// maxFileBytes: Größeres wird übersprungen. Der Arbeiter muss die Datei in eine
// temporäre Kopie schreiben, bevor die Werkzeuge sie lesen können; ein
// 4-GB-Video würde dabei die Platte füllen – und Text ist darin ohnehin keiner.
const maxFileBytes = 512 << 20 // 512 MiB
// Worker arbeitet die Warteschlange ab. Einer genügt: OCR lastet einen Kern
// voll aus, und mehrere parallel machen auf einem Heimserver die Oberfläche
// zäh, ohne insgesamt schneller fertig zu sein.
type Worker struct {
store *Store
blobs storage.Storage
extract *Extractor
log zerolog.Logger
// paused merkt sich, ob wegen Platzmangels gerade nichts läuft – nur damit
// die Warnung einmal im Log steht und nicht alle fünf Sekunden.
paused bool
}
func NewWorker(store *Store, blobs storage.Storage, ex *Extractor, log zerolog.Logger) *Worker {
return &Worker{store: store, blobs: blobs, extract: ex, log: log.With().Str("component", "ocr").Logger()}
}
// Run läuft, bis der Kontext endet. Gedacht für `go worker.Run(ctx)` beim Start.
func (w *Worker) Run(ctx context.Context) {
w.log.Info().
Str("languages", w.extract.Languages).
Int("max_pages", w.extract.MaxPages).
Msg("OCR-Arbeiter gestartet")
// Angefangene Dateien eines hart beendeten Laufs wegräumen (siehe
// sweepTemp). Einmal beim Start genügt: Während des Betriebs räumt der
// Arbeiter selbst auf.
if n, freed := sweepTemp(w.extract.TmpDir); n > 0 {
w.log.Info().Int("files", n).Int64("bytes", freed).
Msg("Zwischendateien eines früheren Laufs entfernt")
}
for {
wait := idleInterval
if w.step(ctx) {
wait = busyInterval
}
select {
case <-ctx.Done():
w.log.Info().Msg("OCR-Arbeiter beendet")
return
case <-time.After(wait):
}
}
}
// step erledigt höchstens einen Auftrag. Rückgabe: ob etwas zu tun war.
func (w *Worker) step(ctx context.Context) bool {
// Vor dem Zugreifen nachsehen, nicht danach: Ein Auftrag, den wir schon
// auf 'running' gesetzt haben, hätte einen Versuch verbraucht, obwohl es
// gar nicht an ihm lag.
if free, err := freeBytes(w.extract.TmpDir); err == nil && free < minFreeBytes {
// Nur einmal je Zustandswechsel melden – sonst füllt die Warnung im
// Fünf-Sekunden-Takt den Log, und das ausgerechnet, wenn der Platz
// knapp ist.
if !w.paused {
w.paused = true
w.log.Warn().
Uint64("free_mb", free/(1<<20)).
Uint64("needed_mb", uint64(minFreeBytes)/(1<<20)).
Msg("Texterkennung pausiert: zu wenig Platz auf der Platte")
}
return false
} else if w.paused {
w.paused = false
w.log.Info().Msg("Texterkennung fortgesetzt: wieder genug Platz")
}
job, err := w.store.Claim(ctx)
if errors.Is(err, ErrNoJob) {
return false
}
if err != nil {
// Datenbank weg oder Kontext beendet – nicht in einer engen Schleife
// weiterprobieren.
if ctx.Err() == nil {
w.log.Warn().Err(err).Msg("Auftrag holen fehlgeschlagen")
}
return false
}
start := time.Now()
res, err := w.process(ctx, job)
if err != nil {
if ctx.Err() != nil {
// Herunterfahren mitten im Lauf ist kein Fehler der Datei. Der
// Auftrag steht auf 'running' und wird beim nächsten Start wieder
// aufgegriffen – genau dafür nimmt Claim auch 'running' mit.
return false
}
w.log.Warn().Err(err).
Str("file", job.Name).Int("attempt", job.Attempts).
Msg("Texterkennung fehlgeschlagen")
if err := w.store.Fail(context.WithoutCancel(ctx), job, err); err != nil {
w.log.Error().Err(err).Msg("Fehlschlag konnte nicht vermerkt werden")
}
return true
}
if err := w.store.Complete(ctx, job, res); err != nil {
w.log.Error().Err(err).Str("file", job.Name).Msg("Text konnte nicht gespeichert werden")
return true
}
w.log.Info().
Str("file", job.Name).
Str("source", res.Source).
Int("chars", res.Chars).
Int("pages", res.Pages).
Dur("took", time.Since(start)).
Msg("Text erkannt")
return true
}
func (w *Worker) process(ctx context.Context, job *Job) (Result, error) {
if job.Size > maxFileBytes {
return Result{}, fmt.Errorf("Datei zu groß für die Texterkennung (%d Bytes)", job.Size)
}
if !Supported(job.MimeType, job.Name) {
// Kann vorkommen, wenn der MIME-Typ nachträglich korrigiert wurde.
// Kein Fehler – nur nichts zu tun.
return Result{}, w.store.Skip(ctx, job.ID, "Dateityp trägt keinen Text")
}
// Die Werkzeuge sind externe Programme und brauchen einen Pfad; die
// Ablageschicht gibt grundsätzlich nur einen Datenstrom heraus, weil sie
// später auch S3 bedienen soll.
//
// Bei der lokalen Ablage gibt es den Pfad aber wirklich – dann wird
// **nicht** kopiert. Das ist kein Feinschliff: Eine Kopie bedeutet, dass
// neben jedem Dokument kurzzeitig noch einmal dieselbe Menge auf der
// Platte liegt, und die Erkennung läuft ausgerechnet auf einer Maschine,
// die auch alle Dateien beherbergt.
path, cleanup, err := w.materialize(ctx, job)
if err != nil {
return Result{}, err
}
defer cleanup()
return w.extract.Extract(ctx, path, job.MimeType, job.Name)
}
// materialize besorgt einen Pfad zur Datei – ohne Kopie, wo es geht.
//
// Der zurückgegebene cleanup räumt nur auf, wenn tatsächlich kopiert wurde. Er
// darf das Original niemals löschen; deshalb die klare Trennung der beiden
// Zweige statt eines gemeinsamen Pfads mit einem Merker.
func (w *Worker) materialize(ctx context.Context, job *Job) (string, func(), error) {
if lp, ok := w.blobs.(storage.LocalPather); ok {
path, err := lp.LocalPath(job.StorageKey)
if err == nil {
// Nichts zu räumen: Das ist die Datei selbst. Gelesen wird sie nur.
return path, func() {}, nil
}
// Kein Abbruch – der Weg über die Kopie funktioniert weiterhin.
w.log.Debug().Err(err).Str("key", job.StorageKey).
Msg("kein lokaler Pfad, weiche auf Kopie aus")
}
rc, err := w.blobs.Get(ctx, job.StorageKey)
if err != nil {
return "", nil, fmt.Errorf("Datei lesen: %w", err)
}
defer rc.Close()
// Die Endung mitnehmen: ocrmypdf und tesseract entscheiden auch danach,
// was sie vor sich haben.
pattern := "ocr-*" + extOf(job.Name)
var tmp *os.File
if tf, ok := w.blobs.(storage.TempFiler); ok {
// Über die Ablageschicht, damit die Kopie auf derselben Platte landet
// wie die Daten – nicht in einem knappen /tmp im Container.
tmp, err = tf.CreateTemp(pattern)
} else {
tmp, err = os.CreateTemp("", pattern)
}
if err != nil {
return "", nil, fmt.Errorf("Zwischendatei anlegen: %w", err)
}
name := tmp.Name()
cleanup := func() { os.Remove(name) }
if _, err := io.Copy(tmp, rc); err != nil {
tmp.Close()
cleanup()
return "", nil, fmt.Errorf("Zwischendatei schreiben: %w", err)
}
if err := tmp.Close(); err != nil {
cleanup()
return "", nil, fmt.Errorf("Zwischendatei schließen: %w", err)
}
return name, cleanup, nil
}
func extOf(name string) string {
for i := len(name) - 1; i >= 0 && len(name)-i <= 6; i-- {
if name[i] == '.' {
return name[i:]
}
if name[i] == '/' {
break
}
}
return ""
}
+63
View File
@@ -0,0 +1,63 @@
package files
import (
"errors"
"net/http"
"github.com/google/uuid"
"aether/internal/acl"
"aether/internal/auth"
"aether/pkg/response"
)
// Zugriffsprüfung für Dateien und Ordner, die dem Anfragenden nicht selbst
// gehören müssen.
//
// Die Datenbankschicht arbeitet durchgehend eigentümerbezogen (`WHERE owner_id
// = $x`) – daran ändert sich nichts. Stattdessen klären diese Helfer vorab, in
// wessen Namen die Operation läuft: bei eigenen Daten der Anfragende selbst,
// bei geliehenen der Eigentümer. Danach sieht der Rest des Handlers aus wie
// vorher.
// resolveFile liefert den Zugriffskontext für eine Datei. Ist der Zugriff nicht
// erlaubt, ist die Antwort bereits geschrieben und der zweite Rückgabewert
// false – der Handler bricht dann einfach ab.
func (h *Handler) resolveFile(w http.ResponseWriter, r *http.Request, id uuid.UUID, needWrite bool) (acl.Access, bool) {
user := auth.UserFromContext(r.Context())
if h.acl == nil {
return acl.Access{OwnerID: user.ID, OwnerUsername: user.Username, OwnerQuota: user.QuotaBytes, Permission: acl.PermWrite}, true
}
a, err := h.acl.ResolveFile(r.Context(), user.ID, id)
return finish(w, a, err, needWrite, "file")
}
// resolveFolder ist dasselbe für Ordner. folderID darf nil sein – das ist die
// eigene Wurzel, die niemandem sonst gehört.
func (h *Handler) resolveFolder(w http.ResponseWriter, r *http.Request, id *uuid.UUID, needWrite bool) (acl.Access, bool) {
user := auth.UserFromContext(r.Context())
own := acl.Access{OwnerID: user.ID, OwnerUsername: user.Username, OwnerQuota: user.QuotaBytes, Permission: acl.PermWrite}
if h.acl == nil || id == nil {
return own, true
}
a, err := h.acl.ResolveFolder(r.Context(), user.ID, *id)
return finish(w, a, err, needWrite, "folder")
}
func finish(w http.ResponseWriter, a acl.Access, err error, needWrite bool, kind string) (acl.Access, bool) {
switch {
case errors.Is(err, acl.ErrNotFound), errors.Is(err, acl.ErrNoAccess):
// Absichtlich 404 statt 403: Ein „verboten" verriete, dass es die ID
// gibt. Wer keinen Zugriff hat, soll nicht einmal das erfahren.
response.Error(w, http.StatusNotFound, kind+" not found")
return acl.Access{}, false
case err != nil:
response.Error(w, http.StatusInternalServerError, "database error")
return acl.Access{}, false
}
if needWrite && !a.CanWrite() {
response.Error(w, http.StatusForbidden, "you only have read access to this "+kind)
return acl.Access{}, false
}
return a, true
}
+165
View File
@@ -0,0 +1,165 @@
package files
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Gruppenordner: Ordner, die keinem Konto gehören, sondern einer Gruppe.
// Sichtbar für jedes Mitglied, angelegt und entfernt ausschließlich vom Admin.
// ErrGroupRootProtected: Der Wurzelordner einer Gruppe lässt sich nicht aus der
// Dateiansicht heraus löschen oder umbenennen. Er ist kein gewöhnlicher Ordner,
// sondern der Zugang der Gruppe – wenn ein beliebiges Mitglied ihn entfernen
// könnte, wären die Daten aller anderen mit weg. Verwaltet wird er im
// Admin-Bereich; alles *innerhalb* bleibt normal löschbar.
var ErrGroupRootProtected = errors.New("group folder can only be removed by an administrator")
// ErrGroupFolderExists: Eine Gruppe hat genau einen Ordner, nicht mehrere.
// Das hält das Modell verständlich („der Ordner der Gruppe") und erlaubt es,
// beim Löschen den gesamten Speicherbereich der Gruppe in einem Zug zu
// entfernen, statt Blob für Blob – wodurch keine leeren Verzeichnisse
// zurückbleiben. Wer mehr Struktur braucht, legt Unterordner an.
var ErrGroupFolderExists = errors.New("this group already has a folder")
// GroupFolder ist ein Gruppenordner samt Gruppe – für die Admin-Übersicht.
type GroupFolder struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
GroupID uuid.UUID `json:"group_id"`
GroupName string `json:"group_name"`
Members int `json:"members"`
UsedBytes int64 `json:"used_bytes"`
QuotaByte int64 `json:"quota_bytes"`
}
// IsGroupRoot sagt, ob ein Ordner der Wurzelordner einer Gruppe ist.
func (s *Store) IsGroupRoot(ctx context.Context, folderID uuid.UUID) (bool, error) {
var isRoot bool
err := s.db.QueryRow(ctx,
`SELECT group_id IS NOT NULL AND parent_id IS NULL FROM folders WHERE id = $1`,
folderID).Scan(&isRoot)
if errors.Is(err, pgx.ErrNoRows) {
return false, ErrNotFound
}
if err != nil {
return false, fmt.Errorf("check group root: %w", err)
}
return isRoot, nil
}
// GroupName liefert den Namen einer Gruppe – für die Beschriftung des
// Gruppenordners in WebDAV.
func (s *Store) GroupName(ctx context.Context, groupID uuid.UUID) (string, error) {
var name string
err := s.db.QueryRow(ctx, `SELECT name FROM groups WHERE id = $1`, groupID).Scan(&name)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("group name: %w", err)
}
return name, nil
}
// ListGroupFoldersForUser liefert die Gruppenordner, die dem Nutzer über seine
// Mitgliedschaften zustehen. Sie erscheinen in seiner Wurzel neben den eigenen
// Ordnern – ohne dass er etwas dafür tun muss.
func (s *Store) ListGroupFoldersForUser(ctx context.Context, userID uuid.UUID) ([]*Folder, error) {
rows, err := s.db.Query(ctx,
`SELECT f.id, f.owner_id, f.group_id, f.parent_id, f.name, f.created_at
FROM folders f
JOIN group_members m ON m.group_id = f.group_id
WHERE m.user_id = $1 AND f.parent_id IS NULL
ORDER BY f.name`, userID)
if err != nil {
return nil, fmt.Errorf("list group folders: %w", err)
}
defer rows.Close()
out := []*Folder{}
for rows.Next() {
var f Folder
if err := rows.Scan(&f.ID, &f.OwnerID, &f.GroupID, &f.ParentID, &f.Name, &f.CreatedAt); err != nil {
return nil, fmt.Errorf("scan group folder: %w", err)
}
out = append(out, &f)
}
return out, rows.Err()
}
// ListGroupFolders liefert alle Gruppenordner für den Admin-Bereich.
func (s *Store) ListGroupFolders(ctx context.Context) ([]*GroupFolder, error) {
rows, err := s.db.Query(ctx, `
SELECT f.id, f.name, g.id, g.name, g.quota_bytes,
(SELECT count(*) FROM group_members m WHERE m.group_id = g.id),
COALESCE((SELECT SUM(x.size) FROM files x
WHERE x.group_id = g.id AND x.is_deleted = false), 0)
FROM folders f
JOIN groups g ON g.id = f.group_id
WHERE f.parent_id IS NULL
ORDER BY g.name, f.name`)
if err != nil {
return nil, fmt.Errorf("list group folders: %w", err)
}
defer rows.Close()
out := []*GroupFolder{}
for rows.Next() {
var gf GroupFolder
if err := rows.Scan(&gf.ID, &gf.Name, &gf.GroupID, &gf.GroupName,
&gf.QuotaByte, &gf.Members, &gf.UsedBytes); err != nil {
return nil, fmt.Errorf("scan group folder: %w", err)
}
out = append(out, &gf)
}
return out, rows.Err()
}
// CreateGroupFolder legt den Wurzelordner einer Gruppe an.
func (s *Store) CreateGroupFolder(ctx context.Context, groupID uuid.UUID, name string) (*Folder, error) {
var exists bool
if err := s.db.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM folders WHERE group_id = $1 AND parent_id IS NULL)`,
groupID).Scan(&exists); err != nil {
return nil, fmt.Errorf("check existing group folder: %w", err)
}
if exists {
return nil, ErrGroupFolderExists
}
return s.CreateFolder(ctx, &Folder{GroupID: &groupID, Name: name})
}
// DeleteGroupFolderTree entfernt einen Gruppenordner mit allem darin und gibt
// die Gruppen-ID zurück, damit der Aufrufer deren Speicherbereich am Stück
// entfernen kann. Nur für den Admin-Bereich gedacht.
func (s *Store) DeleteGroupFolderTree(ctx context.Context, folderID uuid.UUID) (uuid.UUID, error) {
var groupID *uuid.UUID
var parentID *uuid.UUID
err := s.db.QueryRow(ctx,
`SELECT group_id, parent_id FROM folders WHERE id = $1`, folderID).Scan(&groupID, &parentID)
if errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, ErrNotFound
}
if err != nil {
return uuid.Nil, fmt.Errorf("load group folder: %w", err)
}
if groupID == nil || parentID != nil {
return uuid.Nil, ErrNotFound
}
// Dateien zuerst: Die Kaskade über folders nimmt nur mit, was unter dem
// Ordner hängt. Dateien direkt in der Wurzel hätten sonst keinen
// Elternteil mehr, der sie entfernt.
if _, err := s.db.Exec(ctx, `DELETE FROM files WHERE group_id = $1`, *groupID); err != nil {
return uuid.Nil, fmt.Errorf("delete group files: %w", err)
}
if _, err := s.db.Exec(ctx, `DELETE FROM folders WHERE group_id = $1`, *groupID); err != nil {
return uuid.Nil, fmt.Errorf("delete group folders: %w", err)
}
return *groupID, nil
}
+781
View File
@@ -0,0 +1,781 @@
package files
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"mime"
"net/http"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"aether/internal/acl"
"aether/internal/audit"
"aether/internal/auth"
"aether/internal/documents"
"aether/internal/storage"
"aether/internal/tags"
"aether/pkg/middleware"
"aether/pkg/response"
)
const maxUploadSize = 5 << 30 // 5 GB
// maxVersionsKept begrenzt den Verlauf einer Datei mit aktivierter
// Versionierung. Ältere Fassungen werden samt Blob entfernt.
const maxVersionsKept = 5
type Handler struct {
store *Store
storage storage.Storage
auditor *audit.Logger
acl *acl.Store
// tags darf nil sein – die Dateiliste funktioniert dann ohne Aufkleber.
tags *tags.Store
// docs darf nil sein – dann ist die Texterkennung abgeschaltet und der
// PDF-Betrachter sucht nur in dem Text, den das PDF selbst mitbringt.
docs *documents.Store
}
func NewHandler(store *Store, st storage.Storage, auditor *audit.Logger, aclStore *acl.Store, tagStore *tags.Store, docStore *documents.Store) *Handler {
return &Handler{store: store, storage: st, auditor: auditor, acl: aclStore, tags: tagStore, docs: docStore}
}
// logFile protokolliert Löschvorgänge. Normale Uploads und Downloads bleiben
// bewusst draußen: bei Dutzenden Dateien am Tag ersäuft sonst das, worauf es
// ankommt – nämlich was verschwunden ist.
func (h *Handler) logFile(r *http.Request, action, targetID, targetName, detail string) {
user := auth.UserFromContext(r.Context())
e := audit.Event{
Action: action,
TargetType: "file",
TargetID: targetID,
TargetName: targetName,
Detail: detail,
IP: middleware.RealIP(r),
}
if user != nil {
e.ActorID = &user.ID
e.ActorName = user.Username
}
h.auditor.Log(r.Context(), e)
}
// POST /api/v1/files
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
mr, err := r.MultipartReader()
if err != nil {
response.Error(w, http.StatusBadRequest, "expected multipart upload")
return
}
// The frontend sends folder_id BEFORE the file part, so we know the target
// folder by the time we reach the streamed file content.
var folderID *uuid.UUID
for {
part, err := mr.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
response.Error(w, http.StatusBadRequest, "failed to read upload")
return
}
switch part.FormName() {
case "folder_id":
buf, _ := io.ReadAll(io.LimitReader(part, 256))
part.Close()
fid := strings.TrimSpace(string(buf))
if fid == "" {
continue
}
id, err := uuid.Parse(fid)
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid folder_id")
return
}
folderID = &id
case "file":
// Erst hier steht der Zielordner fest – das Frontend schickt
// folder_id vor dem Dateiteil. Zugriff und Eigentümer werden
// deshalb an dieser Stelle geklärt, nicht früher.
owner, ok := h.resolveFolder(w, r, folderID, true)
if !ok {
part.Close()
return
}
filename := SanitizeFilename(part.FileName())
if filename == "" {
part.Close()
response.Error(w, http.StatusBadRequest, "file field required")
return
}
mimeType := detectMIME(filename, part.Header.Get("Content-Type"))
// Stream the part body straight into storage – no full-file buffering.
h.storeUploadedFile(w, r, owner, folderID, filename, mimeType, part)
part.Close()
return
default:
part.Close()
}
}
response.Error(w, http.StatusBadRequest, "file field required")
}
// storeUploadedFile streams content into storage and writes the file metadata.
// Handles versioning when a file of the same name already exists. It writes the
// HTTP response itself (success or error).
// storeUploadedFile schreibt im Namen von owner. Bei eigenen Uploads ist das
// der Anfragende selbst; in einem geliehenen Ordner der Eigentümer – die Datei
// landet in dessen Bereich und zählt gegen dessen Kontingent.
func (h *Handler) storeUploadedFile(w http.ResponseWriter, r *http.Request, owner acl.Access, folderID *uuid.UUID, filename, mimeType string, content io.Reader) {
// Build human-readable storage key mirroring the app folder structure.
folderPath, err := h.store.GetFolderPath(r.Context(), folderID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
storageKey := BuildStorageKey(owner.StorageRoot(), folderPath, filename)
// Versioning: if a file with the same name already exists, preserve old content.
existing, err := h.store.GetFileByName(r.Context(), owner.OwnerID, folderID, filename)
if err != nil && !errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if existing != nil {
// Die alte Fassung zählt nach dem Überschreiben nicht mehr zum Verbrauch.
remaining, unlimited, err := h.store.RemainingQuota(r.Context(), owner.OwnerID, owner.OwnerQuota, existing.Size)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
// Nur bei aktivierter Versionierung wird die alte Fassung aufgehoben.
// Sonst ersetzt Put sie atomar (Temp + Rename) und der Platz bleibt frei.
var versionKey string
if existing.VersioningEnabled {
versionKey = fmt.Sprintf("%s/.versions/%s", owner.StorageRoot(), uuid.New().String())
if err := h.storage.Move(r.Context(), existing.StorageKey, versionKey); err != nil {
response.Error(w, http.StatusInternalServerError, "storage error")
return
}
}
if !unlimited {
content = newQuotaReader(content, remaining)
}
checksum, size, err := h.storage.Put(r.Context(), storageKey, content)
if err != nil {
// Der Blob ist bereits verschoben – zurückrollen, sonst verliert der
// Nutzer die bestehende Datei an einen Upload, der nie ankam.
if versionKey != "" {
if mvErr := h.storage.Move(r.Context(), versionKey, existing.StorageKey); mvErr != nil {
log.Ctx(r.Context()).Error().Err(mvErr).
Str("file_id", existing.ID.String()).
Msg("rollback of version move failed – blob orphaned")
}
}
if errors.Is(err, ErrQuotaExceeded) {
response.Error(w, http.StatusRequestEntityTooLarge, "storage quota exceeded")
return
}
response.Error(w, http.StatusInternalServerError, "storage error")
return
}
if versionKey != "" {
if err := h.store.CreateVersion(r.Context(), existing.ID, existing.Version, existing.Size, existing.Checksum, versionKey); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
} else if existing.StorageKey != storageKey {
// Altbestand unter abweichendem Pfad (z.B. früherer WebDAV-Upload):
// Put hat ihn nicht ersetzt, er wäre sonst verwaist.
_ = h.storage.Delete(r.Context(), existing.StorageKey)
}
updated, err := h.store.UpdateFileContent(r.Context(), existing.ID, size, checksum, storageKey)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if oldKeys, err := h.store.PruneVersions(r.Context(), existing.ID, maxVersionsKept); err == nil {
for _, key := range oldKeys {
_ = h.storage.Delete(r.Context(), key)
}
}
_ = h.storage.Delete(r.Context(), "thumbs/"+existing.ID.String()+".jpg")
response.JSON(w, http.StatusCreated, updated)
return
}
remaining, unlimited, err := h.store.RemainingQuota(r.Context(), owner.OwnerID, owner.OwnerQuota, 0)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if !unlimited {
content = newQuotaReader(content, remaining)
}
checksum, size, err := h.storage.Put(r.Context(), storageKey, content)
if err != nil {
if errors.Is(err, ErrQuotaExceeded) {
response.Error(w, http.StatusRequestEntityTooLarge, "storage quota exceeded")
return
}
response.Error(w, http.StatusInternalServerError, "storage error")
return
}
created, err := h.store.CreateFile(r.Context(), &File{
OwnerID: owner.OwnerRef(),
GroupID: owner.GroupRef(),
FolderID: folderID,
Name: filename,
MimeType: mimeType,
Size: size,
Checksum: checksum,
StorageKey: storageKey,
})
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.JSON(w, http.StatusCreated, created)
}
// GET /api/v1/files
// Search GET /api/v1/search?q=…
//
// Der Umfang steckt in der Abfrage, nicht hier: Store.Search liefert nur, was
// der Anfragende ohnehin erreichen darf. Ein nachgelagerter Filter wäre die
// Variante, bei der man einen Fall vergisst.
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
query := strings.TrimSpace(r.URL.Query().Get("q"))
// Ein einzelnes Zeichen trifft fast alles und kostet nur – die Oberfläche
// fragt deshalb erst ab zwei Zeichen, und der Server hält sich daran.
if len([]rune(query)) < 2 {
response.JSON(w, http.StatusOK, map[string]any{"hits": []any{}, "query": query})
return
}
hits, err := h.store.Search(r.Context(), user.ID, query)
if err != nil {
log.Ctx(r.Context()).Error().Err(err).Msg("search failed")
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.JSON(w, http.StatusOK, map[string]any{
"hits": hits,
"query": query,
// truncated sagt der Oberfläche, dass sie „mehr als 100" anzeigen soll –
// sonst sieht eine abgeschnittene Liste wie eine vollständige aus.
"truncated": len(hits) >= SearchLimit,
})
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
var folderID *uuid.UUID
if fid := r.URL.Query().Get("folder_id"); fid != "" {
id, err := uuid.Parse(fid)
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid folder_id")
return
}
folderID = &id
}
owner, ok := h.resolveFolder(w, r, folderID, false)
if !ok {
return
}
files, err := h.store.ListFiles(r.Context(), owner.OwnerID, folderID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
folders, err := h.store.ListFolders(r.Context(), owner.OwnerID, folderID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
// In der Wurzel kommen die Gruppenordner dazu, in denen der Anfragende
// Mitglied ist. Sie gehören ihm nicht, erscheinen aber neben seinen eigenen
// Ordnern – das ist der Punkt an einem Gruppenordner.
if folderID == nil {
user := auth.UserFromContext(r.Context())
groupFolders, err := h.store.ListGroupFoldersForUser(r.Context(), user.ID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
folders = append(folders, groupFolders...)
}
if files == nil {
files = []*File{}
}
if folders == nil {
folders = []*Folder{}
}
// Etiketten zu allen Dateien in *einer* Abfrage nachladen, nicht je Datei.
// Bei fünfzig Kacheln wären das sonst fünfzig Rundreisen zur Datenbank, nur
// um ein paar Aufkleber zu zeichnen.
fileTags := map[string]any{}
fileCorrespondents := map[string]any{}
if h.tags != nil && len(files) > 0 {
ids := make([]uuid.UUID, 0, len(files))
for _, f := range files {
ids = append(ids, f.ID)
}
if m, err := h.tags.ForFiles(r.Context(), ids); err == nil {
for id, list := range m {
fileTags[id.String()] = list
}
}
// Der Absender kommt auf demselben Weg: eine Abfrage für alle Kacheln.
if m, err := h.tags.CorrespondentsForFiles(r.Context(), ids); err == nil {
for id, c := range m {
fileCorrespondents[id.String()] = c
}
}
// Ein Fehler hier ist kein Grund, die Dateiliste zu verweigern – dann
// fehlen eben die Aufkleber.
}
response.JSON(w, http.StatusOK, map[string]any{
"files": files,
"folders": folders,
// Nicht an der Datei selbst, damit File weiterhin genau das ist, was in
// der Tabelle steht – und damit jede andere Stelle, die Dateien
// ausliefert, nicht plötzlich leere Etikettenlisten mitschickt.
"tags": fileTags,
"correspondents": fileCorrespondents,
})
}
// GET /api/v1/files/{id}/download
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r, "id")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid file id")
return
}
owner, ok := h.resolveFile(w, r, id, false)
if !ok {
return
}
f, err := h.store.GetFile(r.Context(), id, owner.OwnerID)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "file not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
rc, err := h.storage.Get(r.Context(), f.StorageKey)
if err != nil {
response.Error(w, http.StatusInternalServerError, "storage error")
return
}
defer rc.Close()
// Für ältere Dateien steht in der Datenbank oft application/octet-stream:
// Bis zum 23.08.2026 kannte der Server die Endungen .mp3, .mp4 und .zip
// nicht (siehe extraMIMETypes). Statt 94 Zeilen nachträglich umzuschreiben,
// wird der Typ beim Ausliefern neu bestimmt, wenn der gespeicherte nichts
// aussagt – das wirkt sofort und rührt die Daten nicht an.
serveType := f.MimeType
if serveType == "" || serveType == "application/octet-stream" {
serveType = detectMIME(f.Name, "")
}
w.Header().Set("Content-Type", serveType)
w.Header().Set("X-Checksum-SHA256", f.Checksum)
// Ohne diesen Kopf hält der Browser die Antwort für unteilbar und gibt in
// der Abspielleiste von Video und Ton kein Springen frei – man kann dann
// nur von vorn bis hinten hören.
w.Header().Set("Accept-Ranges", "bytes")
// Die Vorschau braucht die Datei *im* Browser statt im Download-Ordner.
// Das ist die gefährliche Richtung: Eine hochgeladene HTML- oder SVG-Datei
// liefe im selben Ursprung wie die Anwendung und käme damit an die
// Sitzung. Deshalb inline nur für eine feste Liste harmloser Typen, und
// alles andere fällt still auf den Anhang zurück.
if r.URL.Query().Get("inline") == "1" && inlineSafe(serveType) {
w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename=%q`, f.Name))
// Kein Typ-Raten: Sonst könnte eine als Bild deklarierte Datei doch als
// HTML gedeutet werden.
w.Header().Set("X-Content-Type-Options", "nosniff")
// sandbox nimmt der Antwort ihren Ursprung – selbst wenn doch etwas
// Ausführbares durchkäme, hätte es keinen Zugriff auf Cookies.
// allow-scripts nur für PDF: Der eingebaute Betrachter von Chrome ist
// selbst ein Skript und zeigt sonst nichts an. Ohne allow-same-origin
// bleibt der Ursprung trotzdem fremd, die Sitzung also unerreichbar.
sandbox := "sandbox"
if strings.HasPrefix(serveType, "application/pdf") {
sandbox = "sandbox allow-scripts"
}
w.Header().Set("Content-Security-Policy",
sandbox+"; default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'; frame-ancestors 'self'")
// Die Vorschau zeigt die Datei in einem Rahmen der eigenen Anwendung.
// Die allgemeine Kopfzeile setzt DENY und verbietet damit auch das –
// hier also gezielt auf den eigenen Ursprung heruntergestuft. Fremde
// Seiten können die Datei weiterhin nicht einbetten.
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
} else {
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, f.Name))
}
// Springt der Abspieler mitten hinein, übernimmt ServeContent: Es
// beantwortet den Range-Kopf mit 206 und dem passenden Ausschnitt.
//
// Die Prüfsumme bleibt dabei außen vor – sie deckt die ganze Datei ab, und
// ein Ausschnitt lässt sich nicht dagegen halten. Beim vollständigen
// Abruf, also bei jedem echten Download, wird weiter geprüft.
if seeker, ok := rc.(io.ReadSeeker); ok && r.Header.Get("Range") != "" {
http.ServeContent(w, r, f.Name, f.UpdatedAt, seeker)
return
}
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
// Verify checksum while streaming.
h256 := sha256.New()
reader := io.TeeReader(rc, h256)
if _, err := io.Copy(w, reader); err != nil {
return // client disconnected
}
actual := hex.EncodeToString(h256.Sum(nil))
if actual != f.Checksum {
// Log corruption; headers already sent, can't change status.
// TODO: alert / observability hook
_ = actual
}
}
// inlineSafeTypes sind die Typen, die im Browser angezeigt werden dürfen.
// Bewusst eine Positivliste: Was nicht daraufsteht, wird heruntergeladen.
// Nicht drauf steht insbesondere SVG – eine SVG-Datei darf Skripte enthalten –
// und alles, was nach HTML aussieht.
var inlineSafeTypes = map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/gif": true,
"image/webp": true,
"image/bmp": true,
"application/pdf": true,
"text/plain": true,
"video/mp4": true,
"video/webm": true,
"audio/mpeg": true,
"audio/ogg": true,
"audio/wav": true,
}
func inlineSafe(mimeType string) bool {
// Der gespeicherte Typ kann Parameter tragen ("text/plain; charset=utf-8").
base := strings.TrimSpace(strings.Split(mimeType, ";")[0])
return inlineSafeTypes[strings.ToLower(base)]
}
// DELETE /api/v1/files/{id}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r, "id")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid file id")
return
}
owner, ok := h.resolveFile(w, r, id, true)
if !ok {
return
}
current, err := h.store.GetFile(r.Context(), id, owner.OwnerID)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "file not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
trashKey := fmt.Sprintf("%s/.trash/%s_%s", owner.StorageRoot(), id.String()[:8], current.Name)
if err := h.storage.Move(r.Context(), current.StorageKey, trashKey); err != nil {
response.Error(w, http.StatusInternalServerError, "storage error")
return
}
if err := h.store.SoftDeleteFile(r.Context(), id, owner.OwnerID, trashKey); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logFile(r, audit.ActionFileDelete, id.String(), current.Name, "moved to trash")
response.NoContent(w)
}
// PATCH /api/v1/files/{id}/rename
func (h *Handler) Rename(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r, "id")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid file id")
return
}
owner, ok := h.resolveFile(w, r, id, true)
if !ok {
return
}
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
response.Error(w, http.StatusBadRequest, "name required")
return
}
newName := SanitizeFilename(body.Name)
if newName == "" {
response.Error(w, http.StatusBadRequest, "name required")
return
}
current, err := h.store.GetFile(r.Context(), id, owner.OwnerID)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "file not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
folderPath, err := h.store.GetFolderPath(r.Context(), current.FolderID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
newStorageKey := BuildStorageKey(owner.StorageRoot(), folderPath, newName)
if err := h.storage.Move(r.Context(), current.StorageKey, newStorageKey); err != nil {
response.Error(w, http.StatusInternalServerError, "storage error")
return
}
f, err := h.store.RenameFile(r.Context(), id, owner.OwnerID, newName, newStorageKey)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "file not found")
return
}
if errors.Is(err, ErrNameTaken) {
response.Error(w, http.StatusConflict, "name already exists")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.JSON(w, http.StatusOK, f)
}
// PATCH /api/v1/files/{id}/move
func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r, "id")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid file id")
return
}
owner, ok := h.resolveFile(w, r, id, true)
if !ok {
return
}
var body struct {
FolderID *string `json:"folder_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid request body")
return
}
var folderID *uuid.UUID
if body.FolderID != nil && *body.FolderID != "" {
fid, err := uuid.Parse(*body.FolderID)
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid folder_id")
return
}
// Ziel muss demselben Eigentümer gehören. Ein Verschieben über
// Eigentumsgrenzen hinweg würde Bytes zwischen Kontingenten umbuchen
// und Freigaben unter der Hand umhängen – bewusst nicht erlaubt.
// Zum Übertragen kopiert man herunter und lädt wieder hoch.
if _, err := h.store.GetFolder(r.Context(), fid, owner.OwnerID); err != nil {
response.Error(w, http.StatusNotFound, "target folder not found in the same account")
return
}
folderID = &fid
}
current, err := h.store.GetFile(r.Context(), id, owner.OwnerID)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "file not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
newFolderPath, err := h.store.GetFolderPath(r.Context(), folderID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
newStorageKey := BuildStorageKey(owner.StorageRoot(), newFolderPath, current.Name)
if err := h.storage.Move(r.Context(), current.StorageKey, newStorageKey); err != nil {
response.Error(w, http.StatusInternalServerError, "storage error")
return
}
f, err := h.store.MoveFile(r.Context(), id, owner.OwnerID, folderID, newStorageKey)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "file not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.JSON(w, http.StatusOK, f)
}
// GET /api/v1/files/{id}/thumbnail
func (h *Handler) GetThumbnail(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r, "id")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid file id")
return
}
owner, ok := h.resolveFile(w, r, id, false)
if !ok {
return
}
f, err := h.store.GetFile(r.Context(), id, owner.OwnerID)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "file not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
thumbKey := "thumbs/" + f.ID.String() + ".jpg"
rc, err := h.storage.Get(r.Context(), thumbKey)
if err != nil {
http.NotFound(w, r)
return
}
defer rc.Close()
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(w, rc)
}
// ── Favorites ─────────────────────────────────────────────────────────────────
// ── helpers ───────────────────────────────────────────────────────────────────
func parseID(r *http.Request, param string) (uuid.UUID, error) {
return uuid.Parse(chi.URLParam(r, param))
}
// extraMIMETypes füllt eine Lücke des Alpine-Images: Go liest die
// Typtabelle aus /etc/mime.types, und die Datei existiert dort nicht. Die
// eingebaute Ersatztabelle kennt nur eine Handvoll Web-Typen – .mp3, .mp4 und
// .zip gehören nicht dazu.
//
// Sichtbare Folge vor dieser Ergänzung: Eine hochgeladene MP3 lag als
// application/octet-stream in der Datenbank, ließ sich deshalb nicht abspielen
// (die Vorschau entscheidet am Typ) und bekam das Symbol für „unbekannt".
//
// Eine Tabelle im Code statt eines Pakets im Image: Sie gilt überall gleich,
// auch wenn das Grundbild wechselt, und lässt sich testen.
var extraMIMETypes = map[string]string{
".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac",
".ogg": "audio/ogg", ".oga": "audio/ogg", ".opus": "audio/ogg",
".wav": "audio/wav", ".flac": "audio/flac",
".mp4": "video/mp4", ".m4v": "video/mp4", ".webm": "video/webm",
".mkv": "video/x-matroska", ".mov": "video/quicktime", ".avi": "video/x-msvideo",
".zip": "application/zip", ".tar": "application/x-tar",
".gz": "application/gzip", ".tgz": "application/gzip",
".bz2": "application/x-bzip2", ".xz": "application/x-xz",
".7z": "application/x-7z-compressed", ".rar": "application/vnd.rar",
".zst": "application/zstd", ".deb": "application/vnd.debian.binary-package",
".csv": "text/csv", ".md": "text/markdown", ".txt": "text/plain",
".log": "text/plain", ".yaml": "text/yaml", ".yml": "text/yaml",
".toml": "text/plain", ".ini": "text/plain", ".conf": "text/plain",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".odt": "application/vnd.oasis.opendocument.text",
".ods": "application/vnd.oasis.opendocument.spreadsheet",
".ppt": "application/vnd.ms-powerpoint",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".odp": "application/vnd.oasis.opendocument.presentation",
".rtf": "application/rtf",
}
func detectMIME(filename, contentType string) string {
if ext := strings.ToLower(filepath.Ext(filename)); ext != "" {
if t := mime.TypeByExtension(ext); t != "" {
return t
}
if t, ok := extraMIMETypes[ext]; ok {
return t
}
}
if contentType != "" && contentType != "application/octet-stream" {
return contentType
}
return "application/octet-stream"
}
+131
View File
@@ -0,0 +1,131 @@
package files
import (
"encoding/json"
"errors"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"net/http"
"github.com/google/uuid"
"aether/internal/auth"
"aether/pkg/response"
)
// Favoriten der Seitenleiste: anlegen, entfernen, sortieren, auf- und
// zuklappen. Der Zustand liegt serverseitig, damit er auf jedem Gerät gleich ist.
// GET /api/v1/favorites
func (h *Handler) ListFavorites(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
favs, err := h.store.ListFavorites(r.Context(), user.ID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if favs == nil {
favs = []*Favorite{}
}
response.JSON(w, http.StatusOK, favs)
}
// POST /api/v1/favorites
func (h *Handler) AddFavorite(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
var body struct {
FolderID string `json:"folder_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.FolderID == "" {
response.Error(w, http.StatusBadRequest, "folder_id required")
return
}
folderID, err := uuid.Parse(body.FolderID)
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid folder_id")
return
}
// Lesezugriff genügt: Ein Favorit ist ein Lesezeichen, keine Änderung.
// Die frühere Besitzprüfung schloss Gruppenordner und geteilte Ordner aus –
// sie gehören dem Anfragenden nicht, sind aber genau die, die man sich
// merken will.
if _, ok := h.resolveFolder(w, r, &folderID, false); !ok {
return
}
fav, err := h.store.AddFavorite(r.Context(), user.ID, folderID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.JSON(w, http.StatusCreated, fav)
}
// DELETE /api/v1/favorites/{folderId}
func (h *Handler) RemoveFavorite(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
folderID, err := parseID(r, "folderId")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid folder id")
return
}
if err := h.store.RemoveFavorite(r.Context(), user.ID, folderID); errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "favorite not found")
return
} else if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.NoContent(w)
}
// PATCH /api/v1/favorites/{folderId}/expand
func (h *Handler) SetFavoriteExpanded(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
folderID, err := parseID(r, "folderId")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid folder id")
return
}
var body struct {
Expanded bool `json:"expanded"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
if err := h.store.SetFavoriteExpanded(r.Context(), user.ID, folderID, body.Expanded); errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "favorite not found")
return
} else if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.NoContent(w)
}
// PATCH /api/v1/favorites/reorder
func (h *Handler) ReorderFavorites(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
var body struct {
FolderIDs []string `json:"folder_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
ids := make([]uuid.UUID, 0, len(body.FolderIDs))
for _, s := range body.FolderIDs {
id, err := uuid.Parse(s)
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid folder_id: "+s)
return
}
ids = append(ids, id)
}
if err := h.store.ReorderFavorites(r.Context(), user.ID, ids); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.NoContent(w)
}
+273
View File
@@ -0,0 +1,273 @@
package files
import (
"encoding/json"
"errors"
"fmt"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"net/http"
"strings"
"github.com/google/uuid"
"aether/pkg/response"
)
// Ordner anlegen, umbenennen, verschieben und löschen.
//
// Umbenennen und Verschieben ziehen die Blobs auf der Platte mit – der
// Speicherpfad soll lesbar bleiben und dem Ordnerbaum entsprechen.
// POST /api/v1/folders
func (h *Handler) CreateFolder(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
ParentID *string `json:"parent_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
response.Error(w, http.StatusBadRequest, "name required")
return
}
// Ordnernamen werden Teil des Storage-Pfads – gleiche Regeln wie für Dateien.
folderName := SanitizeFilename(body.Name)
if folderName == "" {
response.Error(w, http.StatusBadRequest, "name required")
return
}
// Ein Unterordner in einem geliehenen Ordner gehört dem Eigentümer des
// Elternordners, nicht dem, der ihn anlegt: Die Bytes liegen in dessen
// Bereich, und beim Entzug der Freigabe soll er nicht plötzlich fremde
// Ordner in seinem Baum haben.
var parentID *uuid.UUID
if body.ParentID != nil && *body.ParentID != "" {
pid, err := uuid.Parse(*body.ParentID)
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid parent_id")
return
}
parentID = &pid
}
owner, ok := h.resolveFolder(w, r, parentID, true)
if !ok {
return
}
f := &Folder{
OwnerID: owner.OwnerRef(),
GroupID: owner.GroupRef(),
Name: folderName,
ParentID: parentID,
}
created, err := h.store.CreateFolder(r.Context(), f)
if errors.Is(err, ErrNameTaken) {
response.Error(w, http.StatusConflict, "folder name already exists")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.JSON(w, http.StatusCreated, created)
}
// PATCH /api/v1/folders/{id}/rename
func (h *Handler) RenameFolder(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r, "id")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid folder id")
return
}
owner, ok := h.resolveFolder(w, r, &id, true)
if !ok {
return
}
// Der Wurzelordner einer Gruppe ist kein gewöhnlicher Ordner, sondern der
// Zugang der Gruppe. Verwaltet wird er im Admin-Bereich.
if isRoot, err := h.store.IsGroupRoot(r.Context(), id); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
} else if isRoot {
response.Error(w, http.StatusForbidden, ErrGroupRootProtected.Error())
return
}
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
response.Error(w, http.StatusBadRequest, "name required")
return
}
newName := SanitizeFilename(body.Name)
if newName == "" {
response.Error(w, http.StatusBadRequest, "name required")
return
}
// Collect files before renaming so we have the old storage keys.
filesToMove, err := h.store.ListFilesInSubtree(r.Context(), owner.OwnerID, id)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
// Rename the folder in DB first — GetFolderPath will now return the new path.
folder, err := h.store.RenameFolder(r.Context(), id, owner.OwnerID, newName)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "folder not found")
return
}
if errors.Is(err, ErrNameTaken) {
response.Error(w, http.StatusConflict, "name already exists")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
// Cascade: move each blob to the updated path.
ResyncBlobPaths(r.Context(), h.store, h.storage, owner.StorageRoot(), filesToMove)
response.JSON(w, http.StatusOK, folder)
}
// DELETE /api/v1/folders/{id}
func (h *Handler) DeleteFolder(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r, "id")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid folder id")
return
}
owner, ok := h.resolveFolder(w, r, &id, true)
if !ok {
return
}
// Der Wurzelordner einer Gruppe ist kein gewöhnlicher Ordner, sondern der
// Zugang der Gruppe. Verwaltet wird er im Admin-Bereich.
if isRoot, err := h.store.IsGroupRoot(r.Context(), id); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
} else if isRoot {
response.Error(w, http.StatusForbidden, ErrGroupRootProtected.Error())
return
}
_, err = h.store.GetFolder(r.Context(), id, owner.OwnerID)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "folder not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
// Move blobs to .trash/ before soft-deleting so the file system stays consistent.
filesToTrash, err := h.store.ListFilesInSubtree(r.Context(), owner.OwnerID, id)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
for _, f := range filesToTrash {
trashKey := fmt.Sprintf("%s/.trash/%s_%s", owner.StorageRoot(), f.ID.String()[:8], f.Name)
if err := h.storage.Move(r.Context(), f.StorageKey, trashKey); err != nil {
continue
}
_ = h.store.UpdateFileStorageKey(r.Context(), f.ID, trashKey)
}
if err := h.store.DeleteFolder(r.Context(), id, owner.OwnerID); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
w.WriteHeader(http.StatusNoContent)
}
// PATCH /api/v1/folders/{id}/move
func (h *Handler) MoveFolder(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r, "id")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid folder id")
return
}
owner, ok := h.resolveFolder(w, r, &id, true)
if !ok {
return
}
// Der Wurzelordner einer Gruppe ist kein gewöhnlicher Ordner, sondern der
// Zugang der Gruppe. Verwaltet wird er im Admin-Bereich.
if isRoot, err := h.store.IsGroupRoot(r.Context(), id); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
} else if isRoot {
response.Error(w, http.StatusForbidden, ErrGroupRootProtected.Error())
return
}
var body struct {
ParentID *uuid.UUID `json:"parent_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
// Reject self-move.
if body.ParentID != nil && *body.ParentID == id {
response.Error(w, http.StatusBadRequest, "cannot move folder into itself")
return
}
// Verify target folder exists and is owned by user.
if body.ParentID != nil {
if _, err := h.store.GetFolder(r.Context(), *body.ParentID, owner.OwnerID); err != nil {
response.Error(w, http.StatusNotFound, "target folder not found")
return
}
}
// Collect files before moving so we have the old storage keys.
filesToMove, err := h.store.ListFilesInSubtree(r.Context(), owner.OwnerID, id)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
folder, err := h.store.MoveFolder(r.Context(), id, owner.OwnerID, body.ParentID)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "folder not found")
return
}
if errors.Is(err, ErrCycle) {
response.Error(w, http.StatusBadRequest, "cannot move folder into its own subtree")
return
}
if errors.Is(err, ErrNameTaken) {
response.Error(w, http.StatusConflict, "name already exists in target folder")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
// Cascade blob moves to updated paths.
ResyncBlobPaths(r.Context(), h.store, h.storage, owner.StorageRoot(), filesToMove)
response.JSON(w, http.StatusOK, folder)
}
+50
View File
@@ -0,0 +1,50 @@
package files
import (
"net/http"
"aether/pkg/response"
)
// TextPages beantwortet GET /api/v1/files/{id}/text.
//
// **Wofür das da ist:** Der PDF-Betrachter im Browser sucht im Dokument – aber
// bei einem *Scan* steht im PDF kein einziges Wort, nur ein Bild. Der erkannte
// Text liegt daneben in der Datenbank. Ohne diesen Endpunkt fände die Suche im
// Dokument ausgerechnet bei dem Dateityp nichts, für den die Texterkennung
// überhaupt gebaut wurde.
//
// Geliefert wird der Text **in Seiten zerlegt**. Mehr geht nicht: Wo genau auf
// dem Bild ein Wort steht, weiß nur die Erkennung selbst, und wir bewahren
// ihre Koordinaten nicht auf. Die Seite ist aber der Sprung, auf den es
// ankommt – bei einem zwölfseitigen Beleg ist das der Unterschied zwischen
// „gefunden" und „such weiter".
//
// Die Prüfung ist dieselbe wie beim Herunterladen: Wer die Datei nicht lesen
// darf, bekommt hier auch ihren Text nicht. Das ist keine Kleinigkeit – der
// Text *ist* der Inhalt der Rechnung.
func (h *Handler) TextPages(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r, "id")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid file id")
return
}
if _, ok := h.resolveFile(w, r, id, false); !ok {
return
}
// Ohne Texterkennung gibt es keinen Text – das ist eine Auskunft, kein
// Fehler. Der Betrachter zeigt dann schlicht keine Fundstellen an.
if h.docs == nil {
response.JSON(w, http.StatusOK, map[string]any{"pages": []string{}, "source": ""})
return
}
pages, source, err := h.docs.TextPages(r.Context(), id)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if pages == nil {
pages = []string{}
}
response.JSON(w, http.StatusOK, map[string]any{"pages": pages, "source": source})
}
+144
View File
@@ -0,0 +1,144 @@
package files
import (
"errors"
"fmt"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"net/http"
"github.com/rs/zerolog/log"
"aether/internal/audit"
"aether/internal/auth"
"aether/pkg/response"
)
// Papierkorb: auflisten, wiederherstellen, endgültig entfernen.
//
// Gelöscht wird nie hart, sondern weich – erst von hier aus verschwinden die
// Bytes wirklich.
// GET /api/v1/trash
func (h *Handler) ListTrash(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
items, err := h.store.ListTrash(r.Context(), user.ID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if items == nil {
items = []*TrashFile{}
}
response.JSON(w, http.StatusOK, items)
}
// POST /api/v1/trash/{id}/restore
func (h *Handler) RestoreFile(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r, "id")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid file id")
return
}
owner, ok := h.resolveFile(w, r, id, true)
if !ok {
return
}
trashed, err := h.store.GetTrashedFile(r.Context(), id, owner.OwnerID)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "file not found in trash")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
folderPath, err := h.store.GetFolderPath(r.Context(), trashed.FolderID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
newStorageKey := BuildStorageKey(owner.StorageRoot(), folderPath, trashed.Name)
if err := h.storage.Move(r.Context(), trashed.StorageKey, newStorageKey); err != nil {
response.Error(w, http.StatusInternalServerError, "storage error")
return
}
f, err := h.store.RestoreFile(r.Context(), id, owner.OwnerID, newStorageKey)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "file not found in trash")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.logFile(r, audit.ActionFileRestore, id.String(), trashed.Name, "restored from trash")
response.JSON(w, http.StatusOK, f)
}
// DELETE /api/v1/trash/{id} – permanent delete
// DELETE /api/v1/trash – leert den Papierkorb vollständig.
func (h *Handler) EmptyTrash(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
fileIDs, keys, err := h.store.EmptyTrash(r.Context(), user.ID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
// Blobs erst nach dem Datenbank-Commit entfernen. Scheitert eine Löschung,
// bleibt höchstens eine Dateileiche zurück – das ist harmloser als ein
// Eintrag ohne Inhalt.
for _, key := range keys {
if err := h.storage.Delete(r.Context(), key); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Str("key", key).
Msg("empty trash: blob not removed")
}
}
for _, id := range fileIDs {
_ = h.storage.Delete(r.Context(), "thumbs/"+id.String()+".jpg")
}
log.Ctx(r.Context()).Info().
Int("files", len(fileIDs)).Int("blobs", len(keys)).
Msg("trash emptied")
h.logFile(r, audit.ActionTrashEmpty, "", "",
fmt.Sprintf("%d files permanently deleted", len(fileIDs)))
response.NoContent(w)
}
func (h *Handler) HardDelete(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r, "id")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid file id")
return
}
owner, ok := h.resolveFile(w, r, id, true)
if !ok {
return
}
storageKey, versionKeys, err := h.store.HardDeleteFile(r.Context(), id, owner.OwnerID)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "file not found in trash")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
_ = h.storage.Delete(r.Context(), storageKey)
for _, key := range versionKeys {
_ = h.storage.Delete(r.Context(), key)
}
_ = h.storage.Delete(r.Context(), "thumbs/"+id.String()+".jpg")
h.logFile(r, audit.ActionHardDelete, id.String(), "", "permanently deleted from trash")
response.NoContent(w)
}
+202
View File
@@ -0,0 +1,202 @@
package files
import (
"encoding/json"
"errors"
"fmt"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"aether/pkg/response"
)
// Versionen einer Datei: an- und abschalten, auflisten, herunterladen,
// wiederherstellen.
//
// Versionierung ist bewusst pro Datei opt-in.
// GET /api/v1/files/{id}/versions
// PATCH /api/v1/files/{id}/versioning
func (h *Handler) SetVersioning(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r, "id")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid file id")
return
}
owner, ok := h.resolveFile(w, r, id, true)
if !ok {
return
}
var body struct {
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Enabled == nil {
response.Error(w, http.StatusBadRequest, "enabled required")
return
}
f, err := h.store.SetVersioning(r.Context(), id, owner.OwnerID, *body.Enabled)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "file not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.JSON(w, http.StatusOK, f)
}
func (h *Handler) ListVersions(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r, "id")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid file id")
return
}
owner, ok := h.resolveFile(w, r, id, false)
if !ok {
return
}
versions, err := h.store.ListVersions(r.Context(), id, owner.OwnerID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if versions == nil {
versions = []*FileVersion{}
}
response.JSON(w, http.StatusOK, versions)
}
// GET /api/v1/files/{id}/versions/{versionId}/download
func (h *Handler) DownloadVersion(w http.ResponseWriter, r *http.Request) {
fileID, err := parseID(r, "id")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid file id")
return
}
versionID, err := parseID(r, "versionId")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid version id")
return
}
owner, ok := h.resolveFile(w, r, fileID, false)
if !ok {
return
}
f, err := h.store.GetFile(r.Context(), fileID, owner.OwnerID)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "file not found")
return
}
v, err := h.store.GetVersion(r.Context(), versionID, fileID, owner.OwnerID)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "version not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
rc, err := h.storage.Get(r.Context(), v.StorageKey)
if err != nil {
response.Error(w, http.StatusInternalServerError, "storage error")
return
}
defer rc.Close()
w.Header().Set("Content-Type", f.MimeType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, f.Name))
w.Header().Set("Content-Length", fmt.Sprintf("%d", v.Size))
_, _ = io.Copy(w, rc)
}
// POST /api/v1/files/{id}/versions/{versionId}/restore
func (h *Handler) RestoreVersion(w http.ResponseWriter, r *http.Request) {
fileID, err := parseID(r, "id")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid file id")
return
}
versionID, err := parseID(r, "versionId")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid version id")
return
}
owner, ok := h.resolveFile(w, r, fileID, true)
if !ok {
return
}
current, err := h.store.GetFile(r.Context(), fileID, owner.OwnerID)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "file not found")
return
}
v, err := h.store.GetVersion(r.Context(), versionID, fileID, owner.OwnerID)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "version not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
// Die wiederhergestellte Fassung muss auf den lesbaren Pfad der Datei
// wandern – bliebe sie unter .versions/<uuid>, läge die aktive Datei
// plötzlich im Versionsarchiv.
activeKey := current.StorageKey
if current.VersioningEnabled {
// Bisherige Fassung sichern, damit der Weg zurück offen bleibt.
versionKey := fmt.Sprintf("%s/.versions/%s", owner.StorageRoot(), uuid.New().String())
if err := h.storage.Move(r.Context(), activeKey, versionKey); err != nil {
response.Error(w, http.StatusInternalServerError, "storage error")
return
}
if err := h.store.CreateVersion(r.Context(), current.ID, current.Version, current.Size, current.Checksum, versionKey); err != nil {
if mvErr := h.storage.Move(r.Context(), versionKey, activeKey); mvErr != nil {
log.Ctx(r.Context()).Error().Err(mvErr).Str("file_id", current.ID.String()).
Msg("restore: rollback of current-version move failed – blob orphaned")
}
response.Error(w, http.StatusInternalServerError, "database error")
return
}
} else if err := h.storage.Delete(r.Context(), activeKey); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Str("file_id", current.ID.String()).
Msg("restore: could not remove replaced blob")
}
// Versionsblob an den Platz der aktiven Datei holen.
if err := h.storage.Move(r.Context(), v.StorageKey, activeKey); err != nil {
response.Error(w, http.StatusInternalServerError, "storage error")
return
}
// Der Versionseintrag ist damit gegenstandslos – sein Inhalt ist jetzt aktiv.
if err := h.store.DeleteVersion(r.Context(), v.ID, fileID); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
updated, err := h.store.UpdateFileContent(r.Context(), fileID, v.Size, v.Checksum, activeKey)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if oldKeys, err := h.store.PruneVersions(r.Context(), fileID, maxVersionsKept); err == nil {
for _, key := range oldKeys {
_ = h.storage.Delete(r.Context(), key)
}
}
_ = h.storage.Delete(r.Context(), "thumbs/"+fileID.String()+".jpg")
response.JSON(w, http.StatusOK, updated)
}
+210
View File
@@ -0,0 +1,210 @@
package files
import (
"archive/zip"
"fmt"
"io"
"net/http"
"path"
"strings"
"time"
"github.com/google/uuid"
"aether/internal/auth"
"aether/pkg/response"
)
// Bereits komprimierte Formate werden nur *gelagert*, nicht noch einmal
// gepackt. Ein 116-MB-Paket durch Deflate zu schicken kostet Sekunden CPU und
// spart nichts – die Bytes sind schon dicht.
var storeOnlyExt = map[string]bool{
".zip": true, ".gz": true, ".tgz": true, ".bz2": true, ".xz": true, ".7z": true,
".rar": true, ".zst": true, ".deb": true, ".rpm": true,
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
".mp3": true, ".mp4": true, ".m4a": true, ".mkv": true, ".webm": true, ".avi": true,
".pdf": true,
}
// DownloadZip packt eine Auswahl in ein Archiv – POST /api/v1/download/zip.
//
// **Gestreamt, nicht gesammelt.** Das Archiv entsteht direkt in der Antwort;
// es liegt zu keinem Zeitpunkt ganz auf der Platte oder im Speicher. Bei einem
// 5-GB-Ordner wäre das andernfalls 5 GB Bedarf auf einem Speicher, der ohnehin
// knapp ist – und der Nutzer sähe minutenlang nichts.
//
// Der Preis: Ist das Archiv einmal angefangen, steht der Statuscode schon auf
// 200. Ein Fehler danach kann nur noch dazu führen, dass das Archiv unvollständig
// endet. Deshalb wird alles, was scheitern kann – Rechte, Auswahl, Grenzen –
// **vor** dem ersten geschriebenen Byte geprüft.
func (h *Handler) DownloadZip(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
if user == nil {
response.Error(w, http.StatusUnauthorized, "not signed in")
return
}
// Formular *und* JSON: Der Browser lädt eine Antwort nur dann als Datei
// herunter, wenn sie aus einer Navigation stammt – also aus einem
// abgeschickten Formular. Ein fetch() müsste das ganze Archiv erst in den
// Speicher holen, und genau das soll hier nicht passieren.
if err := r.ParseForm(); err != nil {
response.Error(w, http.StatusBadRequest, "invalid form")
return
}
name := strings.TrimSpace(r.FormValue("name"))
folderID := strings.TrimSpace(r.FormValue("folder_id"))
rawIDs := r.Form["file_ids"]
if len(rawIDs) == 1 && strings.Contains(rawIDs[0], ",") {
rawIDs = strings.Split(rawIDs[0], ",")
}
var entries []ZipEntry
var err error
switch {
case folderID != "":
id, perr := uuid.Parse(folderID)
if perr != nil {
response.Error(w, http.StatusBadRequest, "invalid folder_id")
return
}
// Rechteprüfung über dieselbe Stelle wie überall: Wer den Ordner nicht
// lesen darf, bekommt 404 – nicht 403, sonst verrät die Antwort, dass
// es ihn gibt.
access, aerr := h.acl.ResolveFolder(r.Context(), user.ID, id)
if aerr != nil {
response.Error(w, http.StatusNotFound, "not found")
return
}
entries, err = h.store.FilesInFolderTree(r.Context(), access.OwnerID, id)
if name == "" {
if folder, ferr := h.store.GetFolder(r.Context(), id, access.OwnerID); ferr == nil {
name = folder.Name
}
}
default:
ids := make([]uuid.UUID, 0, len(rawIDs))
for _, raw := range rawIDs {
id, perr := uuid.Parse(strings.TrimSpace(raw))
if perr != nil {
response.Error(w, http.StatusBadRequest, "invalid file id")
return
}
ids = append(ids, id)
}
if len(ids) == 0 {
response.Error(w, http.StatusBadRequest, "nothing selected")
return
}
entries, err = h.store.FilesForZipByIDs(r.Context(), user.ID, ids)
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if len(entries) == 0 {
// Auch der Fall „alles herausgefiltert, weil nichts erreichbar war".
response.Error(w, http.StatusNotFound, "nothing to download")
return
}
if len(entries) > MaxZipEntries {
response.Error(w, http.StatusRequestEntityTooLarge,
fmt.Sprintf("too many files (limit %d) — narrow the selection", MaxZipEntries))
return
}
// Ab hier ist die Antwort unterwegs.
filename := zipFilename(name)
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
w.Header().Set("X-Content-Type-Options", "nosniff")
// Keine Länge im Voraus: Sie stünde erst fest, wenn alles gepackt ist – und
// dann wäre das Streamen sinnlos gewesen. Der Browser zeigt deshalb keinen
// Fortschrittsbalken, dafür beginnt der Download sofort.
w.WriteHeader(http.StatusOK)
zw := zip.NewWriter(w)
used := map[string]int{}
for _, e := range entries {
if r.Context().Err() != nil {
// Abgebrochen (Fenster zu, Verbindung weg). Nicht weiterpacken.
break
}
entryPath := uniquePath(used, e.Path)
method := zip.Deflate
if storeOnlyExt[strings.ToLower(path.Ext(e.Name))] {
method = zip.Store
}
hdr := &zip.FileHeader{Name: entryPath, Method: method, Modified: time.Now()}
// Ohne dieses Bit deuten manche Entpacker (Windows-Explorer) den Namen
// in einer alten Codepage – aus „Rechnung Müller.pdf" wird dann
// „Rechnung Müller.pdf".
hdr.Flags |= 0x800
fw, werr := zw.CreateHeader(hdr)
if werr != nil {
break
}
rc, gerr := h.storage.Get(r.Context(), e.StorageKey)
if gerr != nil {
// Eine fehlende Datei darf das ganze Archiv nicht kippen – der Rest
// ist ja in Ordnung. Sie fehlt darin, das ist die ehrlichste
// Auskunft, die zu diesem Zeitpunkt noch möglich ist.
continue
}
_, cerr := io.Copy(fw, rc)
rc.Close()
if cerr != nil {
break
}
}
_ = zw.Close()
}
// zipFilename baut einen Dateinamen, der auf jedem System durchgeht.
func zipFilename(name string) string {
name = strings.TrimSpace(name)
if name == "" {
name = "download-" + time.Now().Format("2006-01-02")
}
// Zeichen, die Windows in Dateinamen verbietet, dazu Pfadtrenner.
name = strings.Map(func(r rune) rune {
if strings.ContainsRune(`/\:*?"<>|`, r) || r < 32 {
return '-'
}
return r
}, name)
return name + ".zip"
}
// uniquePath verhindert doppelte Einträge im Archiv.
//
// Bei einer Auswahl aus mehreren Ordnern – oder bei allem mit einem Etikett –
// können zwei Dateien denselben Namen tragen. Ein Archiv mit zwei gleichen
// Einträgen entpacken manche Programme kommentarlos zu einer Datei; die zweite
// wäre still verloren.
func uniquePath(used map[string]int, p string) string {
used[p]++
if used[p] == 1 {
return p
}
ext := doubleExt(p)
return fmt.Sprintf("%s (%d)%s", strings.TrimSuffix(p, ext), used[p], ext)
}
// doubleExt erkennt zweiteilige Endungen wie .tar.gz.
//
// path.Ext sieht nur ".gz", und die Zahl landete dann mittendrin:
// „paket.tar (2).gz". Gemessen beim Prüfen, nicht vermutet.
func doubleExt(p string) string {
ext := path.Ext(p)
if rest := strings.TrimSuffix(p, ext); strings.HasSuffix(strings.ToLower(rest), ".tar") {
return path.Ext(rest) + ext
}
return ext
}
+37
View File
@@ -0,0 +1,37 @@
package files
import "testing"
// Das Alpine-Image bringt keine /etc/mime.types mit, und Gos eingebaute
// Ersatztabelle kennt nur Web-Typen. Ohne die eigene Tabelle landete eine MP3
// als application/octet-stream in der Datenbank – nicht abspielbar, falsches
// Symbol.
func TestDetectMIMEKennteMedienTypen(t *testing.T) {
cases := map[string]string{
"lied.mp3": "audio/mpeg",
"clip.mp4": "video/mp4",
"paket.zip": "application/zip",
"paket.tar.gz": "application/gzip",
"tabelle.csv": "text/csv",
"BILD.PNG": "image/png", // Endung ohne Rücksicht auf Groß/Klein
"folien.pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"brief.docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"budget.xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}
for name, want := range cases {
if got := detectMIME(name, ""); got != want {
t.Errorf("detectMIME(%q) = %q, want %q", name, got, want)
}
}
}
// Behauptet der Client etwas Sinnvolles und die Endung sagt nichts, gilt der
// Client. Behauptet er octet-stream, ist das keine Aussage.
func TestDetectMIMEFallback(t *testing.T) {
if got := detectMIME("datei.unbekannt", "image/png"); got != "image/png" {
t.Errorf("got %q, want the client type", got)
}
if got := detectMIME("datei.unbekannt", "application/octet-stream"); got != "application/octet-stream" {
t.Errorf("got %q", got)
}
}
+64
View File
@@ -0,0 +1,64 @@
package files
import (
"time"
"github.com/google/uuid"
)
type File struct {
ID uuid.UUID `json:"id"`
// Entweder OwnerID oder GroupID ist gesetzt, nie beides: Eine Datei gehört
// einem Konto oder einer Gruppe. Die Datenbank erzwingt das per CHECK.
OwnerID *uuid.UUID `json:"owner_id"`
GroupID *uuid.UUID `json:"group_id"`
FolderID *uuid.UUID `json:"folder_id"`
Name string `json:"name"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
Checksum string `json:"checksum"`
StorageKey string `json:"-"`
HasThumbnail bool `json:"has_thumbnail"`
Version int `json:"version"`
// VersioningEnabled ist Opt-in: nur dann hebt eine Überschreibung die
// vorige Fassung auf. Sonst wird sie ersetzt und der Platz freigegeben.
VersioningEnabled bool `json:"versioning_enabled"`
// DocumentDate ist das Datum, das auf dem Dokument steht – Kaufdatum,
// Rechnungsdatum. Nicht zu verwechseln mit CreatedAt (dem Upload): Ein
// Beleg von Dezember, im Januar eingescannt, gehört ins alte Jahr.
// NULL heißt „nicht gesetzt"; bei einem Urlaubsfoto gibt es keins.
DocumentDate *time.Time `json:"document_date,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type FileVersion struct {
ID uuid.UUID `json:"id"`
FileID uuid.UUID `json:"file_id"`
Version int `json:"version"`
Size int64 `json:"size"`
Checksum string `json:"checksum"`
StorageKey string `json:"-"`
CreatedAt time.Time `json:"created_at"`
}
type Folder struct {
ID uuid.UUID `json:"id"`
// Siehe File: entweder Konto oder Gruppe.
OwnerID *uuid.UUID `json:"owner_id"`
GroupID *uuid.UUID `json:"group_id"`
ParentID *uuid.UUID `json:"parent_id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}
type Favorite struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
FolderID uuid.UUID `json:"folder_id"`
Position int `json:"position"`
IsExpanded bool `json:"is_expanded"`
CreatedAt time.Time `json:"created_at"`
FolderName string `json:"folder_name"`
FolderParentID *uuid.UUID `json:"folder_parent_id"`
}
+108
View File
@@ -0,0 +1,108 @@
package files
import (
"context"
"strings"
"unicode"
"github.com/rs/zerolog/log"
"aether/internal/storage"
)
// maxFilenameLen begrenzt den Dateinamen so, dass der zusammengesetzte Pfad
// auf gängigen Dateisystemen (255 Bytes pro Segment) sicher passt.
const maxFilenameLen = 200
// BuildStorageKey erzeugt einen menschenlesbaren Pfad, der die Struktur der
// App spiegelt:
//
// users/{username}/home/{folderPath}/{filename}
//
// Bewusst lesbar statt UUID-basiert: fällt die Datenbank aus, sollen die
// Dateien per SFTP weiterhin auffindbar sein. Versionen (.versions/) und
// Papierkorb (.trash/) sind davon ausgenommen – das sind interne Artefakte.
// BuildStorageKey setzt den lesbaren Ablagepfad zusammen. root ist das
// Wurzelsegment – `users/<name>` für persönliche Daten, `groups/<id>` für
// Gruppenordner (siehe acl.Access.StorageRoot).
func BuildStorageKey(root, folderPath, filename string) string {
if folderPath == "" {
return root + "/home/" + filename
}
return root + "/home/" + folderPath + "/" + filename
}
// SanitizeFilename macht einen vom Client gelieferten Namen als einzelnes
// Pfadsegment sicher.
//
// Der Traversal-Schutz sitzt in storage.safePath; hier geht es darum, dass
// Name in der Datenbank und Pfad im Storage nicht auseinanderlaufen: Ein Name
// wie "a/b.txt" würde sonst still eine Ordnerebene im Storage erzeugen, die
// die Datenbank nicht kennt.
//
// Gibt "" zurück, wenn nichts Brauchbares übrig bleibt – Aufrufer müssen das
// als ungültige Eingabe behandeln.
func SanitizeFilename(name string) string {
// Nur das letzte Segment übernehmen – Clients schicken teils ganze Pfade.
if i := strings.LastIndexAny(name, `/\`); i >= 0 {
name = name[i+1:]
}
// Steuerzeichen entfernen (inkl. NUL und Zeilenumbrüche in Namen).
name = strings.Map(func(r rune) rune {
if r == unicode.ReplacementChar || unicode.IsControl(r) {
return -1
}
return r
}, name)
// Führende/abschließende Punkte und Leerraum: "." und ".." wären sonst
// Verzeichnisverweise, abschließende Punkte stören unter Windows.
name = strings.Trim(strings.TrimSpace(name), ".")
name = strings.TrimSpace(name)
if len(name) > maxFilenameLen {
// Auf Rune-Grenze kürzen, damit kein kaputtes UTF-8 entsteht.
runes := []rune(name)
for len(string(runes)) > maxFilenameLen {
runes = runes[:len(runes)-1]
}
name = strings.TrimSpace(string(runes))
}
return name
}
// ResyncBlobPaths bringt die Blobs der übergebenen Dateien auf die Pfade, die
// sich aus dem aktuellen Datenbankstand ergeben.
//
// Wird ein Ordner umbenannt oder verschoben, ändert sich der Pfad aller
// Dateien darunter. Weil die Storage-Schlüssel lesbar sind (und nicht bloß
// UUIDs), müssen die Blobs mitwandern. Aufrufen, *nachdem* die Umbenennung in
// der Datenbank committet ist – erst dann liefert GetFolderPath den neuen Pfad.
//
// Die Dateiliste muss *vorher* eingesammelt werden, damit die alten
// Storage-Schlüssel noch bekannt sind.
func ResyncBlobPaths(ctx context.Context, store *Store, st storage.Storage, root string, moved []*File) {
for _, f := range moved {
newFolderPath, err := store.GetFolderPath(ctx, f.FolderID)
if err != nil {
log.Ctx(ctx).Warn().Err(err).Str("file_id", f.ID.String()).
Msg("resync: folder path lookup failed – blob keeps old path")
continue
}
newKey := BuildStorageKey(root, newFolderPath, f.Name)
if newKey == f.StorageKey {
continue
}
if err := st.Move(ctx, f.StorageKey, newKey); err != nil {
log.Ctx(ctx).Warn().Err(err).Str("file_id", f.ID.String()).
Msg("resync: blob move failed – storage key now stale")
continue
}
if err := store.UpdateFileStorageKey(ctx, f.ID, newKey); err != nil {
log.Ctx(ctx).Error().Err(err).Str("file_id", f.ID.String()).
Msg("resync: blob moved but storage key not updated")
}
}
}
+63
View File
@@ -0,0 +1,63 @@
package files
import (
"context"
"errors"
"fmt"
"io"
"github.com/google/uuid"
)
// ErrQuotaExceeded wird gemeldet, sobald ein Upload das Speicherkontingent des
// Nutzers überschreitet.
var ErrQuotaExceeded = errors.New("storage quota exceeded")
// quotaReader reicht Daten durch und bricht ab, sobald mehr als `remaining`
// Bytes gelesen wurden. Nötig, weil Uploads gestreamt werden: die tatsächliche
// Größe steht erst am Ende fest, und bis dahin wären die Bytes längst auf der
// Platte. So bricht der Schreibvorgang bereits beim Überschreiten ab statt
// erst hinterher zu prüfen.
type quotaReader struct {
r io.Reader
remaining int64
}
func newQuotaReader(r io.Reader, remaining int64) *quotaReader {
if remaining < 0 {
remaining = 0
}
return &quotaReader{r: r, remaining: remaining}
}
func (q *quotaReader) Read(p []byte) (int, error) {
n, err := q.r.Read(p)
q.remaining -= int64(n)
if q.remaining < 0 {
return n, ErrQuotaExceeded
}
return n, err
}
// RemainingQuota liefert, wie viele Bytes der Nutzer noch schreiben darf, und
// ob sein Kontingent unbegrenzt ist.
//
// `allowance` sind Bytes, die durch diesen Vorgang frei werden – beim
// Überschreiben einer Datei zählt die alte Fassung nicht mehr zum Verbrauch,
// sonst schlüge das Ersetzen einer Datei durch eine gleich große fehl.
//
// Ein Kontingent <= 0 gilt als unbegrenzt.
func (s *Store) RemainingQuota(ctx context.Context, userID uuid.UUID, quotaBytes, allowance int64) (int64, bool, error) {
if quotaBytes <= 0 {
return 0, true, nil
}
used, err := s.UsedBytes(ctx, userID)
if err != nil {
return 0, false, fmt.Errorf("read used bytes: %w", err)
}
remaining := quotaBytes - used + allowance
if remaining < 0 {
remaining = 0
}
return remaining, false, nil
}
+93
View File
@@ -0,0 +1,93 @@
package files
import (
"context"
"errors"
"fmt"
"io"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"aether/internal/acl"
"aether/internal/storage"
)
// ReplaceContent ersetzt den Inhalt einer bestehenden Datei.
//
// Gebraucht von der Office-Anbindung: Der Office-Server schickt beim Speichern
// den neuen Inhalt, aber keinen Ordner, keinen Namen und keine Sitzung. Der
// Weg über den Upload-Handler geht deshalb nicht – der liest ein Formular und
// schreibt selbst die HTTP-Antwort.
//
// **Ein Office-Server ist kein Sonderweg an der Datenintegrität vorbei.** Es
// gelten dieselben vier Regeln wie bei jedem Upload: atomar schreiben, Prüfsumme
// neu berechnen, das Kontingent beachten, und bei eingeschalteter Versionierung
// die alte Fassung aufheben.
func ReplaceContent(
ctx context.Context,
store *Store,
blobs storage.Storage,
f *File,
owner acl.Access,
content io.Reader,
) (*File, error) {
// Die alte Fassung zählt nach dem Überschreiben nicht mehr zum Verbrauch –
// deshalb ihre Größe als Freibetrag.
remaining, unlimited, err := store.RemainingQuota(ctx, owner.OwnerID, owner.OwnerQuota, f.Size)
if err != nil {
return nil, fmt.Errorf("quota: %w", err)
}
// Nur bei eingeschalteter Versionierung wird die alte Fassung beiseite
// geschoben. Sonst ersetzt Put sie atomar und der Platz bleibt frei.
var versionKey string
if f.VersioningEnabled {
versionKey = fmt.Sprintf("%s/.versions/%s", owner.StorageRoot(), uuid.New().String())
if err := blobs.Move(ctx, f.StorageKey, versionKey); err != nil {
return nil, fmt.Errorf("keep previous version: %w", err)
}
}
if !unlimited {
content = newQuotaReader(content, remaining)
}
checksum, size, err := blobs.Put(ctx, f.StorageKey, content)
if err != nil {
// Die alte Fassung ist schon verschoben – zurückrollen, sonst
// verliert man die bestehende Datei an ein Speichern, das nie ankam.
if versionKey != "" {
if mvErr := blobs.Move(ctx, versionKey, f.StorageKey); mvErr != nil {
log.Ctx(ctx).Error().Err(mvErr).Str("file_id", f.ID.String()).
Msg("rollback of version move failed – blob orphaned")
}
}
if errors.Is(err, ErrQuotaExceeded) {
return nil, ErrQuotaExceeded
}
return nil, fmt.Errorf("write: %w", err)
}
if versionKey != "" {
if err := store.CreateVersion(ctx, f.ID, f.Version, f.Size, f.Checksum, versionKey); err != nil {
return nil, fmt.Errorf("record version: %w", err)
}
}
updated, err := store.UpdateFileContent(ctx, f.ID, size, checksum, f.StorageKey)
if err != nil {
return nil, fmt.Errorf("update file: %w", err)
}
if versionKey != "" {
// Alte Fassungen über der Grenze wegräumen – sonst wächst der Verlauf
// eines oft gespeicherten Dokuments unbegrenzt. Ein Fehler hier ist
// kein Grund, das Speichern scheitern zu lassen.
if oldKeys, err := store.PruneVersions(ctx, f.ID, maxVersionsKept); err == nil {
for _, k := range oldKeys {
_ = blobs.Delete(ctx, k)
}
}
}
return updated, nil
}
+199
View File
@@ -0,0 +1,199 @@
package files
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// Namenssuche über alles, was der Anfragende erreichen darf.
//
// Der heikle Teil ist nicht die Abfrage, sondern der Umfang: Eine Suche, die
// die Zugriffsregeln nicht mitdenkt, ist ein Leck – sie zeigte Namen aus
// Ordnern, die man nicht betreten darf. Deshalb schneidet die Abfrage den
// erreichbaren Bereich selbst zu, statt Treffer nachträglich zu filtern.
// Nachträglich zu filtern wäre die Variante, bei der man einen Fall vergisst.
//
// Erreichbar ist:
// - was dem Anfragenden gehört
// - was einer seiner Gruppen gehört (Gruppenordner)
// - was ihm freigegeben wurde – direkt oder über eine Gruppe
// - alles *unterhalb* eines freigegebenen Ordners (Vererbung nach unten)
//
// Papierkorb bleibt außen vor: Gelöschtes taucht dort auf, nicht in der Suche.
// SearchLimit begrenzt die Trefferzahl. Wer mehr als 100 Treffer bekommt, hat
// zu ungenau gesucht – eine längere Liste hilft niemandem und kostet nur.
const SearchLimit = 100
// SearchHit ist ein Treffer. Ordner und Dateien kommen in derselben Liste,
// damit die Oberfläche sie in einer Trefferliste zeigen kann.
type SearchHit struct {
Kind string `json:"kind"` // "folder" oder "file"
ID uuid.UUID `json:"id"`
Name string `json:"name"`
// FolderID ist der Ordner, in dem der Treffer liegt – bei einem Ordner der
// übergeordnete. Leer heißt Wurzel. Die Oberfläche springt damit dorthin.
FolderID *uuid.UUID `json:"folder_id"`
// FolderName benennt denselben Ordner, damit ein Treffer einordbar ist:
// Drei Dateien namens „Rechnung.pdf" unterscheiden sich sonst nicht.
FolderName string `json:"folder_name"`
MimeType string `json:"mime_type,omitempty"`
Size int64 `json:"size"`
HasThumbnail bool `json:"has_thumbnail"`
UpdatedAt time.Time `json:"updated_at"`
// MatchedContent heißt: Nicht der Name passte, sondern der erkannte Text
// im Dokument. Für die Oberfläche ein wichtiger Unterschied – sonst wirkt
// ein Treffer namens "scan_0042.pdf" wie ein Fehler der Suche.
MatchedContent bool `json:"matched_content,omitempty"`
// Snippet ist die Fundstelle im Text, Treffer hervorgehoben (<b>…</b>).
// Nur gesetzt, wenn MatchedContent gilt.
Snippet string `json:"snippet,omitempty"`
}
// Search findet Ordner und Dateien – nach Name und, bei Dokumenten mit
// erkanntem Text, auch nach Inhalt.
//
// Beides in *einer* Abfrage und in *einem* Zweig des UNION, nicht in zweien:
// Eine Datei, deren Name und Inhalt beide passen, käme sonst doppelt in der
// Liste an. Welche Hälfte den Treffer verursacht hat, sagt matched_content.
func (s *Store) Search(ctx context.Context, userID uuid.UUID, query string) ([]SearchHit, error) {
query = strings.TrimSpace(query)
if query == "" {
return []SearchHit{}, nil
}
// Die Platzhalter von LIKE gehören nicht dem Nutzer: Ein "%" im Suchwort
// soll ein Prozentzeichen suchen, keine beliebige Zeichenfolge.
pattern := "%" + escapeLike(query) + "%"
// Für den Volltext wird das Suchwort unverändert weitergereicht:
// websearch_to_tsquery versteht das, was Menschen ohnehin eintippen –
// Anführungszeichen für Wortgruppen, "or", ein Minus zum Ausschließen –
// und wirft bei krummer Eingabe keinen Fehler, sondern liefert eine leere
// Abfrage. Eine Suche darf an einem Sonderzeichen nicht scheitern.
const q = `
WITH RECURSIVE my_groups AS (
SELECT group_id FROM group_members WHERE user_id = $1
),
-- Ordner, die dem Anfragenden freigegeben wurden – persönlich oder über eine
-- seiner Gruppen.
granted_roots AS (
SELECT resource_folder_id AS id
FROM acl_entries
WHERE resource_folder_id IS NOT NULL
AND (subject_user_id = $1
OR subject_group_id IN (SELECT group_id FROM my_groups))
),
-- Freigaben vererben sich nach unten: Wer einen Ordner freigibt, meint dessen
-- ganzen Inhalt. Hier deshalb ausnahmsweise nach unten statt nach oben – die
-- Einzelprüfung in internal/acl läuft andersherum, weil sie nur einen Pfad
-- zur Wurzel braucht.
shared_tree AS (
SELECT id FROM granted_roots
UNION
SELECT f.id FROM folders f JOIN shared_tree s ON f.parent_id = s.id
),
-- Einmal berechnet statt in jeder Zeile. NULLIF fängt den Fall ab, dass die
-- Eingabe nur aus Füllwörtern besteht ("und der"): Daraus entsteht eine leere
-- tsquery, und die passt auf nichts – ohne NULLIF stünde stattdessen eine
-- Bedingung da, die nie erfüllt ist, aber trotzdem jede Zeile prüft.
q AS (
SELECT NULLIF(websearch_to_tsquery('german', $4)::text, '')::tsquery AS tsq
),
granted_files AS (
SELECT resource_file_id AS id
FROM acl_entries
WHERE resource_file_id IS NOT NULL
AND (subject_user_id = $1
OR subject_group_id IN (SELECT group_id FROM my_groups))
)
SELECT * FROM (
SELECT 'folder' AS kind, fo.id, fo.name, fo.parent_id AS folder_id,
COALESCE(p.name, '') AS folder_name,
'' AS mime_type, 0::bigint AS size, false AS has_thumbnail, fo.updated_at,
false AS matched_content, '' AS snippet
FROM folders fo
LEFT JOIN folders p ON p.id = fo.parent_id
WHERE fo.name ILIKE $2 ESCAPE '\'
AND (fo.owner_id = $1
OR fo.group_id IN (SELECT group_id FROM my_groups)
OR fo.id IN (SELECT id FROM shared_tree))
UNION ALL
SELECT 'file' AS kind, fi.id, fi.name, fi.folder_id,
COALESCE(p.name, '') AS folder_name,
fi.mime_type, fi.size, fi.has_thumbnail, fi.updated_at,
-- Nur als Inhaltstreffer zählen, was der Name nicht schon hergibt:
-- Passt beides, ist der Name die bessere Erklärung.
(fi.name NOT ILIKE $2 ESCAPE '\') AS matched_content,
CASE WHEN fi.name NOT ILIKE $2 ESCAPE '\' AND ft.file_id IS NOT NULL
-- Eigene Markierungen statt der voreingestellten <b>…</b>: Der
-- Text stammt aus einem fremden Dokument und darf im Frontend
-- niemals als HTML eingesetzt werden. Die Oberfläche zerlegt die
-- Zeichenkette an diesen Marken und baut daraus Textknoten – ein
-- <script> im Dokument bleibt damit sichtbarer Text.
-- MaxWords/MinWords passen zu zwei Zeilen in der Trefferliste.
-- Bei 18 Wörtern brach die Fundstelle mitten im Satz ab, ohne dass
-- die zweite Zeile voll war; 30 füllt sie und wird darunter von
-- der Oberfläche sauber gekappt statt vom Textende.
THEN ts_headline('german', ft.content, q.tsq,
'StartSel=[[hl]], StopSel=[[/hl]], MaxWords=30, MinWords=12, MaxFragments=1')
ELSE '' END AS snippet
FROM files fi
LEFT JOIN folders p ON p.id = fi.folder_id
-- LEFT JOIN, nicht JOIN: Dateien ohne erkannten Text – alles außer PDFs und
-- Bildern – müssen weiterhin über ihren Namen gefunden werden.
LEFT JOIN file_text ft ON ft.file_id = fi.id
CROSS JOIN q
WHERE fi.is_deleted = false
AND (fi.name ILIKE $2 ESCAPE '\' OR (q.tsq IS NOT NULL AND ft.tsv @@ q.tsq))
AND (fi.owner_id = $1
OR fi.group_id IN (SELECT group_id FROM my_groups)
OR fi.folder_id IN (SELECT id FROM shared_tree)
OR fi.id IN (SELECT id FROM granted_files))
) hits
-- Ordner zuerst: Wer einen Ordner sucht, will nicht erst an dreißig Dateien
-- vorbeiscrollen. (Nicht nach kind sortieren – alphabetisch käme "file" vor
-- "folder", also genau andersherum. Und die Sortierung braucht die Klammer
-- drumherum: Über einem UNION erlaubt Postgres nur Spaltennamen, keine
-- Ausdrücke.)
-- Ordner zuerst, dann Namenstreffer, dann Inhaltstreffer. Wer "Stadtwerke"
-- sucht und eine so benannte Datei hat, will die oben sehen – nicht die
-- dreißig Rechnungen, in denen das Wort nur vorkommt.
ORDER BY (kind = 'folder') DESC, matched_content ASC, name ASC
LIMIT $3`
rows, err := s.db.Query(ctx, q, userID, pattern, SearchLimit, query)
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
defer rows.Close()
hits := []SearchHit{}
for rows.Next() {
var h SearchHit
if err := rows.Scan(&h.Kind, &h.ID, &h.Name, &h.FolderID, &h.FolderName,
&h.MimeType, &h.Size, &h.HasThumbnail, &h.UpdatedAt,
&h.MatchedContent, &h.Snippet); err != nil {
return nil, fmt.Errorf("scan search hit: %w", err)
}
hits = append(hits, h)
}
return hits, rows.Err()
}
// escapeLike entschärft die Platzhalter von LIKE. Der Backslash ist dabei
// zuerst dran, sonst verdoppelt der Ersatz die eigenen Fluchtzeichen wieder.
func escapeLike(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, "%", `\%`)
s = strings.ReplaceAll(s, "_", `\_`)
return s
}
+22
View File
@@ -0,0 +1,22 @@
package files
import "testing"
// Die Platzhalter von LIKE gehören nicht dem Nutzer: Wer nach "%" sucht, meint
// ein Prozentzeichen. Ohne Entschärfung fände "%e" alles mit einem "e" darin.
func TestEscapeLike(t *testing.T) {
cases := map[string]string{
"rechnung": `rechnung`,
"100%": `100\%`,
"a_b": `a\_b`,
`c:\pfad`: `c:\\pfad`,
// Der Backslash muss zuerst ersetzt werden, sonst verdoppelt der
// nächste Durchgang die eben gesetzten Fluchtzeichen wieder.
`50%\`: `50\%\\`,
}
for in, want := range cases {
if got := escapeLike(in); got != want {
t.Errorf("escapeLike(%q) = %q, want %q", in, got, want)
}
}
}
+535
View File
@@ -0,0 +1,535 @@
package files
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("not found")
var ErrNameTaken = errors.New("name already exists in this folder")
var ErrCycle = errors.New("target is a descendant of the folder being moved")
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) *Store {
return &Store{db: db}
}
const fileCols = `id, owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key, has_thumbnail, version, versioning_enabled, document_date, created_at, updated_at`
func scanFile(row interface{ Scan(...any) error }, f *File) error {
return row.Scan(
&f.ID, &f.OwnerID, &f.GroupID, &f.FolderID, &f.Name, &f.MimeType,
&f.Size, &f.Checksum, &f.StorageKey, &f.HasThumbnail,
&f.Version, &f.VersioningEnabled, &f.DocumentDate, &f.CreatedAt, &f.UpdatedAt,
)
}
// ── Files ────────────────────────────────────────────────────────────────────
func (s *Store) CreateFile(ctx context.Context, f *File) (*File, error) {
var out File
err := scanFile(s.db.QueryRow(ctx,
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING `+fileCols,
f.OwnerID, f.GroupID, f.FolderID, f.Name, f.MimeType, f.Size, f.Checksum, f.StorageKey,
), &out)
if err != nil {
if isUniqueViolation(err) {
return nil, ErrNameTaken
}
return nil, fmt.Errorf("create file: %w", err)
}
return &out, nil
}
// GetFileByID fetches a file without owner check (used by public share downloads).
func (s *Store) GetFileByID(ctx context.Context, id uuid.UUID) (*File, error) {
var f File
err := scanFile(s.db.QueryRow(ctx,
`SELECT `+fileCols+` FROM files WHERE id = $1 AND is_deleted = false`, id,
), &f)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get file by id: %w", err)
}
return &f, nil
}
func (s *Store) GetFile(ctx context.Context, id, ownerID uuid.UUID) (*File, error) {
var f File
err := scanFile(s.db.QueryRow(ctx,
`SELECT `+fileCols+` FROM files WHERE id = $1 AND (owner_id = $2 OR group_id = $2) AND is_deleted = false`,
id, ownerID,
), &f)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get file: %w", err)
}
return &f, nil
}
func (s *Store) ListFiles(ctx context.Context, ownerID uuid.UUID, folderID *uuid.UUID) ([]*File, error) {
var rows pgx.Rows
var err error
if folderID == nil {
rows, err = s.db.Query(ctx,
`SELECT `+fileCols+` FROM files
WHERE (owner_id = $1 OR group_id = $1) AND folder_id IS NULL AND is_deleted = false ORDER BY name`,
ownerID,
)
} else {
rows, err = s.db.Query(ctx,
`SELECT `+fileCols+` FROM files
WHERE (owner_id = $1 OR group_id = $1) AND folder_id = $2 AND is_deleted = false ORDER BY name`,
ownerID, folderID,
)
}
if err != nil {
return nil, fmt.Errorf("list files: %w", err)
}
defer rows.Close()
return scanFiles(rows)
}
// SoftDeleteFile marks a file as deleted and updates its storage_key to the trash path.
// The caller must move the blob to trashKey before calling this.
func (s *Store) SoftDeleteFile(ctx context.Context, id, ownerID uuid.UUID, trashKey string) error {
tag, err := s.db.Exec(ctx,
`UPDATE files SET is_deleted = true, deleted_at = NOW(), storage_key = $3
WHERE id = $1 AND (owner_id = $2 OR group_id = $2) AND is_deleted = false`,
id, ownerID, trashKey,
)
if err != nil {
return fmt.Errorf("soft delete file: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// GetTrashedFile returns a soft-deleted file by ID (needed before restore/hard-delete).
func (s *Store) GetTrashedFile(ctx context.Context, id, ownerID uuid.UUID) (*File, error) {
var f File
err := scanFile(s.db.QueryRow(ctx,
`SELECT `+fileCols+` FROM files WHERE id=$1 AND (owner_id = $2 OR group_id = $2) AND is_deleted=true`,
id, ownerID,
), &f)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get trashed file: %w", err)
}
return &f, nil
}
func (s *Store) RenameFile(ctx context.Context, id, ownerID uuid.UUID, name, storageKey string) (*File, error) {
var f File
err := scanFile(s.db.QueryRow(ctx,
`UPDATE files SET name=$3, storage_key=$4
WHERE id=$1 AND (owner_id = $2 OR group_id = $2) AND is_deleted=false
RETURNING `+fileCols,
id, ownerID, name, storageKey,
), &f)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
if isUniqueViolation(err) {
return nil, ErrNameTaken
}
return nil, fmt.Errorf("rename file: %w", err)
}
return &f, nil
}
func (s *Store) MoveFile(ctx context.Context, id, ownerID uuid.UUID, newFolderID *uuid.UUID, storageKey string) (*File, error) {
var f File
err := scanFile(s.db.QueryRow(ctx,
`UPDATE files SET folder_id=$3, storage_key=$4
WHERE id=$1 AND (owner_id = $2 OR group_id = $2) AND is_deleted=false
RETURNING `+fileCols,
id, ownerID, newFolderID, storageKey,
), &f)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("move file: %w", err)
}
return &f, nil
}
type TrashFile struct {
File
FolderPath *string `json:"folder_path"`
}
func (s *Store) ListTrash(ctx context.Context, userID uuid.UUID) ([]*TrashFile, error) {
// Sichtbar ist der eigene Papierkorb plus der jeder Gruppe, in der man
// Mitglied ist. Ohne den zweiten Teil verschwände eine gelöschte
// Gruppendatei spurlos: Sie gehört keinem Konto, tauchte also in keinem
// persönlichen Papierkorb auf und wäre nicht wiederherstellbar.
const principal = `(f.owner_id = $1 OR f.group_id IN (
SELECT group_id FROM group_members WHERE user_id = $1))`
rows, err := s.db.Query(ctx,
`WITH RECURSIVE folder_paths AS (
SELECT id, name::text AS full_path
FROM folders
WHERE parent_id IS NULL
AND (owner_id = $1 OR group_id IN (
SELECT group_id FROM group_members WHERE user_id = $1))
UNION ALL
SELECT fo.id, fp.full_path || ' / ' || fo.name
FROM folders fo JOIN folder_paths fp ON fo.parent_id = fp.id
)
SELECT f.id, f.owner_id, f.group_id, f.folder_id, f.name, f.mime_type, f.size, f.checksum,
f.storage_key, f.has_thumbnail, f.version, f.versioning_enabled,
f.created_at, f.updated_at,
fp.full_path AS folder_path
FROM files f
LEFT JOIN folder_paths fp ON fp.id = f.folder_id
WHERE `+principal+` AND f.is_deleted = true
ORDER BY f.deleted_at DESC`,
userID,
)
if err != nil {
return nil, fmt.Errorf("list trash: %w", err)
}
defer rows.Close()
result := make([]*TrashFile, 0)
for rows.Next() {
var item TrashFile
if err := rows.Scan(
&item.ID, &item.OwnerID, &item.GroupID, &item.FolderID, &item.Name, &item.MimeType,
&item.Size, &item.Checksum, &item.StorageKey, &item.HasThumbnail,
&item.Version, &item.VersioningEnabled, &item.CreatedAt, &item.UpdatedAt,
&item.FolderPath,
); err != nil {
return nil, err
}
result = append(result, &item)
}
return result, rows.Err()
}
// RestoreFile marks a file as active again and updates its storage_key to the restored path.
// The caller must move the blob from .trash/ back to newStorageKey before calling this.
func (s *Store) RestoreFile(ctx context.Context, id, ownerID uuid.UUID, newStorageKey string) (*File, error) {
var f File
err := scanFile(s.db.QueryRow(ctx,
`UPDATE files SET is_deleted = false, deleted_at = NULL, storage_key = $3
WHERE id = $1 AND (owner_id = $2 OR group_id = $2) AND is_deleted = true
RETURNING `+fileCols,
id, ownerID, newStorageKey,
), &f)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("restore file: %w", err)
}
return &f, nil
}
// HardDeleteFile permanently removes a file and returns its storage key plus all version
// storage keys so the caller can delete the blobs. file_versions rows are removed by
// the ON DELETE CASCADE on the FK.
// EmptyTrash löscht alle Papierkorb-Einträge eines Nutzers endgültig und
// liefert sämtliche Blob-Schlüssel zurück, damit der Aufrufer die Bytes
// entfernen kann.
//
// Die IDs werden mit zurückgegeben, weil zu jeder Datei noch ein
// Vorschaubild unter "thumbs/<id>.jpg" liegen kann.
func (s *Store) EmptyTrash(ctx context.Context, ownerID uuid.UUID) (fileIDs []uuid.UUID, keys []string, err error) {
// Erst die Versionsblobs einsammeln – die Kaskade räumt die Zeilen mit weg.
rows, err := s.db.Query(ctx,
`SELECT v.storage_key
FROM file_versions v
JOIN files f ON f.id = v.file_id
WHERE (f.owner_id = $1 OR f.group_id = $1) AND f.is_deleted = true`, ownerID)
if err != nil {
return nil, nil, fmt.Errorf("list trash version keys: %w", err)
}
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
rows.Close()
return nil, nil, err
}
keys = append(keys, k)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, nil, err
}
rows, err = s.db.Query(ctx,
`DELETE FROM files WHERE (owner_id = $1 OR group_id = $1) AND is_deleted = true
RETURNING id, storage_key`, ownerID)
if err != nil {
return nil, nil, fmt.Errorf("empty trash: %w", err)
}
defer rows.Close()
for rows.Next() {
var id uuid.UUID
var k string
if err := rows.Scan(&id, &k); err != nil {
return nil, nil, err
}
fileIDs = append(fileIDs, id)
keys = append(keys, k)
}
return fileIDs, keys, rows.Err()
}
func (s *Store) HardDeleteFile(ctx context.Context, id, ownerID uuid.UUID) (storageKey string, versionKeys []string, err error) {
// Collect version blobs before the cascade removes them.
rows, err := s.db.Query(ctx,
`SELECT storage_key FROM file_versions WHERE file_id = $1`, id)
if err != nil {
return "", nil, fmt.Errorf("list version keys: %w", err)
}
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
rows.Close()
return "", nil, err
}
versionKeys = append(versionKeys, k)
}
rows.Close()
if err := rows.Err(); err != nil {
return "", nil, err
}
err = s.db.QueryRow(ctx,
`DELETE FROM files WHERE id = $1 AND (owner_id = $2 OR group_id = $2) AND is_deleted = true
RETURNING storage_key`,
id, ownerID,
).Scan(&storageKey)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil, ErrNotFound
}
if err != nil {
return "", nil, fmt.Errorf("hard delete file: %w", err)
}
return storageKey, versionKeys, nil
}
func (s *Store) UsedBytes(ctx context.Context, ownerID uuid.UUID) (int64, error) {
var n int64
err := s.db.QueryRow(ctx,
`SELECT COALESCE(SUM(size), 0) FROM files WHERE (owner_id = $1 OR group_id = $1) AND is_deleted = false`,
ownerID,
).Scan(&n)
return n, err
}
// GetFolderPath resolves the full slash-separated path for a folder, e.g. "Urlaub/2024".
// Returns "" for root (nil folderID).
func (s *Store) GetFolderPath(ctx context.Context, folderID *uuid.UUID) (string, error) {
if folderID == nil {
return "", nil
}
var path string
err := s.db.QueryRow(ctx,
`WITH RECURSIVE p AS (
SELECT id, name, parent_id, name::text AS full_path
FROM folders WHERE id = $1
UNION ALL
SELECT f.id, f.name, f.parent_id, f.name || '/' || p.full_path
FROM folders f JOIN p ON f.id = p.parent_id
)
SELECT full_path FROM p WHERE parent_id IS NULL`,
*folderID,
).Scan(&path)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return path, err
}
// GetFileByName finds a non-deleted file by name in a folder (for versioning on re-upload).
func (s *Store) GetFileByName(ctx context.Context, ownerID uuid.UUID, folderID *uuid.UUID, name string) (*File, error) {
var f File
var err error
if folderID == nil {
err = scanFile(s.db.QueryRow(ctx,
`SELECT `+fileCols+` FROM files
WHERE (owner_id = $1 OR group_id = $1) AND folder_id IS NULL AND name=$2 AND is_deleted=false`,
ownerID, name,
), &f)
} else {
err = scanFile(s.db.QueryRow(ctx,
`SELECT `+fileCols+` FROM files
WHERE (owner_id = $1 OR group_id = $1) AND folder_id=$2 AND name=$3 AND is_deleted=false`,
ownerID, *folderID, name,
), &f)
}
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &f, err
}
func (s *Store) CreateVersion(ctx context.Context, fileID uuid.UUID, version int, size int64, checksum, storageKey string) error {
_, err := s.db.Exec(ctx,
`INSERT INTO file_versions (file_id, version, size, checksum, storage_key)
VALUES ($1, $2, $3, $4, $5)`,
fileID, version, size, checksum, storageKey,
)
return err
}
// UpdateFileContent replaces blob metadata and increments version. Resets has_thumbnail.
func (s *Store) UpdateFileContent(ctx context.Context, id uuid.UUID, size int64, checksum, storageKey string) (*File, error) {
var f File
err := scanFile(s.db.QueryRow(ctx,
`UPDATE files SET size=$2, checksum=$3, storage_key=$4, version=version+1, has_thumbnail=false
WHERE id=$1 AND is_deleted=false RETURNING `+fileCols,
id, size, checksum, storageKey,
), &f)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &f, err
}
// DeleteVersion entfernt einen Versionseintrag. Wird beim Wiederherstellen
// gebraucht: dessen Inhalt ist danach die aktive Fassung, der Eintrag also
// gegenstandslos. Der Blob wird nicht angefasst – er wandert zur Datei.
func (s *Store) DeleteVersion(ctx context.Context, versionID, fileID uuid.UUID) error {
_, err := s.db.Exec(ctx,
`DELETE FROM file_versions WHERE id=$1 AND file_id=$2`, versionID, fileID)
return err
}
// SetVersioning schaltet die Versionierung einer Datei um.
//
// Bestehende Fassungen bleiben beim Ausschalten erhalten – sie zu löschen wäre
// stiller Datenverlust. Es entstehen ab dann nur keine neuen mehr.
func (s *Store) SetVersioning(ctx context.Context, id, ownerID uuid.UUID, enabled bool) (*File, error) {
var f File
err := scanFile(s.db.QueryRow(ctx,
`UPDATE files SET versioning_enabled=$3
WHERE id=$1 AND (owner_id = $2 OR group_id = $2) AND is_deleted=false RETURNING `+fileCols,
id, ownerID, enabled,
), &f)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &f, err
}
// PruneVersions keeps the newest maxKeep versions and deletes the rest.
// Returns storage keys of deleted versions so the caller can remove the blobs.
func (s *Store) PruneVersions(ctx context.Context, fileID uuid.UUID, maxKeep int) ([]string, error) {
rows, err := s.db.Query(ctx,
`DELETE FROM file_versions
WHERE id IN (
SELECT id FROM file_versions WHERE file_id=$1
ORDER BY version DESC OFFSET $2
) RETURNING storage_key`,
fileID, maxKeep,
)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err == nil {
keys = append(keys, k)
}
}
return keys, rows.Err()
}
func (s *Store) ListVersions(ctx context.Context, fileID, ownerID uuid.UUID) ([]*FileVersion, error) {
// Verify ownership via JOIN
rows, err := s.db.Query(ctx,
`SELECT fv.id, fv.file_id, fv.version, fv.size, fv.checksum, fv.storage_key, fv.created_at
FROM file_versions fv
JOIN files f ON f.id = fv.file_id
WHERE fv.file_id=$1 AND (f.owner_id = $2 OR f.group_id = $2)
ORDER BY fv.version DESC`,
fileID, ownerID,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]*FileVersion, 0)
for rows.Next() {
var v FileVersion
if err := rows.Scan(&v.ID, &v.FileID, &v.Version, &v.Size, &v.Checksum, &v.StorageKey, &v.CreatedAt); err != nil {
return nil, err
}
result = append(result, &v)
}
return result, rows.Err()
}
func (s *Store) GetVersion(ctx context.Context, versionID, fileID, ownerID uuid.UUID) (*FileVersion, error) {
var v FileVersion
err := s.db.QueryRow(ctx,
`SELECT fv.id, fv.file_id, fv.version, fv.size, fv.checksum, fv.storage_key, fv.created_at
FROM file_versions fv
JOIN files f ON f.id = fv.file_id
WHERE fv.id=$1 AND fv.file_id=$2 AND (f.owner_id = $3 OR f.group_id = $3)`,
versionID, fileID, ownerID,
).Scan(&v.ID, &v.FileID, &v.Version, &v.Size, &v.Checksum, &v.StorageKey, &v.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &v, err
}
func (s *Store) UpdateThumbnail(ctx context.Context, id uuid.UUID, hasThumbnail bool) error {
_, err := s.db.Exec(ctx,
`UPDATE files SET has_thumbnail = $2 WHERE id = $1`, id, hasThumbnail)
return err
}
// ── helpers ───────────────────────────────────────────────────────────────────
func scanFiles(rows pgx.Rows) ([]*File, error) {
result := make([]*File, 0)
for rows.Next() {
var f File
if err := scanFile(rows, &f); err != nil {
return nil, err
}
result = append(result, &f)
}
return result, rows.Err()
}
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
type sqlStater interface{ SQLState() string }
var se sqlStater
if errors.As(err, &se) {
return se.SQLState() == "23505"
}
return false
}
+129
View File
@@ -0,0 +1,129 @@
package files
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Favoriten der Seitenleiste. Reihenfolge und Auf-/Zugeklappt-Zustand
// liegen serverseitig, damit sie auf jedem Gerät gleich sind.
// ── Favorites ─────────────────────────────────────────────────────────────────
func (s *Store) ListFavorites(ctx context.Context, userID uuid.UUID) ([]*Favorite, error) {
// Nur Favoriten anzeigen, die noch erreichbar sind. Ein Lesezeichen auf
// einen Ordner, den man nicht mehr öffnen darf – weil man die Gruppe
// verlassen hat oder die Freigabe zurückgezogen wurde – bliebe sonst als
// toter Eintrag in der Seitenleiste stehen und liefe beim Klick ins Leere.
rows, err := s.db.Query(ctx,
`WITH RECURSIVE shared_roots AS (
SELECT e.resource_folder_id AS id
FROM acl_entries e
WHERE e.resource_folder_id IS NOT NULL
AND (e.subject_user_id = $1
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
UNION
-- Nach unten aufspannen: Wer einen Ordner freigegeben bekommt, darf
-- auch alles darunter, und darf sich das ebenso merken.
SELECT fo.id FROM folders fo JOIN shared_roots sr ON fo.parent_id = sr.id
)
SELECT f.id, f.user_id, f.folder_id, f.position, f.is_expanded, f.created_at,
fo.name, fo.parent_id
FROM favorites f
JOIN folders fo ON fo.id = f.folder_id
WHERE f.user_id = $1
AND ( fo.owner_id = $1
OR fo.group_id IN (SELECT group_id FROM group_members WHERE user_id = $1)
OR fo.id IN (SELECT id FROM shared_roots) )
ORDER BY f.position`,
userID,
)
if err != nil {
return nil, fmt.Errorf("list favorites: %w", err)
}
defer rows.Close()
favs := make([]*Favorite, 0)
for rows.Next() {
var fav Favorite
if err := rows.Scan(
&fav.ID, &fav.UserID, &fav.FolderID, &fav.Position, &fav.IsExpanded, &fav.CreatedAt,
&fav.FolderName, &fav.FolderParentID,
); err != nil {
return nil, err
}
favs = append(favs, &fav)
}
return favs, rows.Err()
}
func (s *Store) AddFavorite(ctx context.Context, userID, folderID uuid.UUID) (*Favorite, error) {
var fav Favorite
err := s.db.QueryRow(ctx,
`INSERT INTO favorites (user_id, folder_id, position)
VALUES ($1, $2, (SELECT COALESCE(MAX(position)+1, 0) FROM favorites WHERE user_id=$1))
ON CONFLICT (user_id, folder_id) DO NOTHING
RETURNING id, user_id, folder_id, position, is_expanded, created_at`,
userID, folderID,
).Scan(&fav.ID, &fav.UserID, &fav.FolderID, &fav.Position, &fav.IsExpanded, &fav.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNameTaken // already a favorite
}
if err != nil {
return nil, fmt.Errorf("add favorite: %w", err)
}
// Fetch folder name for response.
var f Folder
_ = s.db.QueryRow(ctx,
`SELECT id, owner_id, group_id, parent_id, name, created_at FROM folders WHERE id=$1`, folderID,
).Scan(&f.ID, &f.OwnerID, &f.GroupID, &f.ParentID, &f.Name, &f.CreatedAt)
fav.FolderName = f.Name
fav.FolderParentID = f.ParentID
return &fav, nil
}
func (s *Store) RemoveFavorite(ctx context.Context, userID, folderID uuid.UUID) error {
tag, err := s.db.Exec(ctx,
`DELETE FROM favorites WHERE user_id=$1 AND folder_id=$2`, userID, folderID)
if err != nil {
return fmt.Errorf("remove favorite: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) SetFavoriteExpanded(ctx context.Context, userID, folderID uuid.UUID, expanded bool) error {
tag, err := s.db.Exec(ctx,
`UPDATE favorites SET is_expanded=$3 WHERE user_id=$1 AND folder_id=$2`,
userID, folderID, expanded,
)
if err != nil {
return fmt.Errorf("set favorite expanded: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) ReorderFavorites(ctx context.Context, userID uuid.UUID, folderIDs []uuid.UUID) error {
tx, err := s.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
for i, fid := range folderIDs {
if _, err := tx.Exec(ctx,
`UPDATE favorites SET position=$3 WHERE user_id=$1 AND folder_id=$2`,
userID, fid, i,
); err != nil {
return fmt.Errorf("reorder favorites: %w", err)
}
}
return tx.Commit(ctx)
}
+209
View File
@@ -0,0 +1,209 @@
package files
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Ordner: anlegen, umbenennen, verschieben, löschen, auflisten.
// ── Folders ──────────────────────────────────────────────────────────────────
func (s *Store) CreateFolder(ctx context.Context, f *Folder) (*Folder, error) {
var out Folder
err := s.db.QueryRow(ctx,
`INSERT INTO folders (owner_id, group_id, parent_id, name)
VALUES ($1, $2, $3, $4)
RETURNING id, owner_id, group_id, parent_id, name, created_at`,
f.OwnerID, f.GroupID, f.ParentID, f.Name,
).Scan(&out.ID, &out.OwnerID, &out.GroupID, &out.ParentID, &out.Name, &out.CreatedAt)
if err != nil {
if isUniqueViolation(err) {
return nil, ErrNameTaken
}
return nil, fmt.Errorf("create folder: %w", err)
}
return &out, nil
}
func (s *Store) GetFolder(ctx context.Context, id, ownerID uuid.UUID) (*Folder, error) {
var f Folder
err := s.db.QueryRow(ctx,
`SELECT id, owner_id, group_id, parent_id, name, created_at FROM folders WHERE id = $1 AND (owner_id = $2 OR group_id = $2)`,
id, ownerID,
).Scan(&f.ID, &f.OwnerID, &f.GroupID, &f.ParentID, &f.Name, &f.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get folder: %w", err)
}
return &f, nil
}
// ListFilesInSubtree returns all non-deleted files inside a folder and all its descendants.
func (s *Store) ListFilesInSubtree(ctx context.Context, ownerID, rootFolderID uuid.UUID) ([]*File, error) {
rows, err := s.db.Query(ctx,
`WITH RECURSIVE subtree AS (
SELECT id FROM folders WHERE id=$1 AND (owner_id = $2 OR group_id = $2)
UNION ALL
SELECT f.id FROM folders f JOIN subtree s ON f.parent_id=s.id WHERE (f.owner_id = $2 OR f.group_id = $2)
)
SELECT `+fileCols+`
FROM files
WHERE (owner_id = $2 OR group_id = $2) AND is_deleted=false AND folder_id IN (SELECT id FROM subtree)`,
rootFolderID, ownerID,
)
if err != nil {
return nil, fmt.Errorf("list files in subtree: %w", err)
}
defer rows.Close()
files := make([]*File, 0)
for rows.Next() {
var f File
if err := scanFile(rows, &f); err != nil {
return nil, err
}
files = append(files, &f)
}
return files, rows.Err()
}
// UpdateFileStorageKey updates only the storage_key of a file (used after blob moves).
func (s *Store) UpdateFileStorageKey(ctx context.Context, id uuid.UUID, storageKey string) error {
_, err := s.db.Exec(ctx, `UPDATE files SET storage_key=$2 WHERE id=$1`, id, storageKey)
return err
}
// RenameFolder renames a folder. Blob path cascading is handled by the handler.
func (s *Store) RenameFolder(ctx context.Context, id, ownerID uuid.UUID, name string) (*Folder, error) {
var f Folder
err := s.db.QueryRow(ctx,
`UPDATE folders SET name=$3 WHERE id=$1 AND (owner_id = $2 OR group_id = $2)
RETURNING id, owner_id, group_id, parent_id, name, created_at`,
id, ownerID, name,
).Scan(&f.ID, &f.OwnerID, &f.GroupID, &f.ParentID, &f.Name, &f.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
if isUniqueViolation(err) {
return nil, ErrNameTaken
}
return nil, fmt.Errorf("rename folder: %w", err)
}
return &f, nil
}
// DeleteFolder soft-deletes all files in the subtree (storage_keys must already be
// updated to trash paths by the caller) and hard-deletes the folder tree.
func (s *Store) DeleteFolder(ctx context.Context, id, ownerID uuid.UUID) error {
tx, err := s.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx,
`WITH RECURSIVE subtree AS (
SELECT id FROM folders WHERE id=$1 AND (owner_id = $2 OR group_id = $2)
UNION ALL
SELECT f.id FROM folders f JOIN subtree s ON f.parent_id=s.id WHERE (f.owner_id = $2 OR f.group_id = $2)
)
UPDATE files SET is_deleted=true, deleted_at=NOW()
WHERE (owner_id = $2 OR group_id = $2) AND is_deleted=false AND folder_id IN (SELECT id FROM subtree)`,
id, ownerID,
)
if err != nil {
return fmt.Errorf("soft-delete files in folder: %w", err)
}
_, err = tx.Exec(ctx,
`WITH RECURSIVE subtree AS (
SELECT id FROM folders WHERE id=$1 AND (owner_id = $2 OR group_id = $2)
UNION ALL
SELECT f.id FROM folders f JOIN subtree s ON f.parent_id=s.id WHERE (f.owner_id = $2 OR f.group_id = $2)
)
DELETE FROM folders WHERE id IN (SELECT id FROM subtree) AND (owner_id = $2 OR group_id = $2)`,
id, ownerID,
)
if err != nil {
return fmt.Errorf("delete folders: %w", err)
}
return tx.Commit(ctx)
}
func (s *Store) ListFolders(ctx context.Context, ownerID uuid.UUID, parentID *uuid.UUID) ([]*Folder, error) {
var rows pgx.Rows
var err error
if parentID == nil {
rows, err = s.db.Query(ctx,
`SELECT id, owner_id, group_id, parent_id, name, created_at FROM folders
WHERE (owner_id = $1 OR group_id = $1) AND parent_id IS NULL ORDER BY name`,
ownerID,
)
} else {
rows, err = s.db.Query(ctx,
`SELECT id, owner_id, group_id, parent_id, name, created_at FROM folders
WHERE (owner_id = $1 OR group_id = $1) AND parent_id = $2 ORDER BY name`,
ownerID, parentID,
)
}
if err != nil {
return nil, fmt.Errorf("list folders: %w", err)
}
defer rows.Close()
folders := make([]*Folder, 0)
for rows.Next() {
var f Folder
if err := rows.Scan(&f.ID, &f.OwnerID, &f.GroupID, &f.ParentID, &f.Name, &f.CreatedAt); err != nil {
return nil, err
}
folders = append(folders, &f)
}
return folders, rows.Err()
}
// MoveFolder changes a folder's parent. Returns ErrCycle if newParentID is a descendant.
func (s *Store) MoveFolder(ctx context.Context, id, ownerID uuid.UUID, newParentID *uuid.UUID) (*Folder, error) {
if newParentID != nil {
var isDescendant bool
err := s.db.QueryRow(ctx,
`WITH RECURSIVE subtree AS (
SELECT id FROM folders WHERE id=$1 AND (owner_id = $2 OR group_id = $2)
UNION ALL
SELECT f.id FROM folders f JOIN subtree s ON f.parent_id=s.id WHERE (f.owner_id = $2 OR f.group_id = $2)
)
SELECT EXISTS(SELECT 1 FROM subtree WHERE id=$3)`,
id, ownerID, *newParentID,
).Scan(&isDescendant)
if err != nil {
return nil, fmt.Errorf("cycle check: %w", err)
}
if isDescendant {
return nil, ErrCycle
}
}
var f Folder
err := s.db.QueryRow(ctx,
`UPDATE folders SET parent_id=$3 WHERE id=$1 AND (owner_id = $2 OR group_id = $2)
RETURNING id, owner_id, group_id, parent_id, name, created_at`,
id, ownerID, newParentID,
).Scan(&f.ID, &f.OwnerID, &f.GroupID, &f.ParentID, &f.Name, &f.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
if isUniqueViolation(err) {
return nil, ErrNameTaken
}
return nil, fmt.Errorf("move folder: %w", err)
}
return &f, nil
}
+102
View File
@@ -0,0 +1,102 @@
package files
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Auflösung von Pfaden zu Ordnern – gebraucht von WebDAV, das über Pfade
// adressiert statt über IDs.
// ── WebDAV helpers ────────────────────────────────────────────────────────────
// FolderByPath resolves a path like ["photos", "2024"] to a Folder.
// Returns a zero-ID folder for an empty parts slice (root).
func (s *Store) FolderByPath(ctx context.Context, principalID uuid.UUID, parts []string) (*Folder, error) {
return s.FolderByPathFrom(ctx, principalID, nil, parts)
}
// FolderByPathFrom löst Pfadteile ab einem bestimmten Ordner auf. base == nil
// heißt: ab der Wurzel des Prinzipals.
//
// Der Einstiegspunkt ist nötig, weil WebDAV geteilte Ordner unter einem
// virtuellen Pfad einhängt: Dort beginnt der Weg nicht bei der Wurzel des
// Eigentümers, sondern genau bei dem Ordner, der freigegeben wurde – alles
// darüber geht den Empfänger nichts an.
func (s *Store) FolderByPathFrom(ctx context.Context, principalID uuid.UUID, base *uuid.UUID, parts []string) (*Folder, error) {
if len(parts) == 0 {
if base == nil {
return &Folder{OwnerID: &principalID, Name: "/"}, nil
}
return s.folderByID(ctx, *base)
}
parentID := base
var current *Folder
for _, part := range parts {
var f Folder
var err error
if parentID == nil {
err = s.db.QueryRow(ctx,
`SELECT id, owner_id, group_id, parent_id, name, created_at FROM folders
WHERE (owner_id = $1 OR group_id = $1) AND parent_id IS NULL AND name=$2`,
principalID, part,
).Scan(&f.ID, &f.OwnerID, &f.GroupID, &f.ParentID, &f.Name, &f.CreatedAt)
} else {
err = s.db.QueryRow(ctx,
`SELECT id, owner_id, group_id, parent_id, name, created_at FROM folders
WHERE (owner_id = $1 OR group_id = $1) AND parent_id=$2 AND name=$3`,
principalID, parentID, part,
).Scan(&f.ID, &f.OwnerID, &f.GroupID, &f.ParentID, &f.Name, &f.CreatedAt)
}
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("folder by path: %w", err)
}
parentID = &f.ID
current = &f
}
return current, nil
}
func (s *Store) folderByID(ctx context.Context, id uuid.UUID) (*Folder, error) {
var f Folder
err := s.db.QueryRow(ctx,
`SELECT id, owner_id, group_id, parent_id, name, created_at FROM folders WHERE id = $1`, id,
).Scan(&f.ID, &f.OwnerID, &f.GroupID, &f.ParentID, &f.Name, &f.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("folder by id: %w", err)
}
return &f, nil
}
// FileInFolder finds a non-deleted file by name within a folder (nil = root).
func (s *Store) FileInFolder(ctx context.Context, ownerID uuid.UUID, folderID *uuid.UUID, name string) (*File, error) {
var f File
var err error
if folderID == nil {
err = scanFile(s.db.QueryRow(ctx,
`SELECT `+fileCols+` FROM files
WHERE (owner_id = $1 OR group_id = $1) AND folder_id IS NULL AND name=$2 AND is_deleted=false`,
ownerID, name,
), &f)
} else {
err = scanFile(s.db.QueryRow(ctx,
`SELECT `+fileCols+` FROM files
WHERE (owner_id = $1 OR group_id = $1) AND folder_id=$2 AND name=$3 AND is_deleted=false`,
ownerID, folderID, name,
), &f)
}
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &f, err
}
+120
View File
@@ -0,0 +1,120 @@
package files
import (
"context"
"fmt"
"github.com/google/uuid"
)
// ZipEntry ist eine Datei samt ihrem Pfad **innerhalb** des Archivs.
type ZipEntry struct {
ID uuid.UUID
Name string
Path string // z. B. "Belege/2026/rechnung.pdf"
Size int64
StorageKey string
MimeType string
}
// maxZipEntries begrenzt, wie viele Dateien in ein Archiv gehen.
//
// Nicht wegen des Speichers – das Archiv wird gestreamt und liegt nie ganz
// irgendwo –, sondern wegen der Zeit: Bei zehntausend Dateien liefe die
// Anfrage minutenlang, und ein Abbruch mittendrin hinterließe ein halbes
// Archiv, dem man das nicht ansieht.
const MaxZipEntries = 2000
// FilesInFolderTree liefert alle Dateien unterhalb eines Ordners **mit ihrem
// relativen Pfad**, damit das Archiv die Ordnerstruktur behält.
//
// Der Pfad entsteht in der Abfrage selbst: Ihn nachträglich in Go
// zusammenzusetzen hieße, für jeden Ordner die Kette nach oben einzeln
// abzufragen – bei fünfzig Unterordnern fünfzig Rundreisen.
func (s *Store) FilesInFolderTree(ctx context.Context, principalID, rootFolderID uuid.UUID) ([]ZipEntry, error) {
const q = `
WITH RECURSIVE tree AS (
-- Die Wurzel selbst trägt den leeren Pfad: Ihr eigener Name wird zum Namen
-- des Archivs, nicht zu einem Ordner darin.
SELECT id, ''::text AS path
FROM folders
WHERE id = $1 AND (owner_id = $2 OR group_id = $2)
UNION ALL
SELECT f.id, CASE WHEN t.path = '' THEN f.name ELSE t.path || '/' || f.name END
FROM folders f
JOIN tree t ON f.parent_id = t.id
WHERE (f.owner_id = $2 OR f.group_id = $2)
)
SELECT fi.id, fi.name,
CASE WHEN t.path = '' THEN fi.name ELSE t.path || '/' || fi.name END AS path,
fi.size, fi.storage_key, fi.mime_type
FROM files fi
JOIN tree t ON t.id = fi.folder_id
WHERE fi.is_deleted = false
AND (fi.owner_id = $2 OR fi.group_id = $2)
ORDER BY path
LIMIT $3`
return s.scanZipEntries(ctx, q, rootFolderID, principalID, MaxZipEntries+1)
}
// FilesForZipByIDs liefert genau die genannten Dateien – aber nur die, die der
// Anfragende auch erreichen darf.
//
// Der Umfang steckt wie bei der Suche in der Abfrage, nicht in einer Prüfung
// danach. Eine erratene Kennung in der Liste fällt hier still heraus, statt
// im Archiv zu landen.
//
// Die Pfade sind flach: Eine Auswahl kommt aus einem Ordner oder aus einer
// Etikettenliste, wo eine nachgebaute Ordnerstruktur nur im Weg wäre.
func (s *Store) FilesForZipByIDs(ctx context.Context, userID uuid.UUID, ids []uuid.UUID) ([]ZipEntry, error) {
const q = `
WITH RECURSIVE my_groups AS (
SELECT group_id FROM group_members WHERE user_id = $2
),
granted_roots AS (
SELECT resource_folder_id AS id FROM acl_entries
WHERE resource_folder_id IS NOT NULL
AND (subject_user_id = $2 OR subject_group_id IN (SELECT group_id FROM my_groups))
),
shared_tree AS (
SELECT id FROM granted_roots
UNION
SELECT f.id FROM folders f JOIN shared_tree s ON f.parent_id = s.id
),
granted_files AS (
SELECT resource_file_id AS id FROM acl_entries
WHERE resource_file_id IS NOT NULL
AND (subject_user_id = $2 OR subject_group_id IN (SELECT group_id FROM my_groups))
)
SELECT fi.id, fi.name, fi.name AS path, fi.size, fi.storage_key, fi.mime_type
FROM files fi
WHERE fi.id = ANY($1)
AND fi.is_deleted = false
AND (fi.owner_id = $2
OR fi.group_id IN (SELECT group_id FROM my_groups)
OR fi.folder_id IN (SELECT id FROM shared_tree)
OR fi.id IN (SELECT id FROM granted_files))
ORDER BY fi.name
LIMIT $3`
return s.scanZipEntries(ctx, q, ids, userID, MaxZipEntries+1)
}
func (s *Store) scanZipEntries(ctx context.Context, q string, args ...any) ([]ZipEntry, error) {
rows, err := s.db.Query(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("zip entries: %w", err)
}
defer rows.Close()
out := []ZipEntry{}
for rows.Next() {
var e ZipEntry
if err := rows.Scan(&e.ID, &e.Name, &e.Path, &e.Size, &e.StorageKey, &e.MimeType); err != nil {
return nil, fmt.Errorf("scan zip entry: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
+245
View File
@@ -0,0 +1,245 @@
// Package gc räumt verwaiste Blobs auf: Dateien im Speicher, auf die keine
// Datenbankzeile mehr zeigt.
//
// Solche Waisen entstehen, wenn ein Vorgang zwischen Blob-Schreiben und
// Datenbank-Commit abbricht – etwa weil der Prozess stirbt oder ein Löschen
// nur halb durchläuft. Ohne Aufräumen belegen sie für immer Platz, ohne dass
// sie irgendwo auftauchen.
//
// Der Ablauf ist bewusst defensiv: Es wird nur gelöscht, was die Datenbank
// nicht kennt UND älter als eine Schonfrist ist. Der Sinn der Schonfrist:
// Zwischen `storage.Put` und dem `INSERT` liegt ein kurzer Moment, in dem ein
// völlig gesunder Upload wie eine Waise aussieht.
package gc
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog/log"
"aether/internal/storage"
)
const (
// gracePeriod schützt frische Blobs, deren Datenbankzeile noch unterwegs
// ist. Großzügig bemessen – ein Tag zu spät aufgeräumt kostet nichts, einen
// laufenden Upload zu löschen dagegen sehr wohl.
gracePeriod = 24 * time.Hour
// Interval zwischen zwei Durchläufen.
Interval = 12 * time.Hour
)
type Collector struct {
db *pgxpool.Pool
storage storage.Storage
}
func New(db *pgxpool.Pool, st storage.Storage) *Collector {
return &Collector{db: db, storage: st}
}
// Result fasst einen Durchlauf zusammen.
type Result struct {
Scanned int
Orphans int
Deleted int
FreedBytes int64
SkippedYoung int
}
// Run führt einen Durchlauf aus.
func (c *Collector) Run(ctx context.Context) (Result, error) {
var res Result
referenced, err := c.referencedKeys(ctx)
if err != nil {
return res, err
}
cutoff := time.Now().Add(-gracePeriod)
type orphan struct {
key string
size int64
}
var orphans []orphan
// "tmp/" bleibt außen vor: Das gehört dem Upload-Pfad und wird dort
// aufgeräumt; ein GC, der dort mitmischt, zerrisse laufende Uploads.
for _, prefix := range []string{"users", "thumbs", "branding"} {
err := c.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error {
res.Scanned++
if referenced[key] {
return nil
}
if info.ModTime.After(cutoff) {
res.SkippedYoung++
return nil
}
orphans = append(orphans, orphan{key: key, size: info.Size})
return nil
})
if err != nil {
return res, fmt.Errorf("walk %q: %w", prefix, err)
}
}
res.Orphans = len(orphans)
if shouldAbort(len(referenced), res.Scanned) {
log.Ctx(ctx).Error().
Int("scanned", res.Scanned).
Msg("gc: database reports no referenced blobs while storage is not empty – skipping run")
return res, nil
}
for _, o := range orphans {
if err := c.storage.Delete(ctx, o.key); err != nil {
log.Ctx(ctx).Warn().Err(err).Str("key", o.key).Msg("gc: could not delete orphan")
continue
}
res.Deleted++
res.FreedBytes += o.size
log.Ctx(ctx).Info().
Str("key", o.key).Int64("bytes", o.size).
Msg("gc: orphaned blob removed")
}
return res, nil
}
// shouldAbort ist die Notbremse vor dem Löschen: Kennt die Datenbank keinen
// einzigen Blob, obwohl im Speicher welche liegen, ist mit hoher
// Wahrscheinlichkeit die Abfrage kaputt oder die Verbindung auf die falsche
// Datenbank gerichtet. Das Ergebnis wäre "alles ist verwaist" – der GC würde
// den kompletten Bestand löschen. Dann lieber gar nichts tun und laut sein.
//
// Die leere Installation (nichts in der Datenbank, nichts auf der Platte) ist
// davon ausgenommen: da gibt es schlicht nichts zu verwechseln.
func shouldAbort(referencedCount, scanned int) bool {
return referencedCount == 0 && scanned > 0
}
// referencedKeys sammelt jeden Speicherschlüssel, auf den die Datenbank zeigt –
// aktive Dateien, Papierkorb und Versionen gleichermaßen. Thumbnails haben
// keine eigene Spalte, ihr Schlüssel leitet sich aus der Datei-ID ab.
func (c *Collector) referencedKeys(ctx context.Context) (map[string]bool, error) {
keys := make(map[string]bool)
// files.storage_key deckt aktive UND gelöschte Dateien ab: beim Löschen
// wandert der Schlüssel auf den Papierkorb-Pfad mit.
rows, err := c.db.Query(ctx, `SELECT storage_key, id, has_thumbnail FROM files`)
if err != nil {
return nil, fmt.Errorf("query file keys: %w", err)
}
for rows.Next() {
var key, id string
var hasThumb bool
if err := rows.Scan(&key, &id, &hasThumb); err != nil {
rows.Close()
return nil, fmt.Errorf("scan file key: %w", err)
}
keys[strings.TrimPrefix(key, "/")] = true
if hasThumb {
keys["thumbs/"+id+".jpg"] = true
}
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate file keys: %w", err)
}
// Logo und App-Symbol haben keine Zeile in files, sondern stehen in den
// Einstellungen. Ohne diese Abfrage hielte der GC sie für verwaist, sobald
// "branding" mit durchsucht wird – und löschte sie beim nächsten Lauf.
//
// Das Logo ist ein einzelner Schlüssel, das App-Symbol ein Verzeichnis:
// Endet der Wert auf "/", zählt alles darunter als referenziert.
brows, err := c.db.Query(ctx,
`SELECT COALESCE(value, '') FROM settings
WHERE key IN ('logo_storage_key', 'app_icon_storage_key')`)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("query branding keys: %w", err)
}
if err == nil {
for brows.Next() {
var key string
if err := brows.Scan(&key); err != nil {
brows.Close()
return nil, fmt.Errorf("scan branding key: %w", err)
}
if key == "" {
continue
}
key = strings.TrimPrefix(key, "/")
if !strings.HasSuffix(key, "/") {
keys[key] = true
continue
}
if err := c.storage.Walk(ctx, key, func(k string, _ storage.FileInfo) error {
keys[k] = true
return nil
}); err != nil {
brows.Close()
return nil, fmt.Errorf("walk branding dir %s: %w", key, err)
}
}
brows.Close()
if err := brows.Err(); err != nil {
return nil, fmt.Errorf("iterate branding keys: %w", err)
}
}
vrows, err := c.db.Query(ctx, `SELECT storage_key FROM file_versions`)
if err != nil {
return nil, fmt.Errorf("query version keys: %w", err)
}
defer vrows.Close()
for vrows.Next() {
var key string
if err := vrows.Scan(&key); err != nil {
return nil, fmt.Errorf("scan version key: %w", err)
}
keys[strings.TrimPrefix(key, "/")] = true
}
return keys, vrows.Err()
}
// Start lässt den GC im Hintergrund laufen: einmal kurz nach dem Start, danach
// im Intervall. Der erste Durchlauf ist verzögert, damit er nicht mit dem
// Hochfahren konkurriert.
func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
go func() {
timer := time.NewTimer(5 * time.Minute)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
}
res, err := c.Run(ctx)
if err != nil {
log.Ctx(ctx).Error().Err(err).Msg("gc: run failed")
} else {
log.Ctx(ctx).Info().
Int("scanned", res.Scanned).
Int("orphans", res.Orphans).
Int("deleted", res.Deleted).
Int64("freed_bytes", res.FreedBytes).
Int("skipped_young", res.SkippedYoung).
Msg("gc: run complete")
if onDone != nil && res.Deleted > 0 {
onDone(res)
}
}
timer.Reset(Interval)
}
}()
}
+28
View File
@@ -0,0 +1,28 @@
package gc
import "testing"
// Die Notbremse ist die letzte Verteidigungslinie: Hinter ihr löscht der GC
// echte Nutzerdateien. Eine kaputte Abfrage oder eine Verbindung zur falschen
// Datenbank sieht von hier aus exakt wie "alles ist verwaist" aus.
func TestShouldAbort(t *testing.T) {
tests := []struct {
name string
referenced int
scanned int
want bool
}{
{"database knows nothing but storage is full", 0, 500, true},
{"fresh install – nothing anywhere", 0, 0, false},
{"normal run", 120, 125, false},
{"single orphan among many", 1, 2, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldAbort(tt.referenced, tt.scanned); got != tt.want {
t.Errorf("shouldAbort(%d, %d) = %v, want %v",
tt.referenced, tt.scanned, got, tt.want)
}
})
}
}
+200
View File
@@ -0,0 +1,200 @@
package office
import (
"context"
"crypto/tls"
"encoding/xml"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// Die Ermittlungsliste („discovery") sagt, welche Adresse ein Office-Server
// für welchen Dateityp anbietet.
//
// Fest verdrahten ließe sich das nicht: Collabora und OnlyOffice bauen ihre
// Adressen verschieden auf, und beide ändern sie zwischen Fassungen. Gefragt
// wird deshalb der Server selbst – einmal, dann für eine Weile gemerkt.
type discoveryXML struct {
NetZone struct {
Apps []struct {
Name string `xml:"name,attr"`
Actions []struct {
Name string `xml:"name,attr"`
Ext string `xml:"ext,attr"`
URLSrc string `xml:"urlsrc,attr"`
} `xml:"action"`
} `xml:"app"`
} `xml:"net-zone"`
}
// discoveryTTL: Wie lange die Liste gilt. Kurz genug, dass ein Neustart des
// Office-Servers nach einer Aktualisierung durchschlägt; lang genug, dass
// nicht jedes Öffnen eine zusätzliche Anfrage auslöst.
const discoveryTTL = 30 * time.Minute
// Client spricht mit dem Office-Server.
type Client struct {
provider string
baseURL string
// publicBase ist die Adresse, unter der der **Browser** den Office-Server
// erreicht. Die Ermittlungsliste nennt die, unter der der Server sich
// selbst sieht – im Compose-Netz also `collabora:9980`. Die schickt man
// keinem Browser.
publicBase string
http *http.Client
mu sync.Mutex
actions map[string]string // ".docx|edit" -> urlsrc
fetched time.Time
}
// NewClient baut den Zugang zum Office-Server.
//
// allowedHosts begrenzt, wohin überhaupt verbunden werden darf. **Das ist
// keine Formalie:** Das Backend ruft hier eine Adresse aus der Konfiguration
// auf – ohne Grenze wird daraus ein Weg ins interne Netz, sobald jemand die
// Konfiguration beeinflussen kann (SSRF). Die Adresse aus OFFICE_URL zählt
// immer dazu, sonst wäre die Voreinstellung unbrauchbar.
func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insecureTLS bool) (*Client, error) {
u, err := url.Parse(baseURL)
if err != nil || u.Host == "" {
return nil, fmt.Errorf("OFFICE_URL is not a valid address: %q", baseURL)
}
allowed := map[string]bool{strings.ToLower(u.Hostname()): true}
for _, h := range allowedHosts {
allowed[strings.ToLower(strings.TrimSpace(h))] = true
}
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
if !allowed[strings.ToLower(host)] {
return nil, fmt.Errorf("office: host %q is not in OFFICE_ALLOWED_HOSTS", host)
}
return (&net.Dialer{Timeout: 10 * time.Second}).DialContext(ctx, network, addr)
},
// #nosec G402 – nur wenn ausdrücklich eingeschaltet, für den
// Testaufbau mit selbstsigniertem Zertifikat auf demselben Rechner.
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecureTLS},
}
if publicURL == "" {
publicURL = baseURL
}
return &Client{
provider: provider,
baseURL: strings.TrimRight(baseURL, "/"),
publicBase: strings.TrimRight(publicURL, "/"),
http: &http.Client{Timeout: 20 * time.Second, Transport: transport},
}, nil
}
// toPublic schreibt Schema und Host einer Adresse aus der Ermittlungsliste auf
// die Adresse um, unter der der Browser den Server erreicht.
//
// Der Pfad bleibt unangetastet – er enthält die Fassungsnummer des Servers
// (`/browser/1ec9f2b9/cool.html`) und ändert sich mit jeder Aktualisierung.
func (c *Client) toPublic(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return raw
}
pub, err := url.Parse(c.publicBase)
if err != nil || pub.Host == "" {
return raw
}
u.Scheme, u.Host = pub.Scheme, pub.Host
return u.String()
}
func (c *Client) Provider() string { return c.provider }
// EditURL liefert die Adresse, unter der ein Dateityp bearbeitet wird.
//
// Fällt auf „view" zurück, wenn der Server für diesen Typ kein Bearbeiten
// anbietet – lesen ist besser als eine Fehlermeldung.
func (c *Client) EditURL(ctx context.Context, ext string, write bool) (string, error) {
if err := c.refresh(ctx); err != nil {
return "", err
}
c.mu.Lock()
defer c.mu.Unlock()
order := []string{"edit", "view"}
if !write {
order = []string{"view", "edit"}
}
for _, action := range order {
if u, ok := c.actions[ext+"|"+action]; ok {
return c.toPublic(u), nil
}
}
return "", ErrNoSupport
}
// refresh holt die Liste, wenn sie fehlt oder zu alt ist.
func (c *Client) refresh(ctx context.Context) error {
c.mu.Lock()
fresh := c.actions != nil && time.Since(c.fetched) < discoveryTTL
c.mu.Unlock()
if fresh {
return nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/hosting/discovery", nil)
if err != nil {
return err
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("office server unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("office discovery returned %d", resp.StatusCode)
}
// Grenze gegen eine Antwort, die kein Ende nimmt: Die Liste ist wenige
// hundert Kilobyte groß.
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return err
}
var doc discoveryXML
if err := xml.Unmarshal(body, &doc); err != nil {
return fmt.Errorf("office discovery is not valid XML: %w", err)
}
actions := map[string]string{}
for _, app := range doc.NetZone.Apps {
for _, a := range app.Actions {
if a.Ext == "" || a.URLSrc == "" {
continue
}
ext := a.Ext
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
actions[strings.ToLower(ext)+"|"+a.Name] = a.URLSrc
}
}
if len(actions) == 0 {
return fmt.Errorf("office discovery listed no file types")
}
c.mu.Lock()
c.actions, c.fetched = actions, time.Now()
c.mu.Unlock()
return nil
}
+305
View File
@@ -0,0 +1,305 @@
package office
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"aether/internal/acl"
"aether/internal/auth"
"aether/internal/files"
"aether/internal/storage"
"aether/pkg/response"
)
// maxSaveBytes begrenzt, was der Office-Server zurückschreiben darf.
//
// Ein Textdokument, das größer ist, hat kein Mensch geschrieben. Ohne Grenze
// wäre der Endpunkt eine Möglichkeit, die Platte zu füllen – er ist der
// einzige, der ohne Sitzung schreibt.
const maxSaveBytes = 512 << 20
type Handler struct {
client *Client
signer *Signer
files *files.Store
blobs storage.Storage
acl *acl.Store
// baseURL ist die Adresse dieser Anwendung im **Browser**. Sie steht als
// PostMessageOrigin in der Auskunft – der Office-Server prüft damit, an
// wen er Nachrichten aus dem Rahmen schicken darf.
baseURL string
// wopiURL ist die Adresse, unter der der **Office-Server** hierher findet.
// Aus seinem Container heraus ist „localhost" er selbst.
wopiURL string
}
func NewHandler(c *Client, s *Signer, fileStore *files.Store, blobs storage.Storage, aclStore *acl.Store, publicURL, wopiURL string) *Handler {
if wopiURL == "" {
wopiURL = publicURL
}
return &Handler{
client: c, signer: s, files: fileStore, blobs: blobs, acl: aclStore,
baseURL: strings.TrimRight(publicURL, "/"),
wopiURL: strings.TrimRight(wopiURL, "/"),
}
}
// Enabled sagt der Oberfläche, ob es die Bearbeitung überhaupt gibt. Ein
// nil-Handler heißt „abgeschaltet" – dann ist die Antwort eine Auskunft, kein
// Fehler.
func (h *Handler) Enabled() bool { return h != nil && h.client != nil }
// ── Vom Browser aufgerufen ───────────────────────────────────────────────────
// Session beantwortet GET /api/v1/office/session/{id}.
//
// Gibt zurück, wohin der Browser den Rahmen zeigen soll und mit welchem Token.
// Das Token entsteht **hier**, im angemeldeten Zusammenhang – der WOPI-Weg
// selbst kennt die Sitzung nicht.
func (h *Handler) Session(w http.ResponseWriter, r *http.Request) {
if !h.Enabled() {
response.JSON(w, http.StatusOK, map[string]any{"enabled": false})
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return
}
user := auth.UserFromContext(r.Context())
if user == nil {
response.Error(w, http.StatusUnauthorized, "not signed in")
return
}
// Zugriff auflösen: eigene Datei, geliehene oder Gruppenordner. Wer nur
// lesen darf, bekommt ein Token ohne Schreibrecht – nicht etwa gar keins.
access, err := h.acl.ResolveFile(r.Context(), user.ID, id)
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return
}
f, err := h.files.GetFile(r.Context(), id, access.OwnerID)
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return
}
if !Editable(f.Name) {
response.Error(w, http.StatusUnsupportedMediaType, "this file type cannot be opened in Office")
return
}
write := access.Permission == acl.PermWrite
src, err := h.client.EditURL(r.Context(), Extension(f.Name), write)
if err != nil {
log.Ctx(r.Context()).Warn().Err(err).Msg("office: discovery failed")
response.Error(w, http.StatusBadGateway, "the office server is not reachable")
return
}
token, exp := h.signer.Issue(f.ID, user.ID, write)
wopiSrc := fmt.Sprintf("%s/wopi/files/%s", h.wopiURL, f.ID)
// Die Adresse aus der Ermittlungsliste trägt Platzhalter in <>-Klammern
// („<ui=UI_LLCC&>"). Alles, was wir nicht setzen, muss weg – sonst
// schickt der Browser die Klammern mit.
src = stripPlaceholders(src)
sep := "?"
if strings.Contains(src, "?") {
sep = "&"
}
// **Vollständig kodiert**, nicht nur die auffälligen Zeichen: Collabora
// weist eine unkodierte WOPISrc zurück („WOPISrc must be URI-encoded.
// This is highly problematic with proxies, load balancers, and when
// tunneling") – Doppelpunkt und Schrägstrich eingeschlossen.
src = fmt.Sprintf("%s%sWOPISrc=%s", src, sep, url.QueryEscape(wopiSrc))
response.JSON(w, http.StatusOK, map[string]any{
"enabled": true,
"provider": h.client.Provider(),
"url": src,
"token": token,
"token_ttl": exp.UnixMilli(),
"can_write": write,
"file_name": f.Name,
})
}
// ── Vom Office-Server aufgerufen (WOPI) ──────────────────────────────────────
// authorize prüft das Token und holt Datei und Zugriffskontext.
//
// Absichtlich ohne Sitzung und ohne CSRF: Diese Aufrufe kommen vom
// Office-Server, nicht aus einem Browser. Das Token ist die einzige Berechtigung
// – und es gilt für **genau eine Datei**, damit ein entwendetes Token nicht
// zum Generalschlüssel wird.
func (h *Handler) authorize(w http.ResponseWriter, r *http.Request, needWrite bool) (*files.File, acl.Access, bool) {
raw := r.URL.Query().Get("access_token")
if raw == "" {
raw = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
access, err := h.signer.Verify(raw)
if err != nil {
// 401, nicht 403: Der Office-Server versucht es damit gar nicht erst
// erneut, statt in eine Schleife zu laufen.
http.Error(w, "invalid or expired token", http.StatusUnauthorized)
return nil, acl.Access{}, false
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil || id != access.FileID {
// Das Token gehört zu einer anderen Datei. 404 statt 403 – sonst
// verriete die Antwort, dass es die Datei gibt.
http.Error(w, "not found", http.StatusNotFound)
return nil, acl.Access{}, false
}
if needWrite && !access.Write {
http.Error(w, "read only", http.StatusForbidden)
return nil, acl.Access{}, false
}
// Die Rechte werden **bei jedem Aufruf neu** aufgelöst, nicht aus dem
// Token übernommen: Eine zurückgezogene Freigabe soll sofort wirken und
// nicht erst, wenn das Token abläuft.
ctxAccess, err := h.acl.ResolveFile(r.Context(), access.UserID, id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return nil, acl.Access{}, false
}
if needWrite && ctxAccess.Permission != acl.PermWrite {
http.Error(w, "read only", http.StatusForbidden)
return nil, acl.Access{}, false
}
f, err := h.files.GetFile(r.Context(), id, ctxAccess.OwnerID)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return nil, acl.Access{}, false
}
return f, ctxAccess, true
}
// CheckFileInfo beantwortet GET /wopi/files/{id} – die Auskunft über die Datei.
func (h *Handler) CheckFileInfo(w http.ResponseWriter, r *http.Request) {
f, access, ok := h.authorize(w, r, false)
if !ok {
return
}
canWrite := access.Permission == acl.PermWrite
// **Ohne die Hülle `{"data": …}` dieser Anwendung.** WOPI ist ein fremdes
// Protokoll mit festem Antwortformat; ein Feld drumherum verstünde der
// Office-Server nicht.
writeRawJSON(w, map[string]any{
"BaseFileName": f.Name,
"Size": f.Size,
"Version": fmt.Sprintf("%d", f.Version),
// Wer speichert, steht später in der Versionshistorie – dort soll ein
// Name stehen und nicht eine Kennung.
"OwnerId": f.OwnerID.String(),
"UserId": access.OwnerID.String(),
"UserFriendlyName": access.OwnerUsername,
"UserCanWrite": canWrite,
"UserCanNotWriteRelative": true,
// Kein „Speichern unter", kein Drucken-nach-Hause, keine Verweise auf
// fremde Dienste: Was die Datei verlassen könnte, bleibt aus.
"DisablePrint": false,
"DisableExport": false,
"DisableCopy": false,
"HideSaveOption": !canWrite,
"HideExportOption": false,
"PostMessageOrigin": h.baseURL,
"LastModifiedTime": f.UpdatedAt.UTC().Format(time.RFC3339),
})
}
// GetFile beantwortet GET /wopi/files/{id}/contents.
func (h *Handler) GetFile(w http.ResponseWriter, r *http.Request) {
f, _, ok := h.authorize(w, r, false)
if !ok {
return
}
rc, err := h.blobs.Get(r.Context(), f.StorageKey)
if err != nil {
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
defer rc.Close()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("X-WOPI-ItemVersion", fmt.Sprintf("%d", f.Version))
if _, err := io.Copy(w, rc); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Msg("office: sending file failed")
}
}
// PutFile beantwortet POST /wopi/files/{id}/contents – das Speichern.
func (h *Handler) PutFile(w http.ResponseWriter, r *http.Request) {
f, access, ok := h.authorize(w, r, true)
if !ok {
return
}
body := http.MaxBytesReader(w, r.Body, maxSaveBytes)
defer body.Close()
updated, err := files.ReplaceContent(r.Context(), h.files, h.blobs, f, access, body)
if err != nil {
if errors.Is(err, files.ErrQuotaExceeded) {
// 413 sagt dem Office-Server, dass es an der Größe lag – er zeigt
// das dem Menschen an, statt still nicht zu speichern.
http.Error(w, "storage quota exceeded", http.StatusRequestEntityTooLarge)
return
}
log.Ctx(r.Context()).Error().Err(err).Str("file_id", f.ID.String()).
Msg("office: saving failed")
http.Error(w, "could not save", http.StatusInternalServerError)
return
}
w.Header().Set("X-WOPI-ItemVersion", fmt.Sprintf("%d", updated.Version))
w.WriteHeader(http.StatusOK)
}
// stripPlaceholders entfernt die <…>-Platzhalter aus einer urlsrc.
//
// Die Ermittlungsliste liefert z. B.
//
// https://office/browser/dist/cool.html?<ui=UI_LLCC&><rs=DC_LLCC&>
//
// Jeder Platzhalter, den der Host nicht ersetzt, muss weg – sonst landen die
// spitzen Klammern in der Adresse und der Server lehnt sie ab.
func stripPlaceholders(src string) string {
var b strings.Builder
depth := 0
for _, r := range src {
switch r {
case '<':
depth++
case '>':
if depth > 0 {
depth--
}
default:
if depth == 0 {
b.WriteRune(r)
}
}
}
out := b.String()
// Ein zurückbleibendes „?" oder „&" am Ende stört keinen Server, ist aber
// unsauber in der Adresszeile.
return strings.TrimRight(out, "?&")
}
// writeRawJSON schreibt ein Objekt ohne die Hülle dieser Anwendung.
func writeRawJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(v)
}
+167
View File
@@ -0,0 +1,167 @@
// Package office bindet einen Office-Server an – Collabora oder OnlyOffice.
//
// **Ein Adapter für beide.** Beide sprechen WOPI („Web Application Open
// Platform Interface"), die Schnittstelle, über die ein Office-Server sich
// eine Datei holt und wieder zurückschreibt. Zwei getrennte Integrationen zu
// bauen hieße, dieselbe Sache zweimal zu pflegen; umgeschaltet wird über
// OFFICE_PROVIDER in der .env.
//
// **Die Richtung der Aufrufe ist das Entscheidende.** Der Office-Server ist
// kein Anhängsel des Browsers – er holt die Datei selbst hier ab:
//
// Browser ──► Office-Server (die Bedienoberfläche im <iframe>)
// Office-Server ──► Aether /wopi/... (Datei holen, Datei zurückschreiben)
//
// Daraus folgt alles Weitere: Der Office-Server muss Aether unter seiner
// **öffentlichen** Adresse erreichen (PUBLIC_URL), nicht unter localhost. Und
// weil dieser Weg an der Sitzung vorbeiführt, trägt jeder Aufruf ein eigenes,
// kurzlebiges Zugriffstoken.
package office
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
var (
ErrBadToken = errors.New("invalid access token")
ErrExpired = errors.New("access token expired")
ErrNoSupport = errors.New("this file type cannot be opened in Office")
)
// tokenTTL ist die Lebensdauer eines Zugriffstokens.
//
// Zwölf Stunden klingt lang für ein Token – es ist aber die Zeit, die ein
// geöffnetes Dokument im Browser stehen darf, bevor das Speichern scheitert.
// Kürzer hieße: Wer ein Dokument über die Mittagspause offen lässt, verliert
// beim Zurückkommen seine Änderungen. Der Schaden bei einem entwendeten Token
// ist begrenzt, weil es **an genau eine Datei** gebunden ist.
const tokenTTL = 12 * time.Hour
// Access beschreibt, was ein Token erlaubt.
type Access struct {
FileID uuid.UUID
UserID uuid.UUID
Write bool
Expiry time.Time
}
// Signer stellt Zugriffstoken aus und prüft sie.
//
// HMAC über den Inhalt, nichts in der Datenbank: Ein Token ist damit
// überprüfbar, ohne bei jedem Zugriff nachzuschlagen – und es verfällt von
// selbst, statt eine Aufräumaufgabe zu brauchen.
type Signer struct {
key []byte
}
func NewSigner(secret string) *Signer {
// Abgeleitet statt direkt verwendet: Dasselbe Geheimnis signiert die
// Sitzungscookies. Zwei Zwecke sollen nie denselben Schlüssel teilen –
// sonst wäre ein Fehler im einen Verfahren einer im anderen.
sum := sha256.Sum256([]byte("aether-wopi\x00" + secret))
return &Signer{key: sum[:]}
}
// Issue stellt ein Token für genau diese Datei und diesen Nutzer aus.
func (s *Signer) Issue(fileID, userID uuid.UUID, write bool) (string, time.Time) {
exp := time.Now().Add(tokenTTL)
payload := make([]byte, 0, 41)
payload = append(payload, fileID[:]...)
payload = append(payload, userID[:]...)
var w byte
if write {
w = 1
}
payload = append(payload, w)
payload = binary.BigEndian.AppendUint64(payload, uint64(exp.Unix()))
mac := hmac.New(sha256.New, s.key)
mac.Write(payload)
full := append(payload, mac.Sum(nil)...)
return base64.RawURLEncoding.EncodeToString(full), exp
}
// Verify prüft ein Token und gibt zurück, was es erlaubt.
func (s *Signer) Verify(token string) (Access, error) {
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil || len(raw) != 41+sha256.Size {
return Access{}, ErrBadToken
}
payload, sig := raw[:41], raw[41:]
mac := hmac.New(sha256.New, s.key)
mac.Write(payload)
// Zeitkonstanter Vergleich: Ein normaler Vergleich verrät über die
// Laufzeit, wie viele Bytes stimmen – daraus lässt sich eine Signatur
// Byte für Byte erraten.
if !hmac.Equal(sig, mac.Sum(nil)) {
return Access{}, ErrBadToken
}
var a Access
copy(a.FileID[:], payload[0:16])
copy(a.UserID[:], payload[16:32])
a.Write = payload[32] == 1
a.Expiry = time.Unix(int64(binary.BigEndian.Uint64(payload[33:41])), 0)
if time.Now().After(a.Expiry) {
return Access{}, ErrExpired
}
return a, nil
}
// editableExtensions sind die Endungen, die ein Office-Server bearbeiten kann.
//
// Eine **Positivliste**, keine Ausschlussliste: Was hier nicht steht, wird
// nicht an den Office-Server gereicht. Ein PDF gehört bewusst nicht dazu – es
// hat seinen eigenen Betrachter, und ein Office-Server würde daraus beim
// Speichern ein anderes Dokument machen.
var editableExtensions = map[string]bool{
// Textdokumente
"odt": true, "docx": true, "doc": true, "rtf": true, "txt": true,
// Tabellen
"ods": true, "xlsx": true, "xls": true, "csv": true,
// Präsentationen
"odp": true, "pptx": true, "ppt": true,
// Zeichnungen
"odg": true,
}
// Editable sagt, ob eine Datei im Office-Server geöffnet werden kann.
func Editable(name string) bool {
i := strings.LastIndexByte(name, '.')
if i < 0 {
return false
}
return editableExtensions[strings.ToLower(name[i+1:])]
}
// Extension liefert die Endung in Kleinbuchstaben, mit Punkt – so, wie die
// Ermittlungsliste des Office-Servers sie führt.
func Extension(name string) string {
i := strings.LastIndexByte(name, '.')
if i < 0 {
return ""
}
return strings.ToLower(name[i:])
}
// ProviderName ist der Anzeigename für die Oberfläche.
func ProviderName(provider string) string {
switch provider {
case "collabora":
return "Collabora Online"
case "onlyoffice":
return "OnlyOffice"
default:
return fmt.Sprintf("Office (%s)", provider)
}
}
+196
View File
@@ -0,0 +1,196 @@
package oidc
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
gooidc "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"aether/internal/auth"
"aether/pkg/middleware"
"aether/pkg/response"
)
const stateCookie = "oidc_state"
// Config holds OIDC provider settings (all required if OIDC is enabled).
type Config struct {
Issuer string
ClientID string
ClientSecret string
RedirectURL string
// LinkByUsername: bestehendes Konto gleichen Namens übernehmen statt ein
// zweites anzulegen. Siehe auth.Store.GetOrCreateByOIDC.
LinkByUsername bool
}
// Handler implements /auth/oidc/login and /auth/oidc/callback.
type Handler struct {
cfg Config
provider *gooidc.Provider
oauth2 *oauth2.Config
verifier *gooidc.IDTokenVerifier
authStore *auth.Store
sessions *auth.SessionStore
}
// New creates an OIDC handler. Returns nil if cfg.Issuer is empty (OIDC disabled).
func New(ctx context.Context, cfg Config, authStore *auth.Store, sessions *auth.SessionStore) (*Handler, error) {
if cfg.Issuer == "" {
return nil, nil
}
provider, err := gooidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
return nil, fmt.Errorf("oidc provider: %w", err)
}
oauthCfg := &oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: []string{gooidc.ScopeOpenID, "profile"},
}
return &Handler{
cfg: cfg,
provider: provider,
oauth2: oauthCfg,
verifier: provider.Verifier(&gooidc.Config{ClientID: cfg.ClientID}),
authStore: authStore,
sessions: sessions,
}, nil
}
// GET /api/v1/auth/oidc/login – redirect to OIDC provider.
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
state, err := randomState()
if err != nil {
response.Error(w, http.StatusInternalServerError, "state generation failed")
return
}
// Secure wie bei Sitzungs- und CSRF-Cookie: Hinter dem Reverse Proxy sieht
// die App selbst kein TLS, deshalb über IsHTTPS (das X-Forwarded-Proto
// auswertet – aber nur von vertrauenswürdigen Proxys). Ohne das ginge der
// Zustandswert auch über eine unverschlüsselte Verbindung mit.
http.SetCookie(w, &http.Cookie{
Name: stateCookie, Value: state, Path: "/",
MaxAge: 300, HttpOnly: true, SameSite: http.SameSiteLaxMode,
Secure: middleware.IsHTTPS(r),
})
http.Redirect(w, r, h.oauth2.AuthCodeURL(state), http.StatusFound)
}
// GET /api/v1/auth/oidc/callback – exchange code, create session, redirect to SPA.
func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
// Validate state.
stateCk, err := r.Cookie(stateCookie)
if err != nil || r.URL.Query().Get("state") != stateCk.Value {
http.Redirect(w, r, "/?error=oidc_state", http.StatusFound)
return
}
// Clear state cookie.
http.SetCookie(w, &http.Cookie{Name: stateCookie, Value: "", Path: "/", MaxAge: -1})
code := r.URL.Query().Get("code")
if code == "" {
http.Redirect(w, r, "/?error=oidc_no_code", http.StatusFound)
return
}
// Exchange code for tokens.
token, err := h.oauth2.Exchange(r.Context(), code)
if err != nil {
http.Redirect(w, r, "/?error=oidc_exchange", http.StatusFound)
return
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
http.Redirect(w, r, "/?error=oidc_no_id_token", http.StatusFound)
return
}
idToken, err := h.verifier.Verify(r.Context(), rawIDToken)
if err != nil {
http.Redirect(w, r, "/?error=oidc_verify", http.StatusFound)
return
}
// Extract claims.
var claims struct {
Sub string `json:"sub"`
PreferredUsername string `json:"preferred_username"`
Name string `json:"name"`
}
if err := idToken.Claims(&claims); err != nil {
http.Redirect(w, r, "/?error=oidc_claims", http.StatusFound)
return
}
// Derive username: prefer preferred_username, fall back to sub.
username := sanitizeUsername(claims.PreferredUsername)
if username == "" {
username = sanitizeUsername(claims.Sub)
}
if username == "" {
http.Redirect(w, r, "/?error=oidc_no_username", http.StatusFound)
return
}
// Find or create user.
user, err := h.authStore.GetOrCreateByOIDC(r.Context(), claims.Sub, username, claims.Name, h.cfg.LinkByUsername)
if err != nil {
http.Redirect(w, r, "/?error=oidc_user", http.StatusFound)
return
}
if err := h.sessions.SetUser(w, r, user.ID); err != nil {
http.Redirect(w, r, "/?error=oidc_session", http.StatusFound)
return
}
// Set CSRF cookie so the SPA can function immediately after redirect.
_, _ = auth.SetCSRFCookie(w, r)
http.Redirect(w, r, "/", http.StatusFound)
}
// PublicConfig returns whether OIDC is enabled (called without auth).
func (h *Handler) IsEnabled() bool { return h != nil }
// ConfigJSON writes the OIDC config state (for login page).
func ConfigJSON(enabled bool, cloudName string) []byte {
b, _ := json.Marshal(map[string]any{
"oidc_enabled": enabled,
"cloud_name": cloudName,
})
return b
}
// ── helpers ───────────────────────────────────────────────────────────────────
func randomState() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
func sanitizeUsername(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var out strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.' {
out.WriteRune(r)
}
}
return out.String()
}
Loaded 100 of 225 files, more files were not shown because too many files have changed in this diff. Show more