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/, 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) }