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

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