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:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 "aReader{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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user