// 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.Get("/groups", s.handleGroups) r.Post("/bulk/preview", s.handleBulkPreview) r.Post("/bulk", s.handleBulk) r.Get("/renames", s.handleRenamePreview) r.Post("/renames", s.handleRenames) 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), errors.Is(err, engine.ErrInvalidBulk): 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()), "plex_ignore": s.st.Setting(r.Context(), "plex_ignore", "0") == "1", }) } func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"dry_run": s.st.DryRun(r.Context()), "plex_ignore": s.st.Setting(r.Context(), "plex_ignore", "0") == "1"}) } func (s *Server) handlePutSettings(w http.ResponseWriter, r *http.Request) { var body struct { DryRun *bool `json:"dry_run"` PlexIgnore *bool `json:"plex_ignore"` } 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) } if body.PlexIgnore != nil { v := "0" if *body.PlexIgnore { v = "1" } if err := s.st.SetSetting(r.Context(), "plex_ignore", v); err != nil { writeErr(w, 500, err) return } results := s.eng.ApplyPlexIgnore(r.Context(), *body.PlexIgnore) writeJSON(w, 200, map[string]any{"dry_run": s.st.DryRun(r.Context()), "plex_ignore": *body.PlexIgnore, "results": results}) return } 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": []store.Warning{}}) return } if warns == nil { warns = []store.Warning{} } 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 = []store.Warning{} } 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")) var ids []int64 for _, part := range strings.Split(q.Get("ids"), ",") { if n, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64); err == nil && n > 0 { ids = append(ids, n) } } ms, total, err := s.st.Matches(r.Context(), id, store.MatchFilter{Status: q.Get("status"), Query: q.Get("q"), IDs: ids, 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}) } // ---- album grouping and bulk actions ---- func (s *Server) handleGroups(w http.ResponseWriter, r *http.Request) { id, _ := idParam(r) q := r.URL.Query() groups, err := s.eng.Groups(r.Context(), id, store.MatchFilter{Status: q.Get("status"), Query: q.Get("q")}) if err != nil { writeErr(w, 500, err) return } writeJSON(w, 200, groups) } type bulkBody struct { engine.BulkOptions IDs []int64 `json:"ids"` } func (s *Server) handleBulkPreview(w http.ResponseWriter, r *http.Request) { id, _ := idParam(r) var b bulkBody if !readJSON(w, r, &b) { return } p, err := s.eng.PreviewBulk(r.Context(), id, b.IDs, b.BulkOptions) if err != nil { writeErr(w, 400, err) return } writeJSON(w, 200, p) } func (s *Server) handleBulk(w http.ResponseWriter, r *http.Request) { id, _ := idParam(r) var b bulkBody if !readJSON(w, r, &b) { return } switch b.Action { case engine.BulkIgnore, engine.BulkUnignore: n, err := s.eng.ApplyIgnore(r.Context(), id, b.IDs, b.BulkOptions) if err != nil { writeErr(w, 400, err) return } writeJSON(w, 200, map[string]any{"changed": n}) default: j, err := s.eng.StartBulk(id, b.IDs, b.BulkOptions) if err != nil { opErr(w, err) return } s.log.Info("bulk action started", "pair", id, "action", b.Action, "songs", len(b.IDs), "allow_downgrade", b.AllowDowngrade) writeJSON(w, 202, j) } } // ---- folder renames ---- func (s *Server) handleRenamePreview(w http.ResponseWriter, r *http.Request) { id, _ := idParam(r) items, err := s.eng.PreviewRenames(r.Context(), id) if err != nil { opErr(w, err) return } writeJSON(w, 200, map[string]any{"items": items, "dry_run": s.st.DryRun(r.Context())}) } func (s *Server) handleRenames(w http.ResponseWriter, r *http.Request) { id, _ := idParam(r) var body struct { // Only restricts the renames to these slave folders; empty renames all. Only []string `json:"only"` } if !readJSON(w, r, &body) { return } for _, p := range body.Only { if _, err := fsops.CleanRel(p); err != nil { writeErr(w, 400, err) return } } j, err := s.eng.StartRenames(id, body.Only) if err != nil { opErr(w, err) return } s.log.Info("folder renames started", "pair", id, "selected", len(body.Only)) writeJSON(w, 202, j) }