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

+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()
}