Files
Aether/backend/internal/files/handler_versions.go
T
HoneyandClaude Opus 5 31a027c93a 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>
2026-08-24 09:25:36 +02:00

203 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}