improved kv metacore
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/metazla/meta-core/internal/snapshot"
|
||||
)
|
||||
|
||||
// Bound the upload size so a malicious client can't OOM the server.
|
||||
// 2 GiB is well above any realistic export.
|
||||
const maxImportBytes = 2 << 30
|
||||
|
||||
// pluginOutputDir is where plugin-produced artifacts live on disk
|
||||
// (subtitles, posters, etc). Mounted from the host into the meta-core
|
||||
// container at $FILES_PATH/plugin.
|
||||
func (s *Server) pluginOutputDir() string {
|
||||
return filepath.Join(s.config.FilesPath, "plugin")
|
||||
}
|
||||
|
||||
// handleSnapshotExport streams a ZIP snapshot of all metadata.
|
||||
// GET /api/snapshot/export?include=hash-index,plugin-output
|
||||
func (s *Server) handleSnapshotExport(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.storage.IsConnected() {
|
||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||
return
|
||||
}
|
||||
|
||||
// The server has a 30s WriteTimeout (server.go:222). Long exports
|
||||
// blow past that, leaving a truncated ZIP without a central directory.
|
||||
// Clear the deadline for this handler so the export can run as long as
|
||||
// needed. ResponseController is the standard way to do this.
|
||||
rc := http.NewResponseController(w)
|
||||
_ = rc.SetWriteDeadline(time.Time{})
|
||||
|
||||
include := splitCSVParam(r.URL.Query().Get("include"))
|
||||
opts := snapshot.ExportOptions{
|
||||
IncludePluginOutput: include["plugin-output"],
|
||||
PluginOutputDir: s.pluginOutputDir(),
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("metamesh-snapshot-%s.zip", time.Now().UTC().Format("20060102-150405"))
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
// We stream — no Content-Length.
|
||||
|
||||
exporter := snapshot.NewExporter(s.storage)
|
||||
if _, err := exporter.Export(w, opts); err != nil {
|
||||
// Headers may already be sent; log and bail.
|
||||
// Client will see a truncated zip, which is the best signal we can give.
|
||||
fmt.Printf("[Snapshot] export failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handleSnapshotImport applies an uploaded ZIP snapshot.
|
||||
// POST /api/snapshot/import?mode=replace|merge&conflict=mine|source&dry_run=true
|
||||
// Body: application/zip (raw) OR multipart/form-data with field "snapshot"
|
||||
func (s *Server) handleSnapshotImport(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.storage.IsConnected() {
|
||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||
return
|
||||
}
|
||||
|
||||
// Long imports can blow past the server's 30s ReadTimeout/WriteTimeout
|
||||
// when the snapshot is large. Clear both for this handler.
|
||||
rc := http.NewResponseController(w)
|
||||
_ = rc.SetReadDeadline(time.Time{})
|
||||
_ = rc.SetWriteDeadline(time.Time{})
|
||||
|
||||
q := r.URL.Query()
|
||||
opts := snapshot.ImportOptions{
|
||||
Mode: snapshot.Mode(q.Get("mode")),
|
||||
Conflict: snapshot.Conflict(q.Get("conflict")),
|
||||
DryRun: strings.EqualFold(q.Get("dry_run"), "true"),
|
||||
PluginOutputDir: s.pluginOutputDir(),
|
||||
}
|
||||
if opts.Mode == "" {
|
||||
opts.Mode = snapshot.ModeMerge
|
||||
}
|
||||
if opts.Mode == snapshot.ModeMerge && opts.Conflict == "" {
|
||||
opts.Conflict = snapshot.ConflictSource
|
||||
}
|
||||
|
||||
body, err := readImportBody(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
importer := snapshot.NewImporter(s.storage)
|
||||
result, err := importer.Import(bytes.NewReader(body), int64(len(body)), opts)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleSnapshotWipe clears Redis metadata.
|
||||
// POST /api/snapshot/wipe?scope=metadata
|
||||
func (s *Server) handleSnapshotWipe(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.storage.IsConnected() {
|
||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||
return
|
||||
}
|
||||
|
||||
scopes := splitCSVParam(r.URL.Query().Get("scope"))
|
||||
if len(scopes) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "scope query param is required (e.g. scope=metadata)")
|
||||
return
|
||||
}
|
||||
scope := snapshot.WipeScope{
|
||||
Metadata: scopes["metadata"],
|
||||
}
|
||||
if !scope.Metadata {
|
||||
writeError(w, http.StatusBadRequest, "no supported scope requested; scope=metadata is the only accepted value")
|
||||
return
|
||||
}
|
||||
|
||||
wiper := snapshot.NewWiper(s.storage)
|
||||
res, err := wiper.Wipe(scope)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// readImportBody accepts either a raw application/zip body or a multipart
|
||||
// upload with a field named "snapshot". Buffers up to maxImportBytes.
|
||||
func readImportBody(r *http.Request) ([]byte, error) {
|
||||
r.Body = http.MaxBytesReader(nil, r.Body, maxImportBytes)
|
||||
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
return nil, fmt.Errorf("parse multipart: %w", err)
|
||||
}
|
||||
f, _, err := r.FormFile("snapshot")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("missing 'snapshot' file field: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(f)
|
||||
}
|
||||
|
||||
return io.ReadAll(r.Body)
|
||||
}
|
||||
|
||||
// splitCSVParam parses "a,b,c" into a set. Empty values yield an empty set.
|
||||
func splitCSVParam(v string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, part := range strings.Split(v, ",") {
|
||||
p := strings.TrimSpace(part)
|
||||
if p != "" {
|
||||
out[p] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user