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

+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()
}
+202
View File
@@ -0,0 +1,202 @@
package sharing
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"aether/internal/audit"
"aether/internal/auth"
"aether/internal/files"
"aether/internal/storage"
"aether/pkg/middleware"
"aether/pkg/response"
)
type Handler struct {
store *Store
fileStore *files.Store
storage storage.Storage
auditor *audit.Logger
}
func NewHandler(store *Store, fileStore *files.Store, st storage.Storage, auditor *audit.Logger) *Handler {
return &Handler{store: store, fileStore: fileStore, storage: st, auditor: auditor}
}
// POST /api/v1/files/{id}/share
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
fileID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid file id")
return
}
if _, err := h.fileStore.GetFile(r.Context(), fileID, user.ID); err != nil {
response.Error(w, http.StatusNotFound, "file not found")
return
}
var body struct {
Password string `json:"password"`
ExpiresIn *int `json:"expires_in_hours"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
var passwordHash string
if body.Password != "" {
passwordHash, err = auth.HashPassword(body.Password)
if err != nil {
response.Error(w, http.StatusInternalServerError, "hash error")
return
}
}
var expiresAt *time.Time
if body.ExpiresIn != nil && *body.ExpiresIn > 0 {
t := time.Now().Add(time.Duration(*body.ExpiresIn) * time.Hour)
expiresAt = &t
}
link, err := h.store.Create(r.Context(), fileID, user.ID, passwordHash, expiresAt)
if err != nil {
response.Error(w, http.StatusInternalServerError, "create share link failed")
return
}
// Ein Freigabelink macht eine Datei öffentlich erreichbar – gehört ins
// Protokoll. Der Token selbst nicht: er IST der Zugang.
detail := "no password"
if passwordHash != "" {
detail = "password protected"
}
if expiresAt != nil {
detail += ", expires " + expiresAt.Format(time.RFC3339)
}
h.auditor.Log(r.Context(), audit.Event{
ActorID: &user.ID,
ActorName: user.Username,
Action: audit.ActionShareCreate,
TargetType: "file",
TargetID: fileID.String(),
IP: middleware.RealIP(r),
Detail: detail,
})
response.JSON(w, http.StatusCreated, link)
}
// GET /api/v1/shares – list all share links owned by user
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
links, err := h.store.ListByOwner(r.Context(), user.ID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if links == nil {
links = []*ShareLink{}
}
response.JSON(w, http.StatusOK, links)
}
// DELETE /api/v1/shares/{id}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid share id")
return
}
if err := h.store.Delete(r.Context(), id, user.ID); errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "share link not found")
return
} else if 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.ActionShareDelete,
TargetType: "share",
TargetID: id.String(),
IP: middleware.RealIP(r),
})
response.NoContent(w)
}
// GET /s/{token} – public share info (no auth required)
func (h *Handler) GetPublicInfo(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
link, _, err := h.store.GetByToken(r.Context(), token)
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrExpired) {
response.Error(w, http.StatusNotFound, "share link not found or expired")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "error")
return
}
f, err := h.fileStore.GetFileByID(r.Context(), link.FileID)
if err != nil {
response.Error(w, http.StatusNotFound, "file not found")
return
}
response.JSON(w, http.StatusOK, map[string]any{
"link": link,
"file_name": f.Name,
"file_size": f.Size,
"mime_type": f.MimeType,
})
}
// POST /s/{token}/download – public download, optional password in body
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
link, passwordHash, err := h.store.GetByToken(r.Context(), token)
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrExpired) {
response.Error(w, http.StatusNotFound, "share link not found or expired")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "error")
return
}
if link.HasPassword {
var body struct {
Password string `json:"password"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
ok, _ := auth.VerifyPassword(body.Password, passwordHash)
if !ok {
response.Error(w, http.StatusUnauthorized, "incorrect password")
return
}
}
f, err := h.fileStore.GetFileByID(r.Context(), link.FileID)
if err != nil {
response.Error(w, http.StatusNotFound, "file not found")
return
}
rc, err := h.storage.Get(r.Context(), f.StorageKey)
if err != nil {
response.Error(w, http.StatusInternalServerError, "storage error")
return
}
defer rc.Close()
go h.store.IncrementDownloads(r.Context(), token)
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", f.Size))
_, _ = io.Copy(w, rc)
}
+18
View File
@@ -0,0 +1,18 @@
package sharing
import (
"time"
"github.com/google/uuid"
)
type ShareLink struct {
ID uuid.UUID `json:"id"`
FileID uuid.UUID `json:"file_id"`
OwnerID uuid.UUID `json:"owner_id"`
Token string `json:"token"`
HasPassword bool `json:"has_password"`
ExpiresAt *time.Time `json:"expires_at"`
DownloadCount int `json:"download_count"`
CreatedAt time.Time `json:"created_at"`
}
+123
View File
@@ -0,0 +1,123 @@
package sharing
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("share link not found")
var ErrExpired = errors.New("share link expired")
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} }
func (s *Store) Create(ctx context.Context, fileID, ownerID uuid.UUID, passwordHash string, expiresAt *time.Time) (*ShareLink, error) {
token, err := generateToken()
if err != nil {
return nil, fmt.Errorf("generate token: %w", err)
}
var ph *string
if passwordHash != "" {
ph = &passwordHash
}
var link ShareLink
err = s.db.QueryRow(ctx,
`INSERT INTO share_links (file_id, owner_id, token, password_hash, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, file_id, owner_id, token,
(password_hash IS NOT NULL), expires_at, download_count, created_at`,
fileID, ownerID, token, ph, expiresAt,
).Scan(&link.ID, &link.FileID, &link.OwnerID, &link.Token,
&link.HasPassword, &link.ExpiresAt, &link.DownloadCount, &link.CreatedAt)
if err != nil {
return nil, fmt.Errorf("create share link: %w", err)
}
return &link, nil
}
func (s *Store) GetByToken(ctx context.Context, token string) (*ShareLink, string, error) {
var link ShareLink
var passwordHash *string
err := s.db.QueryRow(ctx,
`SELECT id, file_id, owner_id, token,
(password_hash IS NOT NULL), password_hash, expires_at, download_count, created_at
FROM share_links WHERE token = $1`,
token,
).Scan(&link.ID, &link.FileID, &link.OwnerID, &link.Token,
&link.HasPassword, &passwordHash, &link.ExpiresAt, &link.DownloadCount, &link.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", ErrNotFound
}
if err != nil {
return nil, "", fmt.Errorf("get share link: %w", err)
}
if link.ExpiresAt != nil && time.Now().After(*link.ExpiresAt) {
return nil, "", ErrExpired
}
ph := ""
if passwordHash != nil {
ph = *passwordHash
}
return &link, ph, nil
}
func (s *Store) ListByOwner(ctx context.Context, ownerID uuid.UUID) ([]*ShareLink, error) {
rows, err := s.db.Query(ctx,
`SELECT id, file_id, owner_id, token,
(password_hash IS NOT NULL), expires_at, download_count, created_at
FROM share_links WHERE owner_id = $1 ORDER BY created_at DESC`,
ownerID,
)
if err != nil {
return nil, fmt.Errorf("list share links: %w", err)
}
defer rows.Close()
var links []*ShareLink
for rows.Next() {
var l ShareLink
if err := rows.Scan(&l.ID, &l.FileID, &l.OwnerID, &l.Token,
&l.HasPassword, &l.ExpiresAt, &l.DownloadCount, &l.CreatedAt); err != nil {
return nil, err
}
links = append(links, &l)
}
return links, rows.Err()
}
func (s *Store) Delete(ctx context.Context, id, ownerID uuid.UUID) error {
tag, err := s.db.Exec(ctx,
`DELETE FROM share_links WHERE id = $1 AND owner_id = $2`, id, ownerID)
if err != nil {
return fmt.Errorf("delete share link: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) IncrementDownloads(ctx context.Context, token string) {
_, _ = s.db.Exec(ctx,
`UPDATE share_links SET download_count = download_count + 1 WHERE token = $1`, token)
}
func generateToken() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
+280
View File
@@ -0,0 +1,280 @@
package storage
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
// LocalFS stores blobs on the local filesystem.
// Every write is atomic: temp file → fsync → rename.
type LocalFS struct {
root string // absolute, canonical path
tmpRoot string // where temp files land during upload
}
func NewLocalFS(root, tmpRoot string) (*LocalFS, error) {
root, err := filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("abs root: %w", err)
}
tmpRoot, err = filepath.Abs(tmpRoot)
if err != nil {
return nil, fmt.Errorf("abs tmpRoot: %w", err)
}
for _, dir := range []string{root, tmpRoot} {
if err := os.MkdirAll(dir, 0750); err != nil {
return nil, fmt.Errorf("mkdir %s: %w", dir, err)
}
}
return &LocalFS{root: root, tmpRoot: tmpRoot}, nil
}
// safePath returns the absolute path for key and ensures it stays under root.
func (fs *LocalFS) safePath(key string) (string, error) {
// Reject keys that try to escape with .. or absolute paths.
if filepath.IsAbs(key) || strings.Contains(key, "..") {
return "", fmt.Errorf("invalid storage key: %q", key)
}
full := filepath.Join(fs.root, filepath.FromSlash(key))
// Canonical check: the resolved path must still be under root.
canonical, err := filepath.Abs(full)
if err != nil {
return "", fmt.Errorf("abs path: %w", err)
}
if !strings.HasPrefix(canonical+string(filepath.Separator), fs.root+string(filepath.Separator)) {
return "", fmt.Errorf("path traversal detected: %q", key)
}
return canonical, nil
}
func (fs *LocalFS) Put(ctx context.Context, key string, r io.Reader) (string, int64, error) {
dest, err := fs.safePath(key)
if err != nil {
return "", 0, err
}
if err := os.MkdirAll(filepath.Dir(dest), 0750); err != nil {
return "", 0, fmt.Errorf("mkdir parent: %w", err)
}
// Write to temp file in tmpRoot (same filesystem for atomic rename).
tmp, err := os.CreateTemp(fs.tmpRoot, "upload-*")
if err != nil {
return "", 0, fmt.Errorf("create temp: %w", err)
}
tmpName := tmp.Name()
committed := false
defer func() {
if !committed {
tmp.Close()
os.Remove(tmpName)
}
}()
h := sha256.New()
multi := io.MultiWriter(tmp, h)
size, err := io.Copy(multi, r)
if err != nil {
return "", 0, fmt.Errorf("write temp: %w", err)
}
// fsync: flush to physical storage before rename.
if err := tmp.Sync(); err != nil {
return "", 0, fmt.Errorf("fsync: %w", err)
}
if err := tmp.Close(); err != nil {
return "", 0, fmt.Errorf("close temp: %w", err)
}
// Atomic rename: either fully committed or not.
if err := os.Rename(tmpName, dest); err != nil {
return "", 0, fmt.Errorf("rename to final: %w", err)
}
committed = true
checksum := hex.EncodeToString(h.Sum(nil))
return checksum, size, nil
}
func (fs *LocalFS) Get(ctx context.Context, key string) (io.ReadCloser, error) {
path, err := fs.safePath(key)
if err != nil {
return nil, err
}
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("blob not found: %q", key)
}
return nil, fmt.Errorf("open blob: %w", err)
}
return f, nil
}
func (fs *LocalFS) Delete(ctx context.Context, key string) error {
path, err := fs.safePath(key)
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("delete blob: %w", err)
}
return nil
}
func (fs *LocalFS) Stat(ctx context.Context, key string) (FileInfo, error) {
path, err := fs.safePath(key)
if err != nil {
return FileInfo{}, err
}
fi, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return FileInfo{}, fmt.Errorf("blob not found: %q", key)
}
return FileInfo{}, fmt.Errorf("stat blob: %w", err)
}
// Recompute checksum on stat (used for integrity verification).
checksum, err := fileChecksum(path)
if err != nil {
return FileInfo{}, err
}
return FileInfo{
Size: fi.Size(),
Checksum: checksum,
ModTime: fi.ModTime().UTC().Truncate(time.Second),
}, nil
}
// CreateTemp legt eine Datei im Staging-Verzeichnis an. Gedacht für Uploads,
// deren Größe erst beim Schreiben feststeht: Der Inhalt landet auf derselben
// Platte wie die späteren Blobs, nicht im Arbeitsspeicher. Der Aufrufer ist für
// Schließen und Entfernen zuständig.
func (fs *LocalFS) CreateTemp(pattern string) (*os.File, error) {
f, err := os.CreateTemp(fs.tmpRoot, pattern)
if err != nil {
return nil, fmt.Errorf("create temp file: %w", err)
}
return f, nil
}
// LocalPath gibt den Pfad im Dateisystem heraus (siehe LocalPather).
//
// Die Prüfung auf Ausbruch aus dem Wurzelverzeichnis ist dieselbe wie bei jedem
// anderen Zugriff – ein Schlüssel mit ".." kommt hier so wenig durch wie bei
// Get oder Delete. Der Rückgabewert ist zum **Lesen** gedacht; wer über diesen
// Pfad schreibt, umgeht das atomare Schreiben und hat die Garantien verspielt.
func (fs *LocalFS) LocalPath(key string) (string, error) {
path, err := fs.safePath(key)
if err != nil {
return "", err
}
if _, err := os.Stat(path); err != nil {
return "", fmt.Errorf("stat %s: %w", key, err)
}
return path, nil
}
// Walk läuft den Baum unterhalb von prefix ab und meldet jede Datei.
// Verzeichnisse werden übersprungen; die Prüfsumme bleibt leer, weil das
// Nachrechnen über den gesamten Speicher unverhältnismäßig teuer wäre – wer
// sie braucht, holt sie über Stat.
func (fs *LocalFS) Walk(ctx context.Context, prefix string, fn func(key string, info FileInfo) error) error {
prefix = strings.Trim(prefix, "/")
base := fs.root
if prefix != "" {
var err error
base, err = fs.safePath(prefix)
if err != nil {
return err
}
}
if _, err := os.Stat(base); os.IsNotExist(err) {
return nil
}
return filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if ctx.Err() != nil {
return ctx.Err()
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
// Datei ist zwischen Auflisten und Abfragen verschwunden – das ist
// im laufenden Betrieb normal und kein Grund abzubrechen.
if os.IsNotExist(err) {
return nil
}
return err
}
rel, err := filepath.Rel(fs.root, path)
if err != nil {
return err
}
return fn(filepath.ToSlash(rel), FileInfo{Size: info.Size(), ModTime: info.ModTime()})
})
}
// DeletePrefix entfernt ein ganzes Unterverzeichnis des Speichers.
//
// Absichtlich streng: ein leerer Präfix würde den gesamten Speicher löschen,
// deshalb wird er abgelehnt. Der aufgelöste Pfad muss ausserdem echt unterhalb
// der Wurzel liegen – safePath prüft das bereits, der zusätzliche Vergleich
// schützt davor, versehentlich die Wurzel selbst zu treffen.
func (fs *LocalFS) DeletePrefix(ctx context.Context, prefix string) error {
prefix = strings.Trim(prefix, "/")
if prefix == "" {
return fmt.Errorf("refusing to delete empty prefix")
}
dir, err := fs.safePath(prefix)
if err != nil {
return err
}
if dir == fs.root {
return fmt.Errorf("refusing to delete storage root")
}
if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("delete prefix %q: %w", prefix, err)
}
return nil
}
func (fs *LocalFS) Move(ctx context.Context, oldKey, newKey string) error {
oldPath, err := fs.safePath(oldKey)
if err != nil {
return err
}
newPath, err := fs.safePath(newKey)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(newPath), 0750); err != nil {
return fmt.Errorf("mkdir parent: %w", err)
}
return os.Rename(oldPath, newPath)
}
func fileChecksum(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("open for checksum: %w", err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", fmt.Errorf("hash file: %w", err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
+223
View File
@@ -0,0 +1,223 @@
package storage
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"io"
"os"
"strings"
"testing"
)
func newTestFS(t *testing.T) *LocalFS {
t.Helper()
root := t.TempDir()
tmp := t.TempDir()
fs, err := NewLocalFS(root, tmp)
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
return fs
}
func TestPutAndGet(t *testing.T) {
fs := newTestFS(t)
ctx := context.Background()
content := []byte("hello aether")
checksum, size, err := fs.Put(ctx, "testfile", bytes.NewReader(content))
if err != nil {
t.Fatalf("Put: %v", err)
}
if size != int64(len(content)) {
t.Errorf("size: got %d want %d", size, len(content))
}
// verify checksum
h := sha256.Sum256(content)
expected := hex.EncodeToString(h[:])
if checksum != expected {
t.Errorf("checksum: got %s want %s", checksum, expected)
}
// get and read back
rc, err := fs.Get(ctx, "testfile")
if err != nil {
t.Fatalf("Get: %v", err)
}
defer rc.Close()
got, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("ReadAll: %v", err)
}
if !bytes.Equal(got, content) {
t.Errorf("content mismatch")
}
}
func TestChecksumVerification(t *testing.T) {
fs := newTestFS(t)
ctx := context.Background()
content := []byte("important data")
storedChecksum, _, err := fs.Put(ctx, "file", bytes.NewReader(content))
if err != nil {
t.Fatalf("Put: %v", err)
}
info, err := fs.Stat(ctx, "file")
if err != nil {
t.Fatalf("Stat: %v", err)
}
if info.Checksum != storedChecksum {
t.Errorf("checksum mismatch after stat: got %s want %s", info.Checksum, storedChecksum)
}
}
func TestPathTraversal(t *testing.T) {
fs := newTestFS(t)
ctx := context.Background()
cases := []string{
"../escape",
"../../etc/passwd",
"/absolute/path",
"a/../../b",
"foo/../../../bar",
}
for _, key := range cases {
_, _, err := fs.Put(ctx, key, strings.NewReader("data"))
if err == nil {
t.Errorf("expected error for traversal key %q but got none", key)
}
}
}
func TestAtomicWrite_NoPartialFile(t *testing.T) {
fs := newTestFS(t)
ctx := context.Background()
// Simulate an interrupted reader (fails mid-stream).
bad := &failReader{data: []byte("partial"), failAt: 3}
_, _, err := fs.Put(ctx, "partial", bad)
if err == nil {
t.Fatal("expected error from failing reader")
}
// The destination file must NOT exist.
dest := fs.root + "/partial"
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
t.Error("partial file must not exist after failed write")
}
}
func TestDelete(t *testing.T) {
fs := newTestFS(t)
ctx := context.Background()
if _, _, err := fs.Put(ctx, "todelete", strings.NewReader("bye")); err != nil {
t.Fatalf("Put: %v", err)
}
if err := fs.Delete(ctx, "todelete"); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := fs.Get(ctx, "todelete"); err == nil {
t.Error("expected error getting deleted file")
}
// Double-delete should be a no-op.
if err := fs.Delete(ctx, "todelete"); err != nil {
t.Errorf("second Delete should be no-op, got: %v", err)
}
}
// failReader returns data normally up to failAt bytes, then errors.
type failReader struct {
data []byte
pos int
failAt int
}
func (r *failReader) Read(p []byte) (int, error) {
if r.pos >= r.failAt {
return 0, io.ErrUnexpectedEOF
}
n := copy(p, r.data[r.pos:min(r.failAt, len(r.data))])
r.pos += n
return n, nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// Walk ist die Grundlage der Blob-Bereinigung: Übersieht es Dateien, bleiben
// Waisen liegen; meldet es Schlüssel in einer anderen Form als die Datenbank
// sie speichert, hält der GC gesunde Blobs für verwaist und löscht sie.
func TestWalkReportsEveryBlobWithDBStyleKeys(t *testing.T) {
fs := newTestFS(t)
ctx := context.Background()
want := map[string]bool{
"users/alice/home/notiz.txt": true,
"users/alice/home/Unterordner/bild.png": true,
"users/alice/.versions/abc-123": true,
"users/alice/.trash/deadbeef_alt.txt": true,
}
for key := range want {
if _, _, err := fs.Put(ctx, key, strings.NewReader("inhalt")); err != nil {
t.Fatalf("Put(%q): %v", key, err)
}
}
// Außerhalb des Präfixes – darf nicht mitkommen.
if _, _, err := fs.Put(ctx, "thumbs/xyz.jpg", strings.NewReader("x")); err != nil {
t.Fatalf("Put thumb: %v", err)
}
got := map[string]bool{}
if err := fs.Walk(ctx, "users", func(key string, info FileInfo) error {
got[key] = true
if info.Size != int64(len("inhalt")) {
t.Errorf("Walk reported size %d for %q, want %d", info.Size, key, len("inhalt"))
}
if info.ModTime.IsZero() {
t.Errorf("Walk reported zero ModTime for %q – the GC grace period relies on it", key)
}
return nil
}); err != nil {
t.Fatalf("Walk: %v", err)
}
for key := range want {
if !got[key] {
t.Errorf("Walk missed %q – the GC would never reclaim it", key)
}
}
if got["thumbs/xyz.jpg"] {
t.Error("Walk crossed the prefix boundary")
}
if len(got) != len(want) {
t.Errorf("Walk returned %d keys, want %d: %v", len(got), len(want), got)
}
}
// Ein fehlender Präfix ist kein Fehler – sonst bräche der GC auf einer frischen
// Installation ab, bevor er irgendetwas tut.
func TestWalkMissingPrefixIsNotAnError(t *testing.T) {
fs := newTestFS(t)
called := false
if err := fs.Walk(context.Background(), "gibtesnicht", func(string, FileInfo) error {
called = true
return nil
}); err != nil {
t.Fatalf("Walk on missing prefix: %v", err)
}
if called {
t.Error("callback ran although the prefix does not exist")
}
}
+70
View File
@@ -0,0 +1,70 @@
package storage
import (
"context"
"io"
"os"
"time"
)
// TempFiler kann Uploads unbekannter Größe auf der Platte zwischenlagern,
// statt sie im Arbeitsspeicher zu halten. Bewusst eine eigene, optionale
// Schnittstelle: *os.File gehört nicht in den allgemeinen Storage-Vertrag, und
// eine Implementierung ohne lokales Dateisystem kann sie schlicht weglassen.
type TempFiler interface {
CreateTemp(pattern string) (*os.File, error)
}
// LocalPather gibt den echten Pfad eines Blobs heraus – nur lesend gedacht.
//
// Gebraucht von der Texterkennung: ocrmypdf und tesseract sind externe
// Programme und wollen einen Pfad, keinen Datenstrom. Ohne diese Schnittstelle
// müsste jede Datei erst in eine vollständige Kopie geschrieben werden, bevor
// sie erkannt werden kann – bei einem 300-MB-Scan also 300 MB zusätzlich auf
// einer Platte, die ohnehin knapp sein kann.
//
// Wie TempFiler bewusst optional: Eine Ablage ohne lokales Dateisystem (S3)
// lässt sie weg, und der Aufrufer fällt dann auf die Kopie zurück.
type LocalPather interface {
LocalPath(key string) (string, error)
}
// FileInfo holds metadata about a stored blob.
type FileInfo struct {
Size int64
Checksum string // SHA-256 hex
ModTime time.Time
}
// Storage is the blob storage abstraction.
// All implementations must guarantee atomic writes and never corrupt existing data.
type Storage interface {
// Put streams r to the given key atomically (temp → fsync → rename).
// Returns the SHA-256 checksum and byte count written.
Put(ctx context.Context, key string, r io.Reader) (checksum string, size int64, err error)
// Get returns a reader for the blob at key. Caller must close it.
Get(ctx context.Context, key string) (io.ReadCloser, error)
// Delete removes a blob. In Phase 1 this is a hard delete used only by
// cleanup/GC paths; user-facing deletes are soft-deletes in the DB.
Delete(ctx context.Context, key string) error
// Stat returns metadata for a blob.
Stat(ctx context.Context, key string) (FileInfo, error)
// Move atomically moves a blob from oldKey to newKey within the same storage root.
Move(ctx context.Context, oldKey, newKey string) error
// Walk ruft fn für jeden Blob unterhalb von prefix auf. Existiert der
// Präfix nicht, ist das kein Fehler – nur nichts zu tun. Gedacht für die
// Bereinigung verwaister Blobs, die den Bestand auf der Platte gegen die
// Datenbank abgleichen muss.
Walk(ctx context.Context, prefix string, fn func(key string, info FileInfo) error) error
// DeletePrefix entfernt alles unterhalb eines Präfixes, etwa den kompletten
// Bereich eines Nutzers ("users/alice/"). Gedacht für das endgültige Löschen
// eines Kontos: danach existiert keine Datenbankzeile mehr, die noch auf die
// einzelnen Blobs zeigen könnte.
DeletePrefix(ctx context.Context, prefix string) error
}
+235
View File
@@ -0,0 +1,235 @@
package tags
// Absender („Korrespondent") – das zweite Ordnungsmerkmal in diesem Paket.
//
// **Warum hier und nicht in einem eigenen Paket:** Etiketten, Belegdatum und
// Absender werden in *einem* Dialog gesetzt und müssen deshalb in *einer*
// Transaktion geschrieben werden (siehe SetForFile). Ein eigenes Paket hieße
// zwei Transaktionen – und damit einen Zustand, in dem die Etiketten sitzen
// und der Absender nicht. Das Paket verwaltet also nicht „Etiketten", sondern
// die Ordnungsmerkmale einer Datei.
//
// **Der Unterschied zum Etikett ist die Anzahl:** Ein Dokument trägt beliebig
// viele Etiketten, aber genau einen Absender. Daraus folgt alles Übrige – die
// eigene Liste in der Seitenleiste, das Feld statt der Mehrfachauswahl im
// Dialog, und die Möglichkeit, „von wem" und „wofür" getrennt zu fragen.
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
var ErrCorrespondentNotFound = errors.New("correspondent not found")
// maxCorrespondentLen: Firmennamen sind länger als Etiketten („Stadtwerke
// München Netzgesellschaft mbH"), aber ein Feld ist kein Aufsatz.
const maxCorrespondentLen = 80
// Correspondent ist ein Absender samt der Zahl seiner Dokumente.
type Correspondent struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
// Icon ist ein Emoji oder leer – dieselbe Regel wie beim Etikett.
Icon string `json:"icon"`
// GroupID ist gesetzt, wenn der Absender einer Gruppe gehört.
GroupID *uuid.UUID `json:"group_id,omitempty"`
GroupName string `json:"group_name,omitempty"`
FileCount int `json:"file_count"`
CreatedAt time.Time `json:"created_at"`
}
// ListCorrespondents liefert die eigenen Absender und die der eigenen Gruppen.
func (s *Store) ListCorrespondents(ctx context.Context, userID uuid.UUID) ([]Correspondent, error) {
const q = `
WITH my_groups AS (
SELECT group_id FROM group_members WHERE user_id = $1
)
SELECT c.id, c.name, c.icon, c.group_id, COALESCE(g.name, ''),
-- Gelöschtes zählt nicht mit: Ein Absender mit „3 Dateien", von denen
-- zwei im Papierkorb liegen, führt beim Filtern zu einer kürzeren
-- Liste als angekündigt.
(SELECT COUNT(*) FROM files f
WHERE f.correspondent_id = c.id AND f.is_deleted = false) AS file_count,
c.created_at
FROM correspondents c
LEFT JOIN groups g ON g.id = c.group_id
WHERE c.owner_id = $1
OR c.group_id IN (SELECT group_id FROM my_groups)
ORDER BY (c.group_id IS NOT NULL), lower(c.name)`
rows, err := s.db.Query(ctx, q, userID)
if err != nil {
return nil, fmt.Errorf("list correspondents: %w", err)
}
defer rows.Close()
out := []Correspondent{}
for rows.Next() {
var c Correspondent
if err := rows.Scan(&c.ID, &c.Name, &c.Icon, &c.GroupID, &c.GroupName,
&c.FileCount, &c.CreatedAt); err != nil {
return nil, fmt.Errorf("scan correspondent: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
// CreateCorrespondent legt einen Absender an.
func (s *Store) CreateCorrespondent(ctx context.Context, userID uuid.UUID, groupID *uuid.UUID, name, icon string) (*Correspondent, error) {
name = normalizeName(name)
icon = normalizeIcon(icon)
if name == "" {
return nil, ErrEmptyName
}
if len([]rune(name)) > maxCorrespondentLen {
return nil, fmt.Errorf("%w: at most %d characters", ErrEmptyName, maxCorrespondentLen)
}
owner := &userID
if groupID != nil {
owner = nil
}
var c Correspondent
err := s.db.QueryRow(ctx,
`INSERT INTO correspondents (owner_id, group_id, name, icon)
VALUES ($1, $2, $3, $4)
RETURNING id, name, icon, group_id, created_at`,
owner, groupID, name, icon,
).Scan(&c.ID, &c.Name, &c.Icon, &c.GroupID, &c.CreatedAt)
if isUniqueViolation(err) {
return nil, ErrNameTaken
}
if err != nil {
return nil, fmt.Errorf("create correspondent: %w", err)
}
return &c, nil
}
// UpdateCorrespondent benennt einen Absender um.
//
// Umbenennen statt neu anlegen ist der Normalfall: Aus „Amazon" wird „Amazon
// EU S.à r.l.", und alle bisherigen Rechnungen sollen mitkommen. Genau das
// leistet ein eigenes Feld gegenüber einem in den Dateinamen geschriebenen
// Absender.
func (s *Store) UpdateCorrespondent(ctx context.Context, userID, id uuid.UUID, name, icon string) (*Correspondent, error) {
name = normalizeName(name)
icon = normalizeIcon(icon)
if name == "" {
return nil, ErrEmptyName
}
if len([]rune(name)) > maxCorrespondentLen {
return nil, fmt.Errorf("%w: at most %d characters", ErrEmptyName, maxCorrespondentLen)
}
var c Correspondent
err := s.db.QueryRow(ctx,
`UPDATE correspondents SET name = $3, icon = $4
WHERE id = $2 AND (owner_id = $1 OR group_id IN (
SELECT group_id FROM group_members WHERE user_id = $1))
RETURNING id, name, icon, group_id, created_at`,
userID, id, name, icon,
).Scan(&c.ID, &c.Name, &c.Icon, &c.GroupID, &c.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrCorrespondentNotFound
}
if isUniqueViolation(err) {
return nil, ErrNameTaken
}
if err != nil {
return nil, fmt.Errorf("update correspondent: %w", err)
}
return &c, nil
}
// DeleteCorrespondent entfernt einen Absender. Die Dokumente bleiben – sie
// verlieren nur ihre Zuordnung (ON DELETE SET NULL in Migration 000017).
func (s *Store) DeleteCorrespondent(ctx context.Context, userID, id uuid.UUID) error {
tag, err := s.db.Exec(ctx,
`DELETE FROM correspondents
WHERE id = $2 AND (owner_id = $1 OR group_id IN (
SELECT group_id FROM group_members WHERE user_id = $1))`,
userID, id)
if err != nil {
return fmt.Errorf("delete correspondent: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrCorrespondentNotFound
}
return nil
}
// FilesWithCorrespondent: „alles von Stadtwerke".
//
// Der Umfang steckt wie bei FilesWithTag in der Abfrage selbst, nicht in einem
// nachträglichen Filter. Sortiert nach Belegdatum absteigend – wer sich alle
// Rechnungen eines Absenders ansieht, will sie in zeitlicher Ordnung.
func (s *Store) FilesWithCorrespondent(ctx context.Context, userID, id uuid.UUID) ([]TaggedFile, error) {
const q = `
WITH my_groups AS (
SELECT group_id FROM group_members WHERE user_id = $1
),
allowed AS (
SELECT id FROM correspondents
WHERE id = $2
AND (owner_id = $1 OR group_id IN (SELECT group_id FROM my_groups))
)
SELECT f.id, f.name, f.folder_id, COALESCE(p.name, ''),
f.mime_type, f.size, f.has_thumbnail, f.document_date, f.updated_at
FROM files f
JOIN allowed a ON a.id = f.correspondent_id
LEFT JOIN folders p ON p.id = f.folder_id
WHERE f.is_deleted = false
ORDER BY f.document_date DESC NULLS LAST, f.name`
rows, err := s.db.Query(ctx, q, userID, id)
if err != nil {
return nil, fmt.Errorf("files with correspondent: %w", err)
}
defer rows.Close()
out := []TaggedFile{}
for rows.Next() {
var f TaggedFile
if err := rows.Scan(&f.ID, &f.Name, &f.FolderID, &f.FolderName,
&f.MimeType, &f.Size, &f.HasThumbnail, &f.DocumentDate, &f.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan file: %w", err)
}
out = append(out, f)
}
return out, rows.Err()
}
// CorrespondentsForFiles liefert die Absender mehrerer Dateien auf einmal.
//
// In einer Abfrage, nicht je Kachel: Eine Ordnerliste mit fünfzig Dateien
// löste sonst einundfünfzig Abfragen aus.
func (s *Store) CorrespondentsForFiles(ctx context.Context, fileIDs []uuid.UUID) (map[uuid.UUID]Correspondent, error) {
out := map[uuid.UUID]Correspondent{}
if len(fileIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx,
`SELECT f.id, c.id, c.name, c.icon, c.group_id
FROM files f
JOIN correspondents c ON c.id = f.correspondent_id
WHERE f.id = ANY($1)`, fileIDs)
if err != nil {
return nil, fmt.Errorf("correspondents for files: %w", err)
}
defer rows.Close()
for rows.Next() {
var fileID uuid.UUID
var c Correspondent
if err := rows.Scan(&fileID, &c.ID, &c.Name, &c.Icon, &c.GroupID); err != nil {
return nil, fmt.Errorf("scan correspondent: %w", err)
}
out[fileID] = c
}
return out, rows.Err()
}
+362
View File
@@ -0,0 +1,362 @@
package tags
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"aether/internal/acl"
"aether/internal/auth"
"aether/pkg/response"
)
type Handler struct {
store *Store
acl *acl.Store
}
func NewHandler(store *Store, aclStore *acl.Store) *Handler {
return &Handler{store: store, acl: aclStore}
}
func userID(r *http.Request) uuid.UUID {
if u := auth.UserFromContext(r.Context()); u != nil {
return u.ID
}
return uuid.Nil
}
// GET /api/v1/tags
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
list, err := h.store.List(r.Context(), userID(r))
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load tags")
return
}
response.JSON(w, http.StatusOK, list)
}
// POST /api/v1/tags
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
Color string `json:"color"`
Icon string `json:"icon"`
GroupID string `json:"group_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
var groupID *uuid.UUID
if body.GroupID != "" {
id, err := uuid.Parse(body.GroupID)
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid group_id")
return
}
// Nur für die eigenen Gruppen. Ohne diese Prüfung könnte man einer
// fremden Gruppe Etiketten in die Liste legen.
member, err := h.acl.IsGroupMember(r.Context(), userID(r), id)
if err != nil || !member {
response.Error(w, http.StatusNotFound, "group not found")
return
}
groupID = &id
}
tag, err := h.store.Create(r.Context(), userID(r), groupID, body.Name, body.Color, body.Icon)
switch {
case errors.Is(err, ErrNameTaken):
response.Error(w, http.StatusConflict, "a tag with that name already exists")
return
case errors.Is(err, ErrEmptyName):
response.Error(w, http.StatusBadRequest, err.Error())
return
case err != nil:
response.Error(w, http.StatusInternalServerError, "could not create tag")
return
}
response.JSON(w, http.StatusCreated, tag)
}
// PATCH /api/v1/tags/{id}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return
}
var body struct {
Name string `json:"name"`
Color string `json:"color"`
Icon string `json:"icon"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
tag, err := h.store.Update(r.Context(), userID(r), id, body.Name, body.Color, body.Icon)
switch {
case errors.Is(err, ErrNotFound):
// 404, nicht 403: Sonst verrät die Antwort, dass es das Etikett gibt.
response.Error(w, http.StatusNotFound, "not found")
return
case errors.Is(err, ErrNameTaken):
response.Error(w, http.StatusConflict, "a tag with that name already exists")
return
case errors.Is(err, ErrEmptyName):
response.Error(w, http.StatusBadRequest, err.Error())
return
case err != nil:
response.Error(w, http.StatusInternalServerError, "could not update tag")
return
}
response.JSON(w, http.StatusOK, tag)
}
// DELETE /api/v1/tags/{id}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return
}
if err := h.store.Delete(r.Context(), userID(r), id); err != nil {
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "not found")
return
}
response.Error(w, http.StatusInternalServerError, "could not delete tag")
return
}
w.WriteHeader(http.StatusNoContent)
}
// GET /api/v1/tags/{id}/files – die Abfrage, für die Etiketten gebaut werden.
func (h *Handler) Files(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return
}
files, err := h.store.FilesWithTag(r.Context(), userID(r), id)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load files")
return
}
response.JSON(w, http.StatusOK, files)
}
// PUT /api/v1/files/{id}/tags – setzt die Etiketten einer Datei.
func (h *Handler) SetForFile(w http.ResponseWriter, r *http.Request) {
fileID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return
}
var body struct {
TagIDs []string `json:"tag_ids"`
DocumentDate *string `json:"document_date"`
// Absender: entweder eine ID (Auswahl aus der Liste, leerer String
// heißt „keiner mehr") oder ein getippter Name, der bei Bedarf
// angelegt wird. Fehlt beides, bleibt der Absender, wie er ist.
CorrespondentID *string `json:"correspondent_id"`
CorrespondentName string `json:"correspondent_name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
// Etikettieren ist eine Änderung an der Datei – also Schreibrecht, nicht
// Leserecht. Wer eine Datei nur lesen darf, soll sie nicht für den
// Eigentümer umsortieren können.
access, err := h.acl.RequireFileWrite(r.Context(), userID(r), fileID)
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return
}
ids := make([]uuid.UUID, 0, len(body.TagIDs))
for _, raw := range body.TagIDs {
id, err := uuid.Parse(raw)
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid tag id")
return
}
ids = append(ids, id)
}
fields := FileFields{
TagIDs: ids,
DocumentDate: body.DocumentDate,
CorrespondentName: body.CorrespondentName,
}
if body.CorrespondentID != nil {
// Leerer String heißt ausdrücklich „keinen Absender mehr" – das ist
// etwas anderes als „Feld nicht mitgeschickt".
cid := uuid.Nil
if *body.CorrespondentID != "" {
parsed, err := uuid.Parse(*body.CorrespondentID)
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid correspondent id")
return
}
cid = parsed
}
fields.CorrespondentID = &cid
}
// Etiketten, Belegdatum und Absender gehen zusammen in einen Vorgang –
// entweder gilt alles oder nichts.
switch err := h.store.SetFieldsForFile(r.Context(), fileID, access.OwnerID, userID(r), fields); {
case errors.Is(err, ErrBadDate):
response.Error(w, http.StatusBadRequest, "invalid document_date (expected YYYY-MM-DD)")
return
case errors.Is(err, ErrWrongOwner):
// Deckt zwei Fälle ab, die von außen ununterscheidbar bleiben sollen:
// Das Etikett gibt es nicht – oder es gehört jemand anderem. Ein
// eigener Text für den zweiten Fall verriete dessen Existenz.
response.Error(w, http.StatusBadRequest,
"unknown tag or correspondent — a file can only carry those of its own owner or group")
return
case err != nil:
response.Error(w, http.StatusInternalServerError, "could not set tags")
return
}
list, err := h.store.ForFile(r.Context(), fileID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load tags")
return
}
response.JSON(w, http.StatusOK, list)
}
// ── Absender ─────────────────────────────────────────────────────────────────
// GET /api/v1/correspondents
func (h *Handler) ListCorrespondents(w http.ResponseWriter, r *http.Request) {
list, err := h.store.ListCorrespondents(r.Context(), userID(r))
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load correspondents")
return
}
response.JSON(w, http.StatusOK, list)
}
// POST /api/v1/correspondents
func (h *Handler) CreateCorrespondent(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
Icon string `json:"icon"`
GroupID string `json:"group_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
var groupID *uuid.UUID
if body.GroupID != "" {
id, err := uuid.Parse(body.GroupID)
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid group_id")
return
}
member, err := h.acl.IsGroupMember(r.Context(), userID(r), id)
if err != nil || !member {
response.Error(w, http.StatusNotFound, "group not found")
return
}
groupID = &id
}
c, err := h.store.CreateCorrespondent(r.Context(), userID(r), groupID, body.Name, body.Icon)
switch {
case errors.Is(err, ErrNameTaken):
response.Error(w, http.StatusConflict, "a correspondent with that name already exists")
return
case errors.Is(err, ErrEmptyName):
response.Error(w, http.StatusBadRequest, err.Error())
return
case err != nil:
response.Error(w, http.StatusInternalServerError, "could not create correspondent")
return
}
response.JSON(w, http.StatusCreated, c)
}
// PATCH /api/v1/correspondents/{id}
func (h *Handler) UpdateCorrespondent(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return
}
var body struct {
Name string `json:"name"`
Icon string `json:"icon"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
c, err := h.store.UpdateCorrespondent(r.Context(), userID(r), id, body.Name, body.Icon)
switch {
case errors.Is(err, ErrCorrespondentNotFound):
response.Error(w, http.StatusNotFound, "not found")
return
case errors.Is(err, ErrNameTaken):
response.Error(w, http.StatusConflict, "a correspondent with that name already exists")
return
case errors.Is(err, ErrEmptyName):
response.Error(w, http.StatusBadRequest, err.Error())
return
case err != nil:
response.Error(w, http.StatusInternalServerError, "could not update correspondent")
return
}
response.JSON(w, http.StatusOK, c)
}
// DELETE /api/v1/correspondents/{id}
//
// Die Dokumente bleiben, sie verlieren nur ihre Zuordnung. Wer einen Absender
// löscht, will ihn aus der Liste haben – nicht seine Rechnungen.
func (h *Handler) DeleteCorrespondent(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return
}
if err := h.store.DeleteCorrespondent(r.Context(), userID(r), id); err != nil {
if errors.Is(err, ErrCorrespondentNotFound) {
response.Error(w, http.StatusNotFound, "not found")
return
}
response.Error(w, http.StatusInternalServerError, "could not delete correspondent")
return
}
w.WriteHeader(http.StatusNoContent)
}
// GET /api/v1/correspondents/{id}/files – „alles von Stadtwerke".
func (h *Handler) CorrespondentFiles(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return
}
files, err := h.store.FilesWithCorrespondent(r.Context(), userID(r), id)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load files")
return
}
response.JSON(w, http.StatusOK, files)
}
+535
View File
@@ -0,0 +1,535 @@
// Package tags verwaltet Etiketten an Dateien.
//
// Etiketten sind das dritte Ordnungsmittel neben Ordnern und Volltext, und sie
// beantworten eine Frage, die die anderen beiden nicht können: **wofür** ein
// Dokument gebraucht wird. „Absetzbar" steht in keiner Rechnung – das ist eine
// Einschätzung des Menschen und muss deshalb irgendwo hin.
//
// Der Fall, an dem Ordner scheitern: Der Kaufbeleg eines Notebooks ist zugleich
// Garantienachweis *und* absetzbar. In einen Ordner passt er nur einmal.
package tags
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrNotFound = errors.New("tag not found")
ErrNameTaken = errors.New("a tag with that name already exists")
ErrEmptyName = errors.New("tag name must not be empty")
ErrWrongOwner = errors.New("unknown tag, or it belongs to someone else")
ErrBadDate = errors.New("invalid date")
)
// maxNameLen hält die Namen kurz genug, dass ein Aufkleber an einer Kachel
// lesbar bleibt. Wer mehr braucht, meint eigentlich einen Ordner.
const maxNameLen = 40
// MaxTagsPerFile: Mehr sind kein Ordnungssystem mehr, sondern ein Symptom.
const MaxTagsPerFile = 20
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) *Store {
return &Store{db: db}
}
// Tag ist ein Etikett samt der Zahl der Dateien, die es tragen.
type Tag struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
// Color ist leer, solange keine gewählt wurde – die Oberfläche leitet dann
// eine aus dem Namen ab, damit ein neues Etikett nicht farblos ist.
Color string `json:"color"`
// Icon ist ein Emoji oder leer. Es steht **neben** der Farbe, nicht statt
// ihrer: Die Farbe trägt die Wiedererkennung im Augenwinkel, das Symbol
// die Bedeutung.
Icon string `json:"icon"`
// GroupID ist gesetzt, wenn das Etikett einer Gruppe gehört. Die Oberfläche
// kennzeichnet solche Etiketten, damit klar ist, dass andere sie sehen.
GroupID *uuid.UUID `json:"group_id,omitempty"`
GroupName string `json:"group_name,omitempty"`
FileCount int `json:"file_count"`
CreatedAt time.Time `json:"created_at"`
}
// normalizeName macht aus einer Eingabe einen speicherbaren Namen.
//
// Nur Rand-Leerraum und Mehrfach-Leerzeichen werden zusammengezogen; die
// Schreibweise bleibt, wie der Mensch sie getippt hat. Verglichen wird
// trotzdem ohne Rücksicht darauf (Index in Migration 000015) – „Stadtwerke" und
// „stadtwerke" sollen nicht nebeneinander stehen, aber wer Großbuchstaben
// tippt, soll sie auch sehen.
func normalizeName(s string) string {
return strings.Join(strings.Fields(s), " ")
}
// maxIconRunes begrenzt das Symbol.
//
// Nicht auf ein Zeichen: Ein Emoji ist selten ein Codepunkt – 👨‍🎓 sind fünf,
// 🏳️‍🌈 vier, Hautfarben und Flaggen kommen noch dazu. Acht lässt jedes
// gängige Emoji durch und verhindert, dass jemand einen Satz ins Feld
// schreibt und damit die Seitenleiste sprengt.
const maxIconRunes = 8
// normalizeIcon macht aus einer Eingabe ein speicherbares Symbol.
//
// Steuerzeichen und Zeilenumbrüche fliegen raus – die haben in einer Kachel
// nichts verloren und würden die Darstellung zerreißen. Was übrig bleibt,
// wird auf maxIconRunes gekürzt statt abgelehnt: Ein zu langes Symbol ist
// kein Grund, das Etikett nicht anzulegen.
func normalizeIcon(s string) string {
var b strings.Builder
n := 0
for _, r := range strings.TrimSpace(s) {
if r < 0x20 || r == 0x7f {
continue
}
if n >= maxIconRunes {
break
}
b.WriteRune(r)
n++
}
return b.String()
}
// List liefert die Etiketten des Anfragenden und die seiner Gruppen.
//
// Beides in einer Liste, unterschieden durch group_id: Für den Benutzer ist es
// dieselbe Auswahl, aus der er beim Etikettieren wählt – nur dass die
// Gruppen-Etiketten auch andere sehen.
func (s *Store) List(ctx context.Context, userID uuid.UUID) ([]Tag, error) {
const q = `
WITH my_groups AS (
SELECT group_id FROM group_members WHERE user_id = $1
)
SELECT t.id, t.name, t.color, t.icon, t.group_id, COALESCE(g.name, ''),
-- Gelöschtes zählt nicht mit: Ein Etikett mit "3 Dateien", von denen
-- zwei im Papierkorb liegen, führt beim Filtern zu einer kürzeren Liste
-- als angekündigt.
(SELECT COUNT(*) FROM file_tags ft
JOIN files f ON f.id = ft.file_id AND f.is_deleted = false
WHERE ft.tag_id = t.id) AS file_count,
t.created_at
FROM tags t
LEFT JOIN groups g ON g.id = t.group_id
WHERE t.owner_id = $1
OR t.group_id IN (SELECT group_id FROM my_groups)
ORDER BY (t.group_id IS NOT NULL), lower(t.name)`
rows, err := s.db.Query(ctx, q, userID)
if err != nil {
return nil, fmt.Errorf("list tags: %w", err)
}
defer rows.Close()
out := []Tag{}
for rows.Next() {
var t Tag
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.Icon, &t.GroupID, &t.GroupName,
&t.FileCount, &t.CreatedAt); err != nil {
return nil, fmt.Errorf("scan tag: %w", err)
}
out = append(out, t)
}
return out, rows.Err()
}
// Create legt ein Etikett an. groupID leer heißt: es gehört dem Anfragenden.
func (s *Store) Create(ctx context.Context, userID uuid.UUID, groupID *uuid.UUID, name, color, icon string) (*Tag, error) {
icon = normalizeIcon(icon)
name = normalizeName(name)
if name == "" {
return nil, ErrEmptyName
}
if len([]rune(name)) > maxNameLen {
return nil, fmt.Errorf("%w: at most %d characters", ErrEmptyName, maxNameLen)
}
var ownerID *uuid.UUID
if groupID == nil {
ownerID = &userID
}
var t Tag
err := s.db.QueryRow(ctx,
`INSERT INTO tags (owner_id, group_id, name, color, icon)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, color, icon, group_id, created_at`,
ownerID, groupID, name, color, icon,
).Scan(&t.ID, &t.Name, &t.Color, &t.Icon, &t.GroupID, &t.CreatedAt)
if isUniqueViolation(err) {
return nil, ErrNameTaken
}
if err != nil {
return nil, fmt.Errorf("create tag: %w", err)
}
return &t, nil
}
// Update ändert Namen und Farbe. Nur der Prinzipal des Etiketts darf das –
// die Prüfung steckt in der WHERE-Klausel, nicht davor.
func (s *Store) Update(ctx context.Context, userID, tagID uuid.UUID, name, color, icon string) (*Tag, error) {
icon = normalizeIcon(icon)
name = normalizeName(name)
if name == "" {
return nil, ErrEmptyName
}
var t Tag
err := s.db.QueryRow(ctx, `
UPDATE tags SET name = $3, color = $4, icon = $5
WHERE id = $2
AND (owner_id = $1
OR group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
RETURNING id, name, color, icon, group_id, created_at`,
userID, tagID, name, color, icon,
).Scan(&t.ID, &t.Name, &t.Color, &t.Icon, &t.GroupID, &t.CreatedAt)
if isUniqueViolation(err) {
return nil, ErrNameTaken
}
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("update tag: %w", err)
}
return &t, nil
}
// Delete entfernt das Etikett; die Zuordnungen fallen per CASCADE mit. Die
// Dateien selbst bleiben unberührt – ein Etikett zu löschen ist eine Aussage
// über die Ordnung, nicht über den Bestand.
func (s *Store) Delete(ctx context.Context, userID, tagID uuid.UUID) error {
tag, err := s.db.Exec(ctx, `
DELETE FROM tags
WHERE id = $2
AND (owner_id = $1
OR group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))`,
userID, tagID)
if err != nil {
return fmt.Errorf("delete tag: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
// ── Zuordnung zu Dateien ─────────────────────────────────────────────────────
// SetForFile setzt die Etiketten einer Datei auf genau diese Liste.
//
// Setzen statt einzeln anhängen und lösen: Der Dialog zeigt ohnehin alle
// Etiketten mit Häkchen, und ein einziger Aufruf kann nicht auf halbem Weg
// stehenbleiben. Zwei Aufrufe („anhängen", „lösen") könnten das.
//
// principalID ist der Eigentümer der Datei – ein Konto oder eine Gruppe (siehe
// acl.Access.OwnerID). **Nur Etiketten desselben Prinzipals sind erlaubt.** Das
// ist die Regel, die Gruppenordner zusammenhält: Eine Datei im Gruppenordner
// trägt Gruppen-Etiketten, die alle Mitglieder sehen – nicht die privaten des
// Einzelnen, die für die anderen unsichtbar blieben.
//
// documentDate wird **im selben Vorgang** gesetzt, nicht in einem zweiten
// Aufruf danach. Der erste Entwurf tat genau das – und ein ungültiges Datum
// hatte die Etiketten dann bereits geändert, bevor es scheiterte. Beim Testen
// aufgefallen: „15.01.2026" wurde abgelehnt, das zweite Etikett war trotzdem
// weg. nil heißt „nicht anfassen", ein leerer Zeiger-Inhalt heißt „löschen".
// FileFields sind die Ordnungsmerkmale, die der Dialog an einer Datei setzt.
//
// Ein Zeiger auf nil heißt jeweils „nicht mitgeschickt, also unverändert" –
// das ist etwas anderes als „leeren". Ohne diese Unterscheidung löschte ein
// Aufruf, der nur die Etiketten ändern will, stillschweigend das Belegdatum.
type FileFields struct {
TagIDs []uuid.UUID
// DocumentDate: "" leert das Feld, nil lässt es stehen.
DocumentDate *string
// CorrespondentID: uuid.Nil leert das Feld, nil lässt es stehen.
CorrespondentID *uuid.UUID
// CorrespondentName wird angelegt, falls es ihn noch nicht gibt – der
// Normalfall beim Tippen im Dialog. Hat Vorrang vor CorrespondentID.
//
// Bewusst hier und nicht als zweiter Aufruf der Oberfläche: Sonst entsteht
// bei einem Fehler dazwischen ein Absender, der an keiner Datei hängt.
CorrespondentName string
}
func (s *Store) SetForFile(ctx context.Context, fileID, principalID, userID uuid.UUID, tagIDs []uuid.UUID, documentDate *string) error {
return s.SetFieldsForFile(ctx, fileID, principalID, userID,
FileFields{TagIDs: tagIDs, DocumentDate: documentDate})
}
// SetFieldsForFile schreibt Etiketten, Belegdatum und Absender in **einem**
// Vorgang. Entweder gilt alles oder nichts – ein Zustand, in dem die
// Etiketten sitzen und der Absender nicht, wäre für den Menschen davor nicht
// erkennbar.
func (s *Store) SetFieldsForFile(ctx context.Context, fileID, principalID, userID uuid.UUID, f FileFields) error {
tagIDs, documentDate := f.TagIDs, f.DocumentDate
if len(tagIDs) > MaxTagsPerFile {
return fmt.Errorf("at most %d tags per file", MaxTagsPerFile)
}
// Das Datum vor allem anderen prüfen: Was scheitern kann, soll scheitern,
// bevor irgendetwas geschrieben wurde.
var parsedDate *time.Time
if documentDate != nil {
if v := strings.TrimSpace(*documentDate); v != "" {
d, err := time.Parse("2006-01-02", v)
if err != nil {
return fmt.Errorf("%w: %s", ErrBadDate, v)
}
parsedDate = &d
}
}
tx, err := s.db.Begin(ctx)
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer tx.Rollback(ctx)
if len(tagIDs) > 0 {
// Erst prüfen, dass jedes genannte Etikett wirklich dem Prinzipal der
// Datei gehört. Ohne diese Prüfung könnte man über eine erratene ID ein
// fremdes Etikett anhängen – und dessen Besitzer sähe eine Datei
// darunter, die ihm nichts sagt.
var ok int
if err := tx.QueryRow(ctx,
`SELECT COUNT(*) FROM tags
WHERE id = ANY($1) AND (owner_id = $2 OR group_id = $2)`,
tagIDs, principalID,
).Scan(&ok); err != nil {
return fmt.Errorf("verify tags: %w", err)
}
if ok != len(tagIDs) {
return ErrWrongOwner
}
}
// Alte Zuordnungen weg, neue rein. Nicht „nur die Differenz", weil die
// Differenz zu berechnen mehr Code wäre als der Gewinn – es geht um
// höchstens 20 Zeilen.
if _, err := tx.Exec(ctx, `DELETE FROM file_tags WHERE file_id = $1`, fileID); err != nil {
return fmt.Errorf("clear tags: %w", err)
}
for _, id := range tagIDs {
if _, err := tx.Exec(ctx,
`INSERT INTO file_tags (file_id, tag_id, tagged_by) VALUES ($1, $2, $3)`,
fileID, id, userID,
); err != nil {
return fmt.Errorf("attach tag: %w", err)
}
}
// In derselben Transaktion: Entweder gilt beides oder nichts.
if documentDate != nil {
if _, err := tx.Exec(ctx,
`UPDATE files SET document_date = $2 WHERE id = $1`, fileID, parsedDate,
); err != nil {
return fmt.Errorf("set document date: %w", err)
}
}
// Absender. Ein getippter Name gewinnt gegen eine mitgeschickte ID: Wer
// im Feld etwas anderes stehen hat als das, was ausgewählt war, meint das
// Getippte.
if name := normalizeName(f.CorrespondentName); name != "" {
if len([]rune(name)) > maxCorrespondentLen {
return fmt.Errorf("%w: at most %d characters", ErrEmptyName, maxCorrespondentLen)
}
// Suchen oder anlegen, beides am Prinzipal der **Datei** – nicht am
// Anfragenden. Sonst bekäme eine Datei im Gruppenordner einen privaten
// Absender, den außer dem Ersteller niemand sieht.
var id uuid.UUID
err := tx.QueryRow(ctx,
`SELECT id FROM correspondents
WHERE (owner_id = $1 OR group_id = $1) AND lower(btrim(name)) = lower(btrim($2))`,
principalID, name,
).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
// Ob der Prinzipal ein Konto oder eine Gruppe ist, steht nicht in
// der ID. Beide Spalten mit derselben ID zu versuchen wäre Raten –
// deshalb die Frage an die Datenbank.
var isGroup bool
if err := tx.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM groups WHERE id = $1)`, principalID,
).Scan(&isGroup); err != nil {
return fmt.Errorf("principal kind: %w", err)
}
var owner, group *uuid.UUID
if isGroup {
group = &principalID
} else {
owner = &principalID
}
if err := tx.QueryRow(ctx,
`INSERT INTO correspondents (owner_id, group_id, name) VALUES ($1, $2, $3)
RETURNING id`, owner, group, name,
).Scan(&id); err != nil {
return fmt.Errorf("create correspondent: %w", err)
}
} else if err != nil {
return fmt.Errorf("find correspondent: %w", err)
}
if _, err := tx.Exec(ctx,
`UPDATE files SET correspondent_id = $2 WHERE id = $1`, fileID, id,
); err != nil {
return fmt.Errorf("set correspondent: %w", err)
}
} else if f.CorrespondentID != nil {
var id *uuid.UUID
if *f.CorrespondentID != uuid.Nil {
// Dieselbe Prüfung wie bei den Etiketten: Über eine erratene ID
// darf kein fremder Absender an eine Datei geraten.
var ok int
if err := tx.QueryRow(ctx,
`SELECT COUNT(*) FROM correspondents
WHERE id = $1 AND (owner_id = $2 OR group_id = $2)`,
*f.CorrespondentID, principalID,
).Scan(&ok); err != nil {
return fmt.Errorf("verify correspondent: %w", err)
}
if ok != 1 {
return ErrWrongOwner
}
id = f.CorrespondentID
}
if _, err := tx.Exec(ctx,
`UPDATE files SET correspondent_id = $2 WHERE id = $1`, fileID, id,
); err != nil {
return fmt.Errorf("set correspondent: %w", err)
}
}
return tx.Commit(ctx)
}
// ForFile liefert die Etiketten einer einzelnen Datei.
func (s *Store) ForFile(ctx context.Context, fileID uuid.UUID) ([]Tag, error) {
rows, err := s.db.Query(ctx, `
SELECT t.id, t.name, t.color, t.icon, t.group_id, t.created_at
FROM file_tags ft JOIN tags t ON t.id = ft.tag_id
WHERE ft.file_id = $1
ORDER BY lower(t.name)`, fileID)
if err != nil {
return nil, fmt.Errorf("tags for file: %w", err)
}
defer rows.Close()
out := []Tag{}
for rows.Next() {
var t Tag
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.Icon, &t.GroupID, &t.CreatedAt); err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
out = append(out, t)
}
return out, rows.Err()
}
// ForFiles holt die Etiketten vieler Dateien auf einmal.
//
// Eine Abfrage statt einer je Datei: Bei fünfzig Kacheln wären das sonst
// fünfzig Rundreisen zur Datenbank, nur um ein paar Aufkleber zu zeichnen.
func (s *Store) ForFiles(ctx context.Context, fileIDs []uuid.UUID) (map[uuid.UUID][]Tag, error) {
out := map[uuid.UUID][]Tag{}
if len(fileIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
SELECT ft.file_id, t.id, t.name, t.color, t.icon, t.group_id, t.created_at
FROM file_tags ft JOIN tags t ON t.id = ft.tag_id
WHERE ft.file_id = ANY($1)
ORDER BY lower(t.name)`, fileIDs)
if err != nil {
return nil, fmt.Errorf("tags for files: %w", err)
}
defer rows.Close()
for rows.Next() {
var fileID uuid.UUID
var t Tag
if err := rows.Scan(&fileID, &t.ID, &t.Name, &t.Color, &t.Icon, &t.GroupID, &t.CreatedAt); err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
out[fileID] = append(out[fileID], t)
}
return out, rows.Err()
}
// TaggedFile ist ein Treffer beim Filtern nach einem Etikett.
type TaggedFile struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
FolderID *uuid.UUID `json:"folder_id"`
FolderName string `json:"folder_name"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
HasThumbnail bool `json:"has_thumbnail"`
DocumentDate *time.Time `json:"document_date,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// FilesWithTag ist die Abfrage, für die Etiketten überhaupt gebaut werden:
// „zeig mir alles mit steuer-2026".
//
// Der Umfang steckt wie bei der Suche in der Abfrage selbst, nicht in einem
// nachträglichen Filter: Erreichbar ist, was dem Anfragenden gehört oder einer
// seiner Gruppen. Etiketten sind Prinzipal-gebunden, deshalb genügt hier die
// einfachere Prüfung – eine Datei mit einem meiner Etiketten kann gar nicht
// jemand anderem gehören.
//
// Sortiert nach Belegdatum, absteigend: Beim Filtern nach „steuer-2026" will
// man die Belege in ihrer zeitlichen Ordnung sehen, nicht alphabetisch. Wer
// kein Belegdatum gesetzt hat, rutscht ans Ende statt zu verschwinden.
func (s *Store) FilesWithTag(ctx context.Context, userID, tagID uuid.UUID) ([]TaggedFile, error) {
const q = `
WITH my_groups AS (
SELECT group_id FROM group_members WHERE user_id = $1
),
allowed AS (
SELECT id FROM tags
WHERE id = $2
AND (owner_id = $1 OR group_id IN (SELECT group_id FROM my_groups))
)
SELECT f.id, f.name, f.folder_id, COALESCE(p.name, ''),
f.mime_type, f.size, f.has_thumbnail, f.document_date, f.updated_at
FROM file_tags ft
JOIN allowed a ON a.id = ft.tag_id
JOIN files f ON f.id = ft.file_id AND f.is_deleted = false
LEFT JOIN folders p ON p.id = f.folder_id
ORDER BY f.document_date DESC NULLS LAST, f.name`
rows, err := s.db.Query(ctx, q, userID, tagID)
if err != nil {
return nil, fmt.Errorf("files with tag: %w", err)
}
defer rows.Close()
out := []TaggedFile{}
for rows.Next() {
var f TaggedFile
if err := rows.Scan(&f.ID, &f.Name, &f.FolderID, &f.FolderName,
&f.MimeType, &f.Size, &f.HasThumbnail, &f.DocumentDate, &f.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan tagged file: %w", err)
}
out = append(out, f)
}
return out, rows.Err()
}
+95
View File
@@ -0,0 +1,95 @@
package thumbnails
import (
"bytes"
"context"
"fmt"
"image"
_ "image/gif"
"image/jpeg"
_ "image/png"
_ "golang.org/x/image/webp" // SupportedMIME nennt WebP – ohne den Dekodierer entstand dafür nie ein Vorschaubild
"io"
"strings"
"golang.org/x/image/draw"
"aether/internal/storage"
)
const (
maxWidth = 256
maxHeight = 256
)
// SupportedMIME returns true if we can generate a thumbnail for this MIME type.
func SupportedMIME(mime string) bool {
m := strings.ToLower(mime)
return strings.HasPrefix(m, "image/jpeg") ||
strings.HasPrefix(m, "image/png") ||
strings.HasPrefix(m, "image/gif") ||
strings.HasPrefix(m, "image/webp")
}
// ThumbnailKey returns the storage key for a file's thumbnail.
func ThumbnailKey(fileID string) string {
return "thumbs/" + fileID + ".jpg"
}
// Generate reads an image from src, creates a thumbnail, and stores it.
// Returns nil if generation succeeds, error otherwise.
func Generate(ctx context.Context, st storage.Storage, fileID, mimeType string, src io.Reader) error {
if !SupportedMIME(mimeType) {
return fmt.Errorf("unsupported MIME type: %s", mimeType)
}
img, _, err := image.Decode(src)
if err != nil {
return fmt.Errorf("decode image: %w", err)
}
thumb := resize(img, maxWidth, maxHeight)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 80}); err != nil {
return fmt.Errorf("encode thumbnail: %w", err)
}
key := ThumbnailKey(fileID)
if _, _, err := st.Put(ctx, key, &buf); err != nil {
return fmt.Errorf("store thumbnail: %w", err)
}
return nil
}
func resize(src image.Image, maxW, maxH int) image.Image {
bounds := src.Bounds()
w, h := bounds.Dx(), bounds.Dy()
if w == 0 || h == 0 {
return src
}
scaleW := float64(maxW) / float64(w)
scaleH := float64(maxH) / float64(h)
scale := scaleW
if scaleH < scale {
scale = scaleH
}
if scale >= 1 {
return src // already smaller than max
}
newW := int(float64(w) * scale)
newH := int(float64(h) * scale)
if newW < 1 {
newW = 1
}
if newH < 1 {
newH = 1
}
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
draw.BiLinear.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
return dst
}
+873
View File
@@ -0,0 +1,873 @@
package webdav
import (
"bytes"
"context"
"fmt"
"io"
"os"
"path"
"strings"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
xwebdav "golang.org/x/net/webdav"
"aether/internal/acl"
"aether/internal/auth"
"aether/internal/files"
"aether/internal/storage"
)
// aetherFS implements webdav.FileSystem for a single authenticated user.
type aetherFS struct {
fileStore *files.Store
storage storage.Storage
acl *acl.Store
user *auth.User
// quotaHit meldet dem Handler, dass ein Schreibvorgang am Kontingent
// gescheitert ist. Nötig, weil x/net/webdav jeden Fehler aus io.Copy
// pauschal als 405 beantwortet – für „voll" ist 507 der richtige Code.
quotaHit *bool
// denied meldet dem Handler, dass eine Aktion an fehlenden Rechten
// gescheitert ist – siehe statusWriter.
denied *bool
// contentLength ist die vom Client angekündigte Größe des Request-Bodys
// (-1 bei chunked). Close vergleicht damit, ob der Upload vollständig
// ankam – siehe davWriteFile.Close.
contentLength int64
}
func newFS(fileStore *files.Store, st storage.Storage, aclStore *acl.Store, user *auth.User, quotaHit, denied *bool, contentLength int64) xwebdav.FileSystem {
return &aetherFS{
fileStore: fileStore,
storage: st,
acl: aclStore,
user: user,
quotaHit: quotaHit,
denied: denied,
contentLength: contentLength,
}
}
// splitPath converts a WebDAV path into clean path parts.
func splitPath(name string) []string {
name = path.Clean("/" + name)
if name == "/" {
return nil
}
return strings.Split(strings.TrimPrefix(name, "/"), "/")
}
// resolve löst einen WebDAV-Pfad auf. Der Scope bestimmt vorher, in wessen
// Namen das geschieht – eigener Baum, Gruppenordner oder fremde Freigabe.
func (fs *aetherFS) resolve(ctx context.Context, name string) (folder *files.Folder, file *files.File, parentID *uuid.UUID, sc *davScope, err error) {
sc, serr := fs.scopeFor(ctx, splitPath(name))
if serr != nil {
return nil, nil, nil, nil, serr
}
// Virtuelle Ebenen haben keinen Ordner dahinter; der Aufrufer erkennt das
// an sc.virtual und behandelt sie als Verzeichnis ohne Inhalt in der DB.
if sc.virtual {
return nil, nil, nil, sc, nil
}
if sc.sharedFile != nil {
return nil, sc.sharedFile, sc.sharedFile.FolderID, sc, nil
}
if f, ferr := fs.fileStore.FolderByPathFrom(ctx, sc.principal, sc.base, sc.parts); ferr == nil {
return f, nil, nil, sc, nil
}
if len(sc.parts) == 0 {
return nil, nil, nil, sc, os.ErrNotExist
}
parentParts := sc.parts[:len(sc.parts)-1]
fileName := sc.parts[len(sc.parts)-1]
parent, perr := fs.fileStore.FolderByPathFrom(ctx, sc.principal, sc.base, parentParts)
if perr != nil {
return nil, nil, nil, sc, os.ErrNotExist
}
var pid *uuid.UUID
if parent.ID != uuid.Nil {
pid = &parent.ID
}
fi, fierr := fs.fileStore.FileInFolder(ctx, sc.principal, pid, fileName)
if fierr != nil {
return nil, nil, pid, sc, os.ErrNotExist
}
return nil, fi, pid, sc, nil
}
func (fs *aetherFS) Mkdir(ctx context.Context, name string, _ os.FileMode) error {
parts := splitPath(name)
if len(parts) == 0 {
return fs.forbid()
}
sc, err := fs.scopeFor(ctx, parts[:len(parts)-1])
if err != nil {
return os.ErrNotExist
}
// In /Shared und /Shared/<Eigentümer> lässt sich nichts anlegen – die
// Ebenen sind virtuell, es gäbe keinen Ort, an dem der Ordner läge.
if sc.virtual || !sc.writable {
return fs.forbid()
}
folderName := parts[len(parts)-1]
parent, err := fs.fileStore.FolderByPathFrom(ctx, sc.principal, sc.base, sc.parts)
if err != nil {
return os.ErrNotExist
}
var parentID *uuid.UUID
if parent.ID != uuid.Nil {
parentID = &parent.ID
}
// Der neue Ordner gehört dem Prinzipal des Elternordners, nicht dem, der
// ihn anlegt – sonst stünde in einem Gruppenordner plötzlich ein privater.
f := &files.Folder{Name: folderName, ParentID: parentID}
if sc.principal == fs.user.ID {
id := fs.user.ID
f.OwnerID = &id
} else if parent.GroupID != nil {
f.GroupID = parent.GroupID
} else {
f.OwnerID = parent.OwnerID
}
_, err = fs.fileStore.CreateFolder(ctx, f)
return err
}
func (fs *aetherFS) RemoveAll(ctx context.Context, name string) error {
folder, file, _, sc, err := fs.resolve(ctx, name)
if err != nil {
return err
}
// /Shared und /Shared/<Eigentümer> gibt es nicht wirklich – da ist nichts
// zu löschen. Und ohne Schreibrecht wird auch darunter nichts entfernt.
if sc.virtual || !sc.writable {
return fs.forbid()
}
if folder != nil && folder.ID != uuid.Nil {
return fs.fileStore.DeleteFolder(ctx, folder.ID, sc.principal)
}
if file != nil {
trashKey := fmt.Sprintf("%s/.trash/%s_%s", sc.storageRoot, uuid.New().String()[:8], file.Name)
if merr := fs.storage.Move(ctx, file.StorageKey, trashKey); merr != nil {
return merr
}
return fs.fileStore.SoftDeleteFile(ctx, file.ID, sc.principal, trashKey)
}
return os.ErrNotExist
}
func (fs *aetherFS) Rename(ctx context.Context, oldName, newName string) error {
newParts := splitPath(newName)
if len(newParts) == 0 {
return fs.forbid()
}
newScope, err := fs.scopeFor(ctx, newParts[:len(newParts)-1])
if err != nil {
return os.ErrNotExist
}
if newScope.virtual || !newScope.writable {
return fs.forbid()
}
newBaseName := newParts[len(newParts)-1]
newParent, err := fs.fileStore.FolderByPathFrom(ctx, newScope.principal, newScope.base, newScope.parts)
if err != nil {
return os.ErrNotExist
}
var newParentID *uuid.UUID
if newParent.ID != uuid.Nil {
newParentID = &newParent.ID
}
folder, file, oldParentID, oldScope, err := fs.resolve(ctx, oldName)
if err != nil {
return err
}
if oldScope.virtual || !oldScope.writable {
return fs.forbid()
}
// Über Eigentumsgrenzen hinweg wird nicht verschoben: Das buchte Bytes
// zwischen Kontingenten um und hängte Freigaben unter der Hand um.
// Herunterladen und neu hochladen ist der ehrliche Weg.
if oldScope.principal != newScope.principal {
return fs.forbid()
}
if file != nil {
// Rename (same parent) or move (different parent).
sameFolder := ptrEqual(oldParentID, newParentID)
renamed := newBaseName != file.Name
if sameFolder && !renamed {
return nil
}
// Der Storage-Pfad enthält Ordner und Dateinamen – der Blob muss der
// Umbenennung folgen, sonst zeigt der Schlüssel in der Datenbank ins Leere.
newFolderPath, err := fs.fileStore.GetFolderPath(ctx, newParentID)
if err != nil {
return err
}
newKey := files.BuildStorageKey(newScope.storageRoot, newFolderPath, newBaseName)
movedBlob := false
if newKey != file.StorageKey {
if err := fs.storage.Move(ctx, file.StorageKey, newKey); err != nil {
return err
}
movedBlob = true
}
// Scheitert die Datenbank, muss der Blob zurück – sonst laufen Metadaten
// und Storage auseinander.
rollback := func() {
if !movedBlob {
return
}
if err := fs.storage.Move(ctx, newKey, file.StorageKey); err != nil {
log.Ctx(ctx).Error().Err(err).
Str("file_id", file.ID.String()).
Msg("webdav: rollback of rename move failed – blob orphaned")
}
}
if !sameFolder {
if _, err = fs.fileStore.MoveFile(ctx, file.ID, oldScope.principal, newParentID, newKey); err != nil {
rollback()
return err
}
}
if renamed {
if _, err = fs.fileStore.RenameFile(ctx, file.ID, oldScope.principal, newBaseName, newKey); err != nil {
rollback()
return err
}
}
return nil
}
if folder != nil && folder.ID != uuid.Nil {
sameParent := true
if folder.ParentID != nil {
sameParent = newParentID != nil && *folder.ParentID == *newParentID
} else {
sameParent = newParentID == nil
}
renamed := newBaseName != folder.Name
if sameParent && !renamed {
return nil
}
// Dateien vor der Umbenennung einsammeln – danach sind die alten
// Storage-Schlüssel nicht mehr rekonstruierbar.
affected, err := fs.fileStore.ListFilesInSubtree(ctx, oldScope.principal, folder.ID)
if err != nil {
return err
}
if renamed {
if _, err = fs.fileStore.RenameFolder(ctx, folder.ID, oldScope.principal, newBaseName); err != nil {
return err
}
}
if !sameParent {
if _, err = fs.fileStore.MoveFolder(ctx, folder.ID, oldScope.principal, newParentID); err != nil {
return err
}
}
// Der Ordnername steckt im Pfad jeder Datei darunter – Blobs nachziehen.
files.ResyncBlobPaths(ctx, fs.fileStore, fs.storage, newScope.storageRoot, affected)
return nil
}
return os.ErrNotExist
}
func (fs *aetherFS) Stat(ctx context.Context, name string) (os.FileInfo, error) {
folder, file, _, sc, err := fs.resolve(ctx, name)
if err != nil {
return nil, err
}
if sc != nil && sc.virtual {
return &davFileInfo{name: path.Base(name), modTime: time.Now(), isDir: true}, nil
}
if folder != nil {
modTime := folder.CreatedAt
if modTime.IsZero() {
modTime = time.Now()
}
return &davFileInfo{name: path.Base(name), modTime: modTime, isDir: true}, nil
}
return &davFileInfo{name: file.Name, size: file.Size, modTime: file.UpdatedAt}, nil
}
func (fs *aetherFS) OpenFile(ctx context.Context, name string, flag int, _ os.FileMode) (xwebdav.File, error) {
parts := splitPath(name)
sc, err := fs.scopeFor(ctx, parts)
if err != nil {
return nil, err
}
writing := flag&(os.O_WRONLY|os.O_RDWR|os.O_CREATE) != 0
// Virtuelle Ebenen: Verzeichnisse ohne Eintrag in der Datenbank.
if sc.virtual {
if writing {
return nil, fs.forbid()
}
return &davDir{
name: path.Base(name),
modTime: time.Now(),
virtual: true,
shared: sc.virtualEntries,
atShared: len(parts) == 1,
}, nil
}
// Einzeln freigegebene Datei – der Scope zeigt direkt auf sie.
if sc.sharedFile != nil {
if writing {
return nil, fs.forbid()
}
return fs.openForReading(ctx, sc.sharedFile)
}
// Verzeichnis im Baum des Prinzipals.
if folder, ferr := fs.fileStore.FolderByPathFrom(ctx, sc.principal, sc.base, sc.parts); ferr == nil {
if writing {
return nil, fs.forbid()
}
var folderID *uuid.UUID
if folder.ID != uuid.Nil {
folderID = &folder.ID
}
d := &davDir{
name: path.Base(name),
modTime: folder.CreatedAt,
fileStore: fs.fileStore,
user: fs.user,
principal: sc.principal,
folderID: folderID,
}
// In der Wurzel kommen die Gruppenordner dazu – gleichrangig neben den
// eigenen, wie in der Weboberfläche. Personen-Freigaben liegen dagegen
// gebündelt unter „Shared", und dieser Eintrag erscheint nur, wenn es
// überhaupt welche gibt.
if len(parts) == 0 && fs.acl != nil {
now := time.Now()
if groups, err := fs.rootGroupFolders(ctx); err == nil {
for _, g := range groups {
d.extra = append(d.extra, &davFileInfo{name: g.name, modTime: now, isDir: true})
}
}
if shares, err := fs.aclShares(ctx); err == nil && len(shares) > 0 {
d.extra = append(d.extra, &davFileInfo{name: sharedRoot, modTime: now, isDir: true})
}
}
return d, nil
}
if len(parts) == 0 {
return nil, os.ErrNotExist
}
if len(sc.parts) == 0 {
return nil, os.ErrNotExist
}
fileName := sc.parts[len(sc.parts)-1]
parent, err := fs.fileStore.FolderByPathFrom(ctx, sc.principal, sc.base, sc.parts[:len(sc.parts)-1])
if err != nil {
return nil, os.ErrNotExist
}
var parentID *uuid.UUID
if parent.ID != uuid.Nil {
parentID = &parent.ID
}
// Write mode.
if writing {
if !sc.writable {
return nil, fs.forbid()
}
return &davWriteFile{
ctx: ctx,
name: fileName,
folderID: parentID,
user: fs.user,
principal: sc.principal,
storageRoot: sc.storageRoot,
quota: sc.quota,
parentOwner: parent,
fileStore: fs.fileStore,
storage: fs.storage,
quotaHit: fs.quotaHit,
expected: fs.contentLength,
}, nil
}
file, err := fs.fileStore.FileInFolder(ctx, sc.principal, parentID, fileName)
if err != nil {
return nil, os.ErrNotExist
}
return fs.openForReading(ctx, file)
}
// virtualEntries baut die Auflistung der beiden künstlichen Ebenen.
func virtualEntries(shared []sharedEntry, atShared bool) []os.FileInfo {
now := time.Now()
if atShared {
out := []os.FileInfo{}
for _, owner := range owners(shared) {
out = append(out, &davFileInfo{name: owner, modTime: now, isDir: true})
}
return out
}
out := []os.FileInfo{}
for _, e := range shared {
if e.file != nil {
out = append(out, &davFileInfo{
name: e.name, size: e.file.Size, modTime: e.file.UpdatedAt,
})
continue
}
out = append(out, &davFileInfo{name: e.name, modTime: now, isDir: true})
}
return out
}
func (fs *aetherFS) openForReading(ctx context.Context, file *files.File) (xwebdav.File, error) {
rc, err := fs.storage.Get(ctx, file.StorageKey)
if err != nil {
return nil, err
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
return nil, err
}
return &davReadFile{
name: file.Name,
size: file.Size,
modTime: file.UpdatedAt,
reader: bytes.NewReader(data),
}, nil
}
// ── FileInfo ──────────────────────────────────────────────────────────────────
type davFileInfo struct {
name string
size int64
modTime time.Time
isDir bool
}
func (fi *davFileInfo) Name() string { return fi.name }
func (fi *davFileInfo) Size() int64 { return fi.size }
func (fi *davFileInfo) Mode() os.FileMode { return 0644 }
func (fi *davFileInfo) ModTime() time.Time { return fi.modTime }
func (fi *davFileInfo) IsDir() bool { return fi.isDir }
func (fi *davFileInfo) Sys() any { return nil }
// ── Directory ────────────────────────────────────────────────────────────────
type davDir struct {
name string
modTime time.Time
fileStore *files.Store
user *auth.User
folderID *uuid.UUID
entries []os.FileInfo
pos int
// principal ist der Eigentümer des Ordners – bei geliehenen Ordnern nicht
// der angemeldete Nutzer.
principal uuid.UUID
// virtual markiert /Shared und /Shared/<Eigentümer>: Ebenen, hinter denen
// kein Ordner in der Datenbank steht.
virtual bool
// atShared unterscheidet die beiden: /Shared listet Eigentümer,
// /Shared/<Eigentümer> listet dessen Freigaben.
atShared bool
shared []sharedEntry
// extra hängt zusätzliche Einträge an eine echte Auflistung – in der
// eigenen Wurzel den virtuellen Shared-Ordner.
extra []os.FileInfo
}
func (d *davDir) Close() error { return nil }
func (d *davDir) Read(_ []byte) (int, error) { return 0, io.EOF }
func (d *davDir) Seek(_ int64, _ int) (int64, error) { return 0, nil }
func (d *davDir) Write(_ []byte) (int, error) { return 0, os.ErrPermission }
func (d *davDir) Stat() (os.FileInfo, error) {
name := d.name
if name == "" {
name = "/"
}
modTime := d.modTime
if modTime.IsZero() {
modTime = time.Now()
}
return &davFileInfo{name: name, modTime: modTime, isDir: true}, nil
}
func (d *davDir) Readdir(count int) ([]os.FileInfo, error) {
if d.entries == nil {
if err := d.loadEntries(); err != nil {
return nil, err
}
}
if d.pos >= len(d.entries) {
if count > 0 {
return nil, io.EOF
}
return nil, nil
}
end := len(d.entries)
if count > 0 && d.pos+count < end {
end = d.pos + count
}
out := d.entries[d.pos:end]
d.pos = end
return out, nil
}
func (d *davDir) loadEntries() error {
if d.virtual {
d.entries = virtualEntries(d.shared, d.atShared)
return nil
}
ctx := context.Background()
childFolders, err := d.fileStore.ListFolders(ctx, d.principal, d.folderID)
if err != nil {
return err
}
childFiles, err := d.fileStore.ListFiles(ctx, d.principal, d.folderID)
if err != nil {
return err
}
d.entries = make([]os.FileInfo, 0, len(childFolders)+len(childFiles))
for _, f := range childFolders {
d.entries = append(d.entries, &davFileInfo{name: f.Name, modTime: f.CreatedAt, isDir: true})
}
for _, f := range childFiles {
d.entries = append(d.entries, &davFileInfo{name: f.Name, size: f.Size, modTime: f.UpdatedAt})
}
d.entries = append(d.entries, d.extra...)
return nil
}
// ── Read file ────────────────────────────────────────────────────────────────
type davReadFile struct {
name string
size int64
modTime time.Time
reader *bytes.Reader
}
func (f *davReadFile) Close() error { return nil }
func (f *davReadFile) Read(p []byte) (int, error) { return f.reader.Read(p) }
func (f *davReadFile) Seek(o int64, w int) (int64, error) { return f.reader.Seek(o, w) }
func (f *davReadFile) Write(_ []byte) (int, error) { return 0, os.ErrPermission }
func (f *davReadFile) Readdir(_ int) ([]os.FileInfo, error) { return nil, os.ErrInvalid }
func (f *davReadFile) Stat() (os.FileInfo, error) {
return &davFileInfo{name: f.name, size: f.size, modTime: f.modTime}, nil
}
// ── Write file ───────────────────────────────────────────────────────────────
type davWriteFile struct {
ctx context.Context
name string
folderID *uuid.UUID
user *auth.User
fileStore *files.Store
storage storage.Storage
// principal, storageRoot und quota beziehen sich auf den Eigentümer des
// Zielordners – in einem Gruppenordner oder einer fremden Freigabe ist das
// nicht der angemeldete Nutzer. Die Datei landet in dessen Bereich und
// zählt gegen dessen Kontingent.
principal uuid.UUID
storageRoot string
quota int64
parentOwner *files.Folder
// Der Upload wird auf der Platte zwischengelagert, nicht im Arbeitsspeicher:
// x/net/webdav übergibt den Inhalt häppchenweise, die endgültige Größe steht
// erst bei Close fest. Gepuffert im RAM belegte eine 122-MB-Datei auch 122 MB
// Speicher – bei mehreren Uploads gleichzeitig ist der Server damit weg.
tmp *os.File
written int64
// Kontingent, lazy beim ersten Write ermittelt – zu diesem Zeitpunkt steht
// fest, ob eine bestehende Datei überschrieben wird.
quotaChecked bool
unlimited bool
remaining int64
quotaHit *bool
// expected ist die angekündigte Body-Größe, writeErr der erste Fehler aus
// Write. Beides entscheidet in Close, ob überhaupt geschrieben wird.
expected int64
writeErr error
}
// checkQuota ermittelt das verbleibende Kontingent einmalig pro Schreibvorgang.
func (f *davWriteFile) checkQuota() error {
if f.quotaChecked {
return nil
}
f.quotaChecked = true
// Wird eine bestehende Datei ersetzt, zählt deren alte Größe nicht mehr mit.
var allowance int64
if existing, err := f.fileStore.FileInFolder(f.ctx, f.principal, f.folderID, f.name); err == nil {
allowance = existing.Size
}
remaining, unlimited, err := f.fileStore.RemainingQuota(f.ctx, f.principal, f.quota, allowance)
if err != nil {
return err
}
f.remaining, f.unlimited = remaining, unlimited
return nil
}
// discardTemp räumt die Zwischendatei weg. Muss auf jedem Pfad laufen, auch
// beim Abbruch – sonst füllt jeder fehlgeschlagene Upload das Staging.
func (f *davWriteFile) discardTemp() {
if f.tmp == nil {
return
}
name := f.tmp.Name()
_ = f.tmp.Close()
if err := os.Remove(name); err != nil && !os.IsNotExist(err) {
log.Ctx(f.ctx).Warn().Err(err).Str("path", name).Msg("webdav: temp file not removed")
}
f.tmp = nil
}
func (f *davWriteFile) Close() error {
defer f.discardTemp()
// x/net/webdav ruft Close per defer auf – auch wenn io.Copy vorher
// abgebrochen ist (Kontingent voll, Verbindung weg). Ohne diese Sperre
// würde der halb gefüllte Puffer als vollständige Datei committet: der
// Client sieht einen Fehler, in der Cloud liegt trotzdem ein abgeschnittenes
// Blob – mit passender Prüfsumme, also unentdeckbar. Ein abgebrochener
// Upload darf nichts hinterlassen.
if f.writeErr != nil {
return f.writeErr
}
if f.expected >= 0 && f.written != f.expected {
return fmt.Errorf("incomplete upload: got %d of %d bytes", f.written, f.expected)
}
// Ab hier wird gelesen, was geschrieben wurde – zurück an den Anfang.
content, err := f.contentReader()
if err != nil {
return err
}
// Gleicher lesbarer Pfad wie beim Web-Upload, damit beide Wege identisch
// ablegen und die Dateien auch ohne Datenbank auffindbar bleiben.
folderPath, err := f.fileStore.GetFolderPath(f.ctx, f.folderID)
if err != nil {
return err
}
storageKey := files.BuildStorageKey(f.storageRoot, folderPath, f.name)
mime := mimeByName(f.name)
existing, findErr := f.fileStore.FileInFolder(f.ctx, f.principal, f.folderID, f.name)
// Overwrite: der neue Blob landet auf demselben lesbaren Pfad wie der alte.
// Bei aktivierter Versionierung muss die bestehende Fassung deshalb erst
// zur Seite – sonst überschriebe Put sie, und die Version zeigte auf den
// neuen Inhalt. Ohne Versionierung ersetzt Put sie atomar.
if findErr == nil {
var versionKey string
if existing.VersioningEnabled {
versionKey = fmt.Sprintf("%s/.versions/%s", f.storageRoot, uuid.New().String())
if err := f.storage.Move(f.ctx, existing.StorageKey, versionKey); err != nil {
return err
}
}
checksum, size, err := f.storage.Put(f.ctx, storageKey, content)
if err != nil {
// Zurückrollen, sonst ist die bestehende Datei verloren.
if versionKey != "" {
if mvErr := f.storage.Move(f.ctx, versionKey, existing.StorageKey); mvErr != nil {
log.Ctx(f.ctx).Error().Err(mvErr).
Str("file_id", existing.ID.String()).
Msg("webdav: rollback of version move failed – blob orphaned")
}
}
return err
}
if versionKey != "" {
if err := f.fileStore.CreateVersion(f.ctx, existing.ID, existing.Version, existing.Size, existing.Checksum, versionKey); err != nil {
return err
}
} else if existing.StorageKey != storageKey {
// Altbestand unter abweichendem Pfad – sonst verwaist der Blob.
_ = f.storage.Delete(f.ctx, existing.StorageKey)
}
_, err = f.fileStore.UpdateFileContent(f.ctx, existing.ID, size, checksum, storageKey)
return err
}
// New file.
checksum, size, err := f.storage.Put(f.ctx, storageKey, content)
if err != nil {
return err
}
// Die neue Datei gehört dem Prinzipal des Zielordners: in einem
// Gruppenordner der Gruppe, in einer fremden Freigabe deren Eigentümer.
newFile := &files.File{
FolderID: f.folderID,
Name: f.name,
MimeType: mime,
Size: size,
Checksum: checksum,
StorageKey: storageKey,
}
if f.parentOwner != nil && f.parentOwner.GroupID != nil {
newFile.GroupID = f.parentOwner.GroupID
} else {
id := f.principal
newFile.OwnerID = &id
}
if _, err := f.fileStore.CreateFile(f.ctx, newFile); err != nil {
// Ohne DB-Zeile ist das Blob unerreichbar und würde für immer Platz
// belegen. Eigener Kontext, weil f.ctx hier oft schon abgebrochen ist.
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(f.ctx), 10*time.Second)
defer cancel()
if delErr := f.storage.Delete(cleanupCtx, storageKey); delErr != nil {
log.Ctx(f.ctx).Error().Err(delErr).
Str("storage_key", storageKey).
Msg("webdav: orphaned blob after failed file creation")
}
return err
}
return nil
}
func (f *davWriteFile) Read(_ []byte) (int, error) { return 0, os.ErrPermission }
func (f *davWriteFile) Seek(_ int64, _ int) (int64, error) { return 0, nil }
// contentReader spult die Zwischendatei zurück, damit Put sie von vorn lesen
// kann. Ohne einen einzigen Write (leere Datei per PUT) gibt es keine – dann
// liefert ein leerer Reader das richtige Ergebnis.
func (f *davWriteFile) contentReader() (io.Reader, error) {
if f.tmp == nil {
return bytes.NewReader(nil), nil
}
if _, err := f.tmp.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("rewind temp file: %w", err)
}
return f.tmp, nil
}
// ensureTemp legt die Zwischendatei beim ersten Schreibzugriff an. Bevorzugt im
// Staging-Verzeichnis des Speichers, damit sie auf derselben Platte liegt wie
// die späteren Blobs; kann der Speicher das nicht, tut es das System-Temp.
func (f *davWriteFile) ensureTemp() error {
if f.tmp != nil {
return nil
}
var err error
if tf, ok := f.storage.(storage.TempFiler); ok {
f.tmp, err = tf.CreateTemp("dav-upload-*")
} else {
f.tmp, err = os.CreateTemp("", "dav-upload-*")
}
if err != nil {
return fmt.Errorf("create upload buffer: %w", err)
}
return nil
}
// Write lagert den Inhalt zwischen und bricht ab, sobald das Kontingent
// überschritten wird – so wächst auch die Zwischendatei nicht über das
// Erlaubte hinaus.
func (f *davWriteFile) Write(p []byte) (int, error) {
if err := f.checkQuota(); err != nil {
return 0, err
}
if !f.unlimited {
if int64(len(p)) > f.remaining {
if f.quotaHit != nil {
*f.quotaHit = true
}
f.writeErr = files.ErrQuotaExceeded
return 0, files.ErrQuotaExceeded
}
f.remaining -= int64(len(p))
}
if err := f.ensureTemp(); err != nil {
f.writeErr = err
return 0, err
}
n, err := f.tmp.Write(p)
f.written += int64(n)
if err != nil {
f.writeErr = err
}
return n, err
}
func (f *davWriteFile) Readdir(_ int) ([]os.FileInfo, error) { return nil, os.ErrInvalid }
func (f *davWriteFile) Stat() (os.FileInfo, error) {
return &davFileInfo{name: f.name, modTime: time.Now()}, nil
}
// ── helpers ───────────────────────────────────────────────────────────────────
func ptrEqual(a, b *uuid.UUID) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
func mimeByName(name string) string {
ext := strings.ToLower(path.Ext(name))
m := map[string]string{
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
".pdf": "application/pdf",
".txt": "text/plain", ".md": "text/markdown",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".mp4": "video/mp4", ".mov": "video/quicktime", ".avi": "video/x-msvideo",
".mp3": "audio/mpeg", ".wav": "audio/wav",
".zip": "application/zip", ".tar": "application/x-tar",
}
if t, ok := m[ext]; ok {
return t
}
return "application/octet-stream"
}
+59
View File
@@ -0,0 +1,59 @@
package webdav
import (
"context"
"errors"
"testing"
"aether/internal/files"
)
// Ein abgebrochener Upload darf nichts hinterlassen. x/net/webdav ruft Close
// per defer auf – auch nach einem Fehler in io.Copy. Ohne die Sperre in Close
// landete der halb gefüllte Puffer als vollständige Datei in Cloud und
// Datenbank: der Client meldet einen Fehler, die Datei ist trotzdem da, nur
// abgeschnitten – und die Prüfsumme passt zum Torso, fällt also nie auf.
//
// fileStore und storage sind hier absichtlich nil: greift Close doch darauf
// zu, paniert der Test statt still durchzulaufen.
func TestWriteFileCloseDiscardsAbortedUpload(t *testing.T) {
t.Run("quota exceeded during write", func(t *testing.T) {
f := &davWriteFile{
ctx: context.Background(), name: "big.bin", expected: -1,
written: 31, writeErr: files.ErrQuotaExceeded,
}
if err := f.Close(); !errors.Is(err, files.ErrQuotaExceeded) {
t.Fatalf("Close() = %v, want ErrQuotaExceeded", err)
}
})
t.Run("connection dropped mid-upload", func(t *testing.T) {
f := &davWriteFile{
ctx: context.Background(), name: "abort.bin",
expected: 4096, written: 14,
}
if err := f.Close(); err == nil {
t.Fatal("Close() = nil, want error for incomplete upload")
}
})
}
// Chunked Uploads kündigen keine Größe an (Content-Length -1). Dann kann Close
// die Vollständigkeit nicht prüfen und darf deshalb nicht blockieren – sonst
// wäre jeder chunked Upload kaputt.
func TestWriteFileCloseAcceptsUnknownLength(t *testing.T) {
f := &davWriteFile{
ctx: context.Background(), name: "chunked.bin",
expected: -1, written: 12,
}
// Kommt bis zum Store durch – der ist nil, also panic statt Fehler.
defer func() {
if recover() == nil {
t.Fatal("Close() should have proceeded to the store, but returned early")
}
}()
_ = f.Close()
}
+155
View File
@@ -0,0 +1,155 @@
package webdav
import (
"io"
"net/http"
"strconv"
"time"
xwebdav "golang.org/x/net/webdav"
"aether/internal/acl"
"aether/internal/auth"
"aether/internal/files"
"aether/internal/storage"
"aether/pkg/middleware"
)
// Brute-force-Schutz für /dav/. Der Endpunkt nutzt Basic Auth und läuft am
// Rate-Limit des normalen Logins vorbei – ohne eigene Bremse ließe sich hier
// beliebig oft raten.
const (
davMaxFailures = 10
davWindow = 15 * time.Minute
davLockout = 15 * time.Minute
)
// maxChallengeDrain begrenzt, wie viel von einem unauthentifizierten Upload
// verworfen wird, um die Verbindung am Leben zu halten – siehe drainForChallenge.
const maxChallengeDrain = 4 << 30 // 4 GiB
// Handler serves WebDAV at /dav/ with HTTP Basic Auth.
type Handler struct {
authStore *auth.Store
fileStore *files.Store
acl *acl.Store
storage storage.Storage
locks xwebdav.LockSystem
limiter *middleware.FailureLimiter
}
func NewHandler(authStore *auth.Store, fileStore *files.Store, aclStore *acl.Store, st storage.Storage) *Handler {
return &Handler{
authStore: authStore,
fileStore: fileStore,
acl: aclStore,
storage: st,
locks: xwebdav.NewMemLS(),
limiter: middleware.NewFailureLimiter(davMaxFailures, davWindow, davLockout),
}
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
clientIP := middleware.RealIP(r)
if allowed, retryAfter := h.limiter.Allowed(clientIP); !allowed {
w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds())+1))
http.Error(w, "Too many failed login attempts", http.StatusTooManyRequests)
return
}
username, password, ok := r.BasicAuth()
if !ok || username == "" || password == "" {
drainForChallenge(r)
challenge(w)
return
}
// Nur App-Passwörter. Das Kontopasswort ist zugleich der Zugang zum
// Admin-Bereich – wer es hier errät, bekommt weit mehr als Dateizugriff.
// Ein App-Passwort öffnet ausschließlich diesen Endpunkt und lässt sich
// einzeln widerrufen, ohne dass jeder andere Client neu eingerichtet werden
// muss. Nebeneffekt: ein indizierter Lookup statt eines Argon2-Durchlaufs
// bei jeder einzelnen Anfrage (gemessen 0,5 ms statt 22,7 ms).
user, err := h.authStore.VerifyAppPassword(r.Context(), username, password)
if err != nil {
h.limiter.Fail(clientIP)
challenge(w)
return
}
h.limiter.Reset(clientIP)
quotaHit, denied := false, false
dav := &xwebdav.Handler{
FileSystem: newFS(h.fileStore, h.storage, h.acl, user, &quotaHit, &denied, r.ContentLength),
LockSystem: h.locks,
Prefix: "/dav",
}
dav.ServeHTTP(&statusWriter{ResponseWriter: w, quotaHit: &quotaHit, denied: &denied}, r)
}
// challenge fordert zur Anmeldung auf. Der Realm-Text nennt ausdrücklich das
// App-Passwort: Clients zeigen ihn im Anmeldedialog an, und ohne diesen Hinweis
// tippt man dort wieder und wieder sein Kontopasswort ein und versteht nicht,
// warum es abgelehnt wird.
func challenge(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="Aether WebDAV - use an app password, not your account password"`)
http.Error(w, "Unauthorized - WebDAV requires an app password (Profile -> App passwords)", http.StatusUnauthorized)
}
// drainForChallenge liest den Rumpf einer unauthentifizierten Anfrage weg,
// bevor die Aufforderung zur Anmeldung rausgeht.
//
// Klingt verschwenderisch, ist aber der Unterschied zwischen „Upload geht" und
// „Upload geht nicht": Manche Clients – KDEs KIO etwa – schicken grundsätzlich
// erst ohne Zugangsdaten und wiederholen die Anfrage nach der 401. Dabei senden
// sie den Rumpf sofort mit, ohne `Expect: 100-continue` abzuwarten. Antwortet
// der Server dann sofort mit 401, kann Go den angefangenen Rumpf nicht mehr
// loswerden und schließt die Verbindung. Der Client steckt aber noch mitten im
// Schreiben, bekommt einen Verbindungsabbruch und meldet einen nichtssagenden
// Fehler, statt die 401 zu lesen und sich anzumelden.
//
// Wer `Expect: 100-continue` schickt (curl, rclone, Finder), zahlt hier nichts:
// Go beantwortet die Aufforderung, bevor auch nur ein Byte des Rumpfes fließt.
// Die doppelte Übertragung trifft also nur Clients, die es nicht besser machen.
func drainForChallenge(r *http.Request) {
if r.ContentLength <= 0 || r.Header.Get("Expect") != "" {
return
}
// Obergrenze, damit eine unauthentifizierte Anfrage den Server nicht
// beliebig lange beschäftigen kann. Darüber bleibt es beim Abbruch.
_, _ = io.CopyN(io.Discard, r.Body, maxChallengeDrain)
}
// statusWriter korrigiert Statuscodes, die x/net/webdav pauschal vergibt.
//
// Die Bibliothek bildet jeden Fehler aus einer Operation auf einen festen Code
// ab – ein gescheiterter io.Copy wird zu 405, ein OpenFile-Fehler zu 404. Der
// Client zeigt dann „Methode nicht erlaubt" oder „nicht gefunden", obwohl die
// Ursache eine ganz andere war. Zwei Fälle lohnen die Korrektur:
//
// - Kontingent voll → 507 Insufficient Storage (RFC 4918)
// - fehlendes Recht → 403 Forbidden
//
// Ohne die zweite Zuordnung sähe jemand, der in einem nur lesbar freigegebenen
// Ordner schreiben will, ein „nicht gefunden" – und suchte den Fehler beim
// Pfad statt bei seinen Rechten.
type statusWriter struct {
http.ResponseWriter
quotaHit *bool
denied *bool
wroteHeader bool
}
func (w *statusWriter) WriteHeader(code int) {
if w.wroteHeader {
return
}
w.wroteHeader = true
switch {
case *w.quotaHit && code == http.StatusMethodNotAllowed:
code = http.StatusInsufficientStorage
case *w.denied && (code == http.StatusMethodNotAllowed || code == http.StatusNotFound || code == http.StatusConflict):
code = http.StatusForbidden
}
w.ResponseWriter.WriteHeader(code)
}
+310
View File
@@ -0,0 +1,310 @@
package webdav
import (
"context"
"os"
"sort"
"strconv"
"github.com/google/uuid"
"aether/internal/acl"
"aether/internal/files"
)
// Geteilte Ordner und Gruppenordner über WebDAV.
//
// WebDAV kennt keine IDs, es navigiert über Pfade – und ein Pfad lief bisher
// immer durch den Baum genau eines Prinzipals: des angemeldeten Nutzers. Damit
// waren Gruppenordner und Freigaben dort unsichtbar; sie gehören einer Gruppe
// oder einem anderen Konto.
//
// Der Aufbau bildet die Weboberfläche nach, damit man nicht umdenken muss:
//
// /dav/<Gruppenordner>/… – wie in der Wurzel der Weboberfläche
// /dav/Shared/<Person>/<Freigabe>/… – wie die Ansicht „Shared with me"
//
// Beim Betreten wechselt der Prinzipal. Personen-Freigaben bleiben verschachtelt:
// Ein flacher Aufbau müsste bei gleichnamigen Freigaben Namen vergeben, die sich
// ändern, sobald eine weitere dazukommt – und ein Sync-Client, dem sich Pfade
// unter den Füßen verschieben, lädt alles neu hoch oder löscht es.
const sharedRoot = "Shared"
// davScope beschreibt, in wessen Namen ein Pfad aufgelöst wird.
type davScope struct {
principal uuid.UUID
storageRoot string
quota int64
writable bool
// base ist der Ordner, ab dem parts zählen. nil = Wurzel des Prinzipals.
// Bei einer Freigabe zeigt er auf den freigegebenen Ordner, damit alles
// darüber unerreichbar bleibt.
base *uuid.UUID
parts []string
// sharedFile ist gesetzt, wenn der Pfad genau auf eine einzeln freigegebene
// Datei zeigt. Ohne dieses Feld liefe die Auflösung in die Wurzel des
// Eigentümers – und die gilt als Ordner, wodurch die Datei als Verzeichnis
// gemeldet würde.
sharedFile *files.File
// virtual markiert die beiden Ebenen ohne echten Ordner dahinter:
// /Shared und /Shared/<Eigentümer>.
virtual bool
// virtualEntries sind die Einträge, die eine virtuelle Ebene auflistet.
virtualEntries []sharedEntry
}
// sharedEntry ist eine Freigabe oder ein Gruppenordner, wie er unter /Shared
// erscheint.
type sharedEntry struct {
owner string // Verzeichnisname der Ebene 2
name string // Name der Freigabe selbst
principal uuid.UUID
root string // Speicherwurzel des Prinzipals
quota int64
writable bool
folderID *uuid.UUID // gesetzt bei Ordnern
file *files.File
}
// forbid meldet dem Handler ein fehlendes Recht und liefert den passenden
// Fehler. Nur für Ablehnungen aus Rechtegründen – nicht für „gibt es nicht",
// sonst würde eine schlicht fehlende Datei als „verboten" gemeldet und
// verriete damit, dass an dem Pfad etwas ist.
func (fs *aetherFS) forbid() error {
if fs.denied != nil {
*fs.denied = true
}
return os.ErrPermission
}
func (fs *aetherFS) ownScope(parts []string) *davScope {
return &davScope{
principal: fs.user.ID,
storageRoot: "users/" + fs.user.Username,
quota: fs.user.QuotaBytes,
writable: true,
parts: parts,
}
}
// scopeFor klärt für einen WebDAV-Pfad, welcher Prinzipal gilt und welcher
// Rest des Pfades in dessen Baum aufzulösen ist.
func (fs *aetherFS) scopeFor(ctx context.Context, parts []string) (*davScope, error) {
if len(parts) == 0 {
return fs.ownScope(parts), nil
}
if parts[0] != sharedRoot {
// Gruppenordner liegen in der Wurzel, gleichrangig neben den eigenen –
// so wie in der Weboberfläche. Die Namen sind bereits eindeutig
// gemacht, ein eigener Ordner gewinnt also bei Gleichheit.
groups, err := fs.rootGroupFolders(ctx)
if err != nil {
return nil, err
}
for _, e := range groups {
if e.name == parts[0] {
return &davScope{
principal: e.principal, storageRoot: e.root, quota: e.quota,
writable: e.writable, base: e.folderID, parts: parts[1:],
}, nil
}
}
return fs.ownScope(parts), nil
}
if fs.acl == nil {
return nil, os.ErrNotExist
}
entries, err := fs.aclShares(ctx)
if err != nil {
return nil, err
}
// Gibt es nichts Geliehenes, gibt es auch kein /Shared. Sonst bliebe bei
// Clients ein leerer Ordner stehen, den niemand loswird.
if len(entries) == 0 {
return nil, os.ErrNotExist
}
// /Shared – Liste der Eigentümer.
if len(parts) == 1 {
return &davScope{virtual: true, virtualEntries: entries}, nil
}
// /Shared/<Eigentümer> – Liste von dessen Freigaben.
owned := []sharedEntry{}
for _, e := range entries {
if e.owner == parts[1] {
owned = append(owned, e)
}
}
if len(owned) == 0 {
return nil, os.ErrNotExist
}
if len(parts) == 2 {
return &davScope{virtual: true, virtualEntries: owned}, nil
}
// /Shared/<Eigentümer>/<Freigabe>/… – ab hier gilt ein echter Prinzipal.
for _, e := range owned {
if e.name != parts[2] {
continue
}
if e.file != nil {
// Eine einzeln freigegebene Datei hat nichts unter sich.
if len(parts) > 3 {
return nil, os.ErrNotExist
}
return &davScope{
principal: e.principal, storageRoot: e.root, quota: e.quota,
writable: e.writable, sharedFile: e.file,
}, nil
}
return &davScope{
principal: e.principal, storageRoot: e.root, quota: e.quota,
writable: e.writable, base: e.folderID, parts: parts[3:],
}, nil
}
return nil, os.ErrNotExist
}
// rootGroupFolders liefert die Gruppenordner des Nutzers mit dem Namen, unter
// dem sie in der Wurzel erscheinen.
//
// Bei Namensgleichheit gewinnt der eigene Ordner und der Gruppenordner bekommt
// den Gruppennamen angehängt. Grund: WebDAV adressiert über Pfade – zwei
// gleichnamige Einträge in einem Verzeichnis kann ein Client nicht auseinander-
// halten. In der Weboberfläche ist das harmlos, dort führen IDs zum Ziel.
func (fs *aetherFS) rootGroupFolders(ctx context.Context) ([]sharedEntry, error) {
groupFolders, err := fs.fileStore.ListGroupFoldersForUser(ctx, fs.user.ID)
if err != nil {
return nil, err
}
if len(groupFolders) == 0 {
return nil, nil
}
ownFolders, err := fs.fileStore.ListFolders(ctx, fs.user.ID, nil)
if err != nil {
return nil, err
}
taken := map[string]bool{}
for _, f := range ownFolders {
taken[f.Name] = true
}
out := make([]sharedEntry, 0, len(groupFolders))
for _, gf := range groupFolders {
if gf.GroupID == nil || fs.acl == nil {
continue
}
info, err := fs.acl.ResolveFolder(ctx, fs.user.ID, gf.ID)
if err != nil {
continue
}
group, _ := fs.fileStore.GroupName(ctx, *gf.GroupID)
name := gf.Name
if taken[name] && group != "" {
name = gf.Name + " (" + group + ")"
}
for n := 2; taken[name]; n++ {
name = gf.Name + " (" + strconv.Itoa(n) + ")"
}
taken[name] = true
id := gf.ID
out = append(out, sharedEntry{
owner: group,
name: name,
principal: info.OwnerID,
root: info.StorageRoot(),
quota: info.OwnerQuota,
writable: info.CanWrite(),
folderID: &id,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name })
return out, nil
}
// aclShares sammelt, was andere Personen freigegeben haben – der Inhalt von
// /Shared. Gruppenordner gehören nicht dazu, die liegen in der Wurzel.
func (fs *aetherFS) aclShares(ctx context.Context) ([]sharedEntry, error) {
var out []sharedEntry
shared, err := fs.acl.ListSharedWith(ctx, fs.user.ID)
if err != nil {
return nil, err
}
for _, r := range shared {
e := sharedEntry{
owner: r.OwnerName,
name: r.Name,
writable: r.Permission == acl.PermWrite,
}
if r.Type == "folder" {
info, err := fs.acl.ResolveFolder(ctx, fs.user.ID, r.ID)
if err != nil {
continue
}
id := r.ID
e.principal, e.root, e.quota = info.OwnerID, info.StorageRoot(), info.OwnerQuota
e.folderID = &id
} else {
info, err := fs.acl.ResolveFile(ctx, fs.user.ID, r.ID)
if err != nil {
continue
}
f, err := fs.fileStore.GetFile(ctx, r.ID, info.OwnerID)
if err != nil {
continue
}
e.principal, e.root, e.quota = info.OwnerID, info.StorageRoot(), info.OwnerQuota
e.file = f
}
out = append(out, e)
}
return dedupe(out), nil
}
// dedupe stellt sicher, dass in einem Verzeichnis kein Name zweimal vorkommt –
// zwei gleichnamige Einträge machen eine PROPFIND-Antwort für Clients
// unbrauchbar. Sortiert wird vorher, damit die Vergabe bei gleichem Bestand
// immer gleich ausfällt und Pfade sich nicht von Lauf zu Lauf ändern.
func dedupe(entries []sharedEntry) []sharedEntry {
sort.Slice(entries, func(i, j int) bool {
if entries[i].owner != entries[j].owner {
return entries[i].owner < entries[j].owner
}
return entries[i].name < entries[j].name
})
seen := map[string]int{}
for i := range entries {
key := entries[i].owner + "/" + entries[i].name
seen[key]++
if n := seen[key]; n > 1 {
entries[i].name += " (" + strconv.Itoa(n) + ")"
}
}
return entries
}
// owners fasst die Einträge zur zweiten Ebene zusammen.
func owners(entries []sharedEntry) []string {
seen := map[string]bool{}
var out []string
for _, e := range entries {
if !seen[e.owner] {
seen[e.owner] = true
out = append(out, e.owner)
}
}
return out
}
+64
View File
@@ -0,0 +1,64 @@
package webdav
import "testing"
// dedupe vergibt Namen für gleichnamige Freigaben. Entscheidend ist, dass die
// Vergabe bei gleichem Bestand immer gleich ausfällt: Ein Sync-Client, dem sich
// Pfade zwischen zwei Läufen verschieben, lädt alles neu hoch oder löscht es.
func TestDedupeIsStable(t *testing.T) {
in := []sharedEntry{
{owner: "anna", name: "Projekt"},
{owner: "ben", name: "Projekt"},
{owner: "anna", name: "Projekt"},
{owner: "anna", name: "Notizen"},
}
first := namesOf(dedupe(append([]sharedEntry(nil), in...)))
// Andere Eingabereihenfolge, gleicher Bestand → gleiches Ergebnis.
shuffled := []sharedEntry{in[3], in[2], in[1], in[0]}
second := namesOf(dedupe(shuffled))
if len(first) != len(second) {
t.Fatalf("unterschiedlich viele Einträge: %v vs %v", first, second)
}
for i := range first {
if first[i] != second[i] {
t.Fatalf("Reihenfolge nicht stabil:\n %v\n %v", first, second)
}
}
}
func TestDedupeMakesNamesUniquePerOwner(t *testing.T) {
got := namesOf(dedupe([]sharedEntry{
{owner: "anna", name: "Projekt"},
{owner: "anna", name: "Projekt"},
{owner: "anna", name: "Projekt"},
// Gleicher Name bei einem anderen Eigentümer ist kein Konflikt – die
// beiden liegen in verschiedenen Verzeichnissen.
{owner: "ben", name: "Projekt"},
}))
want := []string{"anna/Projekt", "anna/Projekt (2)", "anna/Projekt (3)", "ben/Projekt"}
for i := range want {
if got[i] != want[i] {
t.Errorf("Eintrag %d = %q, erwartet %q", i, got[i], want[i])
}
}
}
// owners liefert die zweite Ebene unter /Shared – jede Person genau einmal.
func TestOwnersAreUnique(t *testing.T) {
got := owners([]sharedEntry{
{owner: "anna"}, {owner: "anna"}, {owner: "ben"}, {owner: "anna"},
})
if len(got) != 2 || got[0] != "anna" || got[1] != "ben" {
t.Fatalf("owners = %v, erwartet [anna ben]", got)
}
}
func namesOf(entries []sharedEntry) []string {
out := make([]string, len(entries))
for i, e := range entries {
out[i] = e.owner + "/" + e.name
}
return out
}