package api import ( "encoding/json" "errors" "io" "net/http" "github.com/go-chi/chi/v5" "git.fabledsword.com/bvandeusen/minstrel/internal/acquire" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // Indexers and download clients, admin side (M502 #5518). Rows the operator // adds and configures; nothing is seeded. Secrets are write-only: no response // carries one, only whether it is set. // acquisitionBodyLimit caps a settings body. The largest is a full form of // short strings. const acquisitionBodyLimit = 64 << 10 type acquisitionServiceResp struct { Code string `json:"code"` Kind string `json:"kind"` Protocol string `json:"protocol"` Label string `json:"label"` Enabled bool `json:"enabled"` BaseURL string `json:"base_url"` HasAPIKey bool `json:"has_api_key"` HasPassword bool `json:"has_password"` Username string `json:"username"` Category string `json:"category"` TimeoutMs int32 `json:"timeout_ms"` ServesSearch bool `json:"serves_search"` ServesDownloads bool `json:"serves_downloads"` ServesEnrichment bool `json:"serves_enrichment"` LocalPath string `json:"local_path"` RemotePath string `json:"remote_path"` SearchesPerHour int32 `json:"searches_per_hour"` // Usable is switched on and addressed: the state the jobs read. Usable bool `json:"usable"` } func acquisitionRespOf(r dbq.AcquisitionService) acquisitionServiceResp { return acquisitionServiceResp{ Code: r.Code, Kind: r.Kind, Protocol: r.Protocol, Label: r.Label, Enabled: r.Enabled, BaseURL: r.BaseUrl, HasAPIKey: r.ApiKey != "", HasPassword: r.Password != "", Username: r.Username, Category: r.Category, TimeoutMs: r.TimeoutMs, ServesSearch: r.ServesSearch, ServesDownloads: r.ServesDownloads, ServesEnrichment: r.ServesEnrichment, LocalPath: r.LocalPath, RemotePath: r.RemotePath, SearchesPerHour: r.SearchesPerHour, Usable: r.Enabled && r.BaseUrl != "", } } // handleListAcquisitionServices implements GET /api/admin/acquisition/services. func (h *handlers) handleListAcquisitionServices(w http.ResponseWriter, r *http.Request) { rows, err := h.acquisition.List(r.Context()) if err != nil { writeErrWithLog(w, h.logger, "admin: list acquisition services", apierror.Internal(err)) return } out := make([]acquisitionServiceResp, len(rows)) for i, row := range rows { out[i] = acquisitionRespOf(dbq.AcquisitionService{ Code: row.Code, Kind: row.Kind, Protocol: row.Protocol, Label: row.Label, Enabled: row.Enabled, BaseUrl: row.BaseUrl, Username: row.Username, Category: row.Category, TimeoutMs: row.TimeoutMs, ServesSearch: row.ServesSearch, ServesDownloads: row.ServesDownloads, ServesEnrichment: row.ServesEnrichment, LocalPath: row.LocalPath, RemotePath: row.RemotePath, SearchesPerHour: row.SearchesPerHour, }) out[i].HasAPIKey, out[i].HasPassword = row.HasApiKey, row.HasPassword } writeJSON(w, http.StatusOK, map[string]any{"services": out}) } // handleCreateAcquisitionService implements POST /api/admin/acquisition/services // with {"protocol": "prowlarr"|"torznab"|"qbittorrent"|"exchange"}. The row // starts switched off, with no address. func (h *handlers) handleCreateAcquisitionService(w http.ResponseWriter, r *http.Request) { var body struct { Protocol string `json:"protocol"` } if !decodeAcquisitionBody(w, r, &body) { return } row, err := h.acquisition.Create(r.Context(), body.Protocol) if err != nil { h.writeAcquisitionErr(w, "admin: create acquisition service", err) return } writeJSON(w, http.StatusCreated, acquisitionRespOf(row)) } type updateAcquisitionBody struct { Label string `json:"label"` Enabled bool `json:"enabled"` BaseURL string `json:"base_url"` Username string `json:"username"` Category string `json:"category"` TimeoutMs int32 `json:"timeout_ms"` ServesSearch bool `json:"serves_search"` ServesDownloads bool `json:"serves_downloads"` ServesEnrichment bool `json:"serves_enrichment"` LocalPath string `json:"local_path"` RemotePath string `json:"remote_path"` SearchesPerHour int32 `json:"searches_per_hour"` } // handleUpdateAcquisitionService implements PUT // /api/admin/acquisition/services/{code}: the whole form except credentials. func (h *handlers) handleUpdateAcquisitionService(w http.ResponseWriter, r *http.Request) { var body updateAcquisitionBody if !decodeAcquisitionBody(w, r, &body) { return } row, err := h.acquisition.Save(r.Context(), chi.URLParam(r, "code"), acquire.Update(body)) if err != nil { h.writeAcquisitionErr(w, "admin: update acquisition service", err) return } writeJSON(w, http.StatusOK, acquisitionRespOf(row)) } // handleDeleteAcquisitionService implements DELETE // /api/admin/acquisition/services/{code}. func (h *handlers) handleDeleteAcquisitionService(w http.ResponseWriter, r *http.Request) { if err := h.acquisition.Delete(r.Context(), chi.URLParam(r, "code")); err != nil { h.writeAcquisitionErr(w, "admin: delete acquisition service", err) return } w.WriteHeader(http.StatusNoContent) } // handleTestAcquisitionService implements POST // /api/admin/acquisition/services/{code}/test. POST because it reaches // another machine, which a prefetch must not trigger. Always 200 with // {ok, detail}: a failed connection is the answer, not an error. func (h *handlers) handleTestAcquisitionService(w http.ResponseWriter, r *http.Request) { row, err := h.acquisition.Get(r.Context(), chi.URLParam(r, "code")) if err != nil { h.writeAcquisitionErr(w, "admin: test acquisition service", err) return } writeJSON(w, http.StatusOK, acquire.Test(r.Context(), row)) } // handleSetAcquisitionSecret implements PUT and DELETE on // /api/admin/acquisition/services/{code}/api-key and .../password. PUT takes // {"value": "..."}; DELETE clears it. 204 with no body either way: echoing // anything here is how a secret ends up in a response. func (h *handlers) handleSetAcquisitionSecret(field string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { value := "" if r.Method == http.MethodPut { var body struct { Value string `json:"value"` } if !decodeAcquisitionBody(w, r, &body) { return } if body.Value == "" { writeErr(w, apierror.BadRequest("invalid_setting", "Paste a value, or use Remove to clear the one that is set.")) return } value = body.Value } code := chi.URLParam(r, "code") var err error if field == "password" { err = h.acquisition.SetPassword(r.Context(), code, value) } else { err = h.acquisition.SetAPIKey(r.Context(), code, value) } if err != nil { h.writeAcquisitionErr(w, "admin: set acquisition "+field, err) return } w.WriteHeader(http.StatusNoContent) } } func decodeAcquisitionBody(w http.ResponseWriter, r *http.Request, into any) bool { err := json.NewDecoder(http.MaxBytesReader(w, r.Body, acquisitionBodyLimit)).Decode(into) if err != nil && !errors.Is(err, io.EOF) { writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON")) return false } return true } func (h *handlers) writeAcquisitionErr(w http.ResponseWriter, msg string, err error) { var invalid *acquire.InvalidError switch { case errors.As(err, &invalid): writeErr(w, apierror.BadRequest("invalid_setting", invalid.Message)) case errors.Is(err, acquire.ErrNotFound): writeErr(w, apierror.NotFound("service")) case errors.Is(err, acquire.ErrTooMany): writeErr(w, apierror.Conflict("too_many_services", "There are already twenty of these. Remove one you no longer use first.")) default: writeErrWithLog(w, h.logger, msg, apierror.Internal(err)) } }