Files
playlink/internal/api/server.go
T
cdnutterandClaude Opus 5.5 e8c534a3ae Add CSV export of scan results
The Review tab gets an Export CSV menu: "This view" exports the current
status filter and search, "Entire scan" exports every scanned slave file.

Each row carries status, match type, a recommendation, slave/master paths,
the link target, the match reason, sizes, codec details for both sides,
the quality verdict, notes, errors and timestamps. Rows are streamed in
pages of 1000 so a large export never holds the database connection a
running scan needs. The file starts with a UTF-8 BOM for Excel, and cells
a spreadsheet would evaluate as formulas are prefixed with an apostrophe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 17:47:28 -07:00

570 lines
15 KiB
Go

// Package api exposes the playlink HTTP API and serves the web UI.
package api
import (
"encoding/json"
"errors"
"io"
"io/fs"
"log/slog"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/cdnutter/playlink/internal/engine"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/store"
)
type Config struct {
SecureCookies bool
Version string
}
type Server struct {
cfg Config
st *store.Store
eng *engine.Engine
log *slog.Logger
web fs.FS
loginLimit *limiter
}
func New(cfg Config, st *store.Store, eng *engine.Engine, log *slog.Logger, web fs.FS) *Server {
return &Server{cfg: cfg, st: st, eng: eng, log: log, web: web, loginLimit: newLimiter(10, 5*time.Minute)}
}
func (s *Server) Handler() http.Handler {
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Use(securityHeaders)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) })
r.Route("/api", func(r chi.Router) {
r.Use(s.csrf)
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
w.Header().Set("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
})
r.Get("/auth/state", s.handleAuthState)
r.Post("/auth/setup", s.handleSetup)
r.Post("/auth/login", s.handleLogin)
r.Post("/auth/logout", s.handleLogout)
r.Group(func(r chi.Router) {
r.Use(s.requireAuth)
r.Post("/auth/password", s.handleChangePassword)
r.Get("/info", s.handleInfo)
r.Get("/settings", s.handleGetSettings)
r.Put("/settings", s.handlePutSettings)
r.Get("/browse", s.handleBrowse)
r.Get("/pairs", s.handleListPairs)
r.Post("/pairs", s.handleSavePair)
r.Post("/pairs/validate", s.handleValidatePair)
r.Route("/pairs/{id}", func(r chi.Router) {
r.Get("/", s.handleGetPair)
r.Put("/", s.handleSavePair)
r.Delete("/", s.handleDeletePair)
r.Post("/scan", s.handleScan)
r.Get("/job", s.handleJob)
r.Post("/job/cancel", s.handleCancelJob)
r.Get("/matches", s.handleMatches)
r.Get("/export.csv", s.handleExport)
r.Post("/link-identical", s.handleLinkIdentical)
r.Get("/tree", s.handleTree)
r.Post("/build", s.handleBuild)
r.Post("/remove", s.handleRemove)
})
r.Post("/matches/{id}/link", s.handleLink)
r.Post("/matches/{id}/promote", s.handlePromote)
r.Post("/matches/{id}/ignore", s.handleIgnore)
r.Get("/operations", s.handleOps)
r.Post("/operations/{id}/undo", s.handleUndo)
r.Post("/operations/{id}/purge", s.handlePurge)
})
})
r.NotFound(s.serveUI)
return r
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "+
"script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
next.ServeHTTP(w, r)
})
}
// serveUI serves the embedded single-page app, falling back to index.html.
func (s *Server) serveUI(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
writeErr(w, 404, errors.New("not found"))
return
}
p := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if p == "" {
p = "index.html"
}
if _, err := fs.Stat(s.web, p); err != nil {
p = "index.html"
}
if strings.HasPrefix(p, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
http.ServeFileFS(w, r, s.web, p)
}
// ---- helpers ----
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, code int, err error) {
writeJSON(w, code, map[string]string{"error": err.Error()})
}
func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil && !errors.Is(err, io.EOF) {
writeErr(w, 400, errors.New("invalid request body: "+err.Error()))
return false
}
return true
}
func idParam(r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
return id, err == nil && id > 0
}
// opErr maps engine errors onto HTTP status codes.
func opErr(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, store.ErrNotFound):
writeErr(w, 404, err)
case errors.Is(err, engine.ErrBusy), errors.Is(err, engine.ErrDowngrade), errors.Is(err, engine.ErrConfirm),
errors.Is(err, fsops.ErrChanged), errors.Is(err, fsops.ErrExists):
writeErr(w, 409, err)
case errors.Is(err, fsops.ErrOutsideRoot), errors.Is(err, fsops.ErrNotRegular), errors.Is(err, fsops.ErrCrossDevice):
writeErr(w, 400, err)
default:
writeErr(w, 500, err)
}
}
// ---- info & settings ----
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{
"version": s.cfg.Version,
"allowed_roots": s.eng.AllowedRoots(),
"default_extensions": engine.DefaultExtensions,
"dry_run": s.st.DryRun(r.Context()),
})
}
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"dry_run": s.st.DryRun(r.Context())})
}
func (s *Server) handlePutSettings(w http.ResponseWriter, r *http.Request) {
var body struct {
DryRun *bool `json:"dry_run"`
}
if !readJSON(w, r, &body) {
return
}
if body.DryRun != nil {
v := "0"
if *body.DryRun {
v = "1"
}
if err := s.st.SetSetting(r.Context(), "dry_run", v); err != nil {
writeErr(w, 500, err)
return
}
s.log.Info("dry run changed", "dry_run", *body.DryRun)
}
s.handleGetSettings(w, r)
}
// handleBrowse lists subdirectories for the folder picker, confined to the
// allowed roots.
func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) {
p := r.URL.Query().Get("path")
roots := s.eng.AllowedRoots()
if p == "" {
writeJSON(w, 200, map[string]any{"path": "", "parent": "", "dirs": roots})
return
}
p = filepath.Clean(p)
real, err := filepath.EvalSymlinks(p)
if err != nil {
writeErr(w, 400, err)
return
}
ok := false
for _, root := range roots {
if fsops.Within(root, real) {
ok = true
}
}
if !ok {
writeErr(w, 403, fsops.ErrOutsideRoot)
return
}
ents, err := os.ReadDir(real)
if err != nil {
writeErr(w, 400, err)
return
}
dirs := []string{}
for _, en := range ents {
if en.IsDir() && !strings.HasPrefix(en.Name(), ".") {
dirs = append(dirs, filepath.Join(real, en.Name()))
}
}
sort.Strings(dirs)
parent := filepath.Dir(real)
for _, root := range roots {
if real == root {
parent = ""
}
}
writeJSON(w, 200, map[string]any{"path": real, "parent": parent, "dirs": dirs})
}
// ---- pairs ----
type pairView struct {
store.Pair
Summary store.Summary `json:"summary"`
Job *engine.Job `json:"job"`
}
func (s *Server) view(r *http.Request, p store.Pair) pairView {
sum, _ := s.st.Summary(r.Context(), p.ID)
return pairView{Pair: p, Summary: sum, Job: s.eng.Job(p.ID)}
}
func (s *Server) handleListPairs(w http.ResponseWriter, r *http.Request) {
pairs, err := s.st.Pairs(r.Context())
if err != nil {
writeErr(w, 500, err)
return
}
out := make([]pairView, 0, len(pairs))
for _, p := range pairs {
out = append(out, s.view(r, p))
}
writeJSON(w, 200, out)
}
func (s *Server) handleGetPair(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
p, err := s.st.Pair(r.Context(), id)
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 200, s.view(r, p))
}
type pairInput struct {
Name string `json:"name"`
MasterRoot string `json:"master_root"`
SlaveRoot string `json:"slave_root"`
QuarantineDir string `json:"quarantine_dir"`
Extensions []string `json:"extensions"`
AutoLinkIdentical bool `json:"auto_link_identical"`
ScanIntervalMinutes int `json:"scan_interval_minutes"`
RetentionDays int `json:"retention_days"`
SettleMinutes int `json:"settle_minutes"`
ApproxMatching bool `json:"approx_matching"`
ApproxThreshold int `json:"approx_threshold"`
}
func (in pairInput) apply(p *store.Pair) {
p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir = in.Name, in.MasterRoot, in.SlaveRoot, in.QuarantineDir
p.Extensions, p.AutoLinkIdentical = in.Extensions, in.AutoLinkIdentical
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes = in.ScanIntervalMinutes, in.RetentionDays, in.SettleMinutes
p.ApproxMatching, p.ApproxThreshold = in.ApproxMatching, in.ApproxThreshold
}
func (s *Server) handleValidatePair(w http.ResponseWriter, r *http.Request) {
var in pairInput
if !readJSON(w, r, &in) {
return
}
var p store.Pair
in.apply(&p)
warns, err := s.eng.NormalizePair(&p)
if err != nil {
writeJSON(w, 200, map[string]any{"ok": false, "error": err.Error(), "warnings": []string{}})
return
}
if warns == nil {
warns = []string{}
}
writeJSON(w, 200, map[string]any{"ok": true, "warnings": warns, "pair": p})
}
func (s *Server) handleSavePair(w http.ResponseWriter, r *http.Request) {
var in pairInput
if !readJSON(w, r, &in) {
return
}
var p store.Pair
var old store.Pair
if id, ok := idParam(r); ok {
var err error
if old, err = s.st.Pair(r.Context(), id); err != nil {
opErr(w, err)
return
}
p = old
if j := s.eng.Job(id); j != nil && j.State == "running" {
writeErr(w, 409, engine.ErrBusy)
return
}
}
in.apply(&p)
warns, err := s.eng.NormalizePair(&p)
if err != nil {
writeErr(w, 400, err)
return
}
if err := s.st.SavePair(r.Context(), &p); err != nil {
writeErr(w, 500, err)
return
}
if old.ID != 0 && (old.MasterRoot != p.MasterRoot || old.SlaveRoot != p.SlaveRoot) {
_ = s.st.ClearMatches(r.Context(), p.ID) // results no longer apply
}
p, _ = s.st.Pair(r.Context(), p.ID)
if warns == nil {
warns = []string{}
}
writeJSON(w, 200, map[string]any{"pair": s.view(r, p), "warnings": warns})
}
func (s *Server) handleDeletePair(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
if j := s.eng.Job(id); j != nil && j.State == "running" {
writeErr(w, 409, engine.ErrBusy)
return
}
// Only configuration and history are removed; files are never touched.
if err := s.st.DeletePair(r.Context(), id); err != nil {
writeErr(w, 500, err)
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
if _, err := s.st.Pair(r.Context(), id); err != nil {
opErr(w, err)
return
}
j, err := s.eng.StartScan(id, "manual")
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 202, j)
}
func (s *Server) handleJob(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
writeJSON(w, 200, s.eng.Job(id))
}
func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
writeJSON(w, 200, map[string]any{"cancelled": s.eng.CancelJob(id)})
}
func (s *Server) handleMatches(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
q := r.URL.Query()
limit, _ := strconv.Atoi(q.Get("limit"))
offset, _ := strconv.Atoi(q.Get("offset"))
ms, total, err := s.st.Matches(r.Context(), id, store.MatchFilter{Status: q.Get("status"), Query: q.Get("q"), Limit: limit, Offset: offset})
if err != nil {
writeErr(w, 500, err)
return
}
for i := range ms {
ms[i].LinkTarget = engine.LinkTarget(ms[i].RelSlave, ms[i].RelMaster, false)
}
writeJSON(w, 200, map[string]any{"items": ms, "total": total})
}
func (s *Server) handleLinkIdentical(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
j, err := s.eng.StartLinkIdentical(id)
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 202, j)
}
func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
ents, err := s.eng.Tree(r.Context(), id, r.URL.Query().Get("path"))
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 200, ents)
}
type pathsBody struct {
Paths []string `json:"paths"`
}
func (s *Server) handleBuild(w http.ResponseWriter, r *http.Request) {
s.pathsJob(w, r, s.eng.StartBuild)
}
func (s *Server) handleRemove(w http.ResponseWriter, r *http.Request) {
s.pathsJob(w, r, s.eng.StartRemove)
}
func (s *Server) pathsJob(w http.ResponseWriter, r *http.Request, start func(int64, []string) (*engine.Job, error)) {
id, _ := idParam(r)
var body pathsBody
if !readJSON(w, r, &body) {
return
}
if len(body.Paths) == 0 {
writeErr(w, 400, errors.New("no paths selected"))
return
}
for _, p := range body.Paths {
if _, err := fsops.CleanRel(p); err != nil {
writeErr(w, 400, err)
return
}
}
j, err := start(id, body.Paths)
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 202, j)
}
// ---- match actions ----
func (s *Server) handleLink(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body struct {
AllowDowngrade bool `json:"allow_downgrade"`
KeepFolder bool `json:"keep_folder"`
}
if !readJSON(w, r, &body) {
return
}
op, err := s.eng.Link(r.Context(), id, engine.LinkOptions{AllowDowngrade: body.AllowDowngrade, KeepFolder: body.KeepFolder})
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 200, op)
}
func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body struct {
Confirm bool `json:"confirm"`
}
if !readJSON(w, r, &body) {
return
}
op, err := s.eng.Promote(r.Context(), id, body.Confirm)
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 200, op)
}
func (s *Server) handleIgnore(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body struct {
Ignored bool `json:"ignored"`
}
if !readJSON(w, r, &body) {
return
}
if err := s.st.SetIgnored(r.Context(), id, body.Ignored); err != nil {
writeErr(w, 500, err)
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
// ---- operations ----
func (s *Server) handleOps(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
pairID, _ := strconv.ParseInt(q.Get("pair"), 10, 64)
limit, _ := strconv.Atoi(q.Get("limit"))
offset, _ := strconv.Atoi(q.Get("offset"))
ops, total, err := s.st.Ops(r.Context(), store.OpFilter{PairID: pairID, Quarantine: q.Get("quarantine") == "1", Limit: limit, Offset: offset})
if err != nil {
writeErr(w, 500, err)
return
}
writeJSON(w, 200, map[string]any{"items": ops, "total": total})
}
func (s *Server) handleUndo(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
if err := s.eng.Undo(r.Context(), id); err != nil {
opErr(w, err)
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
func (s *Server) handlePurge(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
if err := s.eng.Purge(r.Context(), id); err != nil {
opErr(w, err)
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}