Am Finger öffnet ein Office-Dokument jetzt das gewandelte PDF im eigenen Betrachter; "Edit" daneben führt trotzdem in den Editor. Mit Maus ändert sich nichts. Der Web-Editor ist eine vollständige Textverarbeitung – zwischen seinen Leisten bleibt auf 400 Pixeln vom Dokument ein Rechteck. Collabora und OnlyOffice verhalten sich gleich; deshalb kein besserer Bedienmodus, sondern ein anderes Werkzeug. Gewandelt wird von dem Server, der auch bearbeitet: Sonst sähe man unterwegs ein LibreOffice-Layout eines Dokuments, für das man OnlyOffice gewählt hat. Je Dokument gibt es GENAU EIN PDF, und das ist keine Frage des Aufräumens, sondern des Schlüssels: previews/<id>.pdf trägt die Fassung nicht im Namen, eine neue Fassung überschreibt. files.preview_version sagt, ob das Abgelegte noch passt. Sicherheit: Die Adresse, die OnlyOffice zurückgibt, wird von uns abgerufen – der Wähler des Transports lässt nur OFFICE_ALLOWED_HOSTS zu, zusätzlich eine ausdrückliche Prüfung mit Test. JWT-Signatur (HS256) für die eigene API des Dokumentenservers. Fremde Kennung 404, ohne Anmeldung 401. Das Ergebnis muss mit %PDF- beginnen, sonst landete eine Fehlerseite im Zwischenspeicher. Gemessen gegen einen echten OnlyOffice-Server im Docker-Netz: erster Abruf 1,05 s -> zweiter 0,009 s erneut hochgeladen -> Fassung 2, preview_version 2, weiterhin 1 Datei GC: herrenloses PDF von vorgestern weg, echtes bleibt endgültig gelöscht -> PDF weg; Papierkorb -> PDF bleibt ohne Anmeldung 401, fremde Kennung 404, Nicht-Office 415 Dazu im Admin-Bereich neu: Platzbedarf der abgeleiteten Daten (Vorschaubilder, PDFs, Profilbilder). Sie zählen gegen kein Kontingent – ein Ordner, der wächst, ohne dass ihn jemand sieht, ist eine stille Überraschung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
150 lines
4.3 KiB
Go
150 lines
4.3 KiB
Go
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")
|
||
// Das als PDF gewandelte Office-Dokument. Es trägt denselben Inhalt
|
||
// wie die Datei – bleibt es liegen, überlebt der Inhalt das endgültige
|
||
// Löschen.
|
||
_ = h.storage.Delete(r.Context(), "previews/"+id.String()+".pdf")
|
||
}
|
||
|
||
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.storage.Delete(r.Context(), "previews/"+id.String()+".pdf")
|
||
h.logFile(r, audit.ActionHardDelete, id.String(), "", "permanently deleted from trash")
|
||
response.NoContent(w)
|
||
}
|