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>
203 lines
6.0 KiB
Go
203 lines
6.0 KiB
Go
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)
|
||
}
|