Initial commit: playlink music library hardlink deduplicator
Go backend (chi, SQLite) with an embedded React/TypeScript UI that finds songs present in both a host and a slave music library and replaces the slave copy with a hardlink to the host copy. - Path-first matching with format/title/album-folder fallbacks - Auto-linking of byte-identical files, re-verified with cmp before linking - Manual review with ffprobe quality comparison, downgrade guard and promote-to-host override - Build mode to populate an empty slave library from the host - Dry run by default, atomic stage+rename replacement, quarantine with retention, undo, path confinement and authenticated UI - Dockerfile and compose file for Unraid Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
ac47e61b08
42 files changed
+8892
No files matched your search
@@ -0,0 +1,281 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/cdnutter/playlink/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
cookieName = "playlink_session"
|
||||
sessionTTL = 30 * 24 * time.Hour
|
||||
csrfHeader = "X-Playlink-Request"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const userKey ctxKey = 1
|
||||
|
||||
func hashToken(t string) string {
|
||||
h := sha256.Sum256([]byte(t))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func newToken() (string, error) {
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
// limiter is a small per-IP fixed-window limiter for login attempts.
|
||||
type limiter struct {
|
||||
mu sync.Mutex
|
||||
hits map[string][]time.Time
|
||||
max int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
func newLimiter(max int, window time.Duration) *limiter {
|
||||
return &limiter{hits: map[string][]time.Time{}, max: max, window: window}
|
||||
}
|
||||
|
||||
func (l *limiter) allow(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
kept := l.hits[key][:0]
|
||||
for _, t := range l.hits[key] {
|
||||
if now.Sub(t) < l.window {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) >= l.max {
|
||||
l.hits[key] = kept
|
||||
return false
|
||||
}
|
||||
l.hits[key] = append(kept, now)
|
||||
return true
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, uid int64) error {
|
||||
tok, err := newToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exp := time.Now().Add(sessionTTL)
|
||||
if err := s.st.CreateSession(r.Context(), hashToken(tok), uid, exp.Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: tok, Path: "/", Expires: exp, HttpOnly: true,
|
||||
Secure: s.cfg.SecureCookies, SameSite: http.SameSiteStrictMode})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) currentUser(r *http.Request) (store.User, string, error) {
|
||||
c, err := r.Cookie(cookieName)
|
||||
if err != nil || c.Value == "" {
|
||||
return store.User{}, "", store.ErrNotFound
|
||||
}
|
||||
th := hashToken(c.Value)
|
||||
uid, err := s.st.SessionUser(r.Context(), th)
|
||||
if err != nil {
|
||||
return store.User{}, "", err
|
||||
}
|
||||
u, err := s.st.UserByID(r.Context(), uid)
|
||||
return u, th, err
|
||||
}
|
||||
|
||||
// requireAuth rejects unauthenticated requests and, for state-changing
|
||||
// methods, requests that are not same-origin API calls (CSRF defence in
|
||||
// addition to the SameSite=Strict cookie).
|
||||
func (s *Server) requireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, _, err := s.currentUser(r)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusUnauthorized, errors.New("not signed in"))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey, u)))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) csrf(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
default:
|
||||
if r.Header.Get(csrfHeader) != "1" {
|
||||
writeErr(w, http.StatusForbidden, errors.New("missing request header"))
|
||||
return
|
||||
}
|
||||
if o := r.Header.Get("Origin"); o != "" {
|
||||
u, err := url.Parse(o)
|
||||
if err != nil || !strings.EqualFold(u.Host, r.Host) {
|
||||
writeErr(w, http.StatusForbidden, errors.New("cross-origin request refused"))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type credentials struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func validPassword(p string) error {
|
||||
if len(p) < 10 {
|
||||
return errors.New("password must be at least 10 characters")
|
||||
}
|
||||
if len(p) > 72 {
|
||||
return errors.New("password must be at most 72 bytes")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthState(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.st.UserCount(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, 500, err)
|
||||
return
|
||||
}
|
||||
resp := map[string]any{"setup_required": n == 0, "authenticated": false}
|
||||
if u, _, err := s.currentUser(r); err == nil {
|
||||
resp["authenticated"] = true
|
||||
resp["username"] = u.Username
|
||||
}
|
||||
writeJSON(w, 200, resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
var c credentials
|
||||
if !readJSON(w, r, &c) {
|
||||
return
|
||||
}
|
||||
c.Username = strings.TrimSpace(c.Username)
|
||||
if c.Username == "" {
|
||||
writeErr(w, 400, errors.New("username is required"))
|
||||
return
|
||||
}
|
||||
if err := validPassword(c.Password); err != nil {
|
||||
writeErr(w, 400, err)
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(c.Password), 12)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err)
|
||||
return
|
||||
}
|
||||
if err := s.st.CreateFirstUser(r.Context(), c.Username, string(hash)); err != nil {
|
||||
writeErr(w, 409, err)
|
||||
return
|
||||
}
|
||||
u, err := s.st.UserByName(r.Context(), c.Username)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err)
|
||||
return
|
||||
}
|
||||
if err := s.startSession(w, r, u.ID); err != nil {
|
||||
writeErr(w, 500, err)
|
||||
return
|
||||
}
|
||||
s.log.Info("initial user created", "user", u.Username)
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// dummyHash keeps login timing similar for unknown usernames.
|
||||
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("playlink-timing-equalizer"), 12)
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.loginLimit.allow(clientIP(r)) {
|
||||
writeErr(w, http.StatusTooManyRequests, errors.New("too many attempts; try again in a few minutes"))
|
||||
return
|
||||
}
|
||||
var c credentials
|
||||
if !readJSON(w, r, &c) {
|
||||
return
|
||||
}
|
||||
u, err := s.st.UserByName(r.Context(), strings.TrimSpace(c.Username))
|
||||
hash := dummyHash
|
||||
if err == nil {
|
||||
hash = []byte(u.PasswordHash)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword(hash, []byte(c.Password)) != nil || err != nil {
|
||||
s.log.Warn("failed login", "ip", clientIP(r))
|
||||
writeErr(w, http.StatusUnauthorized, errors.New("invalid username or password"))
|
||||
return
|
||||
}
|
||||
if err := s.startSession(w, r, u.ID); err != nil {
|
||||
writeErr(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
_ = s.st.DeleteSession(r.Context(), hashToken(c.Value))
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true,
|
||||
Secure: s.cfg.SecureCookies, SameSite: http.SameSiteStrictMode})
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Current string `json:"current"`
|
||||
New string `json:"new"`
|
||||
}
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
u, th, err := s.currentUser(r)
|
||||
if err != nil {
|
||||
writeErr(w, 401, err)
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Current)) != nil {
|
||||
writeErr(w, 400, errors.New("current password is incorrect"))
|
||||
return
|
||||
}
|
||||
if err := validPassword(body.New); err != nil {
|
||||
writeErr(w, 400, err)
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(body.New), 12)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err)
|
||||
return
|
||||
}
|
||||
if err := s.st.SetPassword(r.Context(), u.ID, string(hash)); err != nil {
|
||||
writeErr(w, 500, err)
|
||||
return
|
||||
}
|
||||
_ = s.st.DeleteUserSessions(r.Context(), u.ID, th) // sign out other devices
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
// 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.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"`
|
||||
HostRoot string `json:"host_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"`
|
||||
}
|
||||
|
||||
func (in pairInput) apply(p *store.Pair) {
|
||||
p.Name, p.HostRoot, p.SlaveRoot, p.QuarantineDir = in.Name, in.HostRoot, in.SlaveRoot, in.QuarantineDir
|
||||
p.Extensions, p.AutoLinkIdentical = in.Extensions, in.AutoLinkIdentical
|
||||
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes = in.ScanIntervalMinutes, in.RetentionDays, in.SettleMinutes
|
||||
}
|
||||
|
||||
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.HostRoot != p.HostRoot || 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
|
||||
}
|
||||
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"`
|
||||
}
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
op, err := s.eng.Link(r.Context(), id, engine.LinkOptions{AllowDowngrade: body.AllowDowngrade})
|
||||
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})
|
||||
}
|
||||
Reference in new issue
Block a user