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,622 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/httprate"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"aether/internal/acl"
|
||||
"aether/internal/admin"
|
||||
"aether/internal/audit"
|
||||
"aether/internal/auth"
|
||||
"aether/internal/documents"
|
||||
"aether/internal/files"
|
||||
"aether/internal/gc"
|
||||
"aether/internal/office"
|
||||
appoidc "aether/internal/oidc"
|
||||
"aether/internal/sharing"
|
||||
"aether/internal/storage"
|
||||
"aether/internal/tags"
|
||||
"aether/internal/thumbnails"
|
||||
appwebdav "aether/internal/webdav"
|
||||
"aether/pkg/config"
|
||||
appdb "aether/pkg/db"
|
||||
"aether/pkg/middleware"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// spaHandler liefert statische Dateien aus und fällt für alles andere auf
|
||||
// index.html zurück.
|
||||
//
|
||||
// Nötig, weil das Frontend seine Adressen selbst verwaltet: `/s/<token>`,
|
||||
// `/admin` und `/profile` existieren als Dateien nicht. Ohne Rückfalloption
|
||||
// beantwortet ein reiner FileServer sie mit 404 – ein frisch geteilter Link
|
||||
// landete beim Empfänger also auf einer Fehlerseite. Dass es bisher trotzdem
|
||||
// meist funktionierte, lag allein am Service Worker, der die Navigation
|
||||
// abfing; wer die Seite zum ersten Mal öffnet, hat den noch nicht.
|
||||
func spaHandler(root string) http.Handler {
|
||||
fs := http.FileServer(http.Dir(root))
|
||||
index := filepath.Join(root, "index.html")
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upath := path.Clean("/" + r.URL.Path)
|
||||
|
||||
// Serverseitige Bereiche nie überdecken – dort ist ein 404 die richtige
|
||||
// Antwort und kein Grund, die Anwendung auszuliefern.
|
||||
for _, reserved := range []string{"/api/", "/dav/", "/share/", "/branding/", "/wopi/"} {
|
||||
if strings.HasPrefix(upath+"/", reserved) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(upath))); err == nil {
|
||||
fs.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Nur echte Seitenaufrufe bekommen die Anwendung. Eine fehlende
|
||||
// JavaScript- oder Bilddatei soll als 404 sichtbar bleiben, statt
|
||||
// stillschweigend HTML zu liefern – das erzeugt sonst rätselhafte
|
||||
// Syntaxfehler in der Konsole statt einer klaren Meldung.
|
||||
if r.Method != http.MethodGet || !strings.Contains(r.Header.Get("Accept"), "text/html") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, index)
|
||||
})
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "config error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if cfg.IsDev() {
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
||||
}
|
||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||
|
||||
log.Info().Str("env", cfg.AppEnv).Str("addr", cfg.ListenAddr).Str("public_url", cfg.PublicURL).Msg("Aether starting")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
if err := appdb.Migrate(cfg.DSN()); err != nil {
|
||||
log.Fatal().Err(err).Msg("migrations failed")
|
||||
}
|
||||
log.Info().Msg("migrations up to date")
|
||||
|
||||
pool, err := appdb.Connect(ctx, cfg.DSN())
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("database connection failed")
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
// ── Storage ───────────────────────────────────────────────────────────────
|
||||
// Thumbnails are stored in the main blob store under the "thumbs/" prefix.
|
||||
blobStore, err := storage.NewLocalFS(cfg.StoragePath, cfg.TmpPath)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("blob storage init failed")
|
||||
}
|
||||
|
||||
// ── Packages ──────────────────────────────────────────────────────────────
|
||||
auditor := audit.NewLogger(pool)
|
||||
aclStore := acl.NewStore(pool)
|
||||
aclHandler := acl.NewHandler(aclStore, auditor)
|
||||
|
||||
authStore := auth.NewStore(pool)
|
||||
sessions := auth.NewSessionStore(cfg.SessionSecret, cfg.SessionMaxAge)
|
||||
fileStore := files.NewStore(pool)
|
||||
authHandler := auth.NewHandler(authStore, sessions, fileStore, auditor)
|
||||
tagStore := tags.NewStore(pool)
|
||||
tagHandler := tags.NewHandler(tagStore, aclStore)
|
||||
// ── Texterkennung ─────────────────────────────────────────────────────────
|
||||
// Bleibt nil, wenn abgeschaltet oder die Programme fehlen. Alles, was
|
||||
// docStore benutzt, prüft deshalb auf nil – eine Cloud ohne OCR ist eine
|
||||
// vollständige Cloud, nur ohne Volltextsuche.
|
||||
var docStore *documents.Store
|
||||
if cfg.OCREnabled {
|
||||
if ok, missing := documents.Available(); !ok {
|
||||
log.Warn().Strs("missing", missing).
|
||||
Msg("Texterkennung abgeschaltet: Programme fehlen im Image")
|
||||
} else {
|
||||
docStore = documents.NewStore(pool)
|
||||
worker := documents.NewWorker(docStore, blobStore, &documents.Extractor{
|
||||
Languages: cfg.OCRLanguages,
|
||||
MaxPages: cfg.OCRMaxPages,
|
||||
Timeout: cfg.OCRTimeout,
|
||||
TmpDir: cfg.TmpPath,
|
||||
}, log.Logger)
|
||||
go worker.Run(ctx)
|
||||
|
||||
// Bestand nachtragen: Beim ersten Start nach dem Einbau hat noch
|
||||
// keine einzige Datei einen Auftrag. Ohne das bliebe alles
|
||||
// unauffindbar, was vorher hochgeladen wurde – und das ist am
|
||||
// Anfang der gesamte Bestand.
|
||||
go func() {
|
||||
n, err := docStore.EnqueueMissing(ctx, 5000)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Bestand nachtragen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Info().Int("files", n).Msg("Bestandsdateien zur Texterkennung eingeplant")
|
||||
}
|
||||
|
||||
// Einträge nach älterer Regel erneut erkennen. Siehe
|
||||
// documents.ExtractVersion – die Seitenmarke im Text kam
|
||||
// nachträglich dazu, und ohne sie kann der Betrachter bei
|
||||
// einem Scan die Fundstelle nicht auf einer Seite verorten.
|
||||
m, err := docStore.EnqueueOutdated(ctx, 5000)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("veraltete Texteintraege nachtragen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
if m > 0 {
|
||||
log.Info().Int("files", m).Int("version", documents.ExtractVersion).
|
||||
Msg("veraltete Texteintraege zur erneuten Erkennung eingeplant")
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// fileHandler bekommt docStore mit: Der PDF-Betrachter holt sich darueber
|
||||
// den erkannten Text eines Scans, in Seiten zerlegt.
|
||||
fileHandler := files.NewHandler(fileStore, blobStore, auditor, aclStore, tagStore, docStore)
|
||||
|
||||
shareStore := sharing.NewStore(pool)
|
||||
shareHandler := sharing.NewHandler(shareStore, fileStore, blobStore, auditor)
|
||||
|
||||
collector := gc.New(pool, blobStore)
|
||||
|
||||
adminStore := admin.NewStore(pool)
|
||||
adminHandler := admin.NewHandler(adminStore, blobStore, auditor, collector, fileStore)
|
||||
|
||||
// docHandler ist auch bei abgeschaltetem OCR gültig – er meldet dann
|
||||
// schlicht enabled:false. Eine Methode auf einem nil-Zeiger geht in Go
|
||||
// gut, solange sie ihn nicht dereferenziert; Status prüft genau darauf.
|
||||
docHandler := documents.NewHandler(docStore,
|
||||
func(r *http.Request) bool {
|
||||
u := auth.UserFromContext(r.Context())
|
||||
return u != nil && u.IsAdmin
|
||||
},
|
||||
func(r *http.Request) uuid.UUID {
|
||||
if u := auth.UserFromContext(r.Context()); u != nil {
|
||||
return u.ID
|
||||
}
|
||||
return uuid.Nil
|
||||
})
|
||||
|
||||
// ── Office ────────────────────────────────────────────────────────────────
|
||||
// Bleibt nil, wenn OFFICE_PROVIDER leer ist. Ein nil-Handler meldet der
|
||||
// Oberfläche schlicht „aus"; die Anwendung ist ohne Office vollständig.
|
||||
var officeHandler *office.Handler
|
||||
if cfg.OfficeEnabled() {
|
||||
client, err := office.NewClient(cfg.OfficeProvider, cfg.OfficeURL, cfg.OfficePublicURL,
|
||||
cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS)
|
||||
if err != nil {
|
||||
// Kein Fatal: Eine falsche Office-Adresse soll die Cloud nicht am
|
||||
// Starten hindern. Dateien bleiben erreichbar, nur das Bearbeiten
|
||||
// fehlt – und der Log sagt, warum.
|
||||
log.Warn().Err(err).Msg("Office abgeschaltet: Konfiguration fehlerhaft")
|
||||
} else {
|
||||
officeHandler = office.NewHandler(client, office.NewSigner(cfg.SessionSecret),
|
||||
fileStore, blobStore, aclStore, cfg.PublicURL, cfg.OfficeWopiURL)
|
||||
log.Info().Str("provider", cfg.OfficeProvider).Str("url", cfg.OfficeURL).
|
||||
Msg("Office-Bearbeitung aktiv")
|
||||
}
|
||||
}
|
||||
|
||||
// Der Ursprung, den die Sicherheitsrichtlinie als Rahmen zulassen muss.
|
||||
// Leer, wenn kein Office-Server eingerichtet ist – dann bleibt die
|
||||
// Richtlinie so eng wie zuvor.
|
||||
officeFrameSrc := ""
|
||||
if officeHandler.Enabled() {
|
||||
officeFrameSrc = cfg.OfficePublicURL
|
||||
if officeFrameSrc == "" {
|
||||
officeFrameSrc = cfg.OfficeURL
|
||||
}
|
||||
}
|
||||
|
||||
// WebDAV
|
||||
davHandler := appwebdav.NewHandler(authStore, fileStore, aclStore, blobStore)
|
||||
|
||||
// OIDC (optional – disabled if OIDC_ISSUER not set)
|
||||
oidcHandler, err := appoidc.New(ctx, appoidc.Config{
|
||||
Issuer: cfg.OIDCIssuer,
|
||||
ClientID: cfg.OIDCClientID,
|
||||
ClientSecret: cfg.OIDCClientSecret,
|
||||
RedirectURL: cfg.OIDCRedirectURL,
|
||||
|
||||
LinkByUsername: cfg.OIDCLinkByUsername,
|
||||
}, authStore, sessions)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("OIDC init failed")
|
||||
}
|
||||
|
||||
// ── Admin seed ────────────────────────────────────────────────────────────
|
||||
if cfg.AdminUsername != "" && cfg.AdminPassword != "" {
|
||||
if err := seedAdmin(ctx, authStore, cfg.AdminUsername, cfg.AdminPassword); err != nil {
|
||||
log.Warn().Err(err).Msg("admin seed skipped")
|
||||
} else {
|
||||
log.Info().Str("username", cfg.AdminUsername).Msg("admin account ready")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blob-GC ───────────────────────────────────────────────────────────────
|
||||
// Räumt Blobs weg, auf die keine Datenbankzeile mehr zeigt. Läuft im
|
||||
// Hintergrund; jeder Lauf, der etwas entfernt, landet im Audit-Log.
|
||||
collector.Start(ctx, func(res gc.Result) {
|
||||
auditor.Log(ctx, audit.Event{
|
||||
ActorName: "system",
|
||||
Action: audit.ActionGarbageCollect,
|
||||
Detail: fmt.Sprintf("%d orphaned blobs removed, %d bytes freed",
|
||||
res.Deleted, res.FreedBytes),
|
||||
})
|
||||
})
|
||||
|
||||
// ── Router ────────────────────────────────────────────────────────────────
|
||||
// chi lehnt Methoden ab, die es nicht kennt – ohne diese Registrierung
|
||||
// beantwortet der Router jedes PROPFIND & Co. mit 405, bevor der
|
||||
// WebDAV-Handler (und damit dessen Auth) überhaupt erreicht wird.
|
||||
for _, m := range []string{"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK"} {
|
||||
chi.RegisterMethod(m)
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(chimiddleware.RequestID)
|
||||
r.Use(middleware.TrustedProxy(cfg))
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.SecurityHeaders(officeFrameSrc))
|
||||
r.Use(chimiddleware.Recoverer)
|
||||
|
||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
response.JSON(w, http.StatusOK, map[string]string{"status": "ok", "version": "0.2.0"})
|
||||
})
|
||||
|
||||
// Logo ohne Anmeldung – die Login-Seite zeigt es, bevor jemand angemeldet ist.
|
||||
r.Get("/branding/logo", adminHandler.ServeLogo)
|
||||
r.Get("/branding/icon/{size}", adminHandler.ServeAppIcon)
|
||||
// Das Manifest beschreibt die installierbare App. Es kommt aus der
|
||||
// Datenbank, damit Name, Farbe und Symbol dem Admin-Bereich folgen.
|
||||
r.Get("/manifest.webmanifest", adminHandler.ServeManifest)
|
||||
|
||||
// WOPI – **bewusst ausserhalb der Sitzungs- und CSRF-Kette.** Diese Aufrufe
|
||||
// kommen vom Office-Server, nicht aus einem Browser; ihre Berechtigung ist
|
||||
// allein das kurzlebige Token, das an genau eine Datei gebunden ist.
|
||||
if officeHandler.Enabled() {
|
||||
r.Get("/wopi/files/{id}", officeHandler.CheckFileInfo)
|
||||
r.Get("/wopi/files/{id}/contents", officeHandler.GetFile)
|
||||
r.Post("/wopi/files/{id}/contents", officeHandler.PutFile)
|
||||
}
|
||||
|
||||
// Public share API – completely outside /api/v1 to avoid auth middleware conflict
|
||||
r.Get("/share/{token}", shareHandler.GetPublicInfo)
|
||||
r.Post("/share/{token}/download", shareHandler.Download)
|
||||
|
||||
// WebDAV endpoint – own auth (Basic Auth), outside /api/v1.
|
||||
// Kein StripPrefix: der xwebdav.Handler entfernt "/dav" selbst über sein
|
||||
// Prefix-Feld. Beides zusammen strippt doppelt und liefert nur noch 404.
|
||||
r.Mount("/dav", davHandler)
|
||||
|
||||
// Public config (no auth) – used by login page to discover OIDC availability
|
||||
r.Get("/api/v1/public/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
cloudName := "Aether"
|
||||
// Farben gehören hierher (statt hinter die Auth), damit schon die
|
||||
// Login-Seite im eingestellten Farbschema erscheint.
|
||||
var primaryColor, accentColor, logoURL, faviconURL, appleTouchURL string
|
||||
if s, err := adminStore.GetAllSettings(r.Context()); err == nil {
|
||||
if n, ok := s["cloud_name"]; ok && n != "" {
|
||||
cloudName = n
|
||||
}
|
||||
primaryColor = s["primary_color"]
|
||||
accentColor = s["accent_color"]
|
||||
logoURL = admin.LogoURL(s["logo_storage_key"])
|
||||
// Dasselbe Symbol in zwei Größen: klein für den Tab, groß für den
|
||||
// Startbildschirm von iOS.
|
||||
faviconURL = admin.AppIconURL(s["app_icon_storage_key"], "64")
|
||||
appleTouchURL = admin.AppIconURL(s["app_icon_storage_key"], "192")
|
||||
}
|
||||
apps := cfg.ExternalApps
|
||||
if apps == nil {
|
||||
apps = []config.ExternalApp{} // nie null – das Frontend erwartet eine Liste
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"oidc_enabled": oidcHandler.IsEnabled(),
|
||||
"cloud_name": cloudName,
|
||||
"primary_color": primaryColor,
|
||||
"accent_color": accentColor,
|
||||
"logo_url": logoURL,
|
||||
"favicon_url": faviconURL,
|
||||
"apple_touch_icon_url": appleTouchURL,
|
||||
"external_apps": apps,
|
||||
// Damit die Oberflaeche den Menuepunkt „Edit in Office" gar nicht
|
||||
// erst anbietet, wenn es keinen Office-Server gibt.
|
||||
"office_enabled": officeHandler.Enabled(),
|
||||
})
|
||||
})
|
||||
|
||||
// Auth routes – login/logout strictly rate-limited, /me is lenient
|
||||
r.Route("/api/v1/auth", func(r chi.Router) {
|
||||
r.With(httprate.LimitByIP(20, time.Minute)).Post("/login", authHandler.Login)
|
||||
r.With(httprate.LimitByIP(20, time.Minute)).Post("/logout", authHandler.Logout)
|
||||
r.With(auth.RequireAuth(sessions, authStore)).Get("/me", authHandler.Me)
|
||||
r.With(auth.RequireAuth(sessions, authStore), auth.CSRF).Patch("/profile", authHandler.UpdateProfile)
|
||||
r.With(auth.RequireAuth(sessions, authStore), auth.CSRF).Post("/change-password", authHandler.ChangePassword)
|
||||
// OIDC routes (only wired if oidcHandler != nil)
|
||||
if oidcHandler != nil {
|
||||
r.With(httprate.LimitByIP(20, time.Minute)).Get("/oidc/login", oidcHandler.Login)
|
||||
r.Get("/oidc/callback", oidcHandler.Callback)
|
||||
}
|
||||
})
|
||||
|
||||
// Protected API
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Use(auth.RequireAuth(sessions, authStore))
|
||||
r.Use(auth.CSRF)
|
||||
r.Use(httprate.LimitByIP(2000, time.Minute))
|
||||
|
||||
// Files
|
||||
// Sammel-Download als Archiv. POST, weil eine Auswahl beliebig lang sein
|
||||
// kann – als Adresse würde sie an der Längengrenze scheitern.
|
||||
r.Post("/download/zip", fileHandler.DownloadZip)
|
||||
r.Post("/files", afterUpload(fileHandler, fileStore, blobStore, docStore))
|
||||
r.Get("/files", fileHandler.List)
|
||||
// Suche über Namen von Ordnern und Dateien, begrenzt auf das, was der
|
||||
// Anfragende erreichen darf.
|
||||
r.Get("/search", fileHandler.Search)
|
||||
r.Get("/files/{id}/download", fileHandler.Download)
|
||||
r.Get("/files/{id}/thumbnail", fileHandler.GetThumbnail)
|
||||
// Erkannter Text, in Seiten zerlegt – fuer die Suche im PDF-Betrachter.
|
||||
r.Get("/files/{id}/text", fileHandler.TextPages)
|
||||
// Sitzung fuer den Office-Rahmen: Hier entsteht das WOPI-Token, im
|
||||
// angemeldeten Zusammenhang.
|
||||
r.Get("/office/session/{id}", officeHandler.Session)
|
||||
r.Patch("/files/{id}/versioning", fileHandler.SetVersioning)
|
||||
r.Get("/files/{id}/versions", fileHandler.ListVersions)
|
||||
r.Get("/files/{id}/versions/{versionId}/download", fileHandler.DownloadVersion)
|
||||
r.Post("/files/{id}/versions/{versionId}/restore", fileHandler.RestoreVersion)
|
||||
r.Delete("/files/{id}", fileHandler.Delete)
|
||||
r.Patch("/files/{id}/rename", fileHandler.Rename)
|
||||
r.Patch("/files/{id}/move", fileHandler.Move)
|
||||
r.Post("/files/{id}/share", shareHandler.Create)
|
||||
|
||||
// Folders
|
||||
r.Post("/folders", fileHandler.CreateFolder)
|
||||
r.Patch("/folders/{id}/rename", fileHandler.RenameFolder)
|
||||
r.Patch("/folders/{id}/move", fileHandler.MoveFolder)
|
||||
r.Delete("/folders/{id}", fileHandler.DeleteFolder)
|
||||
|
||||
// Favorites
|
||||
r.Get("/favorites", fileHandler.ListFavorites)
|
||||
r.Post("/favorites", fileHandler.AddFavorite)
|
||||
r.Delete("/favorites/{folderId}", fileHandler.RemoveFavorite)
|
||||
r.Patch("/favorites/{folderId}/expand", fileHandler.SetFavoriteExpanded)
|
||||
r.Patch("/favorites/reorder", fileHandler.ReorderFavorites)
|
||||
|
||||
// Trash
|
||||
// Etiketten – siehe internal/tags. Die Rechteprüfung steckt in den
|
||||
// Abfragen des Stores, nicht in einer Middleware davor.
|
||||
r.Get("/tags", tagHandler.List)
|
||||
r.Post("/tags", tagHandler.Create)
|
||||
r.Patch("/tags/{id}", tagHandler.Update)
|
||||
r.Delete("/tags/{id}", tagHandler.Delete)
|
||||
r.Get("/tags/{id}/files", tagHandler.Files)
|
||||
r.Put("/files/{id}/tags", tagHandler.SetForFile)
|
||||
|
||||
// Absender: dieselbe Form wie die Etiketten, aber genau einer je Datei.
|
||||
// Gesetzt wird er ueber /files/{id}/tags mit – ein Dialog, ein Vorgang.
|
||||
r.Get("/correspondents", tagHandler.ListCorrespondents)
|
||||
r.Post("/correspondents", tagHandler.CreateCorrespondent)
|
||||
r.Patch("/correspondents/{id}", tagHandler.UpdateCorrespondent)
|
||||
r.Delete("/correspondents/{id}", tagHandler.DeleteCorrespondent)
|
||||
r.Get("/correspondents/{id}/files", tagHandler.CorrespondentFiles)
|
||||
|
||||
r.Get("/trash", fileHandler.ListTrash)
|
||||
r.Post("/trash/{id}/restore", fileHandler.RestoreFile)
|
||||
r.Delete("/trash", fileHandler.EmptyTrash)
|
||||
r.Delete("/trash/{id}", fileHandler.HardDelete)
|
||||
|
||||
// Shares
|
||||
r.Get("/shares", shareHandler.List)
|
||||
r.Delete("/shares/{id}", shareHandler.Delete)
|
||||
|
||||
// Interne Freigaben
|
||||
r.Get("/shared-with-me", aclHandler.SharedWithMe)
|
||||
r.Get("/share-recipients", aclHandler.Recipients)
|
||||
r.Get("/folders/{id}/acl", aclHandler.ListFolderACL)
|
||||
r.Post("/folders/{id}/acl", aclHandler.GrantFolderACL)
|
||||
r.Get("/files/{id}/acl", aclHandler.ListFileACL)
|
||||
r.Post("/files/{id}/acl", aclHandler.GrantFileACL)
|
||||
r.Delete("/acl/{id}", aclHandler.RevokeACL)
|
||||
|
||||
// App passwords (WebDAV/sync clients)
|
||||
r.Get("/app-passwords", authHandler.ListAppPasswords)
|
||||
r.Post("/app-passwords", authHandler.CreateAppPassword)
|
||||
r.Delete("/app-passwords/{id}", authHandler.DeleteAppPassword)
|
||||
|
||||
// Settings (readable by all authenticated users for theming)
|
||||
r.Get("/admin/settings", adminHandler.GetSettings)
|
||||
|
||||
// Admin-only routes (server-side admin check inside each handler)
|
||||
r.Patch("/admin/settings", adminHandler.UpdateSettings)
|
||||
r.Get("/admin/users", adminHandler.ListUsers)
|
||||
r.Post("/admin/users", adminHandler.CreateUser)
|
||||
r.Patch("/admin/users/{id}", adminHandler.UpdateUser)
|
||||
r.Post("/admin/users/{id}/reset-password", adminHandler.ResetPassword)
|
||||
r.Delete("/admin/users/{id}", adminHandler.DeleteUser)
|
||||
r.Get("/admin/audit", adminHandler.ListAudit)
|
||||
r.Get("/admin/audit/actions", adminHandler.ListAuditActions)
|
||||
r.Post("/admin/gc", adminHandler.RunGC)
|
||||
// Immer erreichbar, auch wenn OCR aus ist – der Handler antwortet dann
|
||||
// mit enabled:false. Eine Route, die je nach Konfiguration 404 liefert,
|
||||
// wäre für die Oberfläche nicht von einem Fehler zu unterscheiden.
|
||||
r.Get("/admin/ocr", docHandler.Status)
|
||||
// Derselbe Stand, aber nur für die eigenen Dateien – ohne Adminrechte.
|
||||
r.Get("/ocr", docHandler.MyStatus)
|
||||
r.Post("/admin/branding/logo", adminHandler.UploadLogo)
|
||||
r.Delete("/admin/branding/logo", adminHandler.DeleteLogo)
|
||||
r.Post("/admin/branding/icon", adminHandler.UploadAppIcon)
|
||||
r.Delete("/admin/branding/icon", adminHandler.DeleteAppIcon)
|
||||
r.Get("/admin/group-folders", adminHandler.ListGroupFolders)
|
||||
r.Post("/admin/group-folders", adminHandler.CreateGroupFolder)
|
||||
r.Delete("/admin/group-folders/{id}", adminHandler.DeleteGroupFolder)
|
||||
r.Get("/admin/groups", adminHandler.ListGroups)
|
||||
r.Post("/admin/groups", adminHandler.CreateGroup)
|
||||
r.Patch("/admin/groups/{id}", adminHandler.UpdateGroup)
|
||||
r.Delete("/admin/groups/{id}", adminHandler.DeleteGroup)
|
||||
r.Get("/admin/groups/{id}/members", adminHandler.ListGroupMembers)
|
||||
r.Post("/admin/groups/{id}/members", adminHandler.AddGroupMember)
|
||||
r.Delete("/admin/groups/{id}/members/{userId}", adminHandler.RemoveGroupMember)
|
||||
})
|
||||
|
||||
// Serve built frontend
|
||||
r.Handle("/*", spaHandler("./static"))
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: 15 * time.Second,
|
||||
WriteTimeout: 60 * time.Minute, // großzügig für Multi-GB Up-/Downloads
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-quit
|
||||
log.Info().Msg("shutting down...")
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutCtx)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
log.Info().Str("addr", srv.Addr).Msg("listening")
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal().Err(err).Msg("server error")
|
||||
}
|
||||
<-done
|
||||
log.Info().Msg("stopped")
|
||||
}
|
||||
|
||||
// afterUpload hängt sich hinter den Upload und stößt an, was danach im
|
||||
// Hintergrund passieren soll: das Vorschaubild und – bei Dokumenten – die
|
||||
// Texterkennung.
|
||||
//
|
||||
// Der Unterschied zwischen beiden ist Absicht: Das Vorschaubild entsteht hier
|
||||
// direkt in einer Goroutine, die Texterkennung wird nur in die Warteschlange
|
||||
// eingetragen. Ein verlorenes Vorschaubild fällt sofort auf und ist in
|
||||
// Millisekunden neu gemacht; verlorener Text fällt nie auf – man sucht später
|
||||
// vergeblich und weiß nicht, warum. Deshalb ist er die Datenbankzeile wert.
|
||||
//
|
||||
// docStore darf nil sein (OCR abgeschaltet oder Programme fehlen).
|
||||
func afterUpload(fh *files.Handler, fileStore *files.Store, st storage.Storage, docStore *documents.Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
crw := &captureWriter{ResponseWriter: w}
|
||||
fh.Upload(crw, r)
|
||||
|
||||
if crw.status != http.StatusCreated {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse file ID and MIME type from captured JSON response (wrapped in {"data":{...}}).
|
||||
var wrapper struct {
|
||||
Data struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
MimeType string `json:"mime_type"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(crw.body.Bytes(), &wrapper); err != nil || wrapper.Data.ID == uuid.Nil {
|
||||
return
|
||||
}
|
||||
created := wrapper.Data
|
||||
|
||||
if docStore != nil && documents.Supported(created.MimeType, created.Name) {
|
||||
// Eigener Kontext: Der Anfrage-Kontext endet, sobald die Antwort
|
||||
// draußen ist – der Eintrag ginge dann ins Leere.
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second)
|
||||
if err := docStore.Enqueue(ctx, created.ID); err != nil {
|
||||
log.Warn().Err(err).Str("file_id", created.ID.String()).
|
||||
Msg("Texterkennung konnte nicht eingeplant werden")
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
|
||||
if !thumbnails.SupportedMIME(created.MimeType) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate thumbnail in background – non-fatal if it fails.
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
f, err := fileStore.GetFileByID(ctx, created.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
rc, err := st.Get(ctx, f.StorageKey)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
if err := thumbnails.Generate(ctx, st, created.ID.String(), created.MimeType, rc); err != nil {
|
||||
log.Warn().Err(err).Str("file_id", created.ID.String()).Msg("thumbnail generation failed")
|
||||
return
|
||||
}
|
||||
_ = fileStore.UpdateThumbnail(ctx, created.ID, true)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func seedAdmin(ctx context.Context, store *auth.Store, username, password string) error {
|
||||
exists, err := store.AdminExists(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check admin: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return fmt.Errorf("admin already exists, skipping seed")
|
||||
}
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
_, err = store.Create(ctx, username, hash, true)
|
||||
return err
|
||||
}
|
||||
|
||||
// captureWriter buffers the response body while forwarding to the real ResponseWriter.
|
||||
type captureWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
body bytes.Buffer
|
||||
}
|
||||
|
||||
func (cw *captureWriter) WriteHeader(status int) {
|
||||
cw.status = status
|
||||
cw.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (cw *captureWriter) Write(b []byte) (int, error) {
|
||||
cw.body.Write(b)
|
||||
return cw.ResponseWriter.Write(b)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
DROP TRIGGER IF EXISTS trg_files_updated_at ON files;
|
||||
DROP TRIGGER IF EXISTS trg_folders_updated_at ON folders;
|
||||
DROP TRIGGER IF EXISTS trg_users_updated_at ON users;
|
||||
DROP FUNCTION IF EXISTS update_updated_at();
|
||||
|
||||
DROP TABLE IF EXISTS file_versions;
|
||||
DROP TABLE IF EXISTS files;
|
||||
DROP TABLE IF EXISTS folders;
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
DROP TABLE IF EXISTS users;
|
||||
|
||||
DROP EXTENSION IF EXISTS "pgcrypto";
|
||||
@@ -0,0 +1,103 @@
|
||||
-- Aether – Initial schema
|
||||
-- Phase 0: users, sessions, folders, files (no email)
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- ── Users ─────────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL, -- Argon2id hash
|
||||
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
quota_bytes BIGINT NOT NULL DEFAULT 10737418240, -- 10 GB default
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ── Sessions ──────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
data BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
|
||||
|
||||
-- ── Folders ───────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE folders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (owner_id, parent_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_folders_owner ON folders(owner_id);
|
||||
CREATE INDEX idx_folders_parent ON folders(parent_id);
|
||||
|
||||
-- ── Files ─────────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE files (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
folder_id UUID REFERENCES folders(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size BIGINT NOT NULL DEFAULT 0,
|
||||
checksum TEXT NOT NULL, -- SHA-256 hex
|
||||
storage_key TEXT NOT NULL, -- path within blob storage
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_files_owner ON files(owner_id);
|
||||
CREATE INDEX idx_files_folder ON files(folder_id);
|
||||
CREATE INDEX idx_files_deleted ON files(is_deleted);
|
||||
CREATE INDEX idx_files_storage ON files(storage_key);
|
||||
|
||||
-- Partial unique index: no two non-deleted files can share name+folder for the same owner.
|
||||
CREATE UNIQUE INDEX idx_files_unique_name
|
||||
ON files(owner_id, folder_id, name)
|
||||
WHERE NOT is_deleted;
|
||||
|
||||
-- ── File versions ─────────────────────────────────────────────────────────────
|
||||
-- Previous versions of files (kept for Soft-Delete + versioning).
|
||||
CREATE TABLE file_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
size BIGINT NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
storage_key TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_file_versions_file ON file_versions(file_id);
|
||||
|
||||
-- ── Updated_at trigger ────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION update_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_folders_updated_at
|
||||
BEFORE UPDATE ON folders
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_files_updated_at
|
||||
BEFORE UPDATE ON files
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS uploads;
|
||||
DROP TABLE IF EXISTS share_links;
|
||||
ALTER TABLE files DROP COLUMN IF EXISTS has_thumbnail;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Phase 2: share links, resumable uploads, thumbnails
|
||||
|
||||
ALTER TABLE files ADD COLUMN has_thumbnail BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- ── Share links ───────────────────────────────────────────────────────────────
|
||||
CREATE TABLE share_links (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT, -- Argon2id, NULL = no password
|
||||
expires_at TIMESTAMPTZ, -- NULL = never
|
||||
download_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_share_links_token ON share_links(token);
|
||||
CREATE INDEX idx_share_links_owner ON share_links(owner_id);
|
||||
CREATE INDEX idx_share_links_file ON share_links(file_id);
|
||||
|
||||
-- ── Resumable uploads ────────────────────────────────────────────────────────
|
||||
CREATE TABLE uploads (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
||||
filename TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
total_size BIGINT NOT NULL,
|
||||
uploaded BIGINT NOT NULL DEFAULT 0,
|
||||
storage_key TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_uploads_owner ON uploads(owner_id);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS favorites;
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE favorites (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
folder_id UUID NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
is_expanded BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id, folder_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_favorites_user ON favorites(user_id);
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP TABLE IF EXISTS group_members;
|
||||
DROP TABLE IF EXISTS groups;
|
||||
DELETE FROM settings WHERE key IN ('cloud_name','primary_color','accent_color','logo_storage_key');
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS display_name;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Phase 3: Admin panel – groups, settings, user display names
|
||||
|
||||
-- ── Users: display name ───────────────────────────────────────────────────────
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS display_name TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- ── Groups ───────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE groups (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE group_members (
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (group_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_group_members_user ON group_members(user_id);
|
||||
|
||||
-- ── Settings (key-value, admin-managed) ───────────────────────────────────────
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Default settings
|
||||
INSERT INTO settings (key, value) VALUES
|
||||
('cloud_name', 'Aether'),
|
||||
('primary_color', '#6366f1'),
|
||||
('accent_color', '#8b5cf6'),
|
||||
('logo_storage_key', '');
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS oidc_sub;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- OIDC subject for linking OIDC provider accounts to local users.
|
||||
-- NULL means the user only has a local password login.
|
||||
ALTER TABLE users ADD COLUMN oidc_sub TEXT UNIQUE;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE files DROP COLUMN IF EXISTS versioning_enabled;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Versionierung ist ab hier eine Opt-in-Einstellung pro Datei.
|
||||
-- Standard: aus. Ohne den Schalter würde jede Überschreibung eine weitere
|
||||
-- Fassung aufheben und den Speicher unbemerkt volllaufen lassen.
|
||||
ALTER TABLE files
|
||||
ADD COLUMN versioning_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Dateien, die bereits Fassungen haben, behalten ihr bisheriges Verhalten –
|
||||
-- sonst würde ein bestehender Verlauf stillschweigend eingefroren.
|
||||
UPDATE files f
|
||||
SET versioning_enabled = true
|
||||
WHERE EXISTS (SELECT 1 FROM file_versions v WHERE v.file_id = f.id);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE users
|
||||
DROP COLUMN IF EXISTS primary_color,
|
||||
DROP COLUMN IF EXISTS accent_color;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Persönliche Farbpalette pro Nutzer. NULL bedeutet: es gilt die globale
|
||||
-- Einstellung des Administrators.
|
||||
--
|
||||
-- Bis hierher zeigte die Profilseite zwar Farbwähler, schrieb damit aber in die
|
||||
-- globalen Einstellungen – für normale Nutzer scheiterte das an der
|
||||
-- Admin-Prüfung, beim Administrator färbte es die ganze Cloud um.
|
||||
ALTER TABLE users
|
||||
ADD COLUMN primary_color TEXT,
|
||||
ADD COLUMN accent_color TEXT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS use_custom_theme;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Schalter zwischen persönlicher und globaler Palette.
|
||||
--
|
||||
-- Bisher bedeutete "keine eigene Farbe" = globale Einstellung. Zum Zurückschalten
|
||||
-- musste man die eigenen Werte löschen und war sie damit los. Der Schalter trennt
|
||||
-- beides: die Farben bleiben gespeichert, nur ihre Anwendung wird umgelegt.
|
||||
ALTER TABLE users
|
||||
ADD COLUMN use_custom_theme BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Wer bereits eine eigene Farbe gesetzt hat, hat sie sichtbar verwendet –
|
||||
-- das Verhalten soll durch die Migration nicht kippen.
|
||||
UPDATE users
|
||||
SET use_custom_theme = true
|
||||
WHERE primary_color IS NOT NULL OR accent_color IS NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS app_passwords;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- App-Passwörter: widerrufbare Zugangsdaten für WebDAV-/Sync-Clients, damit das
|
||||
-- Hauptpasswort nicht auf Geräten liegt, die man verlieren kann.
|
||||
CREATE TABLE app_passwords (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
-- SHA-256 des Tokens. Kein Argon2: das Token ist serverseitig zufällig und
|
||||
-- hochentropisch, also nicht ratbar – und WebDAV authentifiziert jeden
|
||||
-- einzelnen Request, wo Argon2 pro Aufruf zu teuer wäre.
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_app_passwords_user ON app_passwords(user_id);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS audit_log;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Audit-Log: wer hat was wann getan. Zweck ist die Nachvollziehbarkeit nach
|
||||
-- einem Vorfall – deshalb muss ein Eintrag auch dann noch lesbar sein, wenn der
|
||||
-- handelnde oder betroffene Account längst gelöscht ist.
|
||||
CREATE TABLE audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- ON DELETE SET NULL statt CASCADE: sonst räumte das Löschen eines Users
|
||||
-- genau die Spuren weg, die man danach untersuchen will.
|
||||
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- Denormalisiert, damit der Name den gelöschten Account überlebt.
|
||||
actor_name TEXT NOT NULL,
|
||||
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT,
|
||||
target_id TEXT,
|
||||
target_name TEXT,
|
||||
|
||||
ip TEXT,
|
||||
detail TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Die Admin-Ansicht liest immer neueste zuerst, oft gefiltert nach Aktion oder
|
||||
-- handelndem Konto.
|
||||
CREATE INDEX idx_audit_log_created ON audit_log (created_at DESC);
|
||||
CREATE INDEX idx_audit_log_actor ON audit_log (actor_id, created_at DESC);
|
||||
CREATE INDEX idx_audit_log_action ON audit_log (action, created_at DESC);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS acl_entries;
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Interne Freigaben: Ordner oder Datei an einen anderen Nutzer oder eine Gruppe
|
||||
-- geben, ohne dass die Bytes umziehen. Der Empfänger sieht sie virtuell unter
|
||||
-- „Geteilt mit mir"; auf der Platte bleibt alles beim Eigentümer.
|
||||
--
|
||||
-- Abweichung vom Projektplan: dort war eine polymorphe Tabelle skizziert
|
||||
-- (subject_type + subject_id als Text/UUID ohne Fremdschlüssel). Hier stattdessen
|
||||
-- vier nullbare Fremdschlüssel mit CHECK-Bedingung. Grund: polymorph kann die
|
||||
-- Datenbank nichts erzwingen – ein gelöschter Nutzer oder Ordner hinterließe
|
||||
-- Einträge, die auf nichts mehr zeigen, und aus so einer Leiche später eine
|
||||
-- Freigabe für einen neu vergebenen Namen zu machen wäre ein Sicherheitsloch.
|
||||
-- Mit echten Fremdschlüsseln räumt ON DELETE CASCADE das von selbst auf.
|
||||
CREATE TABLE acl_entries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- Empfänger: genau eines von beiden.
|
||||
subject_user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
subject_group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||
|
||||
-- Freigegebene Ressource: genau eines von beiden.
|
||||
resource_folder_id UUID REFERENCES folders(id) ON DELETE CASCADE,
|
||||
resource_file_id UUID REFERENCES files(id) ON DELETE CASCADE,
|
||||
|
||||
permission TEXT NOT NULL CHECK (permission IN ('read', 'write')),
|
||||
|
||||
-- Wer die Freigabe erteilt hat. SET NULL statt CASCADE: die Freigabe soll
|
||||
-- nicht verschwinden, nur weil das Konto des Erteilers gelöscht wurde.
|
||||
granted_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT acl_one_subject CHECK (num_nonnulls(subject_user_id, subject_group_id) = 1),
|
||||
CONSTRAINT acl_one_resource CHECK (num_nonnulls(resource_folder_id, resource_file_id) = 1)
|
||||
);
|
||||
|
||||
-- Zwei Freigaben derselben Ressource an denselben Empfänger wären mehrdeutig:
|
||||
-- welches Recht gilt dann? Partielle Unique-Indizes, weil NULL in einem
|
||||
-- gewöhnlichen UNIQUE nicht mit sich selbst kollidiert.
|
||||
CREATE UNIQUE INDEX idx_acl_user_folder ON acl_entries (subject_user_id, resource_folder_id)
|
||||
WHERE subject_user_id IS NOT NULL AND resource_folder_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX idx_acl_user_file ON acl_entries (subject_user_id, resource_file_id)
|
||||
WHERE subject_user_id IS NOT NULL AND resource_file_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX idx_acl_group_folder ON acl_entries (subject_group_id, resource_folder_id)
|
||||
WHERE subject_group_id IS NOT NULL AND resource_folder_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX idx_acl_group_file ON acl_entries (subject_group_id, resource_file_id)
|
||||
WHERE subject_group_id IS NOT NULL AND resource_file_id IS NOT NULL;
|
||||
|
||||
-- Die Zugriffsprüfung läuft bei jedem Request; sie fragt nach Ressource.
|
||||
CREATE INDEX idx_acl_folder ON acl_entries (resource_folder_id) WHERE resource_folder_id IS NOT NULL;
|
||||
CREATE INDEX idx_acl_file ON acl_entries (resource_file_id) WHERE resource_file_id IS NOT NULL;
|
||||
-- „Geteilt mit mir" fragt nach Empfänger.
|
||||
CREATE INDEX idx_acl_subject_user ON acl_entries (subject_user_id) WHERE subject_user_id IS NOT NULL;
|
||||
CREATE INDEX idx_acl_subject_group ON acl_entries (subject_group_id) WHERE subject_group_id IS NOT NULL;
|
||||
@@ -0,0 +1,18 @@
|
||||
DROP INDEX IF EXISTS idx_files_group;
|
||||
DROP INDEX IF EXISTS idx_folders_group;
|
||||
DROP INDEX IF EXISTS idx_folders_group_root_name;
|
||||
DROP INDEX IF EXISTS idx_folders_group_name;
|
||||
|
||||
ALTER TABLE groups DROP COLUMN IF EXISTS quota_bytes;
|
||||
ALTER TABLE files DROP CONSTRAINT IF EXISTS files_one_principal;
|
||||
ALTER TABLE folders DROP CONSTRAINT IF EXISTS folders_one_principal;
|
||||
|
||||
-- Erst die Gruppendaten entfernen, sonst scheitert das NOT NULL unten.
|
||||
DELETE FROM files WHERE group_id IS NOT NULL;
|
||||
DELETE FROM folders WHERE group_id IS NOT NULL;
|
||||
|
||||
ALTER TABLE files DROP COLUMN IF EXISTS group_id;
|
||||
ALTER TABLE folders DROP COLUMN IF EXISTS group_id;
|
||||
|
||||
ALTER TABLE files ALTER COLUMN owner_id SET NOT NULL;
|
||||
ALTER TABLE folders ALTER COLUMN owner_id SET NOT NULL;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Gruppenordner: ein Ordner, der keinem einzelnen Konto gehört, sondern einer
|
||||
-- Gruppe. Wer Mitglied ist, sieht ihn; wer die Gruppe verlässt, nicht mehr.
|
||||
--
|
||||
-- Ordner und Dateien gehören ab jetzt entweder einem Nutzer ODER einer Gruppe,
|
||||
-- nie beidem und nie keinem. Deshalb wird owner_id nullbar und bekommt eine
|
||||
-- CHECK-Bedingung als Gegengewicht – ohne die könnte ein Datensatz herrenlos
|
||||
-- werden und wäre über keine Abfrage mehr erreichbar.
|
||||
|
||||
ALTER TABLE folders ALTER COLUMN owner_id DROP NOT NULL;
|
||||
ALTER TABLE files ALTER COLUMN owner_id DROP NOT NULL;
|
||||
|
||||
ALTER TABLE folders ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE;
|
||||
ALTER TABLE files ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE folders ADD CONSTRAINT folders_one_principal
|
||||
CHECK (num_nonnulls(owner_id, group_id) = 1);
|
||||
ALTER TABLE files ADD CONSTRAINT files_one_principal
|
||||
CHECK (num_nonnulls(owner_id, group_id) = 1);
|
||||
|
||||
-- Gruppen bekommen ein eigenes Kontingent. Die Alternative – „zählt beim
|
||||
-- Ersteller" – wäre für die Mitglieder nicht nachvollziehbar: Der Ordner wäre
|
||||
-- plötzlich voll, weil jemand anders privat etwas hochgeladen hat.
|
||||
ALTER TABLE groups ADD COLUMN quota_bytes BIGINT NOT NULL DEFAULT 10737418240;
|
||||
|
||||
-- Die bestehende UNIQUE (owner_id, parent_id, name) greift für Gruppenordner
|
||||
-- nicht: In SQL kollidiert NULL nicht mit NULL, zwei Gruppenordner gleichen
|
||||
-- Namens rutschten also durch.
|
||||
CREATE UNIQUE INDEX idx_folders_group_name ON folders (group_id, parent_id, name)
|
||||
WHERE group_id IS NOT NULL AND parent_id IS NOT NULL;
|
||||
-- Auf oberster Ebene ist parent_id NULL, das braucht einen eigenen Index.
|
||||
CREATE UNIQUE INDEX idx_folders_group_root_name ON folders (group_id, name)
|
||||
WHERE group_id IS NOT NULL AND parent_id IS NULL;
|
||||
|
||||
CREATE INDEX idx_folders_group ON folders (group_id) WHERE group_id IS NOT NULL;
|
||||
CREATE INDEX idx_files_group ON files (group_id) WHERE group_id IS NOT NULL;
|
||||
@@ -0,0 +1,5 @@
|
||||
DROP INDEX IF EXISTS idx_folders_name_trgm;
|
||||
DROP INDEX IF EXISTS idx_files_name_trgm;
|
||||
|
||||
-- Die Erweiterung bleibt bewusst stehen: Sie wieder zu entfernen würde alles
|
||||
-- mitreißen, was sonst noch auf ihr aufbaut, und sie kostet nichts.
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Namenssuche über Dateien und Ordner.
|
||||
--
|
||||
-- pg_trgm statt einer Volltextsuche: Gesucht wird nach Namensteilen
|
||||
-- ("rechn" findet "Rechnung_2026.pdf"), und eine Volltextsuche zerlegt in
|
||||
-- Wörter, findet also gerade Teilwörter nicht. Volltext über den *Inhalt*
|
||||
-- kommt in Phase 6 dazu – das ist eine andere Aufgabe.
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
-- GIN mit Trigrammen macht ILIKE '%…%' indizierbar. Ohne den Index läuft jede
|
||||
-- Suche als vollständiger Tabellendurchlauf.
|
||||
CREATE INDEX idx_files_name_trgm ON files USING gin (name gin_trgm_ops);
|
||||
CREATE INDEX idx_folders_name_trgm ON folders USING gin (name gin_trgm_ops);
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS ocr_jobs;
|
||||
DROP TABLE IF EXISTS file_text;
|
||||
@@ -0,0 +1,63 @@
|
||||
-- Volltext über den *Inhalt* – die zweite Hälfte der Suche.
|
||||
--
|
||||
-- Migration 000013 machte Datei*namen* durchsuchbar. Das reicht, solange
|
||||
-- Dateien sprechende Namen haben. Ein Scanner nennt seine Ablage aber
|
||||
-- "scan_0042.pdf", und genau die Belege sollen auffindbar werden.
|
||||
|
||||
-- ── Der erkannte Text ─────────────────────────────────────────────────────────
|
||||
-- Eigene Tabelle statt einer Spalte an files: Der Text ist um Größenordnungen
|
||||
-- länger als der Rest der Zeile, und files wird bei jedem Auflisten gelesen.
|
||||
-- Postgres lagert lange Werte zwar aus (TOAST), aber die tsvector-Spalte und
|
||||
-- ihr GIN-Index gehörten dann trotzdem an eine Tabelle, die ständig in
|
||||
-- Bewegung ist.
|
||||
CREATE TABLE file_text (
|
||||
file_id UUID PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
-- Woher der Text stammt. 'layer' = das PDF hatte bereits eine Textebene
|
||||
-- (pdftotext, verlustfrei); 'ocr' = aus dem Bild erkannt, kann Fehler
|
||||
-- enthalten. Der Unterschied ist für die Fehlersuche wichtig: Findet die
|
||||
-- Suche ein Wort nicht, sagt die Herkunft, ob OCR es verlesen hat.
|
||||
source TEXT NOT NULL CHECK (source IN ('layer', 'ocr')),
|
||||
-- Die Sprachen, mit denen erkannt wurde ("deu+eng"). Bei 'layer' leer.
|
||||
lang TEXT NOT NULL DEFAULT '',
|
||||
-- Zeichenzahl des Originals vor dem Kürzen – siehe maxTextBytes im Worker.
|
||||
chars INTEGER NOT NULL DEFAULT 0,
|
||||
pages INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Berechnete Spalte statt eines Triggers: Sie kann nie mit dem Inhalt
|
||||
-- auseinanderlaufen, weil Postgres sie bei jedem Schreiben selbst füllt.
|
||||
--
|
||||
-- 'german' fest verdrahtet, nicht 'simple': Ohne Stammformen fände
|
||||
-- "Rechnung" die "Rechnungen" nicht. Deutsch ist die Sprache der Belege,
|
||||
-- um die es hier geht; englische Wörter darin verlieren nur ihre
|
||||
-- Stammformbildung, sie bleiben wörtlich auffindbar. Eine Änderung wäre
|
||||
-- eine Migration – der Preis dafür, dass der Ausdruck IMMUTABLE sein muss.
|
||||
tsv tsvector GENERATED ALWAYS AS (to_tsvector('german', content)) STORED
|
||||
);
|
||||
|
||||
CREATE INDEX idx_file_text_tsv ON file_text USING gin (tsv);
|
||||
|
||||
-- ── Die Warteschlange ─────────────────────────────────────────────────────────
|
||||
-- Warum eine Tabelle und nicht einfach ein Goroutine-Aufruf wie bei den
|
||||
-- Vorschaubildern: OCR dauert Sekunden bis Minuten. Startet der Server in
|
||||
-- dieser Zeit neu – Update, Absturz, Neustart des Wirts –, ist ein Auftrag im
|
||||
-- Speicher verloren, und die Datei bleibt für immer unauffindbar, ohne dass es
|
||||
-- jemand merkt. Bei einem Vorschaubild fällt das auf; bei fehlendem Text nicht.
|
||||
CREATE TABLE ocr_jobs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- Ein Auftrag je Datei. Wird dieselbe Datei erneut hochgeladen, ersetzt der
|
||||
-- neue Auftrag den alten (ON CONFLICT), statt sich daneben zu stapeln.
|
||||
file_id UUID NOT NULL UNIQUE REFERENCES files(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'running', 'done', 'failed')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Der Arbeiter fragt nur nach offenen Aufträgen. Ein Teilindex bleibt klein,
|
||||
-- auch wenn sich zehntausend erledigte Zeilen ansammeln.
|
||||
CREATE INDEX idx_ocr_jobs_pending ON ocr_jobs(created_at)
|
||||
WHERE status IN ('pending', 'running');
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP INDEX IF EXISTS idx_files_document_date;
|
||||
ALTER TABLE files DROP COLUMN IF EXISTS document_date;
|
||||
DROP TABLE IF EXISTS file_tags;
|
||||
DROP TABLE IF EXISTS tags;
|
||||
@@ -0,0 +1,75 @@
|
||||
-- Etiketten für Dateien.
|
||||
--
|
||||
-- Warum neben Ordnern und Volltext noch ein drittes Ordnungsmittel: Ordner
|
||||
-- beantworten „wann" (Rechnungen/2026), der Volltext beantwortet „wo steht
|
||||
-- dieses Wort". Beides beantwortet **nicht**, wofür ein Beleg gebraucht wird –
|
||||
-- „absetzbar" steht in keiner Rechnung, das ist eine Einschätzung des Menschen.
|
||||
--
|
||||
-- Und der Fall, an dem Ordner scheitern: Der Kaufbeleg eines Notebooks ist
|
||||
-- zugleich Garantienachweis *und* absetzbar. In einen Ordner passt er nur
|
||||
-- einmal; Etiketten trägt dieselbe Datei beliebig viele.
|
||||
|
||||
-- ── Die Etiketten selbst ─────────────────────────────────────────────────────
|
||||
-- Prinzipal-Modell wie bei files und folders: Ein Etikett gehört entweder einem
|
||||
-- Konto oder einer Gruppe, nie beidem und nie keinem.
|
||||
--
|
||||
-- Der Grund für die Gruppenvariante: In einem Gruppenordner arbeiten mehrere an
|
||||
-- denselben Dokumenten. Hätte dort jeder seine eigenen Etiketten, sortierte
|
||||
-- jeder dieselben Dateien für sich neu ein und niemand sähe die Arbeit des
|
||||
-- anderen – der Sinn eines geteilten Ordners wäre dahin.
|
||||
CREATE TABLE tags (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
-- Hex-Farbe für den Aufkleber. Leer heißt: die Oberfläche wählt eine aus
|
||||
-- dem Namen, damit ein neues Etikett nicht erst eingefärbt werden muss.
|
||||
color TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT tags_one_principal CHECK (
|
||||
(owner_id IS NOT NULL AND group_id IS NULL) OR
|
||||
(owner_id IS NULL AND group_id IS NOT NULL)
|
||||
),
|
||||
CONSTRAINT tags_name_not_empty CHECK (length(btrim(name)) > 0)
|
||||
);
|
||||
|
||||
-- Namen sind je Prinzipal eindeutig, und zwar **ohne Rücksicht auf
|
||||
-- Groß-/Kleinschreibung**. Sonst stehen nach drei Monaten "Stadtwerke",
|
||||
-- "stadtwerke" und "STADTWERKE" nebeneinander – genau der Zerfall, den
|
||||
-- Etiketten gegenüber freien Ordnernamen verhindern sollen.
|
||||
CREATE UNIQUE INDEX idx_tags_owner_name ON tags (owner_id, lower(btrim(name)))
|
||||
WHERE owner_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX idx_tags_group_name ON tags (group_id, lower(btrim(name)))
|
||||
WHERE group_id IS NOT NULL;
|
||||
|
||||
-- ── Die Zuordnung ────────────────────────────────────────────────────────────
|
||||
-- Eine reine Verknüpfungstabelle. Nichts davon wird in die Datei geschrieben:
|
||||
-- Das Dokument bleibt Byte für Byte, wie es hochgeladen wurde – dieselbe Regel
|
||||
-- wie beim erkannten Text (siehe internal/documents).
|
||||
CREATE TABLE file_tags (
|
||||
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
-- Wer es angehängt hat. In einem Gruppenordner sonst nicht nachvollziehbar.
|
||||
tagged_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (file_id, tag_id)
|
||||
);
|
||||
|
||||
-- Der Primärschlüssel deckt die Richtung Datei → Etiketten ab. Die andere
|
||||
-- Richtung – „zeig mir alles mit steuer-2026" – ist genau die Abfrage, für die
|
||||
-- Etiketten gebaut werden, und braucht ihren eigenen Index.
|
||||
CREATE INDEX idx_file_tags_tag ON file_tags (tag_id);
|
||||
|
||||
-- ── Belegdatum ───────────────────────────────────────────────────────────────
|
||||
-- Das Datum, das auf dem Dokument steht – nicht das des Uploads.
|
||||
--
|
||||
-- Für beide Zwecke entscheidend: Die Garantiefrist läuft ab dem **Kauf**, und in
|
||||
-- die Steuererklärung 2026 gehört, was 2026 gekauft wurde – auch wenn der Beleg
|
||||
-- erst im März 2027 eingescannt wird. created_at wäre für beides das falsche
|
||||
-- Datum, und zwar unauffällig falsch.
|
||||
--
|
||||
-- NULL heißt schlicht „nicht gesetzt": Bei einem Urlaubsfoto gibt es kein
|
||||
-- Belegdatum, und ein Vorgabewert wäre eine Behauptung.
|
||||
ALTER TABLE files ADD COLUMN document_date DATE;
|
||||
CREATE INDEX idx_files_document_date ON files (document_date) WHERE document_date IS NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE file_text DROP COLUMN IF EXISTS extract_version;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Seitengrenzen im erkannten Text.
|
||||
--
|
||||
-- Bis hierher ersetzte clean() den Seitenumbruch (\f) durch einen Zeilenumbruch:
|
||||
-- Für die Volltextsuche ist er bedeutungslos, ein tsvector kennt keine Seiten.
|
||||
-- Für den PDF-Betrachter ist er das Gegenteil von bedeutungslos – bei einem
|
||||
-- Scan steht der Text *nur* hier, im PDF selbst liegt bloß ein Bild. Ohne die
|
||||
-- Marke lässt sich nicht sagen, auf welcher Seite ein Suchwort steht, und der
|
||||
-- Betrachter beginnt bei einem Beleg-Scan wieder stumpf auf Seite 1.
|
||||
--
|
||||
-- extract_version sagt, nach welcher Regel ein Eintrag entstanden ist. Ältere
|
||||
-- Einträge werden beim Start erneut in die Warteschlange gelegt, statt still
|
||||
-- ohne Seitenmarken liegen zu bleiben.
|
||||
ALTER TABLE file_text ADD COLUMN IF NOT EXISTS extract_version smallint NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP INDEX IF EXISTS idx_files_correspondent;
|
||||
ALTER TABLE files DROP COLUMN IF EXISTS correspondent_id;
|
||||
DROP TABLE IF EXISTS correspondents;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- Absender („Korrespondent") an Dateien.
|
||||
--
|
||||
-- Warum neben Etiketten noch ein Feld: Es sind **zwei verschiedene Fragen**.
|
||||
-- Ein Etikett sagt, *wofür* ein Dokument gebraucht wird („absetzbar",
|
||||
-- „garantie") – davon trägt eine Datei beliebig viele. Der Absender sagt, *von
|
||||
-- wem* es kommt („Stadtwerke", „Finanzamt") – davon hat eine Datei genau einen.
|
||||
--
|
||||
-- Als Etikett gelöst stünden beide in derselben Liste: „Stadtwerke" neben
|
||||
-- „absetzbar", Absender und Verwendungszweck durcheinander. Bei zwanzig
|
||||
-- Etiketten fällt das nicht auf, bei achtzig ist die Liste unbenutzbar. Und
|
||||
-- „alle Rechnungen von X" ließe sich nicht von „alles Absetzbare" trennen –
|
||||
-- genau die Verknüpfung, für die es gebaut wird.
|
||||
--
|
||||
-- Die Eins-Regel ist dabei kein Mangel, sondern der Zweck: Sie erzwingt eine
|
||||
-- Entscheidung und hält die Liste kurz.
|
||||
|
||||
CREATE TABLE correspondents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- Prinzipal-Modell wie bei tags: entweder ein Konto oder eine Gruppe.
|
||||
-- In einem Gruppenordner müssen alle dieselben Absender sehen, sonst
|
||||
-- sortiert jeder dieselben Dokumente für sich neu ein.
|
||||
owner_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT correspondents_one_principal CHECK (
|
||||
(owner_id IS NOT NULL AND group_id IS NULL) OR
|
||||
(owner_id IS NULL AND group_id IS NOT NULL)
|
||||
),
|
||||
CONSTRAINT correspondents_name_not_empty CHECK (length(btrim(name)) > 0)
|
||||
);
|
||||
|
||||
-- Eindeutig je Prinzipal, ohne Rücksicht auf Groß-/Kleinschreibung – dieselbe
|
||||
-- Regel wie bei Etiketten. Hier wiegt sie schwerer: „Amazon" und „amazon"
|
||||
-- nebeneinander hieße, dass „alle Rechnungen von Amazon" nur die Hälfte
|
||||
-- findet, und zwar unauffällig.
|
||||
CREATE UNIQUE INDEX idx_correspondents_owner_name
|
||||
ON correspondents (owner_id, lower(btrim(name))) WHERE owner_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX idx_correspondents_group_name
|
||||
ON correspondents (group_id, lower(btrim(name))) WHERE group_id IS NOT NULL;
|
||||
|
||||
-- Am Datei-Datensatz, nicht in einer Verknüpfungstabelle: Genau einer je
|
||||
-- Datei – das steht damit im Schema und nicht bloß in einer Zusicherung des
|
||||
-- Codes.
|
||||
--
|
||||
-- ON DELETE SET NULL statt CASCADE: Wer einen Absender löscht, will ihn aus
|
||||
-- der Liste haben – nicht seine Rechnungen.
|
||||
ALTER TABLE files ADD COLUMN correspondent_id UUID REFERENCES correspondents(id) ON DELETE SET NULL;
|
||||
|
||||
-- „Alles von Stadtwerke" ist die Abfrage, für die das Feld gebaut wird.
|
||||
CREATE INDEX idx_files_correspondent ON files (correspondent_id) WHERE correspondent_id IS NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tags DROP COLUMN IF EXISTS icon;
|
||||
ALTER TABLE correspondents DROP COLUMN IF EXISTS icon;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Ein Symbol je Etikett und je Absender.
|
||||
--
|
||||
-- Die Farbe allein trägt nicht weit genug: Sie unterscheidet zuverlässig etwa
|
||||
-- fünf Einträge. Danach fragt man sich, ob Blau nun „Uni" oder „Versicherung"
|
||||
-- war. Ein Emoji ist sofort erkennbar, braucht keine Legende und ist in jeder
|
||||
-- Schrift schon da – kein Symbolsatz, keine Grafikdateien, keine zusätzliche
|
||||
-- Ladezeit.
|
||||
--
|
||||
-- Leer heißt „keins": Die Farbe bleibt der Rückfall, niemand muss ein Symbol
|
||||
-- wählen. Ein Vorgabe-Emoji wäre eine Behauptung über den Inhalt.
|
||||
--
|
||||
-- TEXT statt eines einzelnen Zeichens: Ein Emoji ist selten ein Codepunkt.
|
||||
-- 👨🎓 sind fünf, 🏳️🌈 vier – ein CHAR(1) hätte sie zerschnitten.
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS icon TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE correspondents ADD COLUMN IF NOT EXISTS icon TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,34 @@
|
||||
module aether
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.19.0
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/go-chi/httprate v0.15.0
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/rs/zerolog v1.33.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/image v0.42.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
)
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
|
||||
github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
|
||||
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,351 @@
|
||||
// Package acl regelt interne Freigaben: Ordner und Dateien an andere Nutzer
|
||||
// oder Gruppen geben, ohne dass Daten umziehen.
|
||||
//
|
||||
// Das Zugriffsmodell in einem Satz: Zugriff hat, wem die Ressource gehört –
|
||||
// oder wer einen Freigabeeintrag darauf besitzt, direkt oder über eine Gruppe,
|
||||
// auf der Ressource selbst oder auf einem Ordner darüber.
|
||||
//
|
||||
// Die Vererbung nach unten ist der Grund, warum das nicht mit einer einfachen
|
||||
// Abfrage geht: Wer einen Ordner freigibt, meint dessen ganzen Inhalt – sonst
|
||||
// müsste man jede Datei einzeln freigeben, und jede neu hinzugefügte gleich
|
||||
// mit. Deshalb läuft die Prüfung den Ordnerbaum nach oben, bis sie einen
|
||||
// Eintrag findet.
|
||||
package acl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNoAccess: weder Eigentümer noch Freigabe. Handler antworten damit
|
||||
// bewusst „nicht gefunden" statt „verboten" – sonst verriete die Antwort,
|
||||
// dass die ID überhaupt existiert.
|
||||
ErrNoAccess = errors.New("no access to this resource")
|
||||
// ErrReadOnly: Zugriff ja, Schreiben nein.
|
||||
ErrReadOnly = errors.New("read-only access")
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
)
|
||||
|
||||
// Permission ist absichtlich klein gehalten. Feinere Rechte (löschen vs.
|
||||
// ändern) brächten Fragen mit sich, die für eine persönliche Cloud niemand
|
||||
// stellt.
|
||||
type Permission string
|
||||
|
||||
const (
|
||||
PermRead Permission = "read"
|
||||
PermWrite Permission = "write"
|
||||
)
|
||||
|
||||
func (p Permission) valid() bool { return p == PermRead || p == PermWrite }
|
||||
|
||||
// Access beschreibt das Ergebnis einer Zugriffsprüfung.
|
||||
type Access struct {
|
||||
// OwnerID ist der *Prinzipal* der Ressource – ein Konto oder eine Gruppe,
|
||||
// nicht zwingend der Anfragende. Alle Speicher- und Datenbankoperationen
|
||||
// laufen weiter über ihn, denn dort liegen die Daten.
|
||||
//
|
||||
// Ein Prinzipal statt eines Eigentümers, weil Gruppenordner niemandem
|
||||
// persönlich gehören. Die Abfragen prüfen deshalb `owner_id = $1 OR
|
||||
// group_id = $1`; UUIDs sind global eindeutig, eine Gruppen-ID kann also
|
||||
// nie versehentlich auf ein Konto passen.
|
||||
OwnerID uuid.UUID
|
||||
// IsGroup unterscheidet die beiden Fälle dort, wo es darauf ankommt:
|
||||
// beim Anlegen (welche Spalte wird gesetzt) und beim Speicherpfad.
|
||||
IsGroup bool
|
||||
// OwnerUsername, weil Speicherpfade daraus gebaut werden
|
||||
// (`users/{name}/home/…`). Wer in einem geliehenen Ordner arbeitet, schreibt
|
||||
// in den Bereich des Eigentümers, nicht in seinen eigenen.
|
||||
OwnerUsername string
|
||||
// OwnerQuota ist das Kontingent des Eigentümers. Wer in einen geliehenen
|
||||
// Ordner hochlädt, verbraucht dessen Platz – die Bytes liegen ja in dessen
|
||||
// Bereich. Alles andere wäre ein Weg, fremde Kontingente zu umgehen.
|
||||
OwnerQuota int64
|
||||
// Permission ist das, was der Anfragende darf.
|
||||
Permission Permission
|
||||
// Shared unterscheidet eigenen Besitz von geliehenem Zugriff.
|
||||
Shared bool
|
||||
}
|
||||
|
||||
func (a Access) CanWrite() bool { return a.Permission == PermWrite }
|
||||
|
||||
// OwnerRef und GroupRef liefern die Werte für die Spalten owner_id / group_id.
|
||||
// Genau einer ist gesetzt – so verlangt es die CHECK-Bedingung.
|
||||
func (a Access) OwnerRef() *uuid.UUID {
|
||||
if a.IsGroup {
|
||||
return nil
|
||||
}
|
||||
id := a.OwnerID
|
||||
return &id
|
||||
}
|
||||
|
||||
func (a Access) GroupRef() *uuid.UUID {
|
||||
if !a.IsGroup {
|
||||
return nil
|
||||
}
|
||||
id := a.OwnerID
|
||||
return &id
|
||||
}
|
||||
|
||||
// StorageRoot ist das Wurzelsegment für Speicherpfade. Gruppendaten liegen
|
||||
// bewusst nicht unter `users/…`: Auf der Platte soll man ansehen können, dass
|
||||
// ein Ordner keinem Konto gehört – sonst sucht man die Daten einer Gruppe im
|
||||
// Verzeichnis eines Nutzers, der zufällig ihr Ersteller war.
|
||||
func (a Access) StorageRoot() string {
|
||||
if a.IsGroup {
|
||||
return "groups/" + a.OwnerID.String()
|
||||
}
|
||||
return "users/" + a.OwnerUsername
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// ── Zugriffsauflösung ────────────────────────────────────────────────────────
|
||||
|
||||
// ResolveFolder klärt, was userID mit diesem Ordner darf.
|
||||
func (s *Store) ResolveFolder(ctx context.Context, userID, folderID uuid.UUID) (Access, error) {
|
||||
// Gruppenordner haben keinen Eigentümer, deshalb beide Fälle in einer
|
||||
// Abfrage. COALESCE macht die Gruppen-ID zum Prinzipal, wenn owner_id leer
|
||||
// ist – genau die ID, mit der die Store-Abfragen weiterarbeiten.
|
||||
var principal uuid.UUID
|
||||
var isGroup bool
|
||||
var ownerName string
|
||||
var quota int64
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT COALESCE(f.owner_id, f.group_id),
|
||||
f.group_id IS NOT NULL,
|
||||
COALESCE(u.username, ''),
|
||||
COALESCE(u.quota_bytes, g.quota_bytes, 0)
|
||||
FROM folders f
|
||||
LEFT JOIN users u ON u.id = f.owner_id
|
||||
LEFT JOIN groups g ON g.id = f.group_id
|
||||
WHERE f.id = $1`, folderID).Scan(&principal, &isGroup, &ownerName, "a)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Access{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Access{}, fmt.Errorf("load folder principal: %w", err)
|
||||
}
|
||||
|
||||
if isGroup {
|
||||
// Mitgliedschaft ersetzt hier die Freigabe: Wer in der Gruppe ist, darf
|
||||
// schreiben; wer sie verlässt, verliert den Zugriff im selben Moment.
|
||||
member, err := s.isGroupMember(ctx, userID, principal)
|
||||
if err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
if !member {
|
||||
return Access{}, ErrNoAccess
|
||||
}
|
||||
return Access{
|
||||
OwnerID: principal, IsGroup: true, OwnerQuota: quota,
|
||||
Permission: PermWrite, Shared: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if principal == userID {
|
||||
return Access{OwnerID: principal, OwnerUsername: ownerName, OwnerQuota: quota, Permission: PermWrite}, nil
|
||||
}
|
||||
ownerID, ownerQuota := principal, quota
|
||||
|
||||
perm, err := s.bestPermissionForFolder(ctx, userID, folderID)
|
||||
if err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
if perm == "" {
|
||||
return Access{}, ErrNoAccess
|
||||
}
|
||||
return Access{OwnerID: ownerID, OwnerUsername: ownerName, OwnerQuota: ownerQuota, Permission: perm, Shared: true}, nil
|
||||
}
|
||||
|
||||
// ResolveFile klärt, was userID mit dieser Datei darf. Geerbt wird über den
|
||||
// Ordner, in dem sie liegt.
|
||||
func (s *Store) ResolveFile(ctx context.Context, userID, fileID uuid.UUID) (Access, error) {
|
||||
var principal uuid.UUID
|
||||
var isGroup bool
|
||||
var ownerName string
|
||||
var quota int64
|
||||
var folderID *uuid.UUID
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT COALESCE(x.owner_id, x.group_id),
|
||||
x.group_id IS NOT NULL,
|
||||
COALESCE(u.username, ''),
|
||||
COALESCE(u.quota_bytes, g.quota_bytes, 0),
|
||||
x.folder_id
|
||||
FROM files x
|
||||
LEFT JOIN users u ON u.id = x.owner_id
|
||||
LEFT JOIN groups g ON g.id = x.group_id
|
||||
WHERE x.id = $1`, fileID).Scan(&principal, &isGroup, &ownerName, "a, &folderID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Access{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Access{}, fmt.Errorf("load file principal: %w", err)
|
||||
}
|
||||
|
||||
if isGroup {
|
||||
member, err := s.isGroupMember(ctx, userID, principal)
|
||||
if err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
if !member {
|
||||
return Access{}, ErrNoAccess
|
||||
}
|
||||
return Access{
|
||||
OwnerID: principal, IsGroup: true, OwnerQuota: quota,
|
||||
Permission: PermWrite, Shared: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if principal == userID {
|
||||
return Access{OwnerID: principal, OwnerUsername: ownerName, OwnerQuota: quota, Permission: PermWrite}, nil
|
||||
}
|
||||
ownerID, ownerQuota := principal, quota
|
||||
|
||||
// Direkte Freigabe auf der Datei.
|
||||
perm, err := s.directFilePermission(ctx, userID, fileID)
|
||||
if err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
// Geerbt aus dem Ordner darüber. Das stärkere Recht gewinnt: eine
|
||||
// Schreibfreigabe auf dem Ordner soll nicht dadurch entwertet werden, dass
|
||||
// jemand zusätzlich eine Lesefreigabe auf einer einzelnen Datei erteilt hat.
|
||||
if folderID != nil && perm != PermWrite {
|
||||
inherited, err := s.bestPermissionForFolder(ctx, userID, *folderID)
|
||||
if err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
perm = stronger(perm, inherited)
|
||||
}
|
||||
if perm == "" {
|
||||
return Access{}, ErrNoAccess
|
||||
}
|
||||
return Access{OwnerID: ownerID, OwnerUsername: ownerName, OwnerQuota: ownerQuota, Permission: perm, Shared: true}, nil
|
||||
}
|
||||
|
||||
// RequireFolderWrite / RequireFileWrite sind die bequeme Form für Handler, die
|
||||
// ändern wollen.
|
||||
func (s *Store) RequireFolderWrite(ctx context.Context, userID, folderID uuid.UUID) (Access, error) {
|
||||
a, err := s.ResolveFolder(ctx, userID, folderID)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
if !a.CanWrite() {
|
||||
return a, ErrReadOnly
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *Store) RequireFileWrite(ctx context.Context, userID, fileID uuid.UUID) (Access, error) {
|
||||
a, err := s.ResolveFile(ctx, userID, fileID)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
if !a.CanWrite() {
|
||||
return a, ErrReadOnly
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// IsGroupMember ist die öffentliche Fassung von isGroupMember – gebraucht von
|
||||
// internal/tags, um zu prüfen, ob jemand einer Gruppe ein Etikett anlegen darf.
|
||||
func (s *Store) IsGroupMember(ctx context.Context, userID, groupID uuid.UUID) (bool, error) {
|
||||
return s.isGroupMember(ctx, userID, groupID)
|
||||
}
|
||||
|
||||
func (s *Store) isGroupMember(ctx context.Context, userID, groupID uuid.UUID) (bool, error) {
|
||||
var exists bool
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM group_members WHERE user_id = $1 AND group_id = $2)`,
|
||||
userID, groupID).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check group membership: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// bestPermissionForFolder läuft vom Ordner aus nach oben und liefert das
|
||||
// stärkste Recht, das userID auf dem Weg findet – direkt oder über eine Gruppe.
|
||||
// Leerer Rückgabewert heißt: keinerlei Zugriff.
|
||||
func (s *Store) bestPermissionForFolder(ctx context.Context, userID, folderID uuid.UUID) (Permission, error) {
|
||||
// Rekursiv nach oben statt nach unten: Der Pfad zur Wurzel ist ein paar
|
||||
// Ebenen lang, der Unterbaum kann Tausende Einträge haben.
|
||||
const q = `
|
||||
WITH RECURSIVE ancestry AS (
|
||||
SELECT id, parent_id FROM folders WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT f.id, f.parent_id
|
||||
FROM folders f
|
||||
JOIN ancestry a ON f.id = a.parent_id
|
||||
)
|
||||
SELECT e.permission
|
||||
FROM acl_entries e
|
||||
JOIN ancestry a ON a.id = e.resource_folder_id
|
||||
WHERE e.subject_user_id = $2
|
||||
OR e.subject_group_id IN (
|
||||
SELECT group_id FROM group_members WHERE user_id = $2
|
||||
)`
|
||||
|
||||
rows, err := s.db.Query(ctx, q, folderID, userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve folder permission: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var best Permission
|
||||
for rows.Next() {
|
||||
var p Permission
|
||||
if err := rows.Scan(&p); err != nil {
|
||||
return "", fmt.Errorf("scan permission: %w", err)
|
||||
}
|
||||
best = stronger(best, p)
|
||||
}
|
||||
return best, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) directFilePermission(ctx context.Context, userID, fileID uuid.UUID) (Permission, error) {
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT permission FROM acl_entries
|
||||
WHERE resource_file_id = $1
|
||||
AND (subject_user_id = $2
|
||||
OR subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $2))`,
|
||||
fileID, userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve file permission: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var best Permission
|
||||
for rows.Next() {
|
||||
var p Permission
|
||||
if err := rows.Scan(&p); err != nil {
|
||||
return "", fmt.Errorf("scan permission: %w", err)
|
||||
}
|
||||
best = stronger(best, p)
|
||||
}
|
||||
return best, rows.Err()
|
||||
}
|
||||
|
||||
// stronger: Mehrere Wege zur selben Ressource (direkt und über zwei Gruppen)
|
||||
// sind normal. Das großzügigste Recht gilt – alles andere wäre für den Nutzer
|
||||
// nicht nachvollziehbar.
|
||||
func stronger(a, b Permission) Permission {
|
||||
if a == PermWrite || b == PermWrite {
|
||||
return PermWrite
|
||||
}
|
||||
if a == PermRead || b == PermRead {
|
||||
return PermRead
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package acl
|
||||
|
||||
import "testing"
|
||||
|
||||
// stronger entscheidet, welches Recht gilt, wenn jemand eine Ressource über
|
||||
// mehrere Wege erreicht – direkt und zusätzlich über zwei Gruppen etwa. Kippt
|
||||
// diese Funktion, bekommt jemand entweder zu viel oder unerwartet zu wenig.
|
||||
func TestStrongerPermissionWins(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b Permission
|
||||
want Permission
|
||||
}{
|
||||
{"nichts und nichts", "", "", ""},
|
||||
{"nur lesen", PermRead, "", PermRead},
|
||||
{"lesen und schreiben", PermRead, PermWrite, PermWrite},
|
||||
{"schreiben und lesen", PermWrite, PermRead, PermWrite},
|
||||
{"zweimal schreiben", PermWrite, PermWrite, PermWrite},
|
||||
{"zweimal lesen", PermRead, PermRead, PermRead},
|
||||
{"schreiben aus dem Nichts", "", PermWrite, PermWrite},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := stronger(tt.a, tt.b); got != tt.want {
|
||||
t.Errorf("stronger(%q, %q) = %q, want %q", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ein unbekanntes Recht darf nicht durchrutschen: Die Datenbank hat zwar einen
|
||||
// CHECK, aber die Prüfung soll vorher greifen und eine klare Fehlermeldung
|
||||
// liefern statt eines Constraint-Fehlers.
|
||||
func TestPermissionValidation(t *testing.T) {
|
||||
for _, p := range []Permission{PermRead, PermWrite} {
|
||||
if !p.valid() {
|
||||
t.Errorf("%q sollte gültig sein", p)
|
||||
}
|
||||
}
|
||||
for _, p := range []Permission{"", "admin", "owner", "READ", "write "} {
|
||||
if p.valid() {
|
||||
t.Errorf("%q sollte ungültig sein", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Access.CanWrite ist die Weiche vor jeder Änderung.
|
||||
func TestAccessCanWrite(t *testing.T) {
|
||||
if (Access{Permission: PermRead}).CanWrite() {
|
||||
t.Error("Lesezugriff darf nicht schreiben dürfen")
|
||||
}
|
||||
if !(Access{Permission: PermWrite}).CanWrite() {
|
||||
t.Error("Schreibzugriff muss schreiben dürfen")
|
||||
}
|
||||
// Der Nullwert entsteht, wenn eine Auflösung fehlschlägt – der darf
|
||||
// keinesfalls Schreibrechte bedeuten.
|
||||
if (Access{}).CanWrite() {
|
||||
t.Error("leerer Zugriff darf nicht schreiben dürfen")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package acl
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"aether/internal/audit"
|
||||
"aether/internal/auth"
|
||||
"aether/pkg/middleware"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// Handler bedient den Freigabedialog. Die Zugriffsprüfung selbst passiert im
|
||||
// files-Paket; hier geht es nur ums Erteilen, Ansehen und Widerrufen.
|
||||
//
|
||||
// Freigeben darf ausschließlich der Eigentümer. Wer selbst nur eine
|
||||
// Schreibfreigabe hat, kann sie nicht weiterreichen – sonst verlöre der
|
||||
// Eigentümer die Kontrolle darüber, wer seine Daten sieht.
|
||||
type Handler struct {
|
||||
store *Store
|
||||
auditor *audit.Logger
|
||||
}
|
||||
|
||||
func NewHandler(store *Store, auditor *audit.Logger) *Handler {
|
||||
return &Handler{store: store, auditor: auditor}
|
||||
}
|
||||
|
||||
// logShare hält fest, wer wem Zugriff auf was gegeben oder genommen hat. Bei
|
||||
// Freigaben ist das die interessanteste Spur überhaupt: Sie erklärt, wie jemand
|
||||
// an Daten kam, die ihm nicht gehören.
|
||||
func (h *Handler) logShare(r *http.Request, action, resourceType, resourceID, detail string) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
e := audit.Event{
|
||||
Action: action,
|
||||
TargetType: resourceType,
|
||||
TargetID: resourceID,
|
||||
Detail: detail,
|
||||
IP: middleware.RealIP(r),
|
||||
}
|
||||
if user != nil {
|
||||
e.ActorID = &user.ID
|
||||
e.ActorName = user.Username
|
||||
}
|
||||
h.auditor.Log(r.Context(), e)
|
||||
}
|
||||
|
||||
// ownedFolder / ownedFile liefern die Ressourcen-ID und den Anfragenden –
|
||||
// aber nur, wenn ihm die Ressource auch gehört. Alles andere ist bereits
|
||||
// beantwortet.
|
||||
func (h *Handler) ownedFolder(w http.ResponseWriter, r *http.Request) (uuid.UUID, uuid.UUID, bool) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid folder id")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
a, err := h.store.ResolveFolder(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "folder not found")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
if a.OwnerID != user.ID {
|
||||
response.Error(w, http.StatusForbidden, "only the owner can manage sharing")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
return id, user.ID, true
|
||||
}
|
||||
|
||||
func (h *Handler) ownedFile(w http.ResponseWriter, r *http.Request) (uuid.UUID, uuid.UUID, bool) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid file id")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
a, err := h.store.ResolveFile(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "file not found")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
if a.OwnerID != user.ID {
|
||||
response.Error(w, http.StatusForbidden, "only the owner can manage sharing")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
return id, user.ID, true
|
||||
}
|
||||
|
||||
func userFrom(r *http.Request) uuid.UUID {
|
||||
if u := auth.UserFromContext(r.Context()); u != nil {
|
||||
return u.ID
|
||||
}
|
||||
return uuid.Nil
|
||||
}
|
||||
|
||||
type grantRequest struct {
|
||||
SubjectID string `json:"subject_id"`
|
||||
SubjectType string `json:"subject_type"` // "user" | "group"
|
||||
Permission Permission `json:"permission"`
|
||||
}
|
||||
|
||||
// GET /api/v1/folders/{id}/acl
|
||||
func (h *Handler) ListFolderACL(w http.ResponseWriter, r *http.Request) {
|
||||
id, _, ok := h.ownedFolder(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entries, err := h.store.ListForFolder(r.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load shares")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// POST /api/v1/folders/{id}/acl
|
||||
func (h *Handler) GrantFolderACL(w http.ResponseWriter, r *http.Request) {
|
||||
id, owner, ok := h.ownedFolder(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req, ok := decodeGrant(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entry, err := h.store.GrantFolder(r.Context(), id, req.subjectID, req.isGroup, req.perm, owner)
|
||||
if err == nil {
|
||||
h.logShare(r, audit.ActionShareGrant, "folder", id.String(),
|
||||
string(req.perm)+" for "+req.subjectType()+" "+req.subjectID.String())
|
||||
}
|
||||
writeGrantResult(w, entry, err)
|
||||
}
|
||||
|
||||
// GET /api/v1/files/{id}/acl
|
||||
func (h *Handler) ListFileACL(w http.ResponseWriter, r *http.Request) {
|
||||
id, _, ok := h.ownedFile(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entries, err := h.store.ListForFile(r.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load shares")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// POST /api/v1/files/{id}/acl
|
||||
func (h *Handler) GrantFileACL(w http.ResponseWriter, r *http.Request) {
|
||||
id, owner, ok := h.ownedFile(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req, ok := decodeGrant(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entry, err := h.store.GrantFile(r.Context(), id, req.subjectID, req.isGroup, req.perm, owner)
|
||||
if err == nil {
|
||||
h.logShare(r, audit.ActionShareGrant, "file", id.String(),
|
||||
string(req.perm)+" for "+req.subjectType()+" "+req.subjectID.String())
|
||||
}
|
||||
writeGrantResult(w, entry, err)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/acl/{id}
|
||||
func (h *Handler) RevokeACL(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r)
|
||||
if user == uuid.Nil {
|
||||
response.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
entryID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := h.store.Revoke(r.Context(), entryID, user); errors.Is(err, ErrNotFound) {
|
||||
response.Error(w, http.StatusNotFound, "share not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not revoke share")
|
||||
return
|
||||
}
|
||||
h.logShare(r, audit.ActionShareRevoke, "acl", entryID.String(), "")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GET /api/v1/shared-with-me
|
||||
func (h *Handler) SharedWithMe(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r)
|
||||
if user == uuid.Nil {
|
||||
response.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
items, err := h.store.ListSharedWith(r.Context(), user)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load shared items")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
// GET /api/v1/share-recipients
|
||||
func (h *Handler) Recipients(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r)
|
||||
if user == uuid.Nil {
|
||||
response.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
list, err := h.store.Recipients(r.Context(), user)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load recipients")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
// ── Hilfen ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type parsedGrant struct {
|
||||
subjectID uuid.UUID
|
||||
isGroup bool
|
||||
perm Permission
|
||||
}
|
||||
|
||||
func (g parsedGrant) subjectType() string { return subjectTypeName(g.isGroup) }
|
||||
|
||||
func decodeGrant(w http.ResponseWriter, r *http.Request) (parsedGrant, bool) {
|
||||
var body grantRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return parsedGrant{}, false
|
||||
}
|
||||
id, err := uuid.Parse(body.SubjectID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid subject_id")
|
||||
return parsedGrant{}, false
|
||||
}
|
||||
if body.SubjectType != "user" && body.SubjectType != "group" {
|
||||
response.Error(w, http.StatusBadRequest, `subject_type must be "user" or "group"`)
|
||||
return parsedGrant{}, false
|
||||
}
|
||||
if !body.Permission.valid() {
|
||||
response.Error(w, http.StatusBadRequest, `permission must be "read" or "write"`)
|
||||
return parsedGrant{}, false
|
||||
}
|
||||
return parsedGrant{subjectID: id, isGroup: body.SubjectType == "group", perm: body.Permission}, true
|
||||
}
|
||||
|
||||
func writeGrantResult(w http.ResponseWriter, entry *Entry, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrSelfShare):
|
||||
response.Error(w, http.StatusBadRequest, "you already have access to this")
|
||||
case err != nil:
|
||||
response.Error(w, http.StatusInternalServerError, "could not share")
|
||||
default:
|
||||
response.JSON(w, http.StatusCreated, entry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package acl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
var ErrSelfShare = errors.New("cannot share with yourself")
|
||||
|
||||
// Entry ist eine Freigabe, wie sie der Eigentümer im Dialog sieht.
|
||||
type Entry struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
SubjectType string `json:"subject_type"` // "user" | "group"
|
||||
SubjectID uuid.UUID `json:"subject_id"`
|
||||
SubjectName string `json:"subject_name"`
|
||||
Permission Permission `json:"permission"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SharedResource ist ein Eintrag in „Geteilt mit mir".
|
||||
type SharedResource struct {
|
||||
Type string `json:"type"` // "folder" | "file"
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
OwnerName string `json:"owner_name"`
|
||||
Permission Permission `json:"permission"`
|
||||
Size int64 `json:"size"`
|
||||
MimeType string `json:"mime_type"`
|
||||
SharedAt time.Time `json:"shared_at"`
|
||||
// ViaGroup nennt die Gruppe, über die der Zugriff kommt – sonst wundert
|
||||
// man sich, wo die Freigabe herkommt, die man nie persönlich bekommen hat.
|
||||
ViaGroup string `json:"via_group"`
|
||||
}
|
||||
|
||||
// ── Freigaben verwalten ──────────────────────────────────────────────────────
|
||||
|
||||
// GrantFolder gibt einen Ordner frei. subjectIsGroup entscheidet, ob subjectID
|
||||
// ein Nutzer oder eine Gruppe ist.
|
||||
func (s *Store) GrantFolder(ctx context.Context, folderID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
return s.grant(ctx, &folderID, nil, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
}
|
||||
|
||||
func (s *Store) GrantFile(ctx context.Context, fileID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
return s.grant(ctx, nil, &fileID, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
}
|
||||
|
||||
func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
if !perm.valid() {
|
||||
return nil, fmt.Errorf("invalid permission %q", perm)
|
||||
}
|
||||
// Sich selbst freigeben ist sinnlos und würde in „Geteilt mit mir" die
|
||||
// eigenen Ordner doppelt anzeigen.
|
||||
if !subjectIsGroup && subjectID == grantedBy {
|
||||
return nil, ErrSelfShare
|
||||
}
|
||||
|
||||
var subjUser, subjGroup *uuid.UUID
|
||||
if subjectIsGroup {
|
||||
subjGroup = &subjectID
|
||||
} else {
|
||||
subjUser = &subjectID
|
||||
}
|
||||
|
||||
var e Entry
|
||||
err := s.db.QueryRow(ctx,
|
||||
`INSERT INTO acl_entries
|
||||
(subject_user_id, subject_group_id, resource_folder_id, resource_file_id, permission, granted_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, permission, created_at`,
|
||||
subjUser, subjGroup, folderID, fileID, perm, grantedBy,
|
||||
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
|
||||
|
||||
// Eine zweite Freigabe an denselben Empfänger ist keine neue, sondern eine
|
||||
// Änderung des Rechts – sonst müsste man erst widerrufen, um von „lesen"
|
||||
// auf „schreiben" zu wechseln. Die vier partiellen Unique-Indizes lassen
|
||||
// sich nicht in einer einzigen ON-CONFLICT-Klausel abdecken, deshalb hier
|
||||
// über den Fehlercode.
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return s.updateExisting(ctx, folderID, fileID, subjUser, subjGroup, perm)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("grant access: %w", err)
|
||||
}
|
||||
|
||||
e.SubjectID = subjectID
|
||||
e.SubjectType = subjectTypeName(subjectIsGroup)
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func (s *Store) updateExisting(ctx context.Context, folderID, fileID, subjUser, subjGroup *uuid.UUID, perm Permission) (*Entry, error) {
|
||||
var e Entry
|
||||
err := s.db.QueryRow(ctx,
|
||||
`UPDATE acl_entries SET permission = $1
|
||||
WHERE resource_folder_id IS NOT DISTINCT FROM $2
|
||||
AND resource_file_id IS NOT DISTINCT FROM $3
|
||||
AND subject_user_id IS NOT DISTINCT FROM $4
|
||||
AND subject_group_id IS NOT DISTINCT FROM $5
|
||||
RETURNING id, permission, created_at`,
|
||||
perm, folderID, fileID, subjUser, subjGroup,
|
||||
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update access: %w", err)
|
||||
}
|
||||
if subjGroup != nil {
|
||||
e.SubjectID, e.SubjectType = *subjGroup, "group"
|
||||
} else if subjUser != nil {
|
||||
e.SubjectID, e.SubjectType = *subjUser, "user"
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// Revoke entfernt eine Freigabe. ownerID stellt sicher, dass nur der
|
||||
// Eigentümer der Ressource widerrufen kann.
|
||||
func (s *Store) Revoke(ctx context.Context, entryID, ownerID uuid.UUID) error {
|
||||
tag, err := s.db.Exec(ctx,
|
||||
`DELETE FROM acl_entries e
|
||||
WHERE e.id = $1
|
||||
AND ( EXISTS (SELECT 1 FROM folders f WHERE f.id = e.resource_folder_id AND f.owner_id = $2)
|
||||
OR EXISTS (SELECT 1 FROM files x WHERE x.id = e.resource_file_id AND x.owner_id = $2) )`,
|
||||
entryID, ownerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke access: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListForFolder / ListForFile zeigen dem Eigentümer, wer aktuell Zugriff hat.
|
||||
func (s *Store) ListForFolder(ctx context.Context, folderID uuid.UUID) ([]Entry, error) {
|
||||
return s.list(ctx, `e.resource_folder_id = $1`, folderID)
|
||||
}
|
||||
|
||||
func (s *Store) ListForFile(ctx context.Context, fileID uuid.UUID) ([]Entry, error) {
|
||||
return s.list(ctx, `e.resource_file_id = $1`, fileID)
|
||||
}
|
||||
|
||||
func (s *Store) list(ctx context.Context, where string, resourceID uuid.UUID) ([]Entry, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT e.id,
|
||||
CASE WHEN e.subject_group_id IS NOT NULL THEN 'group' ELSE 'user' END,
|
||||
COALESCE(e.subject_group_id, e.subject_user_id),
|
||||
COALESCE(g.name, NULLIF(u.display_name, ''), u.username, ''),
|
||||
e.permission, e.created_at
|
||||
FROM acl_entries e
|
||||
LEFT JOIN users u ON u.id = e.subject_user_id
|
||||
LEFT JOIN groups g ON g.id = e.subject_group_id
|
||||
WHERE `+where+`
|
||||
ORDER BY e.created_at`, resourceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list access entries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Entry{}
|
||||
for rows.Next() {
|
||||
var e Entry
|
||||
if err := rows.Scan(&e.ID, &e.SubjectType, &e.SubjectID, &e.SubjectName,
|
||||
&e.Permission, &e.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan access entry: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListSharedWith liefert alles, was userID freigegeben bekommen hat – direkt
|
||||
// oder über eine Gruppe. Das ist der Inhalt von „Geteilt mit mir".
|
||||
//
|
||||
// Nur die obersten freigegebenen Knoten erscheinen: Wer einen Ordner und darin
|
||||
// eine Datei freigegeben bekommt, soll den Ordner sehen und die Datei darin,
|
||||
// nicht beides nebeneinander.
|
||||
func (s *Store) ListSharedWith(ctx context.Context, userID uuid.UUID) ([]SharedResource, error) {
|
||||
const q = `
|
||||
SELECT 'folder', f.id, f.name, f.owner_id,
|
||||
COALESCE(NULLIF(o.display_name, ''), o.username),
|
||||
e.permission, 0::bigint, '', e.created_at,
|
||||
COALESCE(g.name, '')
|
||||
FROM acl_entries e
|
||||
JOIN folders f ON f.id = e.resource_folder_id
|
||||
JOIN users o ON o.id = f.owner_id
|
||||
LEFT JOIN groups g ON g.id = e.subject_group_id
|
||||
WHERE (e.subject_user_id = $1
|
||||
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
|
||||
-- Ordner weglassen, deren Elternteil ohnehin schon freigegeben ist.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM acl_entries pe
|
||||
JOIN folders pf ON pf.id = pe.resource_folder_id
|
||||
WHERE pf.id = f.parent_id
|
||||
AND (pe.subject_user_id = $1
|
||||
OR pe.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
|
||||
)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT 'file', x.id, x.name, x.owner_id,
|
||||
COALESCE(NULLIF(o.display_name, ''), o.username),
|
||||
e.permission, x.size, x.mime_type, e.created_at,
|
||||
COALESCE(g.name, '')
|
||||
FROM acl_entries e
|
||||
JOIN files x ON x.id = e.resource_file_id
|
||||
JOIN users o ON o.id = x.owner_id
|
||||
LEFT JOIN groups g ON g.id = e.subject_group_id
|
||||
WHERE x.is_deleted = false
|
||||
AND (e.subject_user_id = $1
|
||||
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
|
||||
-- Datei weglassen, wenn ihr Ordner bereits freigegeben ist.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM acl_entries fe
|
||||
WHERE fe.resource_folder_id = x.folder_id
|
||||
AND (fe.subject_user_id = $1
|
||||
OR fe.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
|
||||
)
|
||||
|
||||
ORDER BY 1, 3`
|
||||
|
||||
rows, err := s.db.Query(ctx, q, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list shared resources: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []SharedResource{}
|
||||
for rows.Next() {
|
||||
var r SharedResource
|
||||
if err := rows.Scan(&r.Type, &r.ID, &r.Name, &r.OwnerID, &r.OwnerName,
|
||||
&r.Permission, &r.Size, &r.MimeType, &r.SharedAt, &r.ViaGroup); err != nil {
|
||||
return nil, fmt.Errorf("scan shared resource: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ── Verzeichnis für den Freigabedialog ───────────────────────────────────────
|
||||
|
||||
type Recipient struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // "user" | "group"
|
||||
}
|
||||
|
||||
// Recipients listet mögliche Empfänger. Bewusst nur ID, Anzeigename und Art –
|
||||
// Quota, Admin-Status und E-Mail gehen einen normalen Nutzer nichts an, auch
|
||||
// wenn er teilen darf.
|
||||
func (s *Store) Recipients(ctx context.Context, exclude uuid.UUID) ([]Recipient, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, COALESCE(NULLIF(display_name, ''), username), 'user'
|
||||
FROM users WHERE id <> $1
|
||||
UNION ALL
|
||||
SELECT id, name, 'group' FROM groups
|
||||
ORDER BY 3 DESC, 2`, exclude)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list recipients: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Recipient{}
|
||||
for rows.Next() {
|
||||
var r Recipient
|
||||
if err := rows.Scan(&r.ID, &r.Name, &r.Type); err != nil {
|
||||
return nil, fmt.Errorf("scan recipient: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func subjectTypeName(isGroup bool) string {
|
||||
if isGroup {
|
||||
return "group"
|
||||
}
|
||||
return "user"
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
_ "golang.org/x/image/webp" // nur der Dekodierer – x/image ist schon da
|
||||
)
|
||||
|
||||
// Aus einem einzigen hochgeladenen Bild entstehen alle Größen, die Browser und
|
||||
// Android brauchen. Zwei Bilder zu pflegen (Tab-Symbol und App-Symbol) war der
|
||||
// Grund, es zusammenzulegen.
|
||||
|
||||
const (
|
||||
// Chrome verweigert die Installation, wenn im Manifest kein Symbol von
|
||||
// mindestens dieser Kantenlänge steht.
|
||||
minIconSide = 192
|
||||
// Der maskable-Bereich, den Android garantiert nicht wegschneidet: 80 % in
|
||||
// der Mitte. Der Rest verschwindet je nach Gerät im Kreis oder Squircle.
|
||||
maskableSafeZone = 0.8
|
||||
)
|
||||
|
||||
// iconVariant beschreibt eine der erzeugten Dateien.
|
||||
type iconVariant struct {
|
||||
// name ist der Dateiname im Symbol-Verzeichnis, ohne Endung.
|
||||
name string
|
||||
side int
|
||||
// maskable legt das Motiv verkleinert auf einen farbigen Grund.
|
||||
maskable bool
|
||||
}
|
||||
|
||||
// renderAppIcon erzeugt aus der Vorlage die PNG-Dateien des Symbol-Satzes.
|
||||
// Der Schlüssel der Rückgabe ist der Dateiname inklusive Endung.
|
||||
//
|
||||
// Hochskaliert wird nie: Aus einer 256er-Vorlage ein 512er zu rechnen ergibt
|
||||
// ein matschiges Symbol, das schlechter aussieht als gar keins. Fehlt eine
|
||||
// Größe, nennt das Manifest sie einfach nicht.
|
||||
func renderAppIcon(src io.Reader, bg color.Color) (map[string][]byte, error) {
|
||||
img, _, err := image.Decode(src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode image: %w", err)
|
||||
}
|
||||
|
||||
// Nicht quadratische Vorlagen werden gerahmt, nicht beschnitten – ein
|
||||
// Zuschnitt würde ein Logo anschneiden, und das fällt erst auf, wenn das
|
||||
// Symbol schon überall klebt.
|
||||
square := squareCanvas(img)
|
||||
side := square.Bounds().Dx()
|
||||
if side < minIconSide {
|
||||
return nil, fmt.Errorf("image must be at least %dx%d pixels, got %dx%d",
|
||||
minIconSide, minIconSide, img.Bounds().Dx(), img.Bounds().Dy())
|
||||
}
|
||||
|
||||
variants := []iconVariant{
|
||||
// 64 statt 32 fürs Tab: Auf feinen Bildschirmen wird das Symbol mit
|
||||
// doppelter Pixeldichte gezeichnet.
|
||||
{name: "64", side: 64},
|
||||
{name: "192", side: 192},
|
||||
}
|
||||
if side >= 512 {
|
||||
variants = append(variants, iconVariant{name: "512", side: 512})
|
||||
}
|
||||
maskSide := 192
|
||||
if side >= 512 {
|
||||
maskSide = 512
|
||||
}
|
||||
variants = append(variants, iconVariant{name: "maskable", side: maskSide, maskable: true})
|
||||
|
||||
out := make(map[string][]byte, len(variants))
|
||||
for _, v := range variants {
|
||||
buf, err := encodeVariant(square, v, bg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render %s: %w", v.name, err)
|
||||
}
|
||||
out[v.name+".png"] = buf
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func encodeVariant(square image.Image, v iconVariant, bg color.Color) ([]byte, error) {
|
||||
dst := image.NewRGBA(image.Rect(0, 0, v.side, v.side))
|
||||
target := dst.Bounds()
|
||||
|
||||
if v.maskable {
|
||||
// Durchsichtig geht hier nicht: Android legt sonst eine weiße Kachel
|
||||
// unter das Symbol, die im dunklen App-Menü leuchtet.
|
||||
draw.Draw(dst, dst.Bounds(), image.NewUniform(bg), image.Point{}, draw.Src)
|
||||
inset := int(float64(v.side) * (1 - maskableSafeZone) / 2)
|
||||
target = image.Rect(inset, inset, v.side-inset, v.side-inset)
|
||||
}
|
||||
|
||||
// CatmullRom statt des BiLinear aus internal/thumbnails: Vorschaubilder
|
||||
// dürfen weich sein, ein Symbol soll scharfe Kanten behalten.
|
||||
draw.CatmullRom.Scale(dst, target, square, square.Bounds(), draw.Over, nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
// PNG, damit Transparenz erhalten bleibt – ein Symbol mit weißem Kasten
|
||||
// drumherum sieht auf jedem farbigen Hintergrund falsch aus.
|
||||
if err := png.Encode(&buf, dst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// squareCanvas legt das Bild mittig auf eine quadratische, durchsichtige
|
||||
// Fläche. Ist es schon quadratisch, bleibt es unverändert.
|
||||
func squareCanvas(img image.Image) image.Image {
|
||||
b := img.Bounds()
|
||||
if b.Dx() == b.Dy() {
|
||||
return img
|
||||
}
|
||||
side := b.Dx()
|
||||
if b.Dy() > side {
|
||||
side = b.Dy()
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, side, side))
|
||||
draw.Draw(dst, image.Rect(
|
||||
(side-b.Dx())/2, (side-b.Dy())/2,
|
||||
(side-b.Dx())/2+b.Dx(), (side-b.Dy())/2+b.Dy(),
|
||||
), img, b.Min, draw.Src)
|
||||
return dst
|
||||
}
|
||||
|
||||
// parseHexColor liest "#rrggbb". Bei allem anderen kommt die Ersatzfarbe
|
||||
// zurück – eine kaputte Einstellung soll keinen Upload scheitern lassen.
|
||||
func parseHexColor(s string, fallback color.RGBA) color.RGBA {
|
||||
if len(s) != 7 || s[0] != '#' {
|
||||
return fallback
|
||||
}
|
||||
var r, g, b uint8
|
||||
if _, err := fmt.Sscanf(s[1:], "%02x%02x%02x", &r, &g, &b); err != nil {
|
||||
return fallback
|
||||
}
|
||||
return color.RGBA{R: r, G: g, B: b, A: 255}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testImage(w, h int) []byte {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
img.Set(x, y, color.RGBA{R: 20, G: 180, B: 120, A: 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func decodeSize(t *testing.T, blob []byte) (int, int) {
|
||||
t.Helper()
|
||||
img, err := png.Decode(bytes.NewReader(blob))
|
||||
if err != nil {
|
||||
t.Fatalf("decode result: %v", err)
|
||||
}
|
||||
return img.Bounds().Dx(), img.Bounds().Dy()
|
||||
}
|
||||
|
||||
var testBG = color.RGBA{R: 31, G: 107, B: 92, A: 255}
|
||||
|
||||
func TestRenderAppIconSizes(t *testing.T) {
|
||||
out, err := renderAppIcon(bytes.NewReader(testImage(1000, 1000)), testBG)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
for name, want := range map[string]int{
|
||||
"64.png": 64, "192.png": 192, "512.png": 512, "maskable.png": 512,
|
||||
} {
|
||||
blob, ok := out[name]
|
||||
if !ok {
|
||||
t.Fatalf("%s missing", name)
|
||||
}
|
||||
if w, h := decodeSize(t, blob); w != want || h != want {
|
||||
t.Errorf("%s: got %dx%d, want %dx%d", name, w, h, want, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aus einer kleinen Vorlage darf kein 512er entstehen – hochskaliert sähe es
|
||||
// schlechter aus als gar keins, und das Manifest nennt dann nur, was es gibt.
|
||||
func TestRenderAppIconNeverUpscales(t *testing.T) {
|
||||
out, err := renderAppIcon(bytes.NewReader(testImage(256, 256)), testBG)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if _, ok := out["512.png"]; ok {
|
||||
t.Error("512.png was generated from a 256px source")
|
||||
}
|
||||
if w, _ := decodeSize(t, out["maskable.png"]); w != 192 {
|
||||
t.Errorf("maskable: got %d, want 192", w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAppIconRejectsSmall(t *testing.T) {
|
||||
if _, err := renderAppIcon(bytes.NewReader(testImage(100, 100)), testBG); err == nil {
|
||||
t.Fatal("expected an error for a 100px source")
|
||||
}
|
||||
}
|
||||
|
||||
// Nicht quadratische Vorlagen werden gerahmt, nicht beschnitten: Ein Zuschnitt
|
||||
// würde ein Logo anschneiden, und das fällt erst auf, wenn das Symbol klebt.
|
||||
func TestRenderAppIconFramesInsteadOfCropping(t *testing.T) {
|
||||
out, err := renderAppIcon(bytes.NewReader(testImage(400, 150)), testBG)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(out["192.png"]))
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if _, _, _, a := img.At(96, 2).RGBA(); a != 0 {
|
||||
t.Error("top edge should be transparent padding, not image content")
|
||||
}
|
||||
if _, g, _, a := img.At(96, 96).RGBA(); a == 0 || g>>8 < 150 {
|
||||
t.Error("centre should carry the motif")
|
||||
}
|
||||
}
|
||||
|
||||
// Der maskable-Grund ist die Primärfarbe: Durchsichtig füllt Android mit Weiß,
|
||||
// und Weiß leuchtet im dunklen App-Menü.
|
||||
func TestRenderAppIconMaskableHasBackground(t *testing.T) {
|
||||
out, err := renderAppIcon(bytes.NewReader(testImage(512, 512)), testBG)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(out["maskable.png"]))
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
r, g, b, a := img.At(2, 2).RGBA()
|
||||
if a>>8 != 255 || r>>8 != 31 || g>>8 != 107 || b>>8 != 92 {
|
||||
t.Errorf("corner: got %d,%d,%d,%d – want the background colour", r>>8, g>>8, b>>8, a>>8)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHexColor(t *testing.T) {
|
||||
fallback := color.RGBA{R: 1, G: 2, B: 3, A: 255}
|
||||
if got := parseHexColor("#1f6b5c", fallback); got != (color.RGBA{R: 31, G: 107, B: 92, A: 255}) {
|
||||
t.Errorf("valid colour: got %v", got)
|
||||
}
|
||||
// Eine kaputte Einstellung darf keinen Upload scheitern lassen.
|
||||
for _, bad := range []string{"", "1f6b5c", "#xyz", "#1f6b5cff"} {
|
||||
if got := parseHexColor(bad, fallback); got != fallback {
|
||||
t.Errorf("%q: got %v, want the fallback", bad, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"aether/internal/audit"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// Die Audit-Endpunkte liegen im admin-Paket, nicht in audit: `auth` schreibt
|
||||
// selbst Einträge und importiert deshalb `audit` – läge der Handler dort und
|
||||
// bräuchte `auth` für die Admin-Prüfung, wäre der Import-Zyklus fertig.
|
||||
|
||||
// GET /api/v1/admin/audit?action=&actor_id=&limit=&offset=
|
||||
func (h *Handler) ListAudit(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
|
||||
f := audit.Filter{Action: r.URL.Query().Get("action")}
|
||||
|
||||
if raw := r.URL.Query().Get("actor_id"); raw != "" {
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid actor_id")
|
||||
return
|
||||
}
|
||||
f.ActorID = &id
|
||||
}
|
||||
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||
f.Limit, _ = strconv.Atoi(raw)
|
||||
}
|
||||
if raw := r.URL.Query().Get("offset"); raw != "" {
|
||||
f.Offset, _ = strconv.Atoi(raw)
|
||||
}
|
||||
|
||||
entries, total, err := h.auditor.List(r.Context(), f)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load audit log")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"entries": entries,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/audit/actions – füttert das Filter-Dropdown.
|
||||
func (h *Handler) ListAuditActions(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
actions, err := h.auditor.Actions(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load actions")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, actions)
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/gc – Bereinigung von Hand anstoßen.
|
||||
//
|
||||
// Der GC läuft ohnehin im Hintergrund; dieser Endpunkt macht ihn beobachtbar.
|
||||
// Ein Aufräumjob, dessen Wirkung man nicht nachsehen kann, ist schwer zu
|
||||
// vertrauen – und genau darum geht es hier, weil er Dateien löscht.
|
||||
func (h *Handler) RunGC(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
if h.gc == nil {
|
||||
response.Error(w, http.StatusServiceUnavailable, "cleanup not available")
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.gc.Run(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "cleanup failed")
|
||||
return
|
||||
}
|
||||
|
||||
h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "",
|
||||
fmt.Sprintf("manual run: %d blobs scanned, %d removed, %d bytes freed",
|
||||
res.Scanned, res.Deleted, res.FreedBytes))
|
||||
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"scanned": res.Scanned,
|
||||
"orphans": res.Orphans,
|
||||
"deleted": res.Deleted,
|
||||
"freed_bytes": res.FreedBytes,
|
||||
"skipped_young": res.SkippedYoung,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"aether/internal/audit"
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// Bild-Upload für den Admin-Bereich: Logo und App-Symbol.
|
||||
//
|
||||
// Das Logo ist ein einzelnes Blob. Das App-Symbol ist ein Verzeichnis mit
|
||||
// mehreren Größen, die beim Hochladen aus einer Vorlage entstehen – ein Upload
|
||||
// deckt damit Tab-Symbol und installierte App zugleich ab.
|
||||
|
||||
const (
|
||||
logoSettingKey = "logo_storage_key"
|
||||
appIconSettingKey = "app_icon_storage_key"
|
||||
// Genug Bytes, damit http.DetectContentType sicher entscheidet.
|
||||
sniffLen = 512
|
||||
// 2 MiB – ein Logo, das größer ist, ist kein Logo. Fürs App-Symbol gilt
|
||||
// dieselbe Grenze: Die Vorlage darf großzügig sein, ausgeliefert werden
|
||||
// ohnehin nur die gerechneten Größen.
|
||||
maxBrandingBytes = 2 << 20
|
||||
humanMaxSize = "2 MB"
|
||||
humanTypes = "a PNG, JPEG, WebP or GIF image"
|
||||
)
|
||||
|
||||
// Erlaubte Bildformate. SVG fehlt bewusst: Eine SVG-Datei darf Skripte
|
||||
// enthalten, und beide Bilder werden ohne Anmeldung ausgeliefert – also genau
|
||||
// dort, wo noch niemand angemeldet ist. Ein Rasterbild kann das nicht.
|
||||
//
|
||||
// ICO fehlt ebenfalls: Go bringt keinen Dekodierer mit, und ohne Dekodieren
|
||||
// lassen sich keine Größen rechnen.
|
||||
var allowedImageTypes = map[string]string{
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
}
|
||||
|
||||
// Die Dateien, aus denen ein Symbol-Satz besteht. renderAppIcon legt sie an –
|
||||
// welche davon wirklich entstehen, hängt von der Größe der Vorlage ab.
|
||||
var iconSizes = []string{"64", "192", "512", "maskable"}
|
||||
|
||||
// ── Logo ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/v1/admin/branding/logo (multipart, Feld "file")
|
||||
func (h *Handler) UploadLogo(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
data, ext, ok := readUpload(w, r, "logo")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
suffix := randomSuffix()
|
||||
if suffix == "" {
|
||||
response.Error(w, http.StatusInternalServerError, "could not store the logo")
|
||||
return
|
||||
}
|
||||
newKey := "branding/logo-" + suffix + ext
|
||||
|
||||
settings, _ := h.store.GetAllSettings(r.Context())
|
||||
oldKey := settings[logoSettingKey]
|
||||
|
||||
if _, _, err := h.storage.Put(r.Context(), newKey, bytes.NewReader(data)); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not store the logo")
|
||||
return
|
||||
}
|
||||
if err := h.store.SetSetting(r.Context(), logoSettingKey, newKey); err != nil {
|
||||
// Ohne Eintrag zeigt niemand auf das Blob – wieder wegräumen.
|
||||
_ = h.storage.Delete(r.Context(), newKey)
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
|
||||
// Erst jetzt das alte Logo entfernen: Wäre es vorher weg und der neue
|
||||
// Eintrag scheiterte, stünde man ganz ohne da.
|
||||
if oldKey != "" && oldKey != newKey {
|
||||
if err := h.storage.Delete(r.Context(), oldKey); err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Str("key", oldKey).
|
||||
Msg("previous logo could not be removed")
|
||||
}
|
||||
}
|
||||
|
||||
h.logAdmin(r, audit.ActionSettingsUpdate, "settings", "", "", "logo uploaded")
|
||||
response.JSON(w, http.StatusOK, map[string]string{"url": LogoURL(newKey)})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/admin/branding/logo
|
||||
func (h *Handler) DeleteLogo(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
settings, _ := h.store.GetAllSettings(r.Context())
|
||||
key := settings[logoSettingKey]
|
||||
if key == "" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if err := h.store.SetSetting(r.Context(), logoSettingKey, ""); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
if err := h.storage.Delete(r.Context(), key); err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Str("key", key).Msg("logo blob not removed")
|
||||
}
|
||||
h.logAdmin(r, audit.ActionSettingsUpdate, "settings", "", "", "logo removed")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ServeLogo liefert das Logo ohne Anmeldung aus – die Anmeldeseite braucht es,
|
||||
// und dort ist noch niemand angemeldet.
|
||||
func (h *Handler) ServeLogo(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := h.store.GetAllSettings(r.Context())
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
key := settings[logoSettingKey]
|
||||
if key == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
mime := "application/octet-stream"
|
||||
for m, ext := range allowedImageTypes {
|
||||
if path.Ext(key) == ext {
|
||||
mime = m
|
||||
break
|
||||
}
|
||||
}
|
||||
h.serveBlob(w, r, key, mime)
|
||||
}
|
||||
|
||||
// ── App-Symbol ───────────────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/v1/admin/branding/icon (multipart, Feld "file")
|
||||
//
|
||||
// Aus der Vorlage entstehen alle Größen auf einmal. Ein zweites Feld für das
|
||||
// Tab-Symbol gibt es bewusst nicht – zwei Bilder zu pflegen, die dasselbe
|
||||
// zeigen sollen, laufen mit der Zeit auseinander.
|
||||
func (h *Handler) UploadAppIcon(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
data, _, ok := readUpload(w, r, "app icon")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
settings, _ := h.store.GetAllSettings(r.Context())
|
||||
// Der maskable-Grund nimmt die eingestellte Primärfarbe: Weiß leuchtet im
|
||||
// dunklen App-Menü, und durchsichtig füllt Android selbst mit Weiß.
|
||||
bg := parseHexColor(settings["primary_color"], color.RGBA{R: 0x63, G: 0x66, B: 0xf1, A: 255})
|
||||
|
||||
variants, err := renderAppIcon(bytes.NewReader(data), bg)
|
||||
if err != nil {
|
||||
// Zu klein oder unlesbar – beides ist eine Sache des Absenders, keine
|
||||
// des Servers, und die Meldung sagt welche.
|
||||
response.Error(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
suffix := randomSuffix()
|
||||
if suffix == "" {
|
||||
response.Error(w, http.StatusInternalServerError, "could not store the app icon")
|
||||
return
|
||||
}
|
||||
newDir := "branding/icon-" + suffix + "/"
|
||||
oldDir := settings[appIconSettingKey]
|
||||
|
||||
for name, blob := range variants {
|
||||
if _, _, err := h.storage.Put(r.Context(), newDir+name, bytes.NewReader(blob)); err != nil {
|
||||
// Halbe Sätze sind wertlos: was schon liegt, wieder wegräumen.
|
||||
h.removeIconDir(r, newDir)
|
||||
response.Error(w, http.StatusInternalServerError, "could not store the app icon")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.store.SetSetting(r.Context(), appIconSettingKey, newDir); err != nil {
|
||||
h.removeIconDir(r, newDir)
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
|
||||
if oldDir != "" && oldDir != newDir {
|
||||
h.removeIconDir(r, oldDir)
|
||||
}
|
||||
|
||||
h.logAdmin(r, audit.ActionSettingsUpdate, "settings", "", "",
|
||||
fmt.Sprintf("app icon uploaded (%d sizes)", len(variants)))
|
||||
response.JSON(w, http.StatusOK, map[string]string{"url": AppIconURL(newDir, "192")})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/admin/branding/icon
|
||||
func (h *Handler) DeleteAppIcon(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
settings, _ := h.store.GetAllSettings(r.Context())
|
||||
dir := settings[appIconSettingKey]
|
||||
if dir == "" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if err := h.store.SetSetting(r.Context(), appIconSettingKey, ""); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
h.removeIconDir(r, dir)
|
||||
h.logAdmin(r, audit.ActionSettingsUpdate, "settings", "", "", "app icon removed")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ServeAppIcon liefert eine Größe des Symbol-Satzes aus – ohne Anmeldung, der
|
||||
// Browser fragt das Tab-Symbol vor dem Anmelden ab.
|
||||
func (h *Handler) ServeAppIcon(w http.ResponseWriter, r *http.Request) {
|
||||
size := chi.URLParam(r, "size")
|
||||
if !validIconSize(size) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
settings, err := h.store.GetAllSettings(r.Context())
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
dir := settings[appIconSettingKey]
|
||||
if dir == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.serveBlob(w, r, dir+size+".png", "image/png")
|
||||
}
|
||||
|
||||
// removeIconDir räumt ein Symbol-Verzeichnis samt Verzeichnis selbst weg.
|
||||
// Einzelne Dateien zu löschen reichte nicht: Der leere Ordner bliebe stehen,
|
||||
// bei jedem Austausch einer mehr – und der GC sähe ihn nie, weil er nur
|
||||
// Dateien betrachtet.
|
||||
//
|
||||
// Fehler werden nur vermerkt: Ein Rest auf der Platte ist unschön, aber kein
|
||||
// Grund, dem Admin einen erfolgreichen Upload als fehlgeschlagen zu melden.
|
||||
func (h *Handler) removeIconDir(r *http.Request, dir string) {
|
||||
if dir == "" || !strings.HasPrefix(dir, "branding/icon-") {
|
||||
// Ein Präfix, das nicht danach aussieht, wird nicht angefasst –
|
||||
// DeletePrefix löscht rekursiv, da will man sich nicht vertippen.
|
||||
log.Ctx(r.Context()).Warn().Str("dir", dir).Msg("refusing to remove unexpected icon dir")
|
||||
return
|
||||
}
|
||||
if err := h.storage.DeletePrefix(r.Context(), dir); err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Str("dir", dir).Msg("app icon dir not removed")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gemeinsames ──────────────────────────────────────────────────────────────
|
||||
|
||||
// readUpload liest das hochgeladene Bild vollständig ein und prüft Größe und
|
||||
// Format. Vollständig deshalb, weil das App-Symbol ohnehin dekodiert werden
|
||||
// muss – bei höchstens 2 MiB ist das unproblematisch.
|
||||
func readUpload(w http.ResponseWriter, r *http.Request, what string) ([]byte, string, bool) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBrandingBytes+1024)
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
// MaxBytesReader schlägt schon beim Zerlegen des Formulars zu. Ohne
|
||||
// diese Unterscheidung bekäme man bei einer zu großen Datei die Meldung
|
||||
// „kein Bild gefunden" und suchte den Fehler an der falschen Stelle.
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
response.Error(w, http.StatusRequestEntityTooLarge,
|
||||
fmt.Sprintf("%s must be %s or smaller", what, humanMaxSize))
|
||||
return nil, "", false
|
||||
}
|
||||
response.Error(w, http.StatusBadRequest, "expected an image in the 'file' field")
|
||||
return nil, "", false
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxBrandingBytes+1))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "could not read the image")
|
||||
return nil, "", false
|
||||
}
|
||||
if int64(len(data)) > maxBrandingBytes {
|
||||
response.Error(w, http.StatusRequestEntityTooLarge,
|
||||
fmt.Sprintf("%s must be %s or smaller", what, humanMaxSize))
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// Den Typ aus dem Inhalt bestimmen, nicht aus dem Dateinamen oder dem
|
||||
// mitgeschickten Content-Type – beides kann der Client frei behaupten.
|
||||
head := data
|
||||
if len(head) > sniffLen {
|
||||
head = head[:sniffLen]
|
||||
}
|
||||
mime := strings.Split(http.DetectContentType(head), ";")[0]
|
||||
ext, ok := allowedImageTypes[mime]
|
||||
if !ok {
|
||||
response.Error(w, http.StatusUnsupportedMediaType,
|
||||
fmt.Sprintf("%s must be %s", what, humanTypes))
|
||||
return nil, "", false
|
||||
}
|
||||
return data, ext, true
|
||||
}
|
||||
|
||||
// serveBlob liefert ein Branding-Blob aus.
|
||||
func (h *Handler) serveBlob(w http.ResponseWriter, r *http.Request, key, mime string) {
|
||||
// Nur unterhalb von branding/ – der Schlüssel kommt zwar aus den
|
||||
// Einstellungen, aber die Prüfung kostet nichts und schließt aus, dass eine
|
||||
// verbogene Zeile eine fremde Datei ausliefert.
|
||||
if !strings.HasPrefix(key, "branding/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
rc, err := h.storage.Get(r.Context(), key)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
w.Header().Set("Content-Type", mime)
|
||||
// Kein Erraten des Typs im Browser – zusammen mit dem SVG-Verbot schließt
|
||||
// das aus, dass ein hochgeladenes Bild als etwas Ausführbares gedeutet wird.
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
// Der Pfad enthält einen Zufallsanteil, der sich bei jedem Austausch
|
||||
// ändert – deshalb darf lange zwischengespeichert werden.
|
||||
w.Header().Set("Cache-Control", "public, max-age=604800")
|
||||
_, _ = io.Copy(w, rc)
|
||||
}
|
||||
|
||||
func randomSuffix() string {
|
||||
b := make([]byte, 6)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func validIconSize(size string) bool {
|
||||
for _, s := range iconSizes {
|
||||
if s == size {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AvailableIconSizes nennt die Größen, die für den hinterlegten Symbol-Satz
|
||||
// wirklich auf der Platte liegen. Aus einer kleinen Vorlage entsteht kein
|
||||
// 512er, und das Manifest darf nichts nennen, was es nicht gibt.
|
||||
func (h *Handler) AvailableIconSizes(r *http.Request, dir string) []string {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
found := map[string]bool{}
|
||||
err := h.storage.Walk(r.Context(), dir, func(key string, _ storage.FileInfo) error {
|
||||
found[strings.TrimSuffix(path.Base(key), ".png")] = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// Reihenfolge aus iconSizes, nicht die der Platte – im Manifest sollen die
|
||||
// Größen aufsteigend stehen.
|
||||
var sizes []string
|
||||
for _, s := range iconSizes {
|
||||
if found[s] {
|
||||
sizes = append(sizes, s)
|
||||
}
|
||||
}
|
||||
return sizes
|
||||
}
|
||||
|
||||
// LogoURL baut die öffentliche Adresse des Logos.
|
||||
func LogoURL(storageKey string) string {
|
||||
if storageKey == "" {
|
||||
return ""
|
||||
}
|
||||
return "/branding/logo?v=" + version(storageKey)
|
||||
}
|
||||
|
||||
// AppIconURL baut die öffentliche Adresse einer Symbol-Größe.
|
||||
func AppIconURL(dir, size string) string {
|
||||
if dir == "" {
|
||||
return ""
|
||||
}
|
||||
return "/branding/icon/" + size + "?v=" + version(dir)
|
||||
}
|
||||
|
||||
// version ist der Zufallsanteil aus dem Speicherschlüssel. Er hängt an jeder
|
||||
// Adresse, damit ein Austausch sofort sichtbar wird – Browser halten gerade
|
||||
// Tab-Symbole sonst hartnäckig fest.
|
||||
func version(storageKey string) string {
|
||||
base := path.Base(strings.TrimSuffix(storageKey, "/"))
|
||||
return strings.TrimSuffix(base, path.Ext(base))
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"aether/internal/audit"
|
||||
"aether/internal/files"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// Gruppenordner werden ausschließlich hier verwaltet. Bewusst nicht in der
|
||||
// Dateiansicht: Ein Gruppenordner gehört keinem der Mitglieder, also soll ihn
|
||||
// auch keines von ihnen entfernen können.
|
||||
|
||||
// GET /api/v1/admin/group-folders
|
||||
func (h *Handler) ListGroupFolders(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
list, err := h.files.ListGroupFolders(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load group folders")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/group-folders
|
||||
func (h *Handler) CreateGroupFolder(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
GroupID string `json:"group_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
groupID, err := uuid.Parse(body.GroupID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid group_id")
|
||||
return
|
||||
}
|
||||
// Gleiche Namensregeln wie überall – der Name wird Teil des Ablagepfads.
|
||||
name := files.SanitizeFilename(strings.TrimSpace(body.Name))
|
||||
if name == "" {
|
||||
response.Error(w, http.StatusBadRequest, "name required")
|
||||
return
|
||||
}
|
||||
|
||||
folder, err := h.files.CreateGroupFolder(r.Context(), groupID, name)
|
||||
if errors.Is(err, files.ErrGroupFolderExists) {
|
||||
response.Error(w, http.StatusConflict, "this group already has a folder")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, files.ErrNameTaken) {
|
||||
response.Error(w, http.StatusConflict, "a folder with that name already exists")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not create group folder")
|
||||
return
|
||||
}
|
||||
|
||||
h.logAdmin(r, audit.ActionGroupFolderCreate, "folder", folder.ID.String(), folder.Name,
|
||||
"group "+groupID.String())
|
||||
response.JSON(w, http.StatusCreated, folder)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/admin/group-folders/{id}
|
||||
func (h *Handler) DeleteGroupFolder(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid folder id")
|
||||
return
|
||||
}
|
||||
|
||||
groupID, err := h.files.DeleteGroupFolderTree(r.Context(), id)
|
||||
if errors.Is(err, files.ErrNotFound) {
|
||||
response.Error(w, http.StatusNotFound, "group folder not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not delete group folder")
|
||||
return
|
||||
}
|
||||
|
||||
// Speicher erst nach dem Datenbank-Commit, und am Stück statt Blob für
|
||||
// Blob: Da eine Gruppe genau einen Ordner hat, ist `groups/<id>` exakt ihr
|
||||
// Bereich – inklusive Papierkorb und Versionen. Einzeln gelöscht blieben
|
||||
// leere Verzeichnisse zurück.
|
||||
if err := h.storage.DeletePrefix(r.Context(), "groups/"+groupID.String()); err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Str("group_id", groupID.String()).
|
||||
Msg("group folder deleted but storage remains")
|
||||
}
|
||||
|
||||
h.logAdmin(r, audit.ActionGroupFolderDelete, "folder", id.String(), "",
|
||||
"group "+groupID.String()+" and all its files")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"aether/internal/audit"
|
||||
"aether/internal/auth"
|
||||
"aether/internal/files"
|
||||
"aether/internal/gc"
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/middleware"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
store *Store
|
||||
storage storage.Storage
|
||||
auditor *audit.Logger
|
||||
gc *gc.Collector
|
||||
files *files.Store
|
||||
}
|
||||
|
||||
func NewHandler(store *Store, st storage.Storage, auditor *audit.Logger, collector *gc.Collector, fileStore *files.Store) *Handler {
|
||||
return &Handler{store: store, storage: st, auditor: auditor, gc: collector, files: fileStore}
|
||||
}
|
||||
|
||||
// logAdmin schreibt einen Audit-Eintrag für eine Admin-Aktion. Bündelt das
|
||||
// Wiederkehrende (Handelnder, IP), damit die Aufrufstellen kurz bleiben.
|
||||
func (h *Handler) logAdmin(r *http.Request, action, targetType, targetID, targetName, detail string) {
|
||||
actor := auth.UserFromContext(r.Context())
|
||||
e := audit.Event{
|
||||
Action: action,
|
||||
TargetType: targetType,
|
||||
TargetID: targetID,
|
||||
TargetName: targetName,
|
||||
Detail: detail,
|
||||
IP: middleware.RealIP(r),
|
||||
}
|
||||
if actor != nil {
|
||||
e.ActorID = &actor.ID
|
||||
e.ActorName = actor.Username
|
||||
}
|
||||
h.auditor.Log(r.Context(), e)
|
||||
}
|
||||
|
||||
// requireAdmin is called at the start of every handler — server-side check.
|
||||
func requireAdmin(r *http.Request) (*auth.User, bool) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
return user, user != nil && user.IsAdmin
|
||||
}
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/v1/admin/users
|
||||
func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
users, err := h.store.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/users
|
||||
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Password string `json:"password"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
QuotaBytes int64 `json:"quota_bytes"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Username) == "" || strings.TrimSpace(body.Password) == "" {
|
||||
response.Error(w, http.StatusBadRequest, "username and password required")
|
||||
return
|
||||
}
|
||||
if body.QuotaBytes <= 0 {
|
||||
body.QuotaBytes = 10 << 30 // 10 GB default
|
||||
}
|
||||
hash, err := auth.HashPassword(body.Password)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "hash error")
|
||||
return
|
||||
}
|
||||
user, err := h.store.CreateUser(r.Context(), strings.TrimSpace(body.Username), body.DisplayName, hash, body.IsAdmin, body.QuotaBytes)
|
||||
if errors.Is(err, ErrNameTaken) {
|
||||
response.Error(w, http.StatusConflict, "username already taken")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
h.logAdmin(r, audit.ActionUserCreate, "user", user.ID.String(), user.Username,
|
||||
fmt.Sprintf("admin=%t, quota=%d", body.IsAdmin, body.QuotaBytes))
|
||||
response.JSON(w, http.StatusCreated, user)
|
||||
}
|
||||
|
||||
// PATCH /api/v1/admin/users/{id}
|
||||
func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := requireAdmin(r)
|
||||
if !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid user id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
QuotaBytes int64 `json:"quota_bytes"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
if body.QuotaBytes <= 0 {
|
||||
body.QuotaBytes = 10 << 30
|
||||
}
|
||||
user, err := h.store.UpdateUser(r.Context(), id, body.DisplayName, body.IsAdmin, body.QuotaBytes, caller.ID)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
response.Error(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrSelfDemote) {
|
||||
response.Error(w, http.StatusBadRequest, "cannot remove your own admin status")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
h.logAdmin(r, audit.ActionUserUpdate, "user", user.ID.String(), user.Username,
|
||||
fmt.Sprintf("admin=%t, quota=%d", body.IsAdmin, body.QuotaBytes))
|
||||
response.JSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/users/{id}/reset-password
|
||||
func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid user id")
|
||||
return
|
||||
}
|
||||
|
||||
// Accept explicit password or generate a random one.
|
||||
var body struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
|
||||
newPassword := strings.TrimSpace(body.Password)
|
||||
if newPassword == "" {
|
||||
newPassword = randomPassword()
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "hash error")
|
||||
return
|
||||
}
|
||||
if err := h.store.ResetPassword(r.Context(), id, hash); errors.Is(err, ErrNotFound) {
|
||||
response.Error(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
|
||||
// Protokolliert wird, DASS zurückgesetzt wurde – niemals das Passwort selbst.
|
||||
h.logAdmin(r, audit.ActionUserPasswordReset, "user", id.String(), "", "")
|
||||
|
||||
// Return the new plain-text password to the admin (no email in this system).
|
||||
response.JSON(w, http.StatusOK, map[string]string{"new_password": newPassword})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/admin/users/{id}
|
||||
func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := requireAdmin(r)
|
||||
if !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid user id")
|
||||
return
|
||||
}
|
||||
// Den Namen vor dem Löschen holen: danach lässt sich der Speicherbereich
|
||||
// des Kontos nicht mehr ermitteln.
|
||||
victim, err := h.store.GetUser(r.Context(), id)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
response.Error(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteUser(r.Context(), id, caller.ID); errors.Is(err, ErrSelfDemote) {
|
||||
response.Error(w, http.StatusBadRequest, "cannot delete your own account")
|
||||
return
|
||||
} else if errors.Is(err, ErrNotFound) {
|
||||
response.Error(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
|
||||
// Erst die Datenbank, dann die Dateien: Schlägt das Aufräumen fehl, bleiben
|
||||
// höchstens Dateileichen zurück – umgekehrt hätte ein Konto ohne seine
|
||||
// Dateien überlebt. Die Datenbankzeilen sind per Fremdschlüssel-Kaskade weg,
|
||||
// es gibt also keinen Verweis mehr auf diese Blobs.
|
||||
if victim.Username != "" {
|
||||
if err := h.storage.DeletePrefix(r.Context(), "users/"+victim.Username); err != nil {
|
||||
log.Ctx(r.Context()).Error().Err(err).
|
||||
Str("username", victim.Username).
|
||||
Msg("user deleted but their files could not be removed")
|
||||
}
|
||||
}
|
||||
|
||||
h.logAdmin(r, audit.ActionUserDelete, "user", id.String(), victim.Username,
|
||||
"account and all files removed")
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ── Groups ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/v1/admin/groups
|
||||
func (h *Handler) ListGroups(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
groups, err := h.store.ListGroups(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, groups)
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/groups
|
||||
func (h *Handler) CreateGroup(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
|
||||
response.Error(w, http.StatusBadRequest, "name required")
|
||||
return
|
||||
}
|
||||
group, err := h.store.CreateGroup(r.Context(), strings.TrimSpace(body.Name), body.Description)
|
||||
if errors.Is(err, ErrNameTaken) {
|
||||
response.Error(w, http.StatusConflict, "group name already taken")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
h.logAdmin(r, audit.ActionGroupCreate, "group", group.ID.String(), group.Name, "")
|
||||
response.JSON(w, http.StatusCreated, group)
|
||||
}
|
||||
|
||||
// PATCH /api/v1/admin/groups/{id}
|
||||
func (h *Handler) UpdateGroup(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid group id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
|
||||
response.Error(w, http.StatusBadRequest, "name required")
|
||||
return
|
||||
}
|
||||
group, err := h.store.UpdateGroup(r.Context(), id, strings.TrimSpace(body.Name), body.Description)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
response.Error(w, http.StatusNotFound, "group not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrNameTaken) {
|
||||
response.Error(w, http.StatusConflict, "group name already taken")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
h.logAdmin(r, audit.ActionGroupUpdate, "group", group.ID.String(), group.Name, "")
|
||||
response.JSON(w, http.StatusOK, group)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/admin/groups/{id}
|
||||
func (h *Handler) DeleteGroup(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid group id")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteGroup(r.Context(), id); errors.Is(err, ErrNotFound) {
|
||||
response.Error(w, http.StatusNotFound, "group not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
h.logAdmin(r, audit.ActionGroupDelete, "group", id.String(), "", "")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/groups/{id}/members
|
||||
func (h *Handler) ListGroupMembers(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid group id")
|
||||
return
|
||||
}
|
||||
members, err := h.store.ListGroupMembers(r.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, members)
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/groups/{id}/members
|
||||
func (h *Handler) AddGroupMember(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
groupID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid group id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.UserID == uuid.Nil {
|
||||
response.Error(w, http.StatusBadRequest, "user_id required")
|
||||
return
|
||||
}
|
||||
if err := h.store.AddGroupMember(r.Context(), groupID, body.UserID); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
h.logAdmin(r, audit.ActionGroupMemberAdd, "group", groupID.String(), "",
|
||||
"user "+body.UserID.String())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/admin/groups/{id}/members/{userId}
|
||||
func (h *Handler) RemoveGroupMember(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
groupID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid group id")
|
||||
return
|
||||
}
|
||||
userID, err := uuid.Parse(chi.URLParam(r, "userId"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid user id")
|
||||
return
|
||||
}
|
||||
if err := h.store.RemoveGroupMember(r.Context(), groupID, userID); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
h.logAdmin(r, audit.ActionGroupMemberRemove, "group", groupID.String(), "",
|
||||
"user "+userID.String())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/v1/admin/settings
|
||||
func (h *Handler) GetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
// Settings are readable by all authenticated users (needed for theming).
|
||||
if auth.UserFromContext(r.Context()) == nil {
|
||||
response.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
settings, err := h.store.GetAllSettings(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
// PATCH /api/v1/admin/settings
|
||||
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
|
||||
allowed := map[string]bool{
|
||||
"cloud_name": true, "primary_color": true, "accent_color": true,
|
||||
}
|
||||
changed := []string{}
|
||||
for k, v := range body {
|
||||
if !allowed[k] {
|
||||
continue
|
||||
}
|
||||
if err := h.store.SetSetting(r.Context(), k, v); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
changed = append(changed, k+"="+v)
|
||||
}
|
||||
if len(changed) > 0 {
|
||||
sort.Strings(changed) // Map-Reihenfolge ist zufällig – sonst wäre das Protokoll unruhig.
|
||||
h.logAdmin(r, audit.ActionSettingsUpdate, "settings", "", "", strings.Join(changed, ", "))
|
||||
}
|
||||
settings, _ := h.store.GetAllSettings(r.Context())
|
||||
response.JSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func randomPassword() string {
|
||||
b := make([]byte, 12)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Das Web-App-Manifest kommt aus der Datenbank statt aus dem Build. Vorher
|
||||
// stand es fest in vite.config.ts und zeigte auf drei mitgelieferte Dateien –
|
||||
// ein hochgeladenes Symbol konnte die installierte App also nie erreichen.
|
||||
|
||||
// Die mitgelieferten Symbole aus frontend/public/, wenn keins hinterlegt ist.
|
||||
var builtinIcons = []manifestIcon{
|
||||
{Src: "/icon-192.png", Sizes: "192x192", Type: "image/png"},
|
||||
{Src: "/icon-512.png", Sizes: "512x512", Type: "image/png"},
|
||||
{Src: "/icon-maskable-512.png", Sizes: "512x512", Type: "image/png", Purpose: "maskable"},
|
||||
}
|
||||
|
||||
type manifestIcon struct {
|
||||
Src string `json:"src"`
|
||||
Sizes string `json:"sizes"`
|
||||
Type string `json:"type"`
|
||||
// Purpose bleibt bei "any" leer – das ist die Vorgabe, und ein leeres Feld
|
||||
// ist im Manifest weniger missverständlich als ein ausgeschriebenes "any".
|
||||
Purpose string `json:"purpose,omitempty"`
|
||||
}
|
||||
|
||||
// ServeManifest liefert /manifest.webmanifest. Ohne Anmeldung: Der Browser
|
||||
// holt es, bevor irgendjemand angemeldet ist.
|
||||
func (h *Handler) ServeManifest(w http.ResponseWriter, r *http.Request) {
|
||||
name := "Aether"
|
||||
themeColor := "#6366f1"
|
||||
var iconDir string
|
||||
|
||||
if s, err := h.store.GetAllSettings(r.Context()); err == nil {
|
||||
if n := s["cloud_name"]; n != "" {
|
||||
name = n
|
||||
}
|
||||
if c := s["primary_color"]; c != "" {
|
||||
themeColor = c
|
||||
}
|
||||
iconDir = s[appIconSettingKey]
|
||||
}
|
||||
|
||||
icons := builtinIcons
|
||||
if sizes := h.AvailableIconSizes(r, iconDir); len(sizes) > 0 {
|
||||
icons = nil
|
||||
for _, size := range sizes {
|
||||
// Das 64er ist das Tab-Symbol. Android will mindestens 192 und
|
||||
// wertet kleinere Einträge nur als Ballast.
|
||||
if size == "64" {
|
||||
continue
|
||||
}
|
||||
icon := manifestIcon{Src: AppIconURL(iconDir, size), Type: "image/png"}
|
||||
if size == "maskable" {
|
||||
icon.Purpose = "maskable"
|
||||
icon.Sizes = maskableSizeOf(sizes)
|
||||
} else {
|
||||
icon.Sizes = size + "x" + size
|
||||
}
|
||||
icons = append(icons, icon)
|
||||
}
|
||||
}
|
||||
|
||||
manifest := map[string]any{
|
||||
"name": name,
|
||||
"short_name": name,
|
||||
"description": "Your self-hosted cloud",
|
||||
"theme_color": themeColor,
|
||||
"background_color": "#0f172a",
|
||||
"display": "standalone",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"lang": "de",
|
||||
"icons": icons,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/manifest+json")
|
||||
// Die Symbole darin dürfen lange zwischengespeichert werden, die Liste
|
||||
// selbst nicht: Sonst bekäme der Browser einen Symbolwechsel tagelang nicht
|
||||
// mit, und genau darauf wartet die installierte App.
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
_ = json.NewEncoder(w).Encode(manifest)
|
||||
}
|
||||
|
||||
// maskableSizeOf nennt die Kantenlänge der maskable-Fassung. Sie entsteht in
|
||||
// 512, sobald die Vorlage das hergibt – sonst in 192.
|
||||
func maskableSizeOf(sizes []string) string {
|
||||
for _, s := range sizes {
|
||||
if s == "512" {
|
||||
return "512x512"
|
||||
}
|
||||
}
|
||||
return "192x192"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
QuotaBytes int64 `json:"quota_bytes"`
|
||||
UsedBytes int64 `json:"used_bytes"`
|
||||
// Groups sind die Namen der Gruppen, in denen der Nutzer Mitglied ist –
|
||||
// damit die Benutzerliste die Zugehörigkeit zeigt, ohne pro Zeile
|
||||
// nachladen zu müssen.
|
||||
Groups []string `json:"groups"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Group struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
MemberCount int `json:"member_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type GroupMember struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
}
|
||||
|
||||
type Setting struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
var ErrNameTaken = errors.New("name already taken")
|
||||
var ErrSelfDemote = errors.New("cannot remove own admin status")
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) ListUsers(ctx context.Context) ([]*User, error) {
|
||||
rows, err := s.db.Query(ctx,
|
||||
// Die Gruppen kommen als Unterabfrage, nicht als weiterer JOIN: ein JOIN
|
||||
// würde die Zeilen vervielfachen und damit die Summe der Dateigrößen
|
||||
// verfälschen.
|
||||
`SELECT u.id, u.username, u.display_name, u.is_admin, u.quota_bytes,
|
||||
COALESCE(SUM(f.size) FILTER (WHERE f.is_deleted = false), 0) AS used_bytes,
|
||||
COALESCE((SELECT array_agg(g.name ORDER BY g.name)
|
||||
FROM group_members gm
|
||||
JOIN groups g ON g.id = gm.group_id
|
||||
WHERE gm.user_id = u.id), '{}') AS groups,
|
||||
u.created_at
|
||||
FROM users u
|
||||
LEFT JOIN files f ON f.owner_id = u.id
|
||||
GROUP BY u.id
|
||||
ORDER BY u.created_at ASC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
users := make([]*User, 0)
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, &u.UsedBytes, &u.Groups, &u.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, &u)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetUser(ctx context.Context, id uuid.UUID) (*User, error) {
|
||||
var u User
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT u.id, u.username, u.display_name, u.is_admin, u.quota_bytes,
|
||||
COALESCE(SUM(f.size) FILTER (WHERE f.is_deleted = false), 0) AS used_bytes,
|
||||
u.created_at
|
||||
FROM users u
|
||||
LEFT JOIN files f ON f.owner_id = u.id
|
||||
WHERE u.id = $1
|
||||
GROUP BY u.id`,
|
||||
id,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, &u.UsedBytes, &u.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &u, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(ctx context.Context, username, displayName, passwordHash string, isAdmin bool, quotaBytes int64) (*User, error) {
|
||||
var u User
|
||||
err := s.db.QueryRow(ctx,
|
||||
`INSERT INTO users (username, display_name, password, is_admin, quota_bytes)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, username, display_name, is_admin, quota_bytes, 0, created_at`,
|
||||
username, displayName, passwordHash, isAdmin, quotaBytes,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, &u.UsedBytes, &u.CreatedAt)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrNameTaken
|
||||
}
|
||||
return nil, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUser(ctx context.Context, id uuid.UUID, displayName string, isAdmin bool, quotaBytes int64, callerID uuid.UUID) (*User, error) {
|
||||
// Prevent admin from removing their own admin flag.
|
||||
if id == callerID && !isAdmin {
|
||||
return nil, ErrSelfDemote
|
||||
}
|
||||
var u User
|
||||
err := s.db.QueryRow(ctx,
|
||||
`UPDATE users SET display_name=$2, is_admin=$3, quota_bytes=$4
|
||||
WHERE id=$1
|
||||
RETURNING id, username, display_name, is_admin, quota_bytes, created_at`,
|
||||
id, displayName, isAdmin, quotaBytes,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, &u.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &u, err
|
||||
}
|
||||
|
||||
func (s *Store) ResetPassword(ctx context.Context, id uuid.UUID, newHash string) error {
|
||||
tag, err := s.db.Exec(ctx,
|
||||
`UPDATE users SET password=$2 WHERE id=$1`, id, newHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reset password: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUser(ctx context.Context, id, callerID uuid.UUID) error {
|
||||
if id == callerID {
|
||||
return ErrSelfDemote
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `DELETE FROM users WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete user: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Groups ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) ListGroups(ctx context.Context) ([]*Group, error) {
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT g.id, g.name, g.description,
|
||||
COUNT(gm.user_id) AS member_count,
|
||||
g.created_at
|
||||
FROM groups g
|
||||
LEFT JOIN group_members gm ON gm.group_id = g.id
|
||||
GROUP BY g.id
|
||||
ORDER BY g.name`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list groups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
groups := make([]*Group, 0)
|
||||
for rows.Next() {
|
||||
var g Group
|
||||
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.MemberCount, &g.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups = append(groups, &g)
|
||||
}
|
||||
return groups, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateGroup(ctx context.Context, name, description string) (*Group, error) {
|
||||
var g Group
|
||||
err := s.db.QueryRow(ctx,
|
||||
`INSERT INTO groups (name, description)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, name, description, 0, created_at`,
|
||||
name, description,
|
||||
).Scan(&g.ID, &g.Name, &g.Description, &g.MemberCount, &g.CreatedAt)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrNameTaken
|
||||
}
|
||||
return nil, fmt.Errorf("create group: %w", err)
|
||||
}
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
// UpdateGroup ändert Name und Beschreibung einer Gruppe.
|
||||
func (s *Store) UpdateGroup(ctx context.Context, id uuid.UUID, name, description string) (*Group, error) {
|
||||
var g Group
|
||||
err := s.db.QueryRow(ctx,
|
||||
`UPDATE groups SET name=$2, description=$3
|
||||
WHERE id=$1
|
||||
RETURNING id, name, description,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = groups.id),
|
||||
created_at`,
|
||||
id, name, description,
|
||||
).Scan(&g.ID, &g.Name, &g.Description, &g.MemberCount, &g.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrNameTaken
|
||||
}
|
||||
return nil, fmt.Errorf("update group: %w", err)
|
||||
}
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteGroup(ctx context.Context, id uuid.UUID) error {
|
||||
tag, err := s.db.Exec(ctx, `DELETE FROM groups WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListGroupMembers(ctx context.Context, groupID uuid.UUID) ([]*GroupMember, error) {
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT u.id, u.username, u.display_name, gm.joined_at
|
||||
FROM group_members gm
|
||||
JOIN users u ON u.id = gm.user_id
|
||||
WHERE gm.group_id = $1
|
||||
ORDER BY u.username`,
|
||||
groupID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list members: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
members := make([]*GroupMember, 0)
|
||||
for rows.Next() {
|
||||
var m GroupMember
|
||||
if err := rows.Scan(&m.UserID, &m.Username, &m.DisplayName, &m.JoinedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members = append(members, &m)
|
||||
}
|
||||
return members, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) AddGroupMember(ctx context.Context, groupID, userID uuid.UUID) error {
|
||||
_, err := s.db.Exec(ctx,
|
||||
`INSERT INTO group_members (group_id, user_id) VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
groupID, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RemoveGroupMember(ctx context.Context, groupID, userID uuid.UUID) error {
|
||||
_, err := s.db.Exec(ctx,
|
||||
`DELETE FROM group_members WHERE group_id=$1 AND user_id=$2`,
|
||||
groupID, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) GetAllSettings(ctx context.Context) (map[string]string, error) {
|
||||
rows, err := s.db.Query(ctx, `SELECT key, value FROM settings`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[k] = v
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
|
||||
_, err := s.db.Exec(ctx,
|
||||
`INSERT INTO settings (key, value) VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=NOW()`,
|
||||
key, value,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
type sqlStater interface{ SQLState() string }
|
||||
var se sqlStater
|
||||
if errors.As(err, &se) {
|
||||
return se.SQLState() == "23505"
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// Package audit protokolliert sicherheitsrelevante Vorgänge.
|
||||
//
|
||||
// Leitgedanke: Ein Audit-Eintrag ist nur so viel wert, wie er nach einem
|
||||
// Vorfall noch aussagt. Deshalb werden Namen mitgeschrieben statt nur IDs, und
|
||||
// das Protokollieren darf die eigentliche Aktion niemals scheitern lassen –
|
||||
// ein kaputtes Log ist ärgerlich, eine deswegen abgebrochene Anmeldung wäre
|
||||
// schlimmer.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Actions – als Konstanten, damit Filter und Schreiber nicht auseinanderlaufen.
|
||||
const (
|
||||
ActionLogin = "auth.login"
|
||||
ActionLoginFailed = "auth.login_failed"
|
||||
ActionLogout = "auth.logout"
|
||||
ActionPasswordChange = "auth.password_change"
|
||||
|
||||
ActionAppPasswordCreate = "app_password.create"
|
||||
ActionAppPasswordRevoke = "app_password.revoke"
|
||||
|
||||
ActionUserCreate = "admin.user_create"
|
||||
ActionUserUpdate = "admin.user_update"
|
||||
ActionUserDelete = "admin.user_delete"
|
||||
ActionUserPasswordReset = "admin.user_password_reset"
|
||||
|
||||
ActionGroupCreate = "admin.group_create"
|
||||
ActionGroupUpdate = "admin.group_update"
|
||||
ActionGroupDelete = "admin.group_delete"
|
||||
ActionGroupMemberAdd = "admin.group_member_add"
|
||||
ActionGroupMemberRemove = "admin.group_member_remove"
|
||||
|
||||
ActionSettingsUpdate = "admin.settings_update"
|
||||
|
||||
ActionGroupFolderCreate = "admin.group_folder_create"
|
||||
ActionGroupFolderDelete = "admin.group_folder_delete"
|
||||
|
||||
ActionShareCreate = "share.create"
|
||||
ActionShareDelete = "share.delete"
|
||||
ActionShareGrant = "share.grant"
|
||||
ActionShareRevoke = "share.revoke"
|
||||
|
||||
ActionFileDelete = "file.delete"
|
||||
ActionTrashEmpty = "file.trash_empty"
|
||||
ActionHardDelete = "file.hard_delete"
|
||||
ActionFileRestore = "file.restore"
|
||||
|
||||
ActionGarbageCollect = "system.gc"
|
||||
)
|
||||
|
||||
// Entry ist ein Eintrag, wie ihn die Admin-Ansicht liest.
|
||||
type Entry struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ActorID *uuid.UUID `json:"actor_id"`
|
||||
ActorName string `json:"actor_name"`
|
||||
Action string `json:"action"`
|
||||
TargetType *string `json:"target_type"`
|
||||
TargetID *string `json:"target_id"`
|
||||
TargetName *string `json:"target_name"`
|
||||
IP *string `json:"ip"`
|
||||
Detail *string `json:"detail"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Event beschreibt einen zu protokollierenden Vorgang.
|
||||
type Event struct {
|
||||
ActorID *uuid.UUID
|
||||
ActorName string
|
||||
Action string
|
||||
TargetType string
|
||||
TargetID string
|
||||
TargetName string
|
||||
IP string
|
||||
Detail string
|
||||
}
|
||||
|
||||
type Logger struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewLogger(db *pgxpool.Pool) *Logger {
|
||||
return &Logger{db: db}
|
||||
}
|
||||
|
||||
// Log schreibt einen Eintrag. Fehler werden geloggt, nicht zurückgegeben: der
|
||||
// Aufrufer soll seine Aktion nicht abbrechen, nur weil das Protokoll klemmt.
|
||||
//
|
||||
// Bewusst mit eigenem Kontext: bei einem abgebrochenen Request (Client weg,
|
||||
// Timeout) ist der Vorgang trotzdem passiert und gehört ins Protokoll.
|
||||
func (l *Logger) Log(ctx context.Context, e Event) {
|
||||
if l == nil || l.db == nil {
|
||||
return
|
||||
}
|
||||
|
||||
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := l.db.Exec(writeCtx,
|
||||
`INSERT INTO audit_log
|
||||
(actor_id, actor_name, action, target_type, target_id, target_name, ip, detail)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
e.ActorID, e.ActorName, e.Action,
|
||||
nullIfEmpty(e.TargetType), nullIfEmpty(e.TargetID), nullIfEmpty(e.TargetName),
|
||||
nullIfEmpty(e.IP), nullIfEmpty(e.Detail),
|
||||
)
|
||||
if err != nil {
|
||||
log.Ctx(ctx).Error().Err(err).
|
||||
Str("action", e.Action).
|
||||
Str("actor", e.ActorName).
|
||||
Msg("audit: could not write entry")
|
||||
}
|
||||
}
|
||||
|
||||
// Filter für die Admin-Ansicht.
|
||||
type Filter struct {
|
||||
Action string
|
||||
ActorID *uuid.UUID
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
const (
|
||||
defaultLimit = 100
|
||||
maxLimit = 500
|
||||
)
|
||||
|
||||
// List liefert Einträge, neueste zuerst, plus die Gesamtzahl für die
|
||||
// Blätterfunktion.
|
||||
func (l *Logger) List(ctx context.Context, f Filter) ([]Entry, int, error) {
|
||||
limit := f.Limit
|
||||
if limit <= 0 {
|
||||
limit = defaultLimit
|
||||
}
|
||||
if limit > maxLimit {
|
||||
limit = maxLimit
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := l.db.QueryRow(ctx,
|
||||
`SELECT count(*) FROM audit_log
|
||||
WHERE ($1 = '' OR action = $1)
|
||||
AND ($2::uuid IS NULL OR actor_id = $2)`,
|
||||
f.Action, f.ActorID,
|
||||
).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count audit entries: %w", err)
|
||||
}
|
||||
|
||||
rows, err := l.db.Query(ctx,
|
||||
`SELECT id, actor_id, actor_name, action, target_type, target_id,
|
||||
target_name, ip, detail, created_at
|
||||
FROM audit_log
|
||||
WHERE ($1 = '' OR action = $1)
|
||||
AND ($2::uuid IS NULL OR actor_id = $2)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3 OFFSET $4`,
|
||||
f.Action, f.ActorID, limit, f.Offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("query audit entries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
entries := []Entry{}
|
||||
for rows.Next() {
|
||||
var e Entry
|
||||
if err := rows.Scan(&e.ID, &e.ActorID, &e.ActorName, &e.Action,
|
||||
&e.TargetType, &e.TargetID, &e.TargetName, &e.IP, &e.Detail, &e.CreatedAt); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan audit entry: %w", err)
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, total, rows.Err()
|
||||
}
|
||||
|
||||
// Actions listet die tatsächlich vorkommenden Aktionen – füttert das
|
||||
// Filter-Dropdown, damit dort nichts steht, was es im Protokoll gar nicht gibt.
|
||||
func (l *Logger) Actions(ctx context.Context) ([]string, error) {
|
||||
rows, err := l.db.Query(ctx, `SELECT DISTINCT action FROM audit_log ORDER BY action`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query audit actions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []string{}
|
||||
for rows.Next() {
|
||||
var a string
|
||||
if err := rows.Scan(&a); err != nil {
|
||||
return nil, fmt.Errorf("scan audit action: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func nullIfEmpty(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"aether/internal/audit"
|
||||
"aether/pkg/middleware"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// App-Passwörter ersetzen das Hauptpasswort in WebDAV-Clients. Ein verlorenes
|
||||
// Handy kostet dann ein Token, nicht das Konto – und der Widerruf trifft nur
|
||||
// dieses eine Gerät.
|
||||
|
||||
const (
|
||||
appPasswordGroups = 5
|
||||
appPasswordGroupLen = 5
|
||||
// Ohne 0/1/l/o/i – auf einem Handy-Keyboard abgetippt sind die zu leicht
|
||||
// zu verwechseln.
|
||||
appPasswordAlphabet = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||
maxAppPasswords = 20
|
||||
// Schreiblast bremsen: WebDAV authentifiziert jeden Request einzeln, ein
|
||||
// UPDATE pro Aufruf wäre reine Verschwendung.
|
||||
lastUsedResolution = 5 * time.Minute
|
||||
)
|
||||
|
||||
var ErrTooManyAppPasswords = errors.New("too many app passwords")
|
||||
|
||||
type AppPassword struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// GenerateAppPassword liefert ein Token in Fünfergruppen (aaaaa-bbbbb-…),
|
||||
// damit es sich am Handy noch abtippen lässt.
|
||||
func GenerateAppPassword() (string, error) {
|
||||
buf := make([]byte, appPasswordGroups*appPasswordGroupLen)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("read random: %w", err)
|
||||
}
|
||||
var sb strings.Builder
|
||||
for i, b := range buf {
|
||||
if i > 0 && i%appPasswordGroupLen == 0 {
|
||||
sb.WriteByte('-')
|
||||
}
|
||||
sb.WriteByte(appPasswordAlphabet[int(b)%len(appPasswordAlphabet)])
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// HashAppPassword: SHA-256 genügt, weil das Token zufällig und hochentropisch
|
||||
// ist. Argon2 wäre hier nur langsam, nicht sicherer – und liefe bei WebDAV bei
|
||||
// jedem einzelnen Request.
|
||||
func HashAppPassword(token string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// ── Store ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) ListAppPasswords(ctx context.Context, userID uuid.UUID) ([]AppPassword, error) {
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT id, name, last_used_at, created_at
|
||||
FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC`,
|
||||
userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query app passwords: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []AppPassword{}
|
||||
for rows.Next() {
|
||||
var a AppPassword
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.LastUsedAt, &a.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan app password: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateAppPassword(ctx context.Context, userID uuid.UUID, name, token string) (*AppPassword, error) {
|
||||
var count int
|
||||
if err := s.db.QueryRow(ctx,
|
||||
`SELECT count(*) FROM app_passwords WHERE user_id = $1`, userID).Scan(&count); err != nil {
|
||||
return nil, fmt.Errorf("count app passwords: %w", err)
|
||||
}
|
||||
if count >= maxAppPasswords {
|
||||
return nil, ErrTooManyAppPasswords
|
||||
}
|
||||
|
||||
var a AppPassword
|
||||
err := s.db.QueryRow(ctx,
|
||||
`INSERT INTO app_passwords (user_id, name, token_hash)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, last_used_at, created_at`,
|
||||
userID, name, HashAppPassword(token),
|
||||
).Scan(&a.ID, &a.Name, &a.LastUsedAt, &a.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert app password: %w", err)
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAppPassword(ctx context.Context, userID, id uuid.UUID) error {
|
||||
tag, err := s.db.Exec(ctx,
|
||||
`DELETE FROM app_passwords WHERE id = $1 AND user_id = $2`, id, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete app password: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyAppPassword sucht das Token über seinen Hash und gibt den zugehörigen
|
||||
// User zurück. Der Username muss passen, damit ein Token nicht für ein fremdes
|
||||
// Konto gilt.
|
||||
func (s *Store) VerifyAppPassword(ctx context.Context, username, token string) (*User, error) {
|
||||
hash := HashAppPassword(token)
|
||||
|
||||
var u User
|
||||
var storedHash string
|
||||
var pwID uuid.UUID
|
||||
var lastUsed *time.Time
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT ap.id, ap.token_hash, ap.last_used_at,
|
||||
u.id, u.username, u.display_name, u.is_admin, u.quota_bytes, u.created_at
|
||||
FROM app_passwords ap
|
||||
JOIN users u ON u.id = ap.user_id
|
||||
WHERE ap.token_hash = $1 AND u.username = $2`,
|
||||
hash, username,
|
||||
).Scan(&pwID, &storedHash, &lastUsed,
|
||||
&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, &u.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query app password: %w", err)
|
||||
}
|
||||
// Gürtel und Hosenträger – der Vergleich lief schon in der Datenbank.
|
||||
if subtle.ConstantTimeCompare([]byte(storedHash), []byte(hash)) != 1 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
if lastUsed == nil || time.Since(*lastUsed) > lastUsedResolution {
|
||||
if _, err := s.db.Exec(ctx,
|
||||
`UPDATE app_passwords SET last_used_at = now() WHERE id = $1`, pwID); err != nil {
|
||||
return nil, fmt.Errorf("touch app password: %w", err)
|
||||
}
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/v1/app-passwords
|
||||
func (h *Handler) ListAppPasswords(w http.ResponseWriter, r *http.Request) {
|
||||
user := UserFromContext(r.Context())
|
||||
list, err := h.store.ListAppPasswords(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load app passwords")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
// POST /api/v1/app-passwords
|
||||
func (h *Handler) CreateAppPassword(w http.ResponseWriter, r *http.Request) {
|
||||
user := UserFromContext(r.Context())
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
response.Error(w, http.StatusBadRequest, "name required")
|
||||
return
|
||||
}
|
||||
if len(name) > 60 {
|
||||
name = name[:60]
|
||||
}
|
||||
|
||||
token, err := GenerateAppPassword()
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not generate app password")
|
||||
return
|
||||
}
|
||||
|
||||
created, err := h.store.CreateAppPassword(r.Context(), user.ID, name, token)
|
||||
if errors.Is(err, ErrTooManyAppPasswords) {
|
||||
response.Error(w, http.StatusConflict, "app password limit reached")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not create app password")
|
||||
return
|
||||
}
|
||||
|
||||
h.auditor.Log(r.Context(), audit.Event{
|
||||
ActorID: &user.ID,
|
||||
ActorName: user.Username,
|
||||
Action: audit.ActionAppPasswordCreate,
|
||||
TargetType: "app_password",
|
||||
TargetID: created.ID.String(),
|
||||
TargetName: created.Name,
|
||||
IP: middleware.RealIP(r),
|
||||
})
|
||||
|
||||
// Einziger Moment, in dem das Klartext-Token existiert – danach steht nur
|
||||
// noch der Hash in der Datenbank.
|
||||
response.JSON(w, http.StatusCreated, struct {
|
||||
AppPassword
|
||||
Password string `json:"password"`
|
||||
}{AppPassword: *created, Password: token})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/app-passwords/{id}
|
||||
func (h *Handler) DeleteAppPassword(w http.ResponseWriter, r *http.Request) {
|
||||
user := UserFromContext(r.Context())
|
||||
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteAppPassword(r.Context(), user.ID, id); errors.Is(err, ErrNotFound) {
|
||||
response.Error(w, http.StatusNotFound, "app password not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not revoke app password")
|
||||
return
|
||||
}
|
||||
h.auditor.Log(r.Context(), audit.Event{
|
||||
ActorID: &user.ID,
|
||||
ActorName: user.Username,
|
||||
Action: audit.ActionAppPasswordRevoke,
|
||||
TargetType: "app_password",
|
||||
TargetID: id.String(),
|
||||
IP: middleware.RealIP(r),
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateAppPasswordFormat(t *testing.T) {
|
||||
token, err := GenerateAppPassword()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAppPassword: %v", err)
|
||||
}
|
||||
|
||||
groups := strings.Split(token, "-")
|
||||
if len(groups) != appPasswordGroups {
|
||||
t.Fatalf("got %d groups, want %d (%q)", len(groups), appPasswordGroups, token)
|
||||
}
|
||||
for _, g := range groups {
|
||||
if len(g) != appPasswordGroupLen {
|
||||
t.Fatalf("group %q has length %d, want %d", g, len(g), appPasswordGroupLen)
|
||||
}
|
||||
for _, c := range g {
|
||||
if !strings.ContainsRune(appPasswordAlphabet, c) {
|
||||
t.Fatalf("character %q is outside the alphabet – hard to type on a phone", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAppPasswordIsRandom(t *testing.T) {
|
||||
seen := make(map[string]bool, 100)
|
||||
for i := 0; i < 100; i++ {
|
||||
token, err := GenerateAppPassword()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAppPassword: %v", err)
|
||||
}
|
||||
if seen[token] {
|
||||
t.Fatalf("duplicate token after %d draws: %q", i, token)
|
||||
}
|
||||
seen[token] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Der Klartext darf nie gespeichert werden – gehasht wird deterministisch,
|
||||
// damit der Login ein reiner Index-Lookup bleibt.
|
||||
func TestHashAppPassword(t *testing.T) {
|
||||
const token = "abcde-fghij-klmno-pqrst-uvwxy"
|
||||
|
||||
h := HashAppPassword(token)
|
||||
if h == token || strings.Contains(h, token) {
|
||||
t.Fatal("hash leaks the plaintext token")
|
||||
}
|
||||
if len(h) != 64 {
|
||||
t.Fatalf("hash length = %d, want 64 hex chars", len(h))
|
||||
}
|
||||
if h != HashAppPassword(token) {
|
||||
t.Fatal("hash is not deterministic")
|
||||
}
|
||||
if h == HashAppPassword("abcde-fghij-klmno-pqrst-uvwxz") {
|
||||
t.Fatal("different tokens hash to the same value")
|
||||
}
|
||||
// Clients hängen beim Kopieren gern Whitespace an.
|
||||
if h != HashAppPassword(" "+token+"\n") {
|
||||
t.Fatal("surrounding whitespace changes the hash")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"aether/internal/audit"
|
||||
"aether/pkg/middleware"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
store *Store
|
||||
sessions *SessionStore
|
||||
usage UsageQuerier
|
||||
auditor *audit.Logger
|
||||
}
|
||||
|
||||
func NewHandler(store *Store, sessions *SessionStore, usage UsageQuerier, auditor *audit.Logger) *Handler {
|
||||
return &Handler{store: store, sessions: sessions, usage: usage, auditor: auditor}
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/login
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Username == "" || req.Password == "" {
|
||||
response.Error(w, http.StatusBadRequest, "username and password required")
|
||||
return
|
||||
}
|
||||
|
||||
user, hash, err := h.store.GetByUsername(r.Context(), req.Username)
|
||||
if err != nil {
|
||||
// constant-time: always hash even if user not found to prevent timing attacks
|
||||
_, _ = VerifyPassword(req.Password, "$argon2id$v=19$m=65536,t=1,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
|
||||
h.auditor.Log(r.Context(), audit.Event{
|
||||
ActorName: req.Username,
|
||||
Action: audit.ActionLoginFailed,
|
||||
IP: middleware.RealIP(r),
|
||||
Detail: "unknown account",
|
||||
})
|
||||
response.Error(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
ok, err := VerifyPassword(req.Password, hash)
|
||||
if err != nil || !ok {
|
||||
h.auditor.Log(r.Context(), audit.Event{
|
||||
ActorID: &user.ID,
|
||||
ActorName: user.Username,
|
||||
Action: audit.ActionLoginFailed,
|
||||
IP: middleware.RealIP(r),
|
||||
Detail: "wrong password",
|
||||
})
|
||||
response.Error(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.sessions.SetUser(w, r, user.ID); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "session error")
|
||||
return
|
||||
}
|
||||
|
||||
csrfToken, err := SetCSRFCookie(w, r)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "csrf error")
|
||||
return
|
||||
}
|
||||
|
||||
h.auditor.Log(r.Context(), audit.Event{
|
||||
ActorID: &user.ID,
|
||||
ActorName: user.Username,
|
||||
Action: audit.ActionLogin,
|
||||
IP: middleware.RealIP(r),
|
||||
})
|
||||
|
||||
// Denselben Wert wie /me mitgeben: Ohne ihn stand der Speicherbalken
|
||||
// direkt nach der Anmeldung auf 0 B und sprang erst beim nächsten
|
||||
// Nutzer-Abgleich auf den echten Stand.
|
||||
if used, err := h.usage.UsedBytes(r.Context(), user.ID); err == nil {
|
||||
user.UsedBytes = used
|
||||
}
|
||||
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"user": user,
|
||||
"csrf_token": csrfToken,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/logout
|
||||
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
// Die Route liegt bewusst nicht hinter RequireAuth – abmelden soll auch
|
||||
// mit abgelaufener Sitzung klappen und nie mit 401 scheitern. Dadurch
|
||||
// steht aber kein Nutzer im Kontext, und der Eintrag im Protokoll blieb
|
||||
// bisher aus. Also hier selbst aus der Sitzung nachschlagen.
|
||||
if userID, ok := h.sessions.GetUserID(r); ok {
|
||||
name := ""
|
||||
if u, err := h.store.GetByID(r.Context(), userID); err == nil {
|
||||
name = u.Username
|
||||
}
|
||||
h.auditor.Log(r.Context(), audit.Event{
|
||||
ActorID: &userID,
|
||||
ActorName: name,
|
||||
Action: audit.ActionLogout,
|
||||
IP: middleware.RealIP(r),
|
||||
})
|
||||
}
|
||||
_ = h.sessions.Clear(w, r)
|
||||
// clear CSRF cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
})
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// PATCH /api/v1/auth/profile – update own display_name
|
||||
// isHexColor lässt leere Werte zu – leer bedeutet "globale Farbe verwenden".
|
||||
func isHexColor(v string) bool {
|
||||
if v == "" {
|
||||
return true
|
||||
}
|
||||
if len(v) != 7 || v[0] != '#' {
|
||||
return false
|
||||
}
|
||||
for _, c := range v[1:] {
|
||||
if !strings.ContainsRune("0123456789abcdefABCDEF", c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
user := UserFromContext(r.Context())
|
||||
// Zeiger, damit sich "Feld nicht mitgeschickt" von "auf leer setzen"
|
||||
// unterscheiden lässt: Der Profilname und die Farben werden getrennt
|
||||
// gespeichert, sollen sich aber nicht gegenseitig überschreiben.
|
||||
var body struct {
|
||||
DisplayName *string `json:"display_name"`
|
||||
PrimaryColor *string `json:"primary_color"`
|
||||
AccentColor *string `json:"accent_color"`
|
||||
UseCustomTheme *bool `json:"use_custom_theme"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
|
||||
if body.DisplayName != nil {
|
||||
name := strings.TrimSpace(*body.DisplayName)
|
||||
if err := h.store.UpdateDisplayName(r.Context(), user.ID, name); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
user.DisplayName = name
|
||||
}
|
||||
|
||||
if body.PrimaryColor != nil || body.AccentColor != nil {
|
||||
primary, accent := user.PrimaryColor, user.AccentColor
|
||||
if body.PrimaryColor != nil {
|
||||
primary = strings.TrimSpace(*body.PrimaryColor)
|
||||
}
|
||||
if body.AccentColor != nil {
|
||||
accent = strings.TrimSpace(*body.AccentColor)
|
||||
}
|
||||
if !isHexColor(primary) || !isHexColor(accent) {
|
||||
response.Error(w, http.StatusBadRequest, "colors must be hex values like #4f46e5")
|
||||
return
|
||||
}
|
||||
if err := h.store.UpdateColors(r.Context(), user.ID, primary, accent); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
user.PrimaryColor, user.AccentColor = primary, accent
|
||||
}
|
||||
|
||||
if body.UseCustomTheme != nil {
|
||||
if err := h.store.SetUseCustomTheme(r.Context(), user.ID, *body.UseCustomTheme); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
user.UseCustomTheme = *body.UseCustomTheme
|
||||
}
|
||||
|
||||
response.JSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/change-password
|
||||
func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
user := UserFromContext(r.Context())
|
||||
var body struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.CurrentPassword == "" || body.NewPassword == "" {
|
||||
response.Error(w, http.StatusBadRequest, "current_password and new_password required")
|
||||
return
|
||||
}
|
||||
// Konten, die über den Identitätsanbieter kommen, haben hier kein Passwort.
|
||||
// Der Aufruf scheitert ohnehin – '' ist kein gültiger Argon2-Hash –, aber
|
||||
// mit „current password incorrect" statt mit dem wahren Grund. Diese
|
||||
// Prüfung sagt, was los ist; die Oberfläche blendet das Formular
|
||||
// zusätzlich aus.
|
||||
if user.IsOIDC {
|
||||
response.Error(w, http.StatusBadRequest,
|
||||
"this account signs in through your identity provider — change the password there")
|
||||
return
|
||||
}
|
||||
|
||||
_, hash, err := h.store.GetByUsername(r.Context(), user.Username)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
ok, err := VerifyPassword(body.CurrentPassword, hash)
|
||||
if err != nil || !ok {
|
||||
response.Error(w, http.StatusUnauthorized, "current password incorrect")
|
||||
return
|
||||
}
|
||||
newHash, err := HashPassword(body.NewPassword)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "hash error")
|
||||
return
|
||||
}
|
||||
if err := h.store.UpdatePassword(r.Context(), user.ID, newHash); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
h.auditor.Log(r.Context(), audit.Event{
|
||||
ActorID: &user.ID,
|
||||
ActorName: user.Username,
|
||||
Action: audit.ActionPasswordChange,
|
||||
IP: middleware.RealIP(r),
|
||||
})
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// GET /api/v1/auth/me
|
||||
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
user := UserFromContext(r.Context())
|
||||
if used, err := h.usage.UsedBytes(r.Context(), user.ID); err == nil {
|
||||
user.UsedBytes = used
|
||||
}
|
||||
// Refresh CSRF token on each /me call so frontend always has a valid one.
|
||||
csrfToken, err := SetCSRFCookie(w, r)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "csrf error")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"user": user,
|
||||
"csrf_token": csrfToken,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
type contextKey int
|
||||
|
||||
const keyUser contextKey = iota
|
||||
|
||||
// RequireAuth loads the session user and injects it into context.
|
||||
// Returns 401 if not authenticated.
|
||||
func RequireAuth(ss *SessionStore, store *Store) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := ss.GetUserID(r)
|
||||
if !ok {
|
||||
response.Error(w, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
user, err := store.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), keyUser, user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin additionally checks that the user is an admin.
|
||||
func RequireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user := UserFromContext(r.Context())
|
||||
if user == nil || !user.IsAdmin {
|
||||
response.Error(w, http.StatusForbidden, "admin access required")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// CSRF validates the double-submit CSRF token for state-changing requests.
|
||||
func CSRF(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if !ValidateCSRF(r) {
|
||||
response.Error(w, http.StatusForbidden, "invalid CSRF token")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// UserFromContext retrieves the authenticated user from context (may be nil).
|
||||
func UserFromContext(ctx context.Context) *User {
|
||||
u, _ := ctx.Value(keyUser).(*User)
|
||||
return u
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
QuotaBytes int64 `json:"quota_bytes"`
|
||||
UsedBytes int64 `json:"used_bytes"`
|
||||
// Persönliche Palette. Sie bleibt gespeichert, auch wenn UseCustomTheme
|
||||
// aus ist – so kann man jederzeit umschalten, ohne sie neu zu wählen.
|
||||
PrimaryColor string `json:"primary_color"`
|
||||
AccentColor string `json:"accent_color"`
|
||||
UseCustomTheme bool `json:"use_custom_theme"`
|
||||
// IsOIDC heißt: Dieses Konto meldet sich über den Identitätsanbieter an
|
||||
// und hat hier gar kein Passwort (`password` steht auf ''). Die Oberfläche
|
||||
// blendet die Passwortänderung dann aus – ein Formular, das nur
|
||||
// fehlschlagen kann, ist schlimmer als keines.
|
||||
//
|
||||
// Nicht sicherheitsrelevant, sondern eine Auskunft: Anmeldung und
|
||||
// Passwortänderung scheitern für solche Konten ohnehin schon daran, dass
|
||||
// '' kein gültiger Argon2-Hash ist.
|
||||
IsOIDC bool `json:"is_oidc"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// UsageQuerier is satisfied by files.Store – keeps auth ↔ files decoupled.
|
||||
type UsageQuerier interface {
|
||||
UsedBytes(ctx context.Context, userID uuid.UUID) (int64, error)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Prüft den Fall, der beim Einschalten von OIDC über eine bestehende
|
||||
// Installation entscheidet: Wird das vorhandene Konto verbunden – oder entsteht
|
||||
// daneben ein zweites, leeres?
|
||||
//
|
||||
// Läuft nur mit TEST_DSN gegen eine echte Datenbank; ohne wird übersprungen,
|
||||
// damit `go test ./...` überall grün bleibt.
|
||||
func TestGetOrCreateByOIDC_Linking(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DSN nicht gesetzt")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("verbinden: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
s := NewStore(pool)
|
||||
|
||||
// Aufräumen, auch wenn der Test scheitert.
|
||||
cleanup := func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE username LIKE 'linkprobe%'`)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
hash, _ := HashPassword("irrelevant-aber-gueltig")
|
||||
if _, err := pool.Exec(ctx,
|
||||
`INSERT INTO users (username, display_name, password, is_admin)
|
||||
VALUES ('linkprobe', 'Link Probe', $1, true)`, hash); err != nil {
|
||||
t.Fatalf("konto anlegen: %v", err)
|
||||
}
|
||||
|
||||
t.Run("ohne Verknüpfung entsteht ein zweites Konto", func(t *testing.T) {
|
||||
u, err := s.GetOrCreateByOIDC(ctx, "sub-A", "linkprobe", "Link Probe", false)
|
||||
if err != nil {
|
||||
t.Fatalf("aufruf: %v", err)
|
||||
}
|
||||
if u.Username == "linkprobe" {
|
||||
t.Fatal("erwartet: ein neues Konto mit abgewandeltem Namen")
|
||||
}
|
||||
if u.IsAdmin {
|
||||
t.Error("ein neu angelegtes Konto darf keine Adminrechte erben")
|
||||
}
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE oidc_sub = 'sub-A'`)
|
||||
})
|
||||
|
||||
t.Run("mit Verknüpfung bleibt das bestehende Konto", func(t *testing.T) {
|
||||
u, err := s.GetOrCreateByOIDC(ctx, "sub-B", "linkprobe", "Link Probe", true)
|
||||
if err != nil {
|
||||
t.Fatalf("aufruf: %v", err)
|
||||
}
|
||||
if u.Username != "linkprobe" {
|
||||
t.Fatalf("erwartet linkprobe, bekam %q", u.Username)
|
||||
}
|
||||
if !u.IsAdmin {
|
||||
t.Error("Adminrecht des bestehenden Kontos ging verloren")
|
||||
}
|
||||
if !u.IsOIDC {
|
||||
t.Error("Konto sollte jetzt als OIDC-Konto gelten")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("erneutes Anmelden findet dasselbe Konto", func(t *testing.T) {
|
||||
u, err := s.GetOrCreateByOIDC(ctx, "sub-B", "linkprobe", "Link Probe", true)
|
||||
if err != nil || u.Username != "linkprobe" {
|
||||
t.Fatalf("wiederholte Anmeldung: %v / %+v", err, u)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fremde Identität übernimmt ein gebundenes Konto nicht", func(t *testing.T) {
|
||||
u, err := s.GetOrCreateByOIDC(ctx, "sub-FREMD", "linkprobe", "Angreifer", true)
|
||||
if err != nil {
|
||||
t.Fatalf("aufruf: %v", err)
|
||||
}
|
||||
if u.Username == "linkprobe" {
|
||||
t.Fatal("ein bereits gebundenes Konto wurde übernommen – das darf nie passieren")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
argonTime = 1
|
||||
argonMemory = 64 * 1024
|
||||
argonThreads = 4
|
||||
argonKeyLen = 32
|
||||
saltLen = 16
|
||||
)
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, saltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("generate salt: %w", err)
|
||||
}
|
||||
hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||
argonMemory, argonTime, argonThreads,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(hash),
|
||||
), nil
|
||||
}
|
||||
|
||||
func VerifyPassword(password, encoded string) (bool, error) {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false, fmt.Errorf("invalid hash format")
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("decode salt: %w", err)
|
||||
}
|
||||
expected, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("decode hash: %w", err)
|
||||
}
|
||||
check := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, uint32(len(expected)))
|
||||
return subtle.ConstantTimeCompare(expected, check) == 1, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashAndVerify(t *testing.T) {
|
||||
hash, err := HashPassword("correct-horse-battery-staple")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
if hash == "" {
|
||||
t.Fatal("expected non-empty hash")
|
||||
}
|
||||
|
||||
ok, err := VerifyPassword("correct-horse-battery-staple", hash)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("expected password to match")
|
||||
}
|
||||
|
||||
ok, err = VerifyPassword("wrong-password", hash)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword wrong: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("expected wrong password to not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashUniqueness(t *testing.T) {
|
||||
h1, _ := HashPassword("same-password")
|
||||
h2, _ := HashPassword("same-password")
|
||||
if h1 == h2 {
|
||||
t.Error("two hashes of the same password should differ (different salts)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/sessions"
|
||||
|
||||
"aether/pkg/middleware"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionName = "aether_session"
|
||||
sessionKeyUser = "user_id"
|
||||
csrfCookieName = "aether_csrf"
|
||||
csrfHeaderName = "X-CSRF-Token"
|
||||
)
|
||||
|
||||
type SessionStore struct {
|
||||
store *sessions.CookieStore
|
||||
maxAge int
|
||||
}
|
||||
|
||||
func NewSessionStore(secret string, maxAge int) *SessionStore {
|
||||
store := sessions.NewCookieStore([]byte(secret))
|
||||
store.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
return &SessionStore{store: store, maxAge: maxAge}
|
||||
}
|
||||
|
||||
func (ss *SessionStore) SetUser(w http.ResponseWriter, r *http.Request, userID uuid.UUID) error {
|
||||
sess, err := ss.store.Get(r, sessionName)
|
||||
if err != nil {
|
||||
sess, _ = ss.store.New(r, sessionName)
|
||||
}
|
||||
sess.Values[sessionKeyUser] = userID.String()
|
||||
sess.Options.Secure = middleware.IsHTTPS(r)
|
||||
return ss.store.Save(r, w, sess)
|
||||
}
|
||||
|
||||
func (ss *SessionStore) GetUserID(r *http.Request) (uuid.UUID, bool) {
|
||||
sess, err := ss.store.Get(r, sessionName)
|
||||
if err != nil {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
raw, ok := sess.Values[sessionKeyUser].(string)
|
||||
if !ok {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func (ss *SessionStore) Clear(w http.ResponseWriter, r *http.Request) error {
|
||||
sess, err := ss.store.Get(r, sessionName)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
sess.Options.MaxAge = -1
|
||||
return ss.store.Save(r, w, sess)
|
||||
}
|
||||
|
||||
// SetCSRFCookie sets a CSRF token as a readable (non-httpOnly) cookie.
|
||||
func SetCSRFCookie(w http.ResponseWriter, r *http.Request) (string, error) {
|
||||
token := make([]byte, 32)
|
||||
if _, err := rand.Read(token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString(token)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: encoded,
|
||||
Path: "/",
|
||||
HttpOnly: false, // must be readable by JS
|
||||
Secure: middleware.IsHTTPS(r),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
// ValidateCSRF prüft, dass das mitgeschickte Token zum CSRF-Cookie passt
|
||||
// (Double-Submit).
|
||||
//
|
||||
// Neben dem Header wird auch ein Formularfeld akzeptiert. Grund: Ein Download
|
||||
// muss aus einer **Navigation** kommen, damit der Browser ihn als Datei
|
||||
// behandelt statt ihn in den Speicher zu laden – und ein abgeschicktes
|
||||
// Formular kann keine Header setzen. Am Schutz ändert das nichts: Verglichen
|
||||
// wird weiterhin gegen das Cookie, an das eine fremde Seite nicht herankommt.
|
||||
func ValidateCSRF(r *http.Request) bool {
|
||||
cookie, err := r.Cookie(csrfCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return false
|
||||
}
|
||||
if token := r.Header.Get(csrfHeaderName); token != "" {
|
||||
return token == cookie.Value
|
||||
}
|
||||
// Nur bei Formularen nachsehen: Bei anderen Inhaltstypen würde ParseForm
|
||||
// den Rumpf lesen, den der eigentliche Handler danach noch braucht.
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(ct, "application/x-www-form-urlencoded") {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return false
|
||||
}
|
||||
token := r.PostFormValue("csrf_token")
|
||||
return token != "" && token == cookie.Value
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// userCols ist die Spaltenliste jeder Nutzerabfrage – einmal, weil vier
|
||||
// Abfragen mit derselben Reihenfolge scannen und eine übersehene Ergänzung
|
||||
// sonst erst zur Laufzeit auffällt.
|
||||
const userCols = `id, username, display_name, is_admin, quota_bytes,
|
||||
COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme,
|
||||
oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at`
|
||||
|
||||
var ErrNotFound = errors.New("user not found")
|
||||
var ErrUsernameTaken = errors.New("username already taken")
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
func (s *Store) GetByUsername(ctx context.Context, username string) (*User, string, error) {
|
||||
var u User
|
||||
var passwordHash string
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, username, display_name, password, is_admin, quota_bytes, created_at
|
||||
FROM users WHERE username = $1`,
|
||||
username,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &passwordHash, &u.IsAdmin, &u.QuotaBytes, &u.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("query user: %w", err)
|
||||
}
|
||||
return &u, passwordHash, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (*User, error) {
|
||||
var u User
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, username, display_name, is_admin, quota_bytes,
|
||||
COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at
|
||||
FROM users WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
|
||||
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query user: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (s *Store) Create(ctx context.Context, username, passwordHash string, isAdmin bool) (*User, error) {
|
||||
var u User
|
||||
err := s.db.QueryRow(ctx,
|
||||
`INSERT INTO users (username, password, is_admin)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, username, display_name, is_admin, quota_bytes,
|
||||
COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at`,
|
||||
username, passwordHash, isAdmin,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
|
||||
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrUsernameTaken
|
||||
}
|
||||
return nil, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// GetOrCreateByOIDC findet den Nutzer über sein OIDC-Subject – oder legt ihn an.
|
||||
//
|
||||
// linkByUsername entscheidet über den heiklen Fall: Es gibt bereits ein Konto
|
||||
// mit diesem Benutzernamen, aber ohne Bindung an eine OIDC-Identität.
|
||||
//
|
||||
// - **aus (Voreinstellung):** Es entsteht ein *zweites* Konto. Da der Name
|
||||
// schon vergeben ist, heißt es „name2" – leer, ohne Adminrechte, ohne die
|
||||
// bisherigen Dateien. Wer seine Cloud bisher mit Passwort benutzt hat und
|
||||
// dann OIDC einschaltet, landet also in einer fremden, leeren Cloud.
|
||||
// - **an:** Das bestehende Konto wird mit der Identität verbunden. Dateien,
|
||||
// Adminrecht und Freigaben bleiben, künftig meldet man sich darüber an.
|
||||
//
|
||||
// **Warum das nicht die Voreinstellung ist:** Verbinden heißt, dem
|
||||
// Identitätsanbieter zu glauben, dass „marc" bei ihm derselbe „marc" ist wie
|
||||
// hier. Bei einem eigenen Anbieter, dessen Konten man selbst anlegt, stimmt
|
||||
// das. Bei einem Anbieter, bei dem sich Fremde registrieren und ihren
|
||||
// Benutzernamen frei wählen können, wäre es die Übernahme fremder Konten.
|
||||
// Deshalb eine bewusste Entscheidung in der .env statt einer stillen Annahme.
|
||||
//
|
||||
// Verbunden wird nur, was **noch keine** Bindung hat: Ein Konto, das bereits zu
|
||||
// einer anderen Identität gehört, wird nie überschrieben.
|
||||
func (s *Store) GetOrCreateByOIDC(ctx context.Context, sub, username, displayName string, linkByUsername bool) (*User, error) {
|
||||
// First: try to find by oidc_sub.
|
||||
var u User
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, username, display_name, is_admin, quota_bytes,
|
||||
COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at
|
||||
FROM users WHERE oidc_sub=$1`,
|
||||
sub,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
|
||||
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt)
|
||||
if err == nil {
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// Bestehendes Konto übernehmen, falls gewünscht und noch ungebunden.
|
||||
if linkByUsername {
|
||||
err = s.db.QueryRow(ctx,
|
||||
`UPDATE users SET oidc_sub = $1
|
||||
WHERE username = $2 AND (oidc_sub IS NULL OR oidc_sub = '')
|
||||
RETURNING `+userCols,
|
||||
sub, username,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
|
||||
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt)
|
||||
if err == nil {
|
||||
return &u, nil
|
||||
}
|
||||
// Kein Treffer heißt: kein solches Konto oder schon anderweitig
|
||||
// gebunden. Beides führt weiter unten zum Anlegen eines neuen.
|
||||
}
|
||||
|
||||
// Not found: create a new user. Ensure username uniqueness.
|
||||
candidate := username
|
||||
for i := 2; i <= 99; i++ {
|
||||
err = s.db.QueryRow(ctx,
|
||||
`INSERT INTO users (username, display_name, password, oidc_sub)
|
||||
VALUES ($1, $2, '', $3)
|
||||
RETURNING id, username, display_name, is_admin, quota_bytes,
|
||||
COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at`,
|
||||
candidate, displayName, sub,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
|
||||
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt)
|
||||
if err == nil {
|
||||
return &u, nil
|
||||
}
|
||||
if isUniqueViolation(err) {
|
||||
candidate = fmt.Sprintf("%s%d", username, i)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("create oidc user: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("could not find unique username for %q", username)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateDisplayName(ctx context.Context, id uuid.UUID, displayName string) error {
|
||||
_, err := s.db.Exec(ctx, `UPDATE users SET display_name=$2 WHERE id=$1`, id, displayName)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetUseCustomTheme legt um, ob die persönliche Palette angewendet wird.
|
||||
// Die gespeicherten Farben bleiben dabei unangetastet.
|
||||
func (s *Store) SetUseCustomTheme(ctx context.Context, id uuid.UUID, enabled bool) error {
|
||||
_, err := s.db.Exec(ctx, `UPDATE users SET use_custom_theme=$2 WHERE id=$1`, id, enabled)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateColors setzt die persönliche Palette. Leere Werte werden als NULL
|
||||
// gespeichert – dann gilt wieder die globale Einstellung des Administrators.
|
||||
func (s *Store) UpdateColors(ctx context.Context, id uuid.UUID, primary, accent string) error {
|
||||
var p, a *string
|
||||
if primary != "" {
|
||||
p = &primary
|
||||
}
|
||||
if accent != "" {
|
||||
a = &accent
|
||||
}
|
||||
_, err := s.db.Exec(ctx,
|
||||
`UPDATE users SET primary_color=$2, accent_color=$3 WHERE id=$1`, id, p, a)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdatePassword(ctx context.Context, id uuid.UUID, newHash string) error {
|
||||
_, err := s.db.Exec(ctx, `UPDATE users SET password=$2 WHERE id=$1`, id, newHash)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) AdminExists(ctx context.Context) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE is_admin = true`).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
return err != nil && (containsCode(err, "23505"))
|
||||
}
|
||||
|
||||
func containsCode(err error, code string) bool {
|
||||
return err != nil && len(err.Error()) > 0 &&
|
||||
(func() bool {
|
||||
type pgErr interface{ SQLState() string }
|
||||
var pe pgErr
|
||||
if errors.As(err, &pe) {
|
||||
return pe.SQLState() == code
|
||||
}
|
||||
return false
|
||||
}())
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package documents
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// minFreeBytes ist die Untergrenze, unter der die Texterkennung pausiert.
|
||||
//
|
||||
// OCR ist das einzige Stück dieser Anwendung, das von sich aus und ohne
|
||||
// Zutun eines Menschen Platz verbraucht: Es arbeitet eine Warteschlange ab,
|
||||
// solange etwas darin liegt. Eine Cloud, die ihre eigene Platte vollschreibt,
|
||||
// nimmt Uploads nicht mehr an – und das wäre dann kein OCR-Problem mehr,
|
||||
// sondern ein Ausfall.
|
||||
//
|
||||
// 2 GiB ist mit Absicht großzügig: Der Sidecar einer Erkennung ist kaum größer
|
||||
// als ein Megabyte, tesseract und ghostscript brauchen währenddessen aber
|
||||
// Arbeitsdateien, deren Größe von der Auflösung der Vorlage abhängt. Lieber
|
||||
// eine Weile nichts erkennen als eine volle Platte.
|
||||
const minFreeBytes = 2 << 30 // 2 GiB
|
||||
|
||||
// freeBytes meldet den freien Platz auf dem Dateisystem, auf dem path liegt.
|
||||
func freeBytes(path string) (uint64, error) {
|
||||
var st syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &st); err != nil {
|
||||
return 0, fmt.Errorf("statfs %s: %w", path, err)
|
||||
}
|
||||
// Bavail, nicht Bfree: Bfree zählt auch die Reserve mit, die nur root
|
||||
// benutzen darf – der Server läuft aber als unprivilegierter Nutzer und
|
||||
// käme daran nie heran.
|
||||
return st.Bavail * uint64(st.Bsize), nil
|
||||
}
|
||||
|
||||
// sweepTemp räumt Zwischendateien früherer Läufe weg.
|
||||
//
|
||||
// Im Normalfall räumt der Arbeiter selbst auf. Wird der Container aber hart
|
||||
// beendet – `docker kill`, ein Absturz, Stromausfall –, läuft kein defer mehr,
|
||||
// und die angefangene Datei bleibt liegen. Ohne diesen Durchgang sammelt sich
|
||||
// das über Monate an, ausgerechnet auf der Platte, die auch die Dateien trägt.
|
||||
//
|
||||
// Es wird nur weggeräumt, was diese Anwendung selbst angelegt hat: das Präfix
|
||||
// "ocr-" im Zwischenverzeichnis. Fremde Dateien bleiben unangetastet.
|
||||
func sweepTemp(dir string) (int, int64) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
var count int
|
||||
var bytes int64
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasPrefix(e.Name(), "ocr-") {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(dir, e.Name())
|
||||
if info, err := e.Info(); err == nil {
|
||||
bytes += info.Size()
|
||||
}
|
||||
if err := os.Remove(full); err == nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, bytes
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
// Package documents macht den Inhalt von Dokumenten durchsuchbar.
|
||||
//
|
||||
// Die Namenssuche (internal/files/search.go) findet "Rechnung_Stadtwerke.pdf".
|
||||
// Ein Scanner nennt seine Ablage aber "scan_0042.pdf" – der Name sagt dann
|
||||
// nichts, und der Beleg ist praktisch verloren. Dieses Paket holt den Text aus
|
||||
// der Datei und legt ihn nach file_text, wo die Suche ihn findet.
|
||||
package documents
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Herkunft des Textes. Der Unterschied zählt bei der Fehlersuche: Findet die
|
||||
// Suche ein Wort nicht, sagt die Herkunft, ob OCR es verlesen haben kann.
|
||||
const (
|
||||
SourceLayer = "layer" // aus der Textebene des PDFs – verlustfrei
|
||||
SourceOCR = "ocr" // aus dem Bild erkannt – kann Fehler enthalten
|
||||
)
|
||||
|
||||
// minCharsPerPage entscheidet, ob ein PDF schon Text mitbringt.
|
||||
//
|
||||
// Nicht "> 0 Zeichen": Fast jeder Scan trägt eine Kopfzeile, eine Seitenzahl
|
||||
// oder ein Wasserzeichen als echten Text – oft nur zehn Zeichen. Ohne diese
|
||||
// Schwelle gälte er als lesbar, und die eigentliche Rechnung bliebe
|
||||
// unauffindbar. Eine getippte Seite hat mehrere tausend Zeichen; 120 trennt die
|
||||
// beiden Fälle mit reichlich Abstand in beide Richtungen.
|
||||
const minCharsPerPage = 120
|
||||
|
||||
// maxTextBytes deckelt, was in die Datenbank geht. Ein 400-seitiges Handbuch
|
||||
// erzeugt sonst mehrere Megabyte Text pro Datei, und der GIN-Index wächst mit.
|
||||
// Für einen Beleg ist das weit jenseits von allem; abgeschnitten wird am Ende,
|
||||
// wo der Inhalt ohnehin am wenigsten aussagt.
|
||||
const maxTextBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// Extractor holt Text aus einer Datei. Er ruft externe Programme auf –
|
||||
// pdftotext, ocrmypdf, tesseract –, die im Container mitgeliefert werden.
|
||||
type Extractor struct {
|
||||
// Languages sind die Sprachmodelle für tesseract, z. B. "deu+eng".
|
||||
Languages string
|
||||
// MaxPages begrenzt, wie viele Seiten erkannt werden. OCR kostet Sekunden
|
||||
// je Seite; ohne Grenze blockiert ein einzelnes dickes PDF die
|
||||
// Warteschlange für alle anderen.
|
||||
MaxPages int
|
||||
// Timeout je Datei. Greift zusätzlich zu MaxPages, weil auch eine einzelne
|
||||
// Seite entgleisen kann.
|
||||
Timeout time.Duration
|
||||
// TmpDir nimmt die Zwischendateien auf. Leer heißt: das System entscheidet.
|
||||
TmpDir string
|
||||
}
|
||||
|
||||
// Result ist der gefundene Text mitsamt seiner Herkunft.
|
||||
type Result struct {
|
||||
Text string
|
||||
Source string
|
||||
// Lang sind die verwendeten Sprachmodelle – bei SourceLayer leer, weil
|
||||
// dort nichts erkannt, sondern nur ausgelesen wurde.
|
||||
Lang string
|
||||
Pages int
|
||||
// Chars ist die Länge *vor* dem Kürzen auf maxTextBytes.
|
||||
Chars int
|
||||
}
|
||||
|
||||
// Supported sagt, ob aus dieser Datei überhaupt Text zu holen ist.
|
||||
//
|
||||
// Bewusst eng: PDFs und Bilder. Word- und Excel-Dateien bräuchten je einen
|
||||
// eigenen Umwandler; sie tragen ihren Text ohnehin meist schon im Namen und
|
||||
// sind selten das, was eingescannt wird.
|
||||
func Supported(mime, name string) bool {
|
||||
m := strings.ToLower(mime)
|
||||
if strings.Contains(m, "pdf") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(m, "image/") {
|
||||
// SVG ist Text, kein Bild – tesseract kann damit nichts anfangen.
|
||||
return !strings.Contains(m, "svg")
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".pdf", ".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Available prüft, ob die Programme da sind. Fehlen sie, läuft die Cloud
|
||||
// weiter – nur ohne Volltext. Ein fehlendes Paket soll den Start nicht
|
||||
// verhindern.
|
||||
func Available() (bool, []string) {
|
||||
var missing []string
|
||||
for _, bin := range []string{"pdftotext", "ocrmypdf", "tesseract"} {
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
missing = append(missing, bin)
|
||||
}
|
||||
}
|
||||
return len(missing) == 0, missing
|
||||
}
|
||||
|
||||
// Extract liest die Datei unter path. Das Original wird dabei **nicht**
|
||||
// angefasst.
|
||||
//
|
||||
// Das ist die wichtigste Eigenschaft hier: ocrmypdf schreibt normalerweise ein
|
||||
// neues PDF mit eingebetteter Textebene. Täten wir das, änderten sich Größe und
|
||||
// Prüfsumme der Datei – die gespeicherte Prüfsumme passte nicht mehr, die
|
||||
// Versionshistorie bekäme einen Eintrag, den niemand ausgelöst hat, und
|
||||
// WebDAV-Clients hielten die Datei für fremdverändert und lüden sie erneut. Für
|
||||
// ein Archiv ist eine unangetastete Datei mehr wert als ein durchsuchbares PDF.
|
||||
// Der Text landet deshalb daneben in der Datenbank, das PDF bleibt Byte für
|
||||
// Byte, wie es hochgeladen wurde.
|
||||
func (e *Extractor) Extract(ctx context.Context, path, mime, name string) (Result, error) {
|
||||
if e.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, e.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
isPDF := strings.Contains(strings.ToLower(mime), "pdf") ||
|
||||
strings.EqualFold(filepath.Ext(name), ".pdf")
|
||||
if isPDF {
|
||||
return e.extractPDF(ctx, path)
|
||||
}
|
||||
return e.extractImage(ctx, path)
|
||||
}
|
||||
|
||||
func (e *Extractor) extractPDF(ctx context.Context, path string) (Result, error) {
|
||||
pages := e.pageCount(ctx, path)
|
||||
|
||||
// Erst der billige Weg: Hat das PDF eine Textebene, ist pdftotext in
|
||||
// Millisekunden fertig und liefert den Text exakt so, wie er gesetzt wurde.
|
||||
// OCR wäre hier nicht nur langsamer, sondern schlechter – es würde
|
||||
// fehlerfreien Text durch geratenen ersetzen.
|
||||
limit := pages
|
||||
if e.MaxPages > 0 && (limit == 0 || limit > e.MaxPages) {
|
||||
limit = e.MaxPages
|
||||
}
|
||||
args := []string{"-q"}
|
||||
if limit > 0 {
|
||||
args = append(args, "-l", fmt.Sprint(limit))
|
||||
}
|
||||
args = append(args, path, "-")
|
||||
|
||||
layer, err := e.run(ctx, "pdftotext", args...)
|
||||
if err == nil {
|
||||
text := clean(layer)
|
||||
perPage := len(text)
|
||||
if pages > 1 {
|
||||
perPage = len(text) / pages
|
||||
}
|
||||
if perPage >= minCharsPerPage {
|
||||
return finish(text, SourceLayer, "", pages), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Zu wenig Text – also ein Scan. Jetzt OCR.
|
||||
sidecar, err := os.CreateTemp(e.TmpDir, "ocr-*.txt")
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("sidecar anlegen: %w", err)
|
||||
}
|
||||
sidecarPath := sidecar.Name()
|
||||
sidecar.Close()
|
||||
defer os.Remove(sidecarPath)
|
||||
|
||||
ocrArgs := []string{
|
||||
"--quiet",
|
||||
"-l", e.Languages,
|
||||
// Der eigentliche Zweck des Aufrufs: --sidecar schreibt den erkannten
|
||||
// Text als reine Textdatei daneben.
|
||||
"--sidecar", sidecarPath,
|
||||
// Wir landen hier nur, wenn kaum Text da ist. --force-ocr rastert die
|
||||
// Seite und erkennt sie neu, statt an den paar vorhandenen Zeichen zu
|
||||
// scheitern (ocrmypdf bricht sonst mit PriorOcrFoundError ab).
|
||||
"--force-ocr",
|
||||
// Kein Ergebnis-PDF erzeugen. Uns interessiert allein der Sidecar; das
|
||||
// Schreiben (und Optimieren) einer Datei, die sofort wieder
|
||||
// verschwindet, wäre reine Rechenzeit und reiner Plattenplatz.
|
||||
"--output-type", "none",
|
||||
"--optimize", "0",
|
||||
}
|
||||
if e.MaxPages > 0 && pages > e.MaxPages {
|
||||
ocrArgs = append(ocrArgs, "--pages", fmt.Sprintf("1-%d", e.MaxPages))
|
||||
}
|
||||
// Ausgabe nach stdout, und dank --output-type none kommt dort nichts an:
|
||||
// gemessen 0 Bytes, Sidecar trotzdem vollständig. Damit entsteht gar kein
|
||||
// Ergebnis-PDF – wichtig auf einer Platte, die knapp werden kann, denn ein
|
||||
// force-OCR rastert jede Seite und das Ergebnis kann ein Vielfaches der
|
||||
// Vorlage wiegen.
|
||||
//
|
||||
// /dev/null als Ausgabe geht übrigens **nicht** – ocrmypdf bricht damit
|
||||
// mit Exit 3 ab. Der Bindestrich ist der Weg.
|
||||
ocrArgs = append(ocrArgs, path, "-")
|
||||
|
||||
if _, err := e.run(ctx, "ocrmypdf", ocrArgs...); err != nil {
|
||||
return Result{}, fmt.Errorf("ocrmypdf: %w", err)
|
||||
}
|
||||
raw, err := os.ReadFile(sidecarPath)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("sidecar lesen: %w", err)
|
||||
}
|
||||
return finish(clean(string(raw)), SourceOCR, e.Languages, pages), nil
|
||||
}
|
||||
|
||||
func (e *Extractor) extractImage(ctx context.Context, path string) (Result, error) {
|
||||
out, err := e.run(ctx, "tesseract", path, "stdout", "-l", e.Languages)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("tesseract: %w", err)
|
||||
}
|
||||
return finish(clean(out), SourceOCR, e.Languages, 1), nil
|
||||
}
|
||||
|
||||
// pageCount fragt pdfinfo nach der Seitenzahl. Schlägt das fehl, arbeiten wir
|
||||
// mit 0 weiter – die Seitenzahl steuert nur Grenzen, sie ist nicht kritisch.
|
||||
func (e *Extractor) pageCount(ctx context.Context, path string) int {
|
||||
out, err := e.run(ctx, "pdfinfo", path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if !strings.HasPrefix(line, "Pages:") {
|
||||
continue
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(strings.TrimSpace(line), "Pages: %d", &n); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (e *Extractor) run(ctx context.Context, name string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &errOut
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := strings.TrimSpace(errOut.String())
|
||||
if len(msg) > 300 {
|
||||
msg = msg[:300] + "…"
|
||||
}
|
||||
if msg != "" {
|
||||
return "", fmt.Errorf("%s: %w (%s)", name, err, msg)
|
||||
}
|
||||
return "", fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
// clean räumt auf, was aus den Werkzeugen kommt.
|
||||
//
|
||||
// **Der Seitenumbruch (\f) bleibt stehen.** Für die Suche ist er bedeutungslos
|
||||
// – ein tsvector kennt keine Seiten –, für den Betrachter ist er das Einzige,
|
||||
// woran sich ablesen lässt, *wo* ein Wort steht. Bei einem Scan liegt im PDF
|
||||
// nur ein Bild; der erkannte Text steht ausschließlich hier. Ohne die Marke
|
||||
// beginnt der Betrachter bei einem zwölfseitigen Beleg wieder auf Seite 1.
|
||||
//
|
||||
// Ein Wust aus Leerzeilen bläht den Text dagegen nur auf. Ungültiges UTF-8
|
||||
// muss raus, weil Postgres es sonst zurückweist und der ganze Auftrag
|
||||
// scheitert; OCR liefert es bei verrauschten Vorlagen durchaus.
|
||||
func clean(s string) string {
|
||||
s = strings.ReplaceAll(s, "\x00", "")
|
||||
if !utf8.ValidString(s) {
|
||||
s = strings.ToValidUTF8(s, "")
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
blank := 0
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
line = strings.TrimRight(line, " \t\r")
|
||||
// Die Seitenmarke steht bei pdftotext am Ende der letzten Zeile einer
|
||||
// Seite. Sie überlebt das Trimmen und zählt nicht als leer.
|
||||
if strings.TrimSpace(strings.ReplaceAll(line, "\f", "")) == "" && !strings.Contains(line, "\f") {
|
||||
blank++
|
||||
if blank > 1 {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
blank = 0
|
||||
}
|
||||
b.WriteString(line)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
// finish kürzt auf maxTextBytes und merkt sich die ursprüngliche Länge.
|
||||
func finish(text, source, lang string, pages int) Result {
|
||||
chars := utf8.RuneCountInString(text)
|
||||
if len(text) > maxTextBytes {
|
||||
text = text[:maxTextBytes]
|
||||
// Nicht mitten in einem Zeichen abschneiden – das ergäbe ungültiges
|
||||
// UTF-8, und Postgres lehnte den ganzen Text ab.
|
||||
for len(text) > 0 && !utf8.ValidString(text) {
|
||||
text = text[:len(text)-1]
|
||||
}
|
||||
}
|
||||
return Result{Text: text, Source: source, Lang: lang, Pages: pages, Chars: chars}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package documents
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// Handler zeigt den Stand der Texterkennung.
|
||||
//
|
||||
// Ohne diese Auskunft ist nicht zu erkennen, ob der Volltext funktioniert:
|
||||
// Eine Suche, die nichts findet, sieht genauso aus wie eine Suche in einem
|
||||
// Bestand, der noch gar nicht erkannt wurde. Nach dem ersten Start läuft der
|
||||
// Arbeiter je nach Menge Stunden – ohne Zähler weiß niemand, ob er
|
||||
// vorankommt.
|
||||
type Handler struct {
|
||||
store *Store
|
||||
// isAdmin und userID trennen die Rechteprüfung von diesem Paket.
|
||||
// documents kennt weder Sessions noch Rollen, und soll es auch nicht.
|
||||
isAdmin func(r *http.Request) bool
|
||||
userID func(r *http.Request) uuid.UUID
|
||||
}
|
||||
|
||||
func NewHandler(store *Store, isAdmin func(r *http.Request) bool, userID func(r *http.Request) uuid.UUID) *Handler {
|
||||
return &Handler{store: store, isAdmin: isAdmin, userID: userID}
|
||||
}
|
||||
|
||||
// Status beantwortet GET /api/v1/admin/ocr – der Gesamtstand des Servers.
|
||||
func (h *Handler) Status(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.isAdmin(r) {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
// h.store nil heißt: OCR ist abgeschaltet oder die Programme fehlen. Das
|
||||
// ist eine Auskunft, kein Fehler – die Oberfläche soll „aus" anzeigen
|
||||
// können, statt in einen Fehlerzustand zu laufen.
|
||||
if h == nil || h.store == nil {
|
||||
response.JSON(w, http.StatusOK, map[string]any{"enabled": false})
|
||||
return
|
||||
}
|
||||
stats, err := h.store.Stats(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "ocr status failed")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": true,
|
||||
"pending": stats.Pending,
|
||||
"running": stats.Running,
|
||||
"done": stats.Done,
|
||||
"failed": stats.Failed,
|
||||
"indexed": stats.Indexed,
|
||||
})
|
||||
}
|
||||
|
||||
// MyStatus beantwortet GET /api/v1/ocr – der Stand der **eigenen** Dateien.
|
||||
//
|
||||
// Ohne diesen Endpunkt wüsste nur der Administrator, ob ein Dokument schon
|
||||
// gelesen wurde. Das ist die falsche Person: Wissen muss es, wer die Rechnung
|
||||
// hochgeladen hat und sie gerade nicht findet.
|
||||
func (h *Handler) MyStatus(w http.ResponseWriter, r *http.Request) {
|
||||
uid := h.userID(r)
|
||||
if uid == uuid.Nil {
|
||||
response.Error(w, http.StatusUnauthorized, "not signed in")
|
||||
return
|
||||
}
|
||||
if h == nil || h.store == nil {
|
||||
response.JSON(w, http.StatusOK, map[string]any{"enabled": false})
|
||||
return
|
||||
}
|
||||
stats, err := h.store.StatsForUser(r.Context(), uid)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "ocr status failed")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": true,
|
||||
"pending": stats.Pending,
|
||||
"running": stats.Running,
|
||||
"done": stats.Done,
|
||||
"failed": stats.Failed,
|
||||
"indexed": stats.Indexed,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package documents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ExtractVersion ist die Fassung der Textaufbereitung.
|
||||
//
|
||||
// Sie steht an jedem Eintrag in file_text. Wird die Aufbereitung geändert, so
|
||||
// dass alte Einträge nicht mehr taugen, wird die Zahl erhöht – beim Start
|
||||
// werden sie dann erneut erkannt, statt still nach der alten Regel liegen zu
|
||||
// bleiben. Ohne so eine Marke fällt der Unterschied nie auf: Der Text ist ja
|
||||
// da, er kann nur weniger.
|
||||
//
|
||||
// 1 – Seitenumbruch (\f) bleibt im Text stehen (23.08.2026)
|
||||
const ExtractVersion = 1
|
||||
|
||||
// ErrNoJob heißt: gerade nichts zu tun. Kein Fehler, sondern der Normalfall.
|
||||
var ErrNoJob = errors.New("no pending job")
|
||||
|
||||
// maxAttempts begrenzt, wie oft ein Auftrag wiederholt wird.
|
||||
//
|
||||
// Zwei Wiederholungen fangen das ab, was vorübergehend war – ein Neustart
|
||||
// mitten im Lauf, kurzzeitig fehlender Speicherplatz. Was dreimal scheitert,
|
||||
// scheitert an der Datei selbst (kaputtes PDF, passwortgeschützt), und ein
|
||||
// vierter Versuch verbrennt nur Rechenzeit. Der Auftrag bleibt dann als
|
||||
// 'failed' mit Fehlertext stehen, statt still zu verschwinden.
|
||||
const maxAttempts = 3
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// Job ist ein Auftrag samt allem, was der Arbeiter über die Datei braucht.
|
||||
type Job struct {
|
||||
ID uuid.UUID
|
||||
FileID uuid.UUID
|
||||
Attempts int
|
||||
Name string
|
||||
MimeType string
|
||||
StorageKey string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// Enqueue trägt eine Datei zur Texterkennung ein.
|
||||
//
|
||||
// ON CONFLICT statt eines zweiten Eintrags: Wird dieselbe Datei erneut
|
||||
// hochgeladen, ist der alte Text hinfällig – der Auftrag wird auf 'pending'
|
||||
// zurückgesetzt, statt sich daneben zu stapeln. Der Zähler geht dabei auf 0,
|
||||
// weil eine neue Fassung eine neue Chance verdient: Vielleicht war es genau
|
||||
// der kaputte Scan, der ersetzt wurde.
|
||||
func (s *Store) Enqueue(ctx context.Context, fileID uuid.UUID) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
INSERT INTO ocr_jobs (file_id, status)
|
||||
VALUES ($1, 'pending')
|
||||
ON CONFLICT (file_id) DO UPDATE
|
||||
SET status = 'pending', attempts = 0, last_error = '', updated_at = NOW()`,
|
||||
fileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("enqueue ocr job: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Claim holt den ältesten offenen Auftrag und markiert ihn als laufend.
|
||||
//
|
||||
// FOR UPDATE SKIP LOCKED ist der Kern: Zwei Arbeiter greifen damit nie
|
||||
// denselben Auftrag – der zweite überspringt die gesperrte Zeile, statt darauf
|
||||
// zu warten. Heute läuft nur einer, aber die Sperre kostet nichts und erspart
|
||||
// die Suche nach doppelt erkannten Dateien, sobald es zwei werden.
|
||||
//
|
||||
// 'running' wird mit übernommen, nicht nur 'pending': Ein Auftrag, der beim
|
||||
// Neustart mittendrin war, steht sonst für immer auf 'running' und wird nie
|
||||
// wieder angefasst. Der Zähler sorgt dafür, dass daraus keine Endlosschleife
|
||||
// wird, falls die Datei den Server reproduzierbar umbringt.
|
||||
func (s *Store) Claim(ctx context.Context) (*Job, error) {
|
||||
var j Job
|
||||
err := s.db.QueryRow(ctx, `
|
||||
UPDATE ocr_jobs
|
||||
SET status = 'running', attempts = attempts + 1, updated_at = NOW()
|
||||
WHERE id = (
|
||||
SELECT j.id
|
||||
FROM ocr_jobs j
|
||||
JOIN files f ON f.id = j.file_id AND f.is_deleted = false
|
||||
WHERE j.status IN ('pending', 'running')
|
||||
AND j.attempts < $1
|
||||
ORDER BY j.created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1)
|
||||
RETURNING id, file_id, attempts,
|
||||
(SELECT name FROM files WHERE id = file_id),
|
||||
(SELECT mime_type FROM files WHERE id = file_id),
|
||||
(SELECT storage_key FROM files WHERE id = file_id),
|
||||
(SELECT size FROM files WHERE id = file_id)`,
|
||||
maxAttempts,
|
||||
).Scan(&j.ID, &j.FileID, &j.Attempts, &j.Name, &j.MimeType, &j.StorageKey, &j.Size)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNoJob
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claim ocr job: %w", err)
|
||||
}
|
||||
return &j, nil
|
||||
}
|
||||
|
||||
// Complete speichert den Text und schließt den Auftrag ab. Beides in einer
|
||||
// Transaktion: Ein gespeicherter Text ohne erledigten Auftrag würde beim
|
||||
// nächsten Durchgang erneut erkannt, ein erledigter Auftrag ohne Text ließe die
|
||||
// Datei unauffindbar zurück.
|
||||
func (s *Store) Complete(ctx context.Context, job *Job, res Result) error {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO file_text (file_id, content, source, lang, chars, pages, extract_version)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (file_id) DO UPDATE
|
||||
SET content = EXCLUDED.content, source = EXCLUDED.source,
|
||||
lang = EXCLUDED.lang, chars = EXCLUDED.chars,
|
||||
pages = EXCLUDED.pages, extract_version = EXCLUDED.extract_version,
|
||||
created_at = NOW()`,
|
||||
job.FileID, res.Text, res.Source, res.Lang, res.Chars, res.Pages, ExtractVersion,
|
||||
); err != nil {
|
||||
return fmt.Errorf("save text: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE ocr_jobs SET status = 'done', last_error = '', updated_at = NOW() WHERE id = $1`,
|
||||
job.ID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("finish job: %w", err)
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// Fail vermerkt den Fehler. Ist der Zähler ausgeschöpft, bleibt der Auftrag als
|
||||
// 'failed' liegen; sonst geht er zurück in die Schlange.
|
||||
func (s *Store) Fail(ctx context.Context, job *Job, cause error) error {
|
||||
msg := cause.Error()
|
||||
if len(msg) > 500 {
|
||||
msg = msg[:500] + "…"
|
||||
}
|
||||
status := "pending"
|
||||
if job.Attempts >= maxAttempts {
|
||||
status = "failed"
|
||||
}
|
||||
_, err := s.db.Exec(ctx,
|
||||
`UPDATE ocr_jobs SET status = $2, last_error = $3, updated_at = NOW() WHERE id = $1`,
|
||||
job.ID, status, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip schließt einen Auftrag ohne Text ab – etwa wenn die Datei inzwischen
|
||||
// gelöscht wurde. 'done' statt 'failed': Es ist nichts schiefgegangen.
|
||||
func (s *Store) Skip(ctx context.Context, jobID uuid.UUID, reason string) error {
|
||||
_, err := s.db.Exec(ctx,
|
||||
`UPDATE ocr_jobs SET status = 'done', last_error = $2, updated_at = NOW() WHERE id = $1`,
|
||||
jobID, reason)
|
||||
return err
|
||||
}
|
||||
|
||||
// Stats zählt die Aufträge je Zustand – für die Anzeige im Admin-Bereich und
|
||||
// für die Prüfung, ob der Arbeiter überhaupt vorankommt.
|
||||
type Stats struct {
|
||||
Pending int `json:"pending"`
|
||||
Running int `json:"running"`
|
||||
Done int `json:"done"`
|
||||
Failed int `json:"failed"`
|
||||
// Indexed ist die Zahl der Dateien mit gespeichertem Text.
|
||||
Indexed int `json:"indexed"`
|
||||
}
|
||||
|
||||
func (s *Store) Stats(ctx context.Context) (Stats, error) {
|
||||
var st Stats
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FILTER (WHERE status = 'pending'),
|
||||
COUNT(*) FILTER (WHERE status = 'running'),
|
||||
COUNT(*) FILTER (WHERE status = 'done'),
|
||||
COUNT(*) FILTER (WHERE status = 'failed'),
|
||||
(SELECT COUNT(*) FROM file_text)
|
||||
FROM ocr_jobs`,
|
||||
).Scan(&st.Pending, &st.Running, &st.Done, &st.Failed, &st.Indexed)
|
||||
if err != nil {
|
||||
return st, fmt.Errorf("ocr stats: %w", err)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// StatsForUser zählt nur, was diesem Nutzer gehört.
|
||||
//
|
||||
// Warum es das getrennt von Stats gibt: Den Gesamtstand darf nur der
|
||||
// Administrator sehen – die Zahl aller Dokumente auf dem Server verrät etwas
|
||||
// über fremde Bestände. Wissen muss es aber genau der, der die Rechnung
|
||||
// hochgeladen hat: Findet seine Suche sie nicht, ist die Frage „ist sie schon
|
||||
// gelesen?" die erste, die er stellt.
|
||||
//
|
||||
// Gezählt wird über owner_id, also die eigenen Dateien. Dateien in
|
||||
// Gruppenordnern bleiben außen vor – sie gehören der Gruppe, und ihr Stand
|
||||
// wäre für den Einzelnen keine Auskunft über den eigenen Bestand.
|
||||
func (s *Store) StatsForUser(ctx context.Context, userID uuid.UUID) (Stats, error) {
|
||||
var st Stats
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FILTER (WHERE j.status = 'pending'),
|
||||
COUNT(*) FILTER (WHERE j.status = 'running'),
|
||||
COUNT(*) FILTER (WHERE j.status = 'done'),
|
||||
COUNT(*) FILTER (WHERE j.status = 'failed'),
|
||||
COUNT(*) FILTER (WHERE t.file_id IS NOT NULL)
|
||||
FROM files f
|
||||
JOIN ocr_jobs j ON j.file_id = f.id
|
||||
LEFT JOIN file_text t ON t.file_id = f.id
|
||||
WHERE f.owner_id = $1 AND f.is_deleted = false`,
|
||||
userID,
|
||||
).Scan(&st.Pending, &st.Running, &st.Done, &st.Failed, &st.Indexed)
|
||||
if err != nil {
|
||||
return st, fmt.Errorf("ocr stats for user: %w", err)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// EnqueueMissing trägt alle Bestandsdateien nach, die noch keinen Auftrag
|
||||
// haben. Ohne das bliebe alles unauffindbar, was vor dem Einbau hochgeladen
|
||||
// wurde – und das ist beim ersten Start genau der gesamte Bestand.
|
||||
//
|
||||
// Die Auswahl der Typen passiert hier in SQL statt über Supported(): Bei
|
||||
// zehntausend Dateien wäre es Unfug, jede einzelne nach Go zu holen, nur um
|
||||
// ihren MIME-Typ anzusehen.
|
||||
func (s *Store) EnqueueMissing(ctx context.Context, limit int) (int, error) {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
INSERT INTO ocr_jobs (file_id, status)
|
||||
SELECT f.id, 'pending'
|
||||
FROM files f
|
||||
WHERE f.is_deleted = false
|
||||
AND (f.mime_type ILIKE '%pdf%'
|
||||
OR (f.mime_type ILIKE 'image/%' AND f.mime_type NOT ILIKE '%svg%')
|
||||
OR f.name ~* '\.(pdf|png|jpe?g|tiff?|bmp|webp)$')
|
||||
AND NOT EXISTS (SELECT 1 FROM ocr_jobs j WHERE j.file_id = f.id)
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT $1
|
||||
ON CONFLICT (file_id) DO NOTHING`, limit)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("enqueue missing: %w", err)
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
||||
|
||||
// EnqueueOutdated legt Einträge erneut in die Warteschlange, die nach einer
|
||||
// älteren Regel entstanden sind.
|
||||
//
|
||||
// Anlass war die Seitenmarke: Bis Fassung 1 wurde der Seitenumbruch aus dem
|
||||
// Text entfernt. Wer damit indiziert wurde, ist zwar auffindbar – aber der
|
||||
// Betrachter kann bei einem Scan nicht sagen, auf welcher Seite das Wort
|
||||
// steht. Ein stiller Rest, der sich nur durch erneutes Erkennen auflöst.
|
||||
//
|
||||
// Bewusst mit derselben Obergrenze wie EnqueueMissing: Bei einem großen
|
||||
// Bestand soll ein Neustart nicht die ganze Platte an OCR verheizen, sondern
|
||||
// den Rückstand über mehrere Starte abtragen.
|
||||
func (s *Store) EnqueueOutdated(ctx context.Context, limit int) (int, error) {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
INSERT INTO ocr_jobs (file_id, status)
|
||||
SELECT t.file_id, 'pending'
|
||||
FROM file_text t
|
||||
JOIN files f ON f.id = t.file_id AND f.is_deleted = false
|
||||
WHERE t.extract_version < $2
|
||||
LIMIT $1
|
||||
ON CONFLICT (file_id) DO UPDATE
|
||||
SET status = 'pending', attempts = 0, last_error = '', updated_at = NOW()`,
|
||||
limit, ExtractVersion)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("enqueue outdated: %w", err)
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
||||
|
||||
// TextPages liefert den erkannten Text einer Datei, in Seiten zerlegt.
|
||||
//
|
||||
// Für den PDF-Betrachter: Bei einem Scan steht im PDF selbst kein Wort, nur
|
||||
// ein Bild. Ohne diesen Weg fände die Suche im Dokument nichts – ausgerechnet
|
||||
// bei dem Dateityp, für den der erkannte Text überhaupt gebaut wurde.
|
||||
//
|
||||
// Die Zugriffsprüfung passiert im Aufrufer; hier liegt nur die Abfrage.
|
||||
func (s *Store) TextPages(ctx context.Context, fileID uuid.UUID) ([]string, string, error) {
|
||||
var content, source string
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT content, source FROM file_text WHERE file_id = $1`, fileID,
|
||||
).Scan(&content, &source)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("text pages: %w", err)
|
||||
}
|
||||
return strings.Split(content, "\f"), source, nil
|
||||
}
|
||||
|
||||
// DeleteText entfernt den Text einer Datei. Wird beim endgültigen Löschen
|
||||
// gebraucht; solange die Datei nur im Papierkorb liegt, bleibt er stehen –
|
||||
// beim Wiederherstellen wäre er sonst weg.
|
||||
func (s *Store) DeleteText(ctx context.Context, fileID uuid.UUID) error {
|
||||
_, err := s.db.Exec(ctx, `DELETE FROM file_text WHERE file_id = $1`, fileID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package documents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"aether/internal/storage"
|
||||
)
|
||||
|
||||
// idleInterval ist die Pause, wenn nichts zu tun ist.
|
||||
//
|
||||
// Abfragen statt Benachrichtigen (LISTEN/NOTIFY) ist hier die richtige Wahl:
|
||||
// Eine Abfrage auf einen Teilindex kostet nichts, und der Arbeiter muss ohnehin
|
||||
// regelmäßig nachsehen – ein Auftrag kann auch dadurch entstehen, dass der
|
||||
// Server beim letzten Mal mittendrin ausgefallen ist. Fünf Sekunden Verzug
|
||||
// spielen bei einer Aufgabe, die selbst Sekunden bis Minuten dauert, keine
|
||||
// Rolle.
|
||||
const idleInterval = 5 * time.Second
|
||||
|
||||
// busyInterval: Wurde gerade etwas erledigt, sofort weitermachen. Eine kurze
|
||||
// Pause bleibt trotzdem, damit ein Stapel von hundert Scans nicht die ganze
|
||||
// Maschine belegt und die Cloud daneben zäh wird.
|
||||
const busyInterval = 200 * time.Millisecond
|
||||
|
||||
// maxFileBytes: Größeres wird übersprungen. Der Arbeiter muss die Datei in eine
|
||||
// temporäre Kopie schreiben, bevor die Werkzeuge sie lesen können; ein
|
||||
// 4-GB-Video würde dabei die Platte füllen – und Text ist darin ohnehin keiner.
|
||||
const maxFileBytes = 512 << 20 // 512 MiB
|
||||
|
||||
// Worker arbeitet die Warteschlange ab. Einer genügt: OCR lastet einen Kern
|
||||
// voll aus, und mehrere parallel machen auf einem Heimserver die Oberfläche
|
||||
// zäh, ohne insgesamt schneller fertig zu sein.
|
||||
type Worker struct {
|
||||
store *Store
|
||||
blobs storage.Storage
|
||||
extract *Extractor
|
||||
log zerolog.Logger
|
||||
// paused merkt sich, ob wegen Platzmangels gerade nichts läuft – nur damit
|
||||
// die Warnung einmal im Log steht und nicht alle fünf Sekunden.
|
||||
paused bool
|
||||
}
|
||||
|
||||
func NewWorker(store *Store, blobs storage.Storage, ex *Extractor, log zerolog.Logger) *Worker {
|
||||
return &Worker{store: store, blobs: blobs, extract: ex, log: log.With().Str("component", "ocr").Logger()}
|
||||
}
|
||||
|
||||
// Run läuft, bis der Kontext endet. Gedacht für `go worker.Run(ctx)` beim Start.
|
||||
func (w *Worker) Run(ctx context.Context) {
|
||||
w.log.Info().
|
||||
Str("languages", w.extract.Languages).
|
||||
Int("max_pages", w.extract.MaxPages).
|
||||
Msg("OCR-Arbeiter gestartet")
|
||||
|
||||
// Angefangene Dateien eines hart beendeten Laufs wegräumen (siehe
|
||||
// sweepTemp). Einmal beim Start genügt: Während des Betriebs räumt der
|
||||
// Arbeiter selbst auf.
|
||||
if n, freed := sweepTemp(w.extract.TmpDir); n > 0 {
|
||||
w.log.Info().Int("files", n).Int64("bytes", freed).
|
||||
Msg("Zwischendateien eines früheren Laufs entfernt")
|
||||
}
|
||||
|
||||
for {
|
||||
wait := idleInterval
|
||||
if w.step(ctx) {
|
||||
wait = busyInterval
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
w.log.Info().Msg("OCR-Arbeiter beendet")
|
||||
return
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// step erledigt höchstens einen Auftrag. Rückgabe: ob etwas zu tun war.
|
||||
func (w *Worker) step(ctx context.Context) bool {
|
||||
// Vor dem Zugreifen nachsehen, nicht danach: Ein Auftrag, den wir schon
|
||||
// auf 'running' gesetzt haben, hätte einen Versuch verbraucht, obwohl es
|
||||
// gar nicht an ihm lag.
|
||||
if free, err := freeBytes(w.extract.TmpDir); err == nil && free < minFreeBytes {
|
||||
// Nur einmal je Zustandswechsel melden – sonst füllt die Warnung im
|
||||
// Fünf-Sekunden-Takt den Log, und das ausgerechnet, wenn der Platz
|
||||
// knapp ist.
|
||||
if !w.paused {
|
||||
w.paused = true
|
||||
w.log.Warn().
|
||||
Uint64("free_mb", free/(1<<20)).
|
||||
Uint64("needed_mb", uint64(minFreeBytes)/(1<<20)).
|
||||
Msg("Texterkennung pausiert: zu wenig Platz auf der Platte")
|
||||
}
|
||||
return false
|
||||
} else if w.paused {
|
||||
w.paused = false
|
||||
w.log.Info().Msg("Texterkennung fortgesetzt: wieder genug Platz")
|
||||
}
|
||||
|
||||
job, err := w.store.Claim(ctx)
|
||||
if errors.Is(err, ErrNoJob) {
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
// Datenbank weg oder Kontext beendet – nicht in einer engen Schleife
|
||||
// weiterprobieren.
|
||||
if ctx.Err() == nil {
|
||||
w.log.Warn().Err(err).Msg("Auftrag holen fehlgeschlagen")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
res, err := w.process(ctx, job)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
// Herunterfahren mitten im Lauf ist kein Fehler der Datei. Der
|
||||
// Auftrag steht auf 'running' und wird beim nächsten Start wieder
|
||||
// aufgegriffen – genau dafür nimmt Claim auch 'running' mit.
|
||||
return false
|
||||
}
|
||||
w.log.Warn().Err(err).
|
||||
Str("file", job.Name).Int("attempt", job.Attempts).
|
||||
Msg("Texterkennung fehlgeschlagen")
|
||||
if err := w.store.Fail(context.WithoutCancel(ctx), job, err); err != nil {
|
||||
w.log.Error().Err(err).Msg("Fehlschlag konnte nicht vermerkt werden")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if err := w.store.Complete(ctx, job, res); err != nil {
|
||||
w.log.Error().Err(err).Str("file", job.Name).Msg("Text konnte nicht gespeichert werden")
|
||||
return true
|
||||
}
|
||||
w.log.Info().
|
||||
Str("file", job.Name).
|
||||
Str("source", res.Source).
|
||||
Int("chars", res.Chars).
|
||||
Int("pages", res.Pages).
|
||||
Dur("took", time.Since(start)).
|
||||
Msg("Text erkannt")
|
||||
return true
|
||||
}
|
||||
|
||||
func (w *Worker) process(ctx context.Context, job *Job) (Result, error) {
|
||||
if job.Size > maxFileBytes {
|
||||
return Result{}, fmt.Errorf("Datei zu groß für die Texterkennung (%d Bytes)", job.Size)
|
||||
}
|
||||
if !Supported(job.MimeType, job.Name) {
|
||||
// Kann vorkommen, wenn der MIME-Typ nachträglich korrigiert wurde.
|
||||
// Kein Fehler – nur nichts zu tun.
|
||||
return Result{}, w.store.Skip(ctx, job.ID, "Dateityp trägt keinen Text")
|
||||
}
|
||||
|
||||
// Die Werkzeuge sind externe Programme und brauchen einen Pfad; die
|
||||
// Ablageschicht gibt grundsätzlich nur einen Datenstrom heraus, weil sie
|
||||
// später auch S3 bedienen soll.
|
||||
//
|
||||
// Bei der lokalen Ablage gibt es den Pfad aber wirklich – dann wird
|
||||
// **nicht** kopiert. Das ist kein Feinschliff: Eine Kopie bedeutet, dass
|
||||
// neben jedem Dokument kurzzeitig noch einmal dieselbe Menge auf der
|
||||
// Platte liegt, und die Erkennung läuft ausgerechnet auf einer Maschine,
|
||||
// die auch alle Dateien beherbergt.
|
||||
path, cleanup, err := w.materialize(ctx, job)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
return w.extract.Extract(ctx, path, job.MimeType, job.Name)
|
||||
}
|
||||
|
||||
// materialize besorgt einen Pfad zur Datei – ohne Kopie, wo es geht.
|
||||
//
|
||||
// Der zurückgegebene cleanup räumt nur auf, wenn tatsächlich kopiert wurde. Er
|
||||
// darf das Original niemals löschen; deshalb die klare Trennung der beiden
|
||||
// Zweige statt eines gemeinsamen Pfads mit einem Merker.
|
||||
func (w *Worker) materialize(ctx context.Context, job *Job) (string, func(), error) {
|
||||
if lp, ok := w.blobs.(storage.LocalPather); ok {
|
||||
path, err := lp.LocalPath(job.StorageKey)
|
||||
if err == nil {
|
||||
// Nichts zu räumen: Das ist die Datei selbst. Gelesen wird sie nur.
|
||||
return path, func() {}, nil
|
||||
}
|
||||
// Kein Abbruch – der Weg über die Kopie funktioniert weiterhin.
|
||||
w.log.Debug().Err(err).Str("key", job.StorageKey).
|
||||
Msg("kein lokaler Pfad, weiche auf Kopie aus")
|
||||
}
|
||||
|
||||
rc, err := w.blobs.Get(ctx, job.StorageKey)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("Datei lesen: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
// Die Endung mitnehmen: ocrmypdf und tesseract entscheiden auch danach,
|
||||
// was sie vor sich haben.
|
||||
pattern := "ocr-*" + extOf(job.Name)
|
||||
var tmp *os.File
|
||||
if tf, ok := w.blobs.(storage.TempFiler); ok {
|
||||
// Über die Ablageschicht, damit die Kopie auf derselben Platte landet
|
||||
// wie die Daten – nicht in einem knappen /tmp im Container.
|
||||
tmp, err = tf.CreateTemp(pattern)
|
||||
} else {
|
||||
tmp, err = os.CreateTemp("", pattern)
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("Zwischendatei anlegen: %w", err)
|
||||
}
|
||||
name := tmp.Name()
|
||||
cleanup := func() { os.Remove(name) }
|
||||
|
||||
if _, err := io.Copy(tmp, rc); err != nil {
|
||||
tmp.Close()
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("Zwischendatei schreiben: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("Zwischendatei schließen: %w", err)
|
||||
}
|
||||
return name, cleanup, nil
|
||||
}
|
||||
|
||||
func extOf(name string) string {
|
||||
for i := len(name) - 1; i >= 0 && len(name)-i <= 6; i-- {
|
||||
if name[i] == '.' {
|
||||
return name[i:]
|
||||
}
|
||||
if name[i] == '/' {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// Package gc räumt verwaiste Blobs auf: Dateien im Speicher, auf die keine
|
||||
// Datenbankzeile mehr zeigt.
|
||||
//
|
||||
// Solche Waisen entstehen, wenn ein Vorgang zwischen Blob-Schreiben und
|
||||
// Datenbank-Commit abbricht – etwa weil der Prozess stirbt oder ein Löschen
|
||||
// nur halb durchläuft. Ohne Aufräumen belegen sie für immer Platz, ohne dass
|
||||
// sie irgendwo auftauchen.
|
||||
//
|
||||
// Der Ablauf ist bewusst defensiv: Es wird nur gelöscht, was die Datenbank
|
||||
// nicht kennt UND älter als eine Schonfrist ist. Der Sinn der Schonfrist:
|
||||
// Zwischen `storage.Put` und dem `INSERT` liegt ein kurzer Moment, in dem ein
|
||||
// völlig gesunder Upload wie eine Waise aussieht.
|
||||
package gc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"aether/internal/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
// gracePeriod schützt frische Blobs, deren Datenbankzeile noch unterwegs
|
||||
// ist. Großzügig bemessen – ein Tag zu spät aufgeräumt kostet nichts, einen
|
||||
// laufenden Upload zu löschen dagegen sehr wohl.
|
||||
gracePeriod = 24 * time.Hour
|
||||
|
||||
// Interval zwischen zwei Durchläufen.
|
||||
Interval = 12 * time.Hour
|
||||
)
|
||||
|
||||
type Collector struct {
|
||||
db *pgxpool.Pool
|
||||
storage storage.Storage
|
||||
}
|
||||
|
||||
func New(db *pgxpool.Pool, st storage.Storage) *Collector {
|
||||
return &Collector{db: db, storage: st}
|
||||
}
|
||||
|
||||
// Result fasst einen Durchlauf zusammen.
|
||||
type Result struct {
|
||||
Scanned int
|
||||
Orphans int
|
||||
Deleted int
|
||||
FreedBytes int64
|
||||
SkippedYoung int
|
||||
}
|
||||
|
||||
// Run führt einen Durchlauf aus.
|
||||
func (c *Collector) Run(ctx context.Context) (Result, error) {
|
||||
var res Result
|
||||
|
||||
referenced, err := c.referencedKeys(ctx)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(-gracePeriod)
|
||||
type orphan struct {
|
||||
key string
|
||||
size int64
|
||||
}
|
||||
var orphans []orphan
|
||||
|
||||
// "tmp/" bleibt außen vor: Das gehört dem Upload-Pfad und wird dort
|
||||
// aufgeräumt; ein GC, der dort mitmischt, zerrisse laufende Uploads.
|
||||
for _, prefix := range []string{"users", "thumbs", "branding"} {
|
||||
err := c.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error {
|
||||
res.Scanned++
|
||||
if referenced[key] {
|
||||
return nil
|
||||
}
|
||||
if info.ModTime.After(cutoff) {
|
||||
res.SkippedYoung++
|
||||
return nil
|
||||
}
|
||||
orphans = append(orphans, orphan{key: key, size: info.Size})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("walk %q: %w", prefix, err)
|
||||
}
|
||||
}
|
||||
res.Orphans = len(orphans)
|
||||
|
||||
if shouldAbort(len(referenced), res.Scanned) {
|
||||
log.Ctx(ctx).Error().
|
||||
Int("scanned", res.Scanned).
|
||||
Msg("gc: database reports no referenced blobs while storage is not empty – skipping run")
|
||||
return res, nil
|
||||
}
|
||||
|
||||
for _, o := range orphans {
|
||||
if err := c.storage.Delete(ctx, o.key); err != nil {
|
||||
log.Ctx(ctx).Warn().Err(err).Str("key", o.key).Msg("gc: could not delete orphan")
|
||||
continue
|
||||
}
|
||||
res.Deleted++
|
||||
res.FreedBytes += o.size
|
||||
log.Ctx(ctx).Info().
|
||||
Str("key", o.key).Int64("bytes", o.size).
|
||||
Msg("gc: orphaned blob removed")
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// shouldAbort ist die Notbremse vor dem Löschen: Kennt die Datenbank keinen
|
||||
// einzigen Blob, obwohl im Speicher welche liegen, ist mit hoher
|
||||
// Wahrscheinlichkeit die Abfrage kaputt oder die Verbindung auf die falsche
|
||||
// Datenbank gerichtet. Das Ergebnis wäre "alles ist verwaist" – der GC würde
|
||||
// den kompletten Bestand löschen. Dann lieber gar nichts tun und laut sein.
|
||||
//
|
||||
// Die leere Installation (nichts in der Datenbank, nichts auf der Platte) ist
|
||||
// davon ausgenommen: da gibt es schlicht nichts zu verwechseln.
|
||||
func shouldAbort(referencedCount, scanned int) bool {
|
||||
return referencedCount == 0 && scanned > 0
|
||||
}
|
||||
|
||||
// referencedKeys sammelt jeden Speicherschlüssel, auf den die Datenbank zeigt –
|
||||
// aktive Dateien, Papierkorb und Versionen gleichermaßen. Thumbnails haben
|
||||
// keine eigene Spalte, ihr Schlüssel leitet sich aus der Datei-ID ab.
|
||||
func (c *Collector) referencedKeys(ctx context.Context) (map[string]bool, error) {
|
||||
keys := make(map[string]bool)
|
||||
|
||||
// files.storage_key deckt aktive UND gelöschte Dateien ab: beim Löschen
|
||||
// wandert der Schlüssel auf den Papierkorb-Pfad mit.
|
||||
rows, err := c.db.Query(ctx, `SELECT storage_key, id, has_thumbnail FROM files`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query file keys: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var key, id string
|
||||
var hasThumb bool
|
||||
if err := rows.Scan(&key, &id, &hasThumb); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("scan file key: %w", err)
|
||||
}
|
||||
keys[strings.TrimPrefix(key, "/")] = true
|
||||
if hasThumb {
|
||||
keys["thumbs/"+id+".jpg"] = true
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate file keys: %w", err)
|
||||
}
|
||||
|
||||
// Logo und App-Symbol haben keine Zeile in files, sondern stehen in den
|
||||
// Einstellungen. Ohne diese Abfrage hielte der GC sie für verwaist, sobald
|
||||
// "branding" mit durchsucht wird – und löschte sie beim nächsten Lauf.
|
||||
//
|
||||
// Das Logo ist ein einzelner Schlüssel, das App-Symbol ein Verzeichnis:
|
||||
// Endet der Wert auf "/", zählt alles darunter als referenziert.
|
||||
brows, err := c.db.Query(ctx,
|
||||
`SELECT COALESCE(value, '') FROM settings
|
||||
WHERE key IN ('logo_storage_key', 'app_icon_storage_key')`)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("query branding keys: %w", err)
|
||||
}
|
||||
if err == nil {
|
||||
for brows.Next() {
|
||||
var key string
|
||||
if err := brows.Scan(&key); err != nil {
|
||||
brows.Close()
|
||||
return nil, fmt.Errorf("scan branding key: %w", err)
|
||||
}
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimPrefix(key, "/")
|
||||
if !strings.HasSuffix(key, "/") {
|
||||
keys[key] = true
|
||||
continue
|
||||
}
|
||||
if err := c.storage.Walk(ctx, key, func(k string, _ storage.FileInfo) error {
|
||||
keys[k] = true
|
||||
return nil
|
||||
}); err != nil {
|
||||
brows.Close()
|
||||
return nil, fmt.Errorf("walk branding dir %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
brows.Close()
|
||||
if err := brows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate branding keys: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
vrows, err := c.db.Query(ctx, `SELECT storage_key FROM file_versions`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query version keys: %w", err)
|
||||
}
|
||||
defer vrows.Close()
|
||||
for vrows.Next() {
|
||||
var key string
|
||||
if err := vrows.Scan(&key); err != nil {
|
||||
return nil, fmt.Errorf("scan version key: %w", err)
|
||||
}
|
||||
keys[strings.TrimPrefix(key, "/")] = true
|
||||
}
|
||||
return keys, vrows.Err()
|
||||
}
|
||||
|
||||
// Start lässt den GC im Hintergrund laufen: einmal kurz nach dem Start, danach
|
||||
// im Intervall. Der erste Durchlauf ist verzögert, damit er nicht mit dem
|
||||
// Hochfahren konkurriert.
|
||||
func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
|
||||
go func() {
|
||||
timer := time.NewTimer(5 * time.Minute)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
res, err := c.Run(ctx)
|
||||
if err != nil {
|
||||
log.Ctx(ctx).Error().Err(err).Msg("gc: run failed")
|
||||
} else {
|
||||
log.Ctx(ctx).Info().
|
||||
Int("scanned", res.Scanned).
|
||||
Int("orphans", res.Orphans).
|
||||
Int("deleted", res.Deleted).
|
||||
Int64("freed_bytes", res.FreedBytes).
|
||||
Int("skipped_young", res.SkippedYoung).
|
||||
Msg("gc: run complete")
|
||||
if onDone != nil && res.Deleted > 0 {
|
||||
onDone(res)
|
||||
}
|
||||
}
|
||||
timer.Reset(Interval)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package gc
|
||||
|
||||
import "testing"
|
||||
|
||||
// Die Notbremse ist die letzte Verteidigungslinie: Hinter ihr löscht der GC
|
||||
// echte Nutzerdateien. Eine kaputte Abfrage oder eine Verbindung zur falschen
|
||||
// Datenbank sieht von hier aus exakt wie "alles ist verwaist" aus.
|
||||
func TestShouldAbort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
referenced int
|
||||
scanned int
|
||||
want bool
|
||||
}{
|
||||
{"database knows nothing but storage is full", 0, 500, true},
|
||||
{"fresh install – nothing anywhere", 0, 0, false},
|
||||
{"normal run", 120, 125, false},
|
||||
{"single orphan among many", 1, 2, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := shouldAbort(tt.referenced, tt.scanned); got != tt.want {
|
||||
t.Errorf("shouldAbort(%d, %d) = %v, want %v",
|
||||
tt.referenced, tt.scanned, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package office
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Die Ermittlungsliste („discovery") sagt, welche Adresse ein Office-Server
|
||||
// für welchen Dateityp anbietet.
|
||||
//
|
||||
// Fest verdrahten ließe sich das nicht: Collabora und OnlyOffice bauen ihre
|
||||
// Adressen verschieden auf, und beide ändern sie zwischen Fassungen. Gefragt
|
||||
// wird deshalb der Server selbst – einmal, dann für eine Weile gemerkt.
|
||||
|
||||
type discoveryXML struct {
|
||||
NetZone struct {
|
||||
Apps []struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Actions []struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Ext string `xml:"ext,attr"`
|
||||
URLSrc string `xml:"urlsrc,attr"`
|
||||
} `xml:"action"`
|
||||
} `xml:"app"`
|
||||
} `xml:"net-zone"`
|
||||
}
|
||||
|
||||
// discoveryTTL: Wie lange die Liste gilt. Kurz genug, dass ein Neustart des
|
||||
// Office-Servers nach einer Aktualisierung durchschlägt; lang genug, dass
|
||||
// nicht jedes Öffnen eine zusätzliche Anfrage auslöst.
|
||||
const discoveryTTL = 30 * time.Minute
|
||||
|
||||
// Client spricht mit dem Office-Server.
|
||||
type Client struct {
|
||||
provider string
|
||||
baseURL string
|
||||
// publicBase ist die Adresse, unter der der **Browser** den Office-Server
|
||||
// erreicht. Die Ermittlungsliste nennt die, unter der der Server sich
|
||||
// selbst sieht – im Compose-Netz also `collabora:9980`. Die schickt man
|
||||
// keinem Browser.
|
||||
publicBase string
|
||||
http *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
actions map[string]string // ".docx|edit" -> urlsrc
|
||||
fetched time.Time
|
||||
}
|
||||
|
||||
// NewClient baut den Zugang zum Office-Server.
|
||||
//
|
||||
// allowedHosts begrenzt, wohin überhaupt verbunden werden darf. **Das ist
|
||||
// keine Formalie:** Das Backend ruft hier eine Adresse aus der Konfiguration
|
||||
// auf – ohne Grenze wird daraus ein Weg ins interne Netz, sobald jemand die
|
||||
// Konfiguration beeinflussen kann (SSRF). Die Adresse aus OFFICE_URL zählt
|
||||
// immer dazu, sonst wäre die Voreinstellung unbrauchbar.
|
||||
func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insecureTLS bool) (*Client, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return nil, fmt.Errorf("OFFICE_URL is not a valid address: %q", baseURL)
|
||||
}
|
||||
|
||||
allowed := map[string]bool{strings.ToLower(u.Hostname()): true}
|
||||
for _, h := range allowedHosts {
|
||||
allowed[strings.ToLower(strings.TrimSpace(h))] = true
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !allowed[strings.ToLower(host)] {
|
||||
return nil, fmt.Errorf("office: host %q is not in OFFICE_ALLOWED_HOSTS", host)
|
||||
}
|
||||
return (&net.Dialer{Timeout: 10 * time.Second}).DialContext(ctx, network, addr)
|
||||
},
|
||||
// #nosec G402 – nur wenn ausdrücklich eingeschaltet, für den
|
||||
// Testaufbau mit selbstsigniertem Zertifikat auf demselben Rechner.
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecureTLS},
|
||||
}
|
||||
|
||||
if publicURL == "" {
|
||||
publicURL = baseURL
|
||||
}
|
||||
return &Client{
|
||||
provider: provider,
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
publicBase: strings.TrimRight(publicURL, "/"),
|
||||
http: &http.Client{Timeout: 20 * time.Second, Transport: transport},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// toPublic schreibt Schema und Host einer Adresse aus der Ermittlungsliste auf
|
||||
// die Adresse um, unter der der Browser den Server erreicht.
|
||||
//
|
||||
// Der Pfad bleibt unangetastet – er enthält die Fassungsnummer des Servers
|
||||
// (`/browser/1ec9f2b9/cool.html`) und ändert sich mit jeder Aktualisierung.
|
||||
func (c *Client) toPublic(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
pub, err := url.Parse(c.publicBase)
|
||||
if err != nil || pub.Host == "" {
|
||||
return raw
|
||||
}
|
||||
u.Scheme, u.Host = pub.Scheme, pub.Host
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func (c *Client) Provider() string { return c.provider }
|
||||
|
||||
// EditURL liefert die Adresse, unter der ein Dateityp bearbeitet wird.
|
||||
//
|
||||
// Fällt auf „view" zurück, wenn der Server für diesen Typ kein Bearbeiten
|
||||
// anbietet – lesen ist besser als eine Fehlermeldung.
|
||||
func (c *Client) EditURL(ctx context.Context, ext string, write bool) (string, error) {
|
||||
if err := c.refresh(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
order := []string{"edit", "view"}
|
||||
if !write {
|
||||
order = []string{"view", "edit"}
|
||||
}
|
||||
for _, action := range order {
|
||||
if u, ok := c.actions[ext+"|"+action]; ok {
|
||||
return c.toPublic(u), nil
|
||||
}
|
||||
}
|
||||
return "", ErrNoSupport
|
||||
}
|
||||
|
||||
// refresh holt die Liste, wenn sie fehlt oder zu alt ist.
|
||||
func (c *Client) refresh(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
fresh := c.actions != nil && time.Since(c.fetched) < discoveryTTL
|
||||
c.mu.Unlock()
|
||||
if fresh {
|
||||
return nil
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/hosting/discovery", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("office server unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("office discovery returned %d", resp.StatusCode)
|
||||
}
|
||||
// Grenze gegen eine Antwort, die kein Ende nimmt: Die Liste ist wenige
|
||||
// hundert Kilobyte groß.
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var doc discoveryXML
|
||||
if err := xml.Unmarshal(body, &doc); err != nil {
|
||||
return fmt.Errorf("office discovery is not valid XML: %w", err)
|
||||
}
|
||||
|
||||
actions := map[string]string{}
|
||||
for _, app := range doc.NetZone.Apps {
|
||||
for _, a := range app.Actions {
|
||||
if a.Ext == "" || a.URLSrc == "" {
|
||||
continue
|
||||
}
|
||||
ext := a.Ext
|
||||
if !strings.HasPrefix(ext, ".") {
|
||||
ext = "." + ext
|
||||
}
|
||||
actions[strings.ToLower(ext)+"|"+a.Name] = a.URLSrc
|
||||
}
|
||||
}
|
||||
if len(actions) == 0 {
|
||||
return fmt.Errorf("office discovery listed no file types")
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.actions, c.fetched = actions, time.Now()
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package office
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"aether/internal/acl"
|
||||
"aether/internal/auth"
|
||||
"aether/internal/files"
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// maxSaveBytes begrenzt, was der Office-Server zurückschreiben darf.
|
||||
//
|
||||
// Ein Textdokument, das größer ist, hat kein Mensch geschrieben. Ohne Grenze
|
||||
// wäre der Endpunkt eine Möglichkeit, die Platte zu füllen – er ist der
|
||||
// einzige, der ohne Sitzung schreibt.
|
||||
const maxSaveBytes = 512 << 20
|
||||
|
||||
type Handler struct {
|
||||
client *Client
|
||||
signer *Signer
|
||||
files *files.Store
|
||||
blobs storage.Storage
|
||||
acl *acl.Store
|
||||
// baseURL ist die Adresse dieser Anwendung im **Browser**. Sie steht als
|
||||
// PostMessageOrigin in der Auskunft – der Office-Server prüft damit, an
|
||||
// wen er Nachrichten aus dem Rahmen schicken darf.
|
||||
baseURL string
|
||||
// wopiURL ist die Adresse, unter der der **Office-Server** hierher findet.
|
||||
// Aus seinem Container heraus ist „localhost" er selbst.
|
||||
wopiURL string
|
||||
}
|
||||
|
||||
func NewHandler(c *Client, s *Signer, fileStore *files.Store, blobs storage.Storage, aclStore *acl.Store, publicURL, wopiURL string) *Handler {
|
||||
if wopiURL == "" {
|
||||
wopiURL = publicURL
|
||||
}
|
||||
return &Handler{
|
||||
client: c, signer: s, files: fileStore, blobs: blobs, acl: aclStore,
|
||||
baseURL: strings.TrimRight(publicURL, "/"),
|
||||
wopiURL: strings.TrimRight(wopiURL, "/"),
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled sagt der Oberfläche, ob es die Bearbeitung überhaupt gibt. Ein
|
||||
// nil-Handler heißt „abgeschaltet" – dann ist die Antwort eine Auskunft, kein
|
||||
// Fehler.
|
||||
func (h *Handler) Enabled() bool { return h != nil && h.client != nil }
|
||||
|
||||
// ── Vom Browser aufgerufen ───────────────────────────────────────────────────
|
||||
|
||||
// Session beantwortet GET /api/v1/office/session/{id}.
|
||||
//
|
||||
// Gibt zurück, wohin der Browser den Rahmen zeigen soll und mit welchem Token.
|
||||
// Das Token entsteht **hier**, im angemeldeten Zusammenhang – der WOPI-Weg
|
||||
// selbst kennt die Sitzung nicht.
|
||||
func (h *Handler) Session(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.Enabled() {
|
||||
response.JSON(w, http.StatusOK, map[string]any{"enabled": false})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if user == nil {
|
||||
response.Error(w, http.StatusUnauthorized, "not signed in")
|
||||
return
|
||||
}
|
||||
|
||||
// Zugriff auflösen: eigene Datei, geliehene oder Gruppenordner. Wer nur
|
||||
// lesen darf, bekommt ein Token ohne Schreibrecht – nicht etwa gar keins.
|
||||
access, err := h.acl.ResolveFile(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
f, err := h.files.GetFile(r.Context(), id, access.OwnerID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if !Editable(f.Name) {
|
||||
response.Error(w, http.StatusUnsupportedMediaType, "this file type cannot be opened in Office")
|
||||
return
|
||||
}
|
||||
|
||||
write := access.Permission == acl.PermWrite
|
||||
src, err := h.client.EditURL(r.Context(), Extension(f.Name), write)
|
||||
if err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Msg("office: discovery failed")
|
||||
response.Error(w, http.StatusBadGateway, "the office server is not reachable")
|
||||
return
|
||||
}
|
||||
|
||||
token, exp := h.signer.Issue(f.ID, user.ID, write)
|
||||
wopiSrc := fmt.Sprintf("%s/wopi/files/%s", h.wopiURL, f.ID)
|
||||
|
||||
// Die Adresse aus der Ermittlungsliste trägt Platzhalter in <>-Klammern
|
||||
// („<ui=UI_LLCC&>"). Alles, was wir nicht setzen, muss weg – sonst
|
||||
// schickt der Browser die Klammern mit.
|
||||
src = stripPlaceholders(src)
|
||||
sep := "?"
|
||||
if strings.Contains(src, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
// **Vollständig kodiert**, nicht nur die auffälligen Zeichen: Collabora
|
||||
// weist eine unkodierte WOPISrc zurück („WOPISrc must be URI-encoded.
|
||||
// This is highly problematic with proxies, load balancers, and when
|
||||
// tunneling") – Doppelpunkt und Schrägstrich eingeschlossen.
|
||||
src = fmt.Sprintf("%s%sWOPISrc=%s", src, sep, url.QueryEscape(wopiSrc))
|
||||
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": true,
|
||||
"provider": h.client.Provider(),
|
||||
"url": src,
|
||||
"token": token,
|
||||
"token_ttl": exp.UnixMilli(),
|
||||
"can_write": write,
|
||||
"file_name": f.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Vom Office-Server aufgerufen (WOPI) ──────────────────────────────────────
|
||||
|
||||
// authorize prüft das Token und holt Datei und Zugriffskontext.
|
||||
//
|
||||
// Absichtlich ohne Sitzung und ohne CSRF: Diese Aufrufe kommen vom
|
||||
// Office-Server, nicht aus einem Browser. Das Token ist die einzige Berechtigung
|
||||
// – und es gilt für **genau eine Datei**, damit ein entwendetes Token nicht
|
||||
// zum Generalschlüssel wird.
|
||||
func (h *Handler) authorize(w http.ResponseWriter, r *http.Request, needWrite bool) (*files.File, acl.Access, bool) {
|
||||
raw := r.URL.Query().Get("access_token")
|
||||
if raw == "" {
|
||||
raw = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
}
|
||||
access, err := h.signer.Verify(raw)
|
||||
if err != nil {
|
||||
// 401, nicht 403: Der Office-Server versucht es damit gar nicht erst
|
||||
// erneut, statt in eine Schleife zu laufen.
|
||||
http.Error(w, "invalid or expired token", http.StatusUnauthorized)
|
||||
return nil, acl.Access{}, false
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil || id != access.FileID {
|
||||
// Das Token gehört zu einer anderen Datei. 404 statt 403 – sonst
|
||||
// verriete die Antwort, dass es die Datei gibt.
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return nil, acl.Access{}, false
|
||||
}
|
||||
if needWrite && !access.Write {
|
||||
http.Error(w, "read only", http.StatusForbidden)
|
||||
return nil, acl.Access{}, false
|
||||
}
|
||||
|
||||
// Die Rechte werden **bei jedem Aufruf neu** aufgelöst, nicht aus dem
|
||||
// Token übernommen: Eine zurückgezogene Freigabe soll sofort wirken und
|
||||
// nicht erst, wenn das Token abläuft.
|
||||
ctxAccess, err := h.acl.ResolveFile(r.Context(), access.UserID, id)
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return nil, acl.Access{}, false
|
||||
}
|
||||
if needWrite && ctxAccess.Permission != acl.PermWrite {
|
||||
http.Error(w, "read only", http.StatusForbidden)
|
||||
return nil, acl.Access{}, false
|
||||
}
|
||||
f, err := h.files.GetFile(r.Context(), id, ctxAccess.OwnerID)
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return nil, acl.Access{}, false
|
||||
}
|
||||
return f, ctxAccess, true
|
||||
}
|
||||
|
||||
// CheckFileInfo beantwortet GET /wopi/files/{id} – die Auskunft über die Datei.
|
||||
func (h *Handler) CheckFileInfo(w http.ResponseWriter, r *http.Request) {
|
||||
f, access, ok := h.authorize(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
canWrite := access.Permission == acl.PermWrite
|
||||
|
||||
// **Ohne die Hülle `{"data": …}` dieser Anwendung.** WOPI ist ein fremdes
|
||||
// Protokoll mit festem Antwortformat; ein Feld drumherum verstünde der
|
||||
// Office-Server nicht.
|
||||
writeRawJSON(w, map[string]any{
|
||||
"BaseFileName": f.Name,
|
||||
"Size": f.Size,
|
||||
"Version": fmt.Sprintf("%d", f.Version),
|
||||
// Wer speichert, steht später in der Versionshistorie – dort soll ein
|
||||
// Name stehen und nicht eine Kennung.
|
||||
"OwnerId": f.OwnerID.String(),
|
||||
"UserId": access.OwnerID.String(),
|
||||
"UserFriendlyName": access.OwnerUsername,
|
||||
"UserCanWrite": canWrite,
|
||||
"UserCanNotWriteRelative": true,
|
||||
// Kein „Speichern unter", kein Drucken-nach-Hause, keine Verweise auf
|
||||
// fremde Dienste: Was die Datei verlassen könnte, bleibt aus.
|
||||
"DisablePrint": false,
|
||||
"DisableExport": false,
|
||||
"DisableCopy": false,
|
||||
"HideSaveOption": !canWrite,
|
||||
"HideExportOption": false,
|
||||
"PostMessageOrigin": h.baseURL,
|
||||
"LastModifiedTime": f.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// GetFile beantwortet GET /wopi/files/{id}/contents.
|
||||
func (h *Handler) GetFile(w http.ResponseWriter, r *http.Request) {
|
||||
f, _, ok := h.authorize(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rc, err := h.blobs.Get(r.Context(), f.StorageKey)
|
||||
if err != nil {
|
||||
http.Error(w, "storage error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("X-WOPI-ItemVersion", fmt.Sprintf("%d", f.Version))
|
||||
if _, err := io.Copy(w, rc); err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Msg("office: sending file failed")
|
||||
}
|
||||
}
|
||||
|
||||
// PutFile beantwortet POST /wopi/files/{id}/contents – das Speichern.
|
||||
func (h *Handler) PutFile(w http.ResponseWriter, r *http.Request) {
|
||||
f, access, ok := h.authorize(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body := http.MaxBytesReader(w, r.Body, maxSaveBytes)
|
||||
defer body.Close()
|
||||
|
||||
updated, err := files.ReplaceContent(r.Context(), h.files, h.blobs, f, access, body)
|
||||
if err != nil {
|
||||
if errors.Is(err, files.ErrQuotaExceeded) {
|
||||
// 413 sagt dem Office-Server, dass es an der Größe lag – er zeigt
|
||||
// das dem Menschen an, statt still nicht zu speichern.
|
||||
http.Error(w, "storage quota exceeded", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
log.Ctx(r.Context()).Error().Err(err).Str("file_id", f.ID.String()).
|
||||
Msg("office: saving failed")
|
||||
http.Error(w, "could not save", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("X-WOPI-ItemVersion", fmt.Sprintf("%d", updated.Version))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// stripPlaceholders entfernt die <…>-Platzhalter aus einer urlsrc.
|
||||
//
|
||||
// Die Ermittlungsliste liefert z. B.
|
||||
//
|
||||
// https://office/browser/dist/cool.html?<ui=UI_LLCC&><rs=DC_LLCC&>
|
||||
//
|
||||
// Jeder Platzhalter, den der Host nicht ersetzt, muss weg – sonst landen die
|
||||
// spitzen Klammern in der Adresse und der Server lehnt sie ab.
|
||||
func stripPlaceholders(src string) string {
|
||||
var b strings.Builder
|
||||
depth := 0
|
||||
for _, r := range src {
|
||||
switch r {
|
||||
case '<':
|
||||
depth++
|
||||
case '>':
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
default:
|
||||
if depth == 0 {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
// Ein zurückbleibendes „?" oder „&" am Ende stört keinen Server, ist aber
|
||||
// unsauber in der Adresszeile.
|
||||
return strings.TrimRight(out, "?&")
|
||||
}
|
||||
|
||||
// writeRawJSON schreibt ein Objekt ohne die Hülle dieser Anwendung.
|
||||
func writeRawJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Package office bindet einen Office-Server an – Collabora oder OnlyOffice.
|
||||
//
|
||||
// **Ein Adapter für beide.** Beide sprechen WOPI („Web Application Open
|
||||
// Platform Interface"), die Schnittstelle, über die ein Office-Server sich
|
||||
// eine Datei holt und wieder zurückschreibt. Zwei getrennte Integrationen zu
|
||||
// bauen hieße, dieselbe Sache zweimal zu pflegen; umgeschaltet wird über
|
||||
// OFFICE_PROVIDER in der .env.
|
||||
//
|
||||
// **Die Richtung der Aufrufe ist das Entscheidende.** Der Office-Server ist
|
||||
// kein Anhängsel des Browsers – er holt die Datei selbst hier ab:
|
||||
//
|
||||
// Browser ──► Office-Server (die Bedienoberfläche im <iframe>)
|
||||
// Office-Server ──► Aether /wopi/... (Datei holen, Datei zurückschreiben)
|
||||
//
|
||||
// Daraus folgt alles Weitere: Der Office-Server muss Aether unter seiner
|
||||
// **öffentlichen** Adresse erreichen (PUBLIC_URL), nicht unter localhost. Und
|
||||
// weil dieser Weg an der Sitzung vorbeiführt, trägt jeder Aufruf ein eigenes,
|
||||
// kurzlebiges Zugriffstoken.
|
||||
package office
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBadToken = errors.New("invalid access token")
|
||||
ErrExpired = errors.New("access token expired")
|
||||
ErrNoSupport = errors.New("this file type cannot be opened in Office")
|
||||
)
|
||||
|
||||
// tokenTTL ist die Lebensdauer eines Zugriffstokens.
|
||||
//
|
||||
// Zwölf Stunden klingt lang für ein Token – es ist aber die Zeit, die ein
|
||||
// geöffnetes Dokument im Browser stehen darf, bevor das Speichern scheitert.
|
||||
// Kürzer hieße: Wer ein Dokument über die Mittagspause offen lässt, verliert
|
||||
// beim Zurückkommen seine Änderungen. Der Schaden bei einem entwendeten Token
|
||||
// ist begrenzt, weil es **an genau eine Datei** gebunden ist.
|
||||
const tokenTTL = 12 * time.Hour
|
||||
|
||||
// Access beschreibt, was ein Token erlaubt.
|
||||
type Access struct {
|
||||
FileID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
Write bool
|
||||
Expiry time.Time
|
||||
}
|
||||
|
||||
// Signer stellt Zugriffstoken aus und prüft sie.
|
||||
//
|
||||
// HMAC über den Inhalt, nichts in der Datenbank: Ein Token ist damit
|
||||
// überprüfbar, ohne bei jedem Zugriff nachzuschlagen – und es verfällt von
|
||||
// selbst, statt eine Aufräumaufgabe zu brauchen.
|
||||
type Signer struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
func NewSigner(secret string) *Signer {
|
||||
// Abgeleitet statt direkt verwendet: Dasselbe Geheimnis signiert die
|
||||
// Sitzungscookies. Zwei Zwecke sollen nie denselben Schlüssel teilen –
|
||||
// sonst wäre ein Fehler im einen Verfahren einer im anderen.
|
||||
sum := sha256.Sum256([]byte("aether-wopi\x00" + secret))
|
||||
return &Signer{key: sum[:]}
|
||||
}
|
||||
|
||||
// Issue stellt ein Token für genau diese Datei und diesen Nutzer aus.
|
||||
func (s *Signer) Issue(fileID, userID uuid.UUID, write bool) (string, time.Time) {
|
||||
exp := time.Now().Add(tokenTTL)
|
||||
payload := make([]byte, 0, 41)
|
||||
payload = append(payload, fileID[:]...)
|
||||
payload = append(payload, userID[:]...)
|
||||
var w byte
|
||||
if write {
|
||||
w = 1
|
||||
}
|
||||
payload = append(payload, w)
|
||||
payload = binary.BigEndian.AppendUint64(payload, uint64(exp.Unix()))
|
||||
|
||||
mac := hmac.New(sha256.New, s.key)
|
||||
mac.Write(payload)
|
||||
full := append(payload, mac.Sum(nil)...)
|
||||
return base64.RawURLEncoding.EncodeToString(full), exp
|
||||
}
|
||||
|
||||
// Verify prüft ein Token und gibt zurück, was es erlaubt.
|
||||
func (s *Signer) Verify(token string) (Access, error) {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil || len(raw) != 41+sha256.Size {
|
||||
return Access{}, ErrBadToken
|
||||
}
|
||||
payload, sig := raw[:41], raw[41:]
|
||||
|
||||
mac := hmac.New(sha256.New, s.key)
|
||||
mac.Write(payload)
|
||||
// Zeitkonstanter Vergleich: Ein normaler Vergleich verrät über die
|
||||
// Laufzeit, wie viele Bytes stimmen – daraus lässt sich eine Signatur
|
||||
// Byte für Byte erraten.
|
||||
if !hmac.Equal(sig, mac.Sum(nil)) {
|
||||
return Access{}, ErrBadToken
|
||||
}
|
||||
|
||||
var a Access
|
||||
copy(a.FileID[:], payload[0:16])
|
||||
copy(a.UserID[:], payload[16:32])
|
||||
a.Write = payload[32] == 1
|
||||
a.Expiry = time.Unix(int64(binary.BigEndian.Uint64(payload[33:41])), 0)
|
||||
if time.Now().After(a.Expiry) {
|
||||
return Access{}, ErrExpired
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// editableExtensions sind die Endungen, die ein Office-Server bearbeiten kann.
|
||||
//
|
||||
// Eine **Positivliste**, keine Ausschlussliste: Was hier nicht steht, wird
|
||||
// nicht an den Office-Server gereicht. Ein PDF gehört bewusst nicht dazu – es
|
||||
// hat seinen eigenen Betrachter, und ein Office-Server würde daraus beim
|
||||
// Speichern ein anderes Dokument machen.
|
||||
var editableExtensions = map[string]bool{
|
||||
// Textdokumente
|
||||
"odt": true, "docx": true, "doc": true, "rtf": true, "txt": true,
|
||||
// Tabellen
|
||||
"ods": true, "xlsx": true, "xls": true, "csv": true,
|
||||
// Präsentationen
|
||||
"odp": true, "pptx": true, "ppt": true,
|
||||
// Zeichnungen
|
||||
"odg": true,
|
||||
}
|
||||
|
||||
// Editable sagt, ob eine Datei im Office-Server geöffnet werden kann.
|
||||
func Editable(name string) bool {
|
||||
i := strings.LastIndexByte(name, '.')
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
return editableExtensions[strings.ToLower(name[i+1:])]
|
||||
}
|
||||
|
||||
// Extension liefert die Endung in Kleinbuchstaben, mit Punkt – so, wie die
|
||||
// Ermittlungsliste des Office-Servers sie führt.
|
||||
func Extension(name string) string {
|
||||
i := strings.LastIndexByte(name, '.')
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(name[i:])
|
||||
}
|
||||
|
||||
// ProviderName ist der Anzeigename für die Oberfläche.
|
||||
func ProviderName(provider string) string {
|
||||
switch provider {
|
||||
case "collabora":
|
||||
return "Collabora Online"
|
||||
case "onlyoffice":
|
||||
return "OnlyOffice"
|
||||
default:
|
||||
return fmt.Sprintf("Office (%s)", provider)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"aether/internal/auth"
|
||||
"aether/pkg/middleware"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
const stateCookie = "oidc_state"
|
||||
|
||||
// Config holds OIDC provider settings (all required if OIDC is enabled).
|
||||
type Config struct {
|
||||
Issuer string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
// LinkByUsername: bestehendes Konto gleichen Namens übernehmen statt ein
|
||||
// zweites anzulegen. Siehe auth.Store.GetOrCreateByOIDC.
|
||||
LinkByUsername bool
|
||||
}
|
||||
|
||||
// Handler implements /auth/oidc/login and /auth/oidc/callback.
|
||||
type Handler struct {
|
||||
cfg Config
|
||||
provider *gooidc.Provider
|
||||
oauth2 *oauth2.Config
|
||||
verifier *gooidc.IDTokenVerifier
|
||||
|
||||
authStore *auth.Store
|
||||
sessions *auth.SessionStore
|
||||
}
|
||||
|
||||
// New creates an OIDC handler. Returns nil if cfg.Issuer is empty (OIDC disabled).
|
||||
func New(ctx context.Context, cfg Config, authStore *auth.Store, sessions *auth.SessionStore) (*Handler, error) {
|
||||
if cfg.Issuer == "" {
|
||||
return nil, nil
|
||||
}
|
||||
provider, err := gooidc.NewProvider(ctx, cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc provider: %w", err)
|
||||
}
|
||||
oauthCfg := &oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
RedirectURL: cfg.RedirectURL,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: []string{gooidc.ScopeOpenID, "profile"},
|
||||
}
|
||||
return &Handler{
|
||||
cfg: cfg,
|
||||
provider: provider,
|
||||
oauth2: oauthCfg,
|
||||
verifier: provider.Verifier(&gooidc.Config{ClientID: cfg.ClientID}),
|
||||
|
||||
authStore: authStore,
|
||||
sessions: sessions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GET /api/v1/auth/oidc/login – redirect to OIDC provider.
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := randomState()
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "state generation failed")
|
||||
return
|
||||
}
|
||||
// Secure wie bei Sitzungs- und CSRF-Cookie: Hinter dem Reverse Proxy sieht
|
||||
// die App selbst kein TLS, deshalb über IsHTTPS (das X-Forwarded-Proto
|
||||
// auswertet – aber nur von vertrauenswürdigen Proxys). Ohne das ginge der
|
||||
// Zustandswert auch über eine unverschlüsselte Verbindung mit.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: stateCookie, Value: state, Path: "/",
|
||||
MaxAge: 300, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
||||
Secure: middleware.IsHTTPS(r),
|
||||
})
|
||||
http.Redirect(w, r, h.oauth2.AuthCodeURL(state), http.StatusFound)
|
||||
}
|
||||
|
||||
// GET /api/v1/auth/oidc/callback – exchange code, create session, redirect to SPA.
|
||||
func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
// Validate state.
|
||||
stateCk, err := r.Cookie(stateCookie)
|
||||
if err != nil || r.URL.Query().Get("state") != stateCk.Value {
|
||||
http.Redirect(w, r, "/?error=oidc_state", http.StatusFound)
|
||||
return
|
||||
}
|
||||
// Clear state cookie.
|
||||
http.SetCookie(w, &http.Cookie{Name: stateCookie, Value: "", Path: "/", MaxAge: -1})
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
http.Redirect(w, r, "/?error=oidc_no_code", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange code for tokens.
|
||||
token, err := h.oauth2.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?error=oidc_exchange", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/?error=oidc_no_id_token", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
idToken, err := h.verifier.Verify(r.Context(), rawIDToken)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?error=oidc_verify", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract claims.
|
||||
var claims struct {
|
||||
Sub string `json:"sub"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
http.Redirect(w, r, "/?error=oidc_claims", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Derive username: prefer preferred_username, fall back to sub.
|
||||
username := sanitizeUsername(claims.PreferredUsername)
|
||||
if username == "" {
|
||||
username = sanitizeUsername(claims.Sub)
|
||||
}
|
||||
if username == "" {
|
||||
http.Redirect(w, r, "/?error=oidc_no_username", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Find or create user.
|
||||
user, err := h.authStore.GetOrCreateByOIDC(r.Context(), claims.Sub, username, claims.Name, h.cfg.LinkByUsername)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?error=oidc_user", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.sessions.SetUser(w, r, user.ID); err != nil {
|
||||
http.Redirect(w, r, "/?error=oidc_session", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Set CSRF cookie so the SPA can function immediately after redirect.
|
||||
_, _ = auth.SetCSRFCookie(w, r)
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
// PublicConfig returns whether OIDC is enabled (called without auth).
|
||||
func (h *Handler) IsEnabled() bool { return h != nil }
|
||||
|
||||
// ConfigJSON writes the OIDC config state (for login page).
|
||||
func ConfigJSON(enabled bool, cloudName string) []byte {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"oidc_enabled": enabled,
|
||||
"cloud_name": cloudName,
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func randomState() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func sanitizeUsername(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
var out strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.' {
|
||||
out.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package sharing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"aether/internal/audit"
|
||||
"aether/internal/auth"
|
||||
"aether/internal/files"
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/middleware"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
store *Store
|
||||
fileStore *files.Store
|
||||
storage storage.Storage
|
||||
auditor *audit.Logger
|
||||
}
|
||||
|
||||
func NewHandler(store *Store, fileStore *files.Store, st storage.Storage, auditor *audit.Logger) *Handler {
|
||||
return &Handler{store: store, fileStore: fileStore, storage: st, auditor: auditor}
|
||||
}
|
||||
|
||||
// POST /api/v1/files/{id}/share
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
fileID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid file id")
|
||||
return
|
||||
}
|
||||
if _, err := h.fileStore.GetFile(r.Context(), fileID, user.ID); err != nil {
|
||||
response.Error(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Password string `json:"password"`
|
||||
ExpiresIn *int `json:"expires_in_hours"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
|
||||
var passwordHash string
|
||||
if body.Password != "" {
|
||||
passwordHash, err = auth.HashPassword(body.Password)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "hash error")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var expiresAt *time.Time
|
||||
if body.ExpiresIn != nil && *body.ExpiresIn > 0 {
|
||||
t := time.Now().Add(time.Duration(*body.ExpiresIn) * time.Hour)
|
||||
expiresAt = &t
|
||||
}
|
||||
|
||||
link, err := h.store.Create(r.Context(), fileID, user.ID, passwordHash, expiresAt)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "create share link failed")
|
||||
return
|
||||
}
|
||||
// Ein Freigabelink macht eine Datei öffentlich erreichbar – gehört ins
|
||||
// Protokoll. Der Token selbst nicht: er IST der Zugang.
|
||||
detail := "no password"
|
||||
if passwordHash != "" {
|
||||
detail = "password protected"
|
||||
}
|
||||
if expiresAt != nil {
|
||||
detail += ", expires " + expiresAt.Format(time.RFC3339)
|
||||
}
|
||||
h.auditor.Log(r.Context(), audit.Event{
|
||||
ActorID: &user.ID,
|
||||
ActorName: user.Username,
|
||||
Action: audit.ActionShareCreate,
|
||||
TargetType: "file",
|
||||
TargetID: fileID.String(),
|
||||
IP: middleware.RealIP(r),
|
||||
Detail: detail,
|
||||
})
|
||||
response.JSON(w, http.StatusCreated, link)
|
||||
}
|
||||
|
||||
// GET /api/v1/shares – list all share links owned by user
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
links, err := h.store.ListByOwner(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
if links == nil {
|
||||
links = []*ShareLink{}
|
||||
}
|
||||
response.JSON(w, http.StatusOK, links)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/shares/{id}
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid share id")
|
||||
return
|
||||
}
|
||||
if err := h.store.Delete(r.Context(), id, user.ID); errors.Is(err, ErrNotFound) {
|
||||
response.Error(w, http.StatusNotFound, "share link not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
h.auditor.Log(r.Context(), audit.Event{
|
||||
ActorID: &user.ID,
|
||||
ActorName: user.Username,
|
||||
Action: audit.ActionShareDelete,
|
||||
TargetType: "share",
|
||||
TargetID: id.String(),
|
||||
IP: middleware.RealIP(r),
|
||||
})
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// GET /s/{token} – public share info (no auth required)
|
||||
func (h *Handler) GetPublicInfo(w http.ResponseWriter, r *http.Request) {
|
||||
token := chi.URLParam(r, "token")
|
||||
link, _, err := h.store.GetByToken(r.Context(), token)
|
||||
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrExpired) {
|
||||
response.Error(w, http.StatusNotFound, "share link not found or expired")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "error")
|
||||
return
|
||||
}
|
||||
f, err := h.fileStore.GetFileByID(r.Context(), link.FileID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"link": link,
|
||||
"file_name": f.Name,
|
||||
"file_size": f.Size,
|
||||
"mime_type": f.MimeType,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /s/{token}/download – public download, optional password in body
|
||||
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
token := chi.URLParam(r, "token")
|
||||
link, passwordHash, err := h.store.GetByToken(r.Context(), token)
|
||||
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrExpired) {
|
||||
response.Error(w, http.StatusNotFound, "share link not found or expired")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "error")
|
||||
return
|
||||
}
|
||||
|
||||
if link.HasPassword {
|
||||
var body struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
ok, _ := auth.VerifyPassword(body.Password, passwordHash)
|
||||
if !ok {
|
||||
response.Error(w, http.StatusUnauthorized, "incorrect password")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
f, err := h.fileStore.GetFileByID(r.Context(), link.FileID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
}
|
||||
|
||||
rc, err := h.storage.Get(r.Context(), f.StorageKey)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "storage error")
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
go h.store.IncrementDownloads(r.Context(), token)
|
||||
|
||||
w.Header().Set("Content-Type", f.MimeType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, f.Name))
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
|
||||
_, _ = io.Copy(w, rc)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package sharing
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ShareLink struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
FileID uuid.UUID `json:"file_id"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
Token string `json:"token"`
|
||||
HasPassword bool `json:"has_password"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
DownloadCount int `json:"download_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package sharing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("share link not found")
|
||||
var ErrExpired = errors.New("share link expired")
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} }
|
||||
|
||||
func (s *Store) Create(ctx context.Context, fileID, ownerID uuid.UUID, passwordHash string, expiresAt *time.Time) (*ShareLink, error) {
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate token: %w", err)
|
||||
}
|
||||
|
||||
var ph *string
|
||||
if passwordHash != "" {
|
||||
ph = &passwordHash
|
||||
}
|
||||
|
||||
var link ShareLink
|
||||
err = s.db.QueryRow(ctx,
|
||||
`INSERT INTO share_links (file_id, owner_id, token, password_hash, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, file_id, owner_id, token,
|
||||
(password_hash IS NOT NULL), expires_at, download_count, created_at`,
|
||||
fileID, ownerID, token, ph, expiresAt,
|
||||
).Scan(&link.ID, &link.FileID, &link.OwnerID, &link.Token,
|
||||
&link.HasPassword, &link.ExpiresAt, &link.DownloadCount, &link.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create share link: %w", err)
|
||||
}
|
||||
return &link, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetByToken(ctx context.Context, token string) (*ShareLink, string, error) {
|
||||
var link ShareLink
|
||||
var passwordHash *string
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, file_id, owner_id, token,
|
||||
(password_hash IS NOT NULL), password_hash, expires_at, download_count, created_at
|
||||
FROM share_links WHERE token = $1`,
|
||||
token,
|
||||
).Scan(&link.ID, &link.FileID, &link.OwnerID, &link.Token,
|
||||
&link.HasPassword, &passwordHash, &link.ExpiresAt, &link.DownloadCount, &link.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("get share link: %w", err)
|
||||
}
|
||||
if link.ExpiresAt != nil && time.Now().After(*link.ExpiresAt) {
|
||||
return nil, "", ErrExpired
|
||||
}
|
||||
ph := ""
|
||||
if passwordHash != nil {
|
||||
ph = *passwordHash
|
||||
}
|
||||
return &link, ph, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListByOwner(ctx context.Context, ownerID uuid.UUID) ([]*ShareLink, error) {
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT id, file_id, owner_id, token,
|
||||
(password_hash IS NOT NULL), expires_at, download_count, created_at
|
||||
FROM share_links WHERE owner_id = $1 ORDER BY created_at DESC`,
|
||||
ownerID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list share links: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var links []*ShareLink
|
||||
for rows.Next() {
|
||||
var l ShareLink
|
||||
if err := rows.Scan(&l.ID, &l.FileID, &l.OwnerID, &l.Token,
|
||||
&l.HasPassword, &l.ExpiresAt, &l.DownloadCount, &l.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
links = append(links, &l)
|
||||
}
|
||||
return links, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, id, ownerID uuid.UUID) error {
|
||||
tag, err := s.db.Exec(ctx,
|
||||
`DELETE FROM share_links WHERE id = $1 AND owner_id = $2`, id, ownerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete share link: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) IncrementDownloads(ctx context.Context, token string) {
|
||||
_, _ = s.db.Exec(ctx,
|
||||
`UPDATE share_links SET download_count = download_count + 1 WHERE token = $1`, token)
|
||||
}
|
||||
|
||||
func generateToken() (string, error) {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LocalFS stores blobs on the local filesystem.
|
||||
// Every write is atomic: temp file → fsync → rename.
|
||||
type LocalFS struct {
|
||||
root string // absolute, canonical path
|
||||
tmpRoot string // where temp files land during upload
|
||||
}
|
||||
|
||||
func NewLocalFS(root, tmpRoot string) (*LocalFS, error) {
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("abs root: %w", err)
|
||||
}
|
||||
tmpRoot, err = filepath.Abs(tmpRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("abs tmpRoot: %w", err)
|
||||
}
|
||||
for _, dir := range []string{root, tmpRoot} {
|
||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||
return nil, fmt.Errorf("mkdir %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
return &LocalFS{root: root, tmpRoot: tmpRoot}, nil
|
||||
}
|
||||
|
||||
// safePath returns the absolute path for key and ensures it stays under root.
|
||||
func (fs *LocalFS) safePath(key string) (string, error) {
|
||||
// Reject keys that try to escape with .. or absolute paths.
|
||||
if filepath.IsAbs(key) || strings.Contains(key, "..") {
|
||||
return "", fmt.Errorf("invalid storage key: %q", key)
|
||||
}
|
||||
full := filepath.Join(fs.root, filepath.FromSlash(key))
|
||||
// Canonical check: the resolved path must still be under root.
|
||||
canonical, err := filepath.Abs(full)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("abs path: %w", err)
|
||||
}
|
||||
if !strings.HasPrefix(canonical+string(filepath.Separator), fs.root+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("path traversal detected: %q", key)
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func (fs *LocalFS) Put(ctx context.Context, key string, r io.Reader) (string, int64, error) {
|
||||
dest, err := fs.safePath(key)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0750); err != nil {
|
||||
return "", 0, fmt.Errorf("mkdir parent: %w", err)
|
||||
}
|
||||
|
||||
// Write to temp file in tmpRoot (same filesystem for atomic rename).
|
||||
tmp, err := os.CreateTemp(fs.tmpRoot, "upload-*")
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("create temp: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
h := sha256.New()
|
||||
multi := io.MultiWriter(tmp, h)
|
||||
|
||||
size, err := io.Copy(multi, r)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("write temp: %w", err)
|
||||
}
|
||||
|
||||
// fsync: flush to physical storage before rename.
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return "", 0, fmt.Errorf("fsync: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", 0, fmt.Errorf("close temp: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename: either fully committed or not.
|
||||
if err := os.Rename(tmpName, dest); err != nil {
|
||||
return "", 0, fmt.Errorf("rename to final: %w", err)
|
||||
}
|
||||
committed = true
|
||||
|
||||
checksum := hex.EncodeToString(h.Sum(nil))
|
||||
return checksum, size, nil
|
||||
}
|
||||
|
||||
func (fs *LocalFS) Get(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||
path, err := fs.safePath(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("blob not found: %q", key)
|
||||
}
|
||||
return nil, fmt.Errorf("open blob: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (fs *LocalFS) Delete(ctx context.Context, key string) error {
|
||||
path, err := fs.safePath(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("delete blob: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fs *LocalFS) Stat(ctx context.Context, key string) (FileInfo, error) {
|
||||
path, err := fs.safePath(key)
|
||||
if err != nil {
|
||||
return FileInfo{}, err
|
||||
}
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return FileInfo{}, fmt.Errorf("blob not found: %q", key)
|
||||
}
|
||||
return FileInfo{}, fmt.Errorf("stat blob: %w", err)
|
||||
}
|
||||
// Recompute checksum on stat (used for integrity verification).
|
||||
checksum, err := fileChecksum(path)
|
||||
if err != nil {
|
||||
return FileInfo{}, err
|
||||
}
|
||||
return FileInfo{
|
||||
Size: fi.Size(),
|
||||
Checksum: checksum,
|
||||
ModTime: fi.ModTime().UTC().Truncate(time.Second),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateTemp legt eine Datei im Staging-Verzeichnis an. Gedacht für Uploads,
|
||||
// deren Größe erst beim Schreiben feststeht: Der Inhalt landet auf derselben
|
||||
// Platte wie die späteren Blobs, nicht im Arbeitsspeicher. Der Aufrufer ist für
|
||||
// Schließen und Entfernen zuständig.
|
||||
func (fs *LocalFS) CreateTemp(pattern string) (*os.File, error) {
|
||||
f, err := os.CreateTemp(fs.tmpRoot, pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// LocalPath gibt den Pfad im Dateisystem heraus (siehe LocalPather).
|
||||
//
|
||||
// Die Prüfung auf Ausbruch aus dem Wurzelverzeichnis ist dieselbe wie bei jedem
|
||||
// anderen Zugriff – ein Schlüssel mit ".." kommt hier so wenig durch wie bei
|
||||
// Get oder Delete. Der Rückgabewert ist zum **Lesen** gedacht; wer über diesen
|
||||
// Pfad schreibt, umgeht das atomare Schreiben und hat die Garantien verspielt.
|
||||
func (fs *LocalFS) LocalPath(key string) (string, error) {
|
||||
path, err := fs.safePath(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return "", fmt.Errorf("stat %s: %w", key, err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Walk läuft den Baum unterhalb von prefix ab und meldet jede Datei.
|
||||
// Verzeichnisse werden übersprungen; die Prüfsumme bleibt leer, weil das
|
||||
// Nachrechnen über den gesamten Speicher unverhältnismäßig teuer wäre – wer
|
||||
// sie braucht, holt sie über Stat.
|
||||
func (fs *LocalFS) Walk(ctx context.Context, prefix string, fn func(key string, info FileInfo) error) error {
|
||||
prefix = strings.Trim(prefix, "/")
|
||||
base := fs.root
|
||||
if prefix != "" {
|
||||
var err error
|
||||
base, err = fs.safePath(prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(base); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
// Datei ist zwischen Auflisten und Abfragen verschwunden – das ist
|
||||
// im laufenden Betrieb normal und kein Grund abzubrechen.
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(fs.root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fn(filepath.ToSlash(rel), FileInfo{Size: info.Size(), ModTime: info.ModTime()})
|
||||
})
|
||||
}
|
||||
|
||||
// DeletePrefix entfernt ein ganzes Unterverzeichnis des Speichers.
|
||||
//
|
||||
// Absichtlich streng: ein leerer Präfix würde den gesamten Speicher löschen,
|
||||
// deshalb wird er abgelehnt. Der aufgelöste Pfad muss ausserdem echt unterhalb
|
||||
// der Wurzel liegen – safePath prüft das bereits, der zusätzliche Vergleich
|
||||
// schützt davor, versehentlich die Wurzel selbst zu treffen.
|
||||
func (fs *LocalFS) DeletePrefix(ctx context.Context, prefix string) error {
|
||||
prefix = strings.Trim(prefix, "/")
|
||||
if prefix == "" {
|
||||
return fmt.Errorf("refusing to delete empty prefix")
|
||||
}
|
||||
dir, err := fs.safePath(prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dir == fs.root {
|
||||
return fmt.Errorf("refusing to delete storage root")
|
||||
}
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return fmt.Errorf("delete prefix %q: %w", prefix, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fs *LocalFS) Move(ctx context.Context, oldKey, newKey string) error {
|
||||
oldPath, err := fs.safePath(oldKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newPath, err := fs.safePath(newKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(newPath), 0750); err != nil {
|
||||
return fmt.Errorf("mkdir parent: %w", err)
|
||||
}
|
||||
return os.Rename(oldPath, newPath)
|
||||
}
|
||||
|
||||
func fileChecksum(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open for checksum: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", fmt.Errorf("hash file: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestFS(t *testing.T) *LocalFS {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
tmp := t.TempDir()
|
||||
fs, err := NewLocalFS(root, tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalFS: %v", err)
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
func TestPutAndGet(t *testing.T) {
|
||||
fs := newTestFS(t)
|
||||
ctx := context.Background()
|
||||
content := []byte("hello aether")
|
||||
|
||||
checksum, size, err := fs.Put(ctx, "testfile", bytes.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
if size != int64(len(content)) {
|
||||
t.Errorf("size: got %d want %d", size, len(content))
|
||||
}
|
||||
|
||||
// verify checksum
|
||||
h := sha256.Sum256(content)
|
||||
expected := hex.EncodeToString(h[:])
|
||||
if checksum != expected {
|
||||
t.Errorf("checksum: got %s want %s", checksum, expected)
|
||||
}
|
||||
|
||||
// get and read back
|
||||
rc, err := fs.Get(ctx, "testfile")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
got, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, content) {
|
||||
t.Errorf("content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChecksumVerification(t *testing.T) {
|
||||
fs := newTestFS(t)
|
||||
ctx := context.Background()
|
||||
content := []byte("important data")
|
||||
|
||||
storedChecksum, _, err := fs.Put(ctx, "file", bytes.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
info, err := fs.Stat(ctx, "file")
|
||||
if err != nil {
|
||||
t.Fatalf("Stat: %v", err)
|
||||
}
|
||||
if info.Checksum != storedChecksum {
|
||||
t.Errorf("checksum mismatch after stat: got %s want %s", info.Checksum, storedChecksum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTraversal(t *testing.T) {
|
||||
fs := newTestFS(t)
|
||||
ctx := context.Background()
|
||||
|
||||
cases := []string{
|
||||
"../escape",
|
||||
"../../etc/passwd",
|
||||
"/absolute/path",
|
||||
"a/../../b",
|
||||
"foo/../../../bar",
|
||||
}
|
||||
for _, key := range cases {
|
||||
_, _, err := fs.Put(ctx, key, strings.NewReader("data"))
|
||||
if err == nil {
|
||||
t.Errorf("expected error for traversal key %q but got none", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicWrite_NoPartialFile(t *testing.T) {
|
||||
fs := newTestFS(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Simulate an interrupted reader (fails mid-stream).
|
||||
bad := &failReader{data: []byte("partial"), failAt: 3}
|
||||
_, _, err := fs.Put(ctx, "partial", bad)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from failing reader")
|
||||
}
|
||||
|
||||
// The destination file must NOT exist.
|
||||
dest := fs.root + "/partial"
|
||||
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
|
||||
t.Error("partial file must not exist after failed write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
fs := newTestFS(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, _, err := fs.Put(ctx, "todelete", strings.NewReader("bye")); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
if err := fs.Delete(ctx, "todelete"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := fs.Get(ctx, "todelete"); err == nil {
|
||||
t.Error("expected error getting deleted file")
|
||||
}
|
||||
// Double-delete should be a no-op.
|
||||
if err := fs.Delete(ctx, "todelete"); err != nil {
|
||||
t.Errorf("second Delete should be no-op, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// failReader returns data normally up to failAt bytes, then errors.
|
||||
type failReader struct {
|
||||
data []byte
|
||||
pos int
|
||||
failAt int
|
||||
}
|
||||
|
||||
func (r *failReader) Read(p []byte) (int, error) {
|
||||
if r.pos >= r.failAt {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
n := copy(p, r.data[r.pos:min(r.failAt, len(r.data))])
|
||||
r.pos += n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Walk ist die Grundlage der Blob-Bereinigung: Übersieht es Dateien, bleiben
|
||||
// Waisen liegen; meldet es Schlüssel in einer anderen Form als die Datenbank
|
||||
// sie speichert, hält der GC gesunde Blobs für verwaist und löscht sie.
|
||||
func TestWalkReportsEveryBlobWithDBStyleKeys(t *testing.T) {
|
||||
fs := newTestFS(t)
|
||||
ctx := context.Background()
|
||||
|
||||
want := map[string]bool{
|
||||
"users/alice/home/notiz.txt": true,
|
||||
"users/alice/home/Unterordner/bild.png": true,
|
||||
"users/alice/.versions/abc-123": true,
|
||||
"users/alice/.trash/deadbeef_alt.txt": true,
|
||||
}
|
||||
for key := range want {
|
||||
if _, _, err := fs.Put(ctx, key, strings.NewReader("inhalt")); err != nil {
|
||||
t.Fatalf("Put(%q): %v", key, err)
|
||||
}
|
||||
}
|
||||
// Außerhalb des Präfixes – darf nicht mitkommen.
|
||||
if _, _, err := fs.Put(ctx, "thumbs/xyz.jpg", strings.NewReader("x")); err != nil {
|
||||
t.Fatalf("Put thumb: %v", err)
|
||||
}
|
||||
|
||||
got := map[string]bool{}
|
||||
if err := fs.Walk(ctx, "users", func(key string, info FileInfo) error {
|
||||
got[key] = true
|
||||
if info.Size != int64(len("inhalt")) {
|
||||
t.Errorf("Walk reported size %d for %q, want %d", info.Size, key, len("inhalt"))
|
||||
}
|
||||
if info.ModTime.IsZero() {
|
||||
t.Errorf("Walk reported zero ModTime for %q – the GC grace period relies on it", key)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("Walk: %v", err)
|
||||
}
|
||||
|
||||
for key := range want {
|
||||
if !got[key] {
|
||||
t.Errorf("Walk missed %q – the GC would never reclaim it", key)
|
||||
}
|
||||
}
|
||||
if got["thumbs/xyz.jpg"] {
|
||||
t.Error("Walk crossed the prefix boundary")
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("Walk returned %d keys, want %d: %v", len(got), len(want), got)
|
||||
}
|
||||
}
|
||||
|
||||
// Ein fehlender Präfix ist kein Fehler – sonst bräche der GC auf einer frischen
|
||||
// Installation ab, bevor er irgendetwas tut.
|
||||
func TestWalkMissingPrefixIsNotAnError(t *testing.T) {
|
||||
fs := newTestFS(t)
|
||||
|
||||
called := false
|
||||
if err := fs.Walk(context.Background(), "gibtesnicht", func(string, FileInfo) error {
|
||||
called = true
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("Walk on missing prefix: %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Error("callback ran although the prefix does not exist")
|
||||
}
|
||||
}
|
||||
Loaded 100 of 225 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user