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:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+202
View File
@@ -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)
}