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,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)
|
||||
}
|
||||
Reference in new issue
Block a user