В карточке списка и шапке /download/{id} показываем и копируем download.id
(ULID) — тот же ключ, что в логах (download_id), удобно грепать. Infohash
остаётся в блоке «Информация о торренте». Поиск по списку расширен: матчит
любой идентификатор (download.id ИЛИ infohash), плюс название/контекст.
Удалены осиротевшие поля Infohash/InfohashShort и хелпер shortenHash.
Дельта web-ui влита в спеки, change заархивирован.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
705 lines
25 KiB
Go
705 lines
25 KiB
Go
// Package httpapi предоставляет HTTP API и веб-UI (server-rendered).
|
||
//
|
||
// Тонкий транспорт над ядром: приём идёт в ingest, команды (cancel/retry) —
|
||
// в worker, чтение — в store. В v1 без авторизации (доверенная LAN).
|
||
package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"html/template"
|
||
"io/fs"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
"github.com/go-chi/chi/v5/middleware"
|
||
|
||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||
"git.vakhrushev.me/av/jellybit/web"
|
||
)
|
||
|
||
// Ingestor принимает загрузку (ingest.Service).
|
||
type Ingestor interface {
|
||
Ingest(ctx context.Context, req ingest.Request) (ingest.Result, error)
|
||
}
|
||
|
||
// Commander исполняет команды над задачей (worker.Worker).
|
||
type Commander interface {
|
||
Cancel(ctx context.Context, id string) error
|
||
Retry(ctx context.Context, id string) error
|
||
}
|
||
|
||
// Reader читает задачи (store.Store).
|
||
type Reader interface {
|
||
ListDownloads(ctx context.Context) ([]store.Download, error)
|
||
ListDownloadsPage(ctx context.Context, f store.ListFilter) ([]store.Download, int, error)
|
||
GetDownload(ctx context.Context, id string) (*store.Download, error)
|
||
}
|
||
|
||
// Deps — зависимости транспорта.
|
||
type Deps struct {
|
||
Logger *slog.Logger
|
||
Ingestor Ingestor
|
||
Commander Commander
|
||
Reader Reader
|
||
Reviewer Reviewer
|
||
Live LiveStatus
|
||
}
|
||
|
||
type server struct {
|
||
deps Deps
|
||
tmpl *template.Template
|
||
assetVer string
|
||
}
|
||
|
||
// NewRouter собирает HTTP-обработчик сервиса.
|
||
func NewRouter(d Deps) (http.Handler, error) {
|
||
assetVer, err := assetVersion()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
funcs := template.FuncMap{
|
||
"add": func(a, b int) int { return a + b },
|
||
"asset": func(p string) string { return "/static/" + p + "?v=" + assetVer },
|
||
"badgeLabel": badgeLabel,
|
||
}
|
||
tmpl, err := template.New("").Funcs(funcs).
|
||
ParseFS(web.FS, "templates/*.html", "templates/partials/*.html")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
staticFS, err := fs.Sub(web.FS, "static")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if d.Live == nil {
|
||
d.Live = noLive{} // источник телеметрии не подключён — деградируем штатно
|
||
}
|
||
s := &server{deps: d, tmpl: tmpl, assetVer: assetVer}
|
||
|
||
r := chi.NewRouter()
|
||
r.Use(middleware.RequestID)
|
||
r.Use(middleware.Recoverer)
|
||
r.Use(requestLogger(d.Logger))
|
||
|
||
r.Get("/healthz", handleHealthz)
|
||
|
||
// Статика (встроенная, с длинным кэшем; URL версионируются ?v=).
|
||
r.Handle("/static/*", http.StripPrefix("/static/", staticHandler(staticFS)))
|
||
|
||
// Веб-UI.
|
||
r.Get("/", s.handleIndex)
|
||
r.Get("/download/{id}", s.handleDownload)
|
||
|
||
// Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера).
|
||
r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress)
|
||
r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding)
|
||
r.Post("/ui/downloads", s.handleUIAdd)
|
||
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
|
||
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
|
||
|
||
// Веб-UI: ревью раскладки.
|
||
r.Get("/review/{id}", s.handleReview)
|
||
r.Post("/ui/downloads/{id}/apply", s.handleApply)
|
||
r.Post("/ui/downloads/{id}/refine", s.handleRefine)
|
||
r.Post("/ui/downloads/{id}/rerecognize", s.handleRerecognize)
|
||
r.Post("/ui/downloads/{id}/ignore", s.handleIgnore)
|
||
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
|
||
r.Post("/ui/downloads/{id}/provider", s.handleSetProvider)
|
||
r.Post("/ui/downloads/{id}/source", s.handleAddSource)
|
||
r.Post("/ui/downloads/{id}/nobase", s.handleNoBase)
|
||
r.Post("/ui/downloads/{id}/defer", s.handleDefer)
|
||
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
|
||
r.Post("/ui/downloads/{id}/relink", s.handleRelink)
|
||
|
||
// REST API.
|
||
r.Route("/api", func(r chi.Router) {
|
||
r.Get("/downloads", s.handleAPIList)
|
||
r.Post("/downloads", s.handleAPIAdd)
|
||
r.Get("/downloads/{id}", s.handleAPIGet)
|
||
r.Post("/downloads/{id}/cancel", s.handleAPICancel)
|
||
r.Post("/downloads/{id}/retry", s.handleAPIRetry)
|
||
})
|
||
|
||
return r, nil
|
||
}
|
||
|
||
func handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||
}
|
||
|
||
// --- Веб-UI ---
|
||
|
||
// pageSize — размер страницы списка загрузок (серверная пагинация).
|
||
const pageSize = 25
|
||
|
||
type indexView struct {
|
||
Error string
|
||
Downloads []downloadView
|
||
|
||
// Фильтр/поиск (серверные, в query).
|
||
Filter string // активная группа (all/review/active/done/problem)
|
||
Query string // текст поиска
|
||
ShowAll bool // показывать удалённые
|
||
Chips []filterChip // чипы фильтра со ссылками
|
||
ShowURL string // ссылка тумблера «показать всё»
|
||
|
||
// Пагинация.
|
||
Page int
|
||
Pages int // всего страниц (>=1)
|
||
Total int // всего строк под фильтром
|
||
Searching bool // активны фильтр/поиск — влияет на текст пустого состояния
|
||
PrevURL string // пусто — на первой странице
|
||
NextURL string // пусто — на последней
|
||
PageLinks []pageLink // пронумерованные страницы (окно)
|
||
}
|
||
|
||
type filterChip struct {
|
||
Key string
|
||
Label string
|
||
URL string
|
||
Active bool
|
||
}
|
||
|
||
type pageLink struct {
|
||
Num int
|
||
URL string
|
||
Active bool
|
||
}
|
||
|
||
type downloadView struct {
|
||
ID string
|
||
Title string // отображаемый заголовок карточки
|
||
Context string
|
||
State string
|
||
Error string
|
||
Terminal bool
|
||
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг
|
||
Progress progressView // живой прогресс (заполняется в handleIndex из снимка)
|
||
Reviewable bool // review/deferred — есть экран ревью
|
||
Undoable bool // done — можно откатить раскладку
|
||
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
|
||
Retriable bool // failed/stuck — можно повторить попытку
|
||
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
|
||
}
|
||
|
||
// listChips — определения чипов фильтра списка (порядок = порядок показа).
|
||
var listChips = []struct {
|
||
Key string
|
||
Label string
|
||
Group store.StateGroup
|
||
}{
|
||
{"all", "Все", store.GroupAll},
|
||
{"review", "Ждут меня", store.GroupReview},
|
||
{"active", "В работе", store.GroupActive},
|
||
{"done", "Готово", store.GroupDone},
|
||
{"problem", "Проблемы", store.GroupProblem},
|
||
}
|
||
|
||
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||
q := r.URL.Query()
|
||
group := parseGroup(q.Get("f"))
|
||
query := strings.TrimSpace(q.Get("q"))
|
||
showAll := q.Get("all") == "1"
|
||
page := parsePage(q.Get("page"))
|
||
|
||
downloads, total, err := s.deps.Reader.ListDownloadsPage(r.Context(), store.ListFilter{
|
||
Group: group,
|
||
Query: query,
|
||
IncludeDeleted: showAll,
|
||
Limit: pageSize,
|
||
Offset: (page - 1) * pageSize,
|
||
})
|
||
if err != nil {
|
||
s.deps.Logger.Error("list downloads", "error", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
pages := max((total+pageSize-1)/pageSize, 1)
|
||
|
||
view := indexView{
|
||
Error: q.Get("err"),
|
||
Filter: string(group),
|
||
Query: query,
|
||
ShowAll: showAll,
|
||
Page: page,
|
||
Pages: pages,
|
||
Total: total,
|
||
Searching: query != "" || group != store.GroupAll || showAll || page > 1,
|
||
}
|
||
// Чипы: сохраняют q/all, сбрасывают страницу.
|
||
for _, c := range listChips {
|
||
view.Chips = append(view.Chips, filterChip{
|
||
Key: c.Key,
|
||
Label: c.Label,
|
||
URL: listURL(c.Group, query, showAll, 1),
|
||
Active: c.Group == group,
|
||
})
|
||
}
|
||
// Тумблер «показать всё» переключает all, сохраняя фильтр/поиск.
|
||
view.ShowURL = listURL(group, query, !showAll, 1)
|
||
// Пагинация: сохраняет f/q/all.
|
||
if page > 1 {
|
||
view.PrevURL = listURL(group, query, showAll, page-1)
|
||
}
|
||
if page < pages {
|
||
view.NextURL = listURL(group, query, showAll, page+1)
|
||
}
|
||
for _, n := range pageWindow(page, pages) {
|
||
view.PageLinks = append(view.PageLinks, pageLink{
|
||
Num: n, URL: listURL(group, query, showAll, n), Active: n == page,
|
||
})
|
||
}
|
||
|
||
for _, d := range downloads {
|
||
v := toView(d)
|
||
// Живой прогресс активных загрузок — со значениями уже в первом кадре
|
||
// (без мигания); дальше карточка дозапрашивает фрагмент поллингом.
|
||
if v.IsDownloading {
|
||
l, ok := s.liveFor(d)
|
||
v.Progress = buildProgress(d.ID, true, l, ok)
|
||
}
|
||
view.Downloads = append(view.Downloads, v)
|
||
}
|
||
s.render(w, "index.html", view)
|
||
}
|
||
|
||
// parseGroup разбирает параметр фильтра `f`; неизвестное → all.
|
||
func parseGroup(s string) store.StateGroup {
|
||
switch store.StateGroup(s) {
|
||
case store.GroupReview:
|
||
return store.GroupReview
|
||
case store.GroupActive:
|
||
return store.GroupActive
|
||
case store.GroupDone:
|
||
return store.GroupDone
|
||
case store.GroupProblem:
|
||
return store.GroupProblem
|
||
default:
|
||
return store.GroupAll
|
||
}
|
||
}
|
||
|
||
// parsePage разбирает номер страницы (1-based); мусор/<1 → 1. За последней
|
||
// страницей отдаём как есть — запрос вернёт пустую страницу (не ошибка).
|
||
func parsePage(s string) int {
|
||
n, err := strconv.Atoi(s)
|
||
if err != nil || n < 1 {
|
||
return 1
|
||
}
|
||
return n
|
||
}
|
||
|
||
// listURL строит ссылку списка с сохранением состояния фильтра/поиска/страницы.
|
||
// Дефолты (all, пустой поиск, page 1) в query не пишем — URL чистый.
|
||
func listURL(group store.StateGroup, query string, showAll bool, page int) string {
|
||
v := url.Values{}
|
||
if group != store.GroupAll {
|
||
v.Set("f", string(group))
|
||
}
|
||
if query != "" {
|
||
v.Set("q", query)
|
||
}
|
||
if showAll {
|
||
v.Set("all", "1")
|
||
}
|
||
if page > 1 {
|
||
v.Set("page", strconv.Itoa(page))
|
||
}
|
||
if len(v) == 0 {
|
||
return "/"
|
||
}
|
||
return "/?" + v.Encode()
|
||
}
|
||
|
||
// pageWindow возвращает номера страниц вокруг текущей (окно до 7), чтобы пагинация
|
||
// не разрасталась на больших списках.
|
||
func pageWindow(page, pages int) []int {
|
||
const win = 7
|
||
if pages <= win {
|
||
out := make([]int, pages)
|
||
for i := range out {
|
||
out[i] = i + 1
|
||
}
|
||
return out
|
||
}
|
||
start := max(page-win/2, 1)
|
||
end := start + win - 1
|
||
if end > pages {
|
||
end = pages
|
||
start = end - win + 1
|
||
}
|
||
out := make([]int, 0, win)
|
||
for n := start; n <= end; n++ {
|
||
out = append(out, n)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
|
||
if err := r.ParseForm(); err != nil {
|
||
redirectErr(w, r, "не удалось разобрать форму")
|
||
return
|
||
}
|
||
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{
|
||
Source: r.PostForm.Get("source"),
|
||
Context: r.PostForm.Get("context"),
|
||
})
|
||
if err != nil {
|
||
redirectErr(w, r, userErr(r, err, res.DownloadID))
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||
}
|
||
|
||
func (s *server) handleUICancel(w http.ResponseWriter, r *http.Request) {
|
||
id, err := pathID(r)
|
||
if err != nil {
|
||
redirectErr(w, r, "некорректный id")
|
||
return
|
||
}
|
||
if err := s.deps.Commander.Cancel(r.Context(), id); err != nil {
|
||
redirectErr(w, r, userErr(r, err, id))
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||
}
|
||
|
||
func (s *server) handleUIRetry(w http.ResponseWriter, r *http.Request) {
|
||
id, err := pathID(r)
|
||
if err != nil {
|
||
redirectErr(w, r, "некорректный id")
|
||
return
|
||
}
|
||
if err := s.deps.Commander.Retry(r.Context(), id); err != nil {
|
||
redirectErr(w, r, userErr(r, err, id))
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||
}
|
||
|
||
// --- REST API ---
|
||
|
||
type downloadDTO struct {
|
||
ID string `json:"id"` // ULID (lowercase)
|
||
SourceType string `json:"source_type"`
|
||
Infohashes []string `json:"infohashes,omitempty"` // все хеши загрузки (v1 раньше v2)
|
||
Context string `json:"context,omitempty"`
|
||
State string `json:"state"`
|
||
ErrorCode string `json:"error_code,omitempty"`
|
||
ErrorMsg string `json:"error_msg,omitempty"`
|
||
CreatedAt string `json:"created_at"`
|
||
UpdatedAt string `json:"updated_at"`
|
||
}
|
||
|
||
type addRequest struct {
|
||
Source string `json:"source"`
|
||
Context string `json:"context"`
|
||
}
|
||
|
||
type addResponse struct {
|
||
ID string `json:"id"` // ULID (lowercase)
|
||
Infohashes []string `json:"infohashes"` // хеши принятого источника (v1 раньше v2) — симметрично downloadDTO
|
||
State string `json:"state"`
|
||
Deduplicated bool `json:"deduplicated"`
|
||
}
|
||
|
||
func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) {
|
||
downloads, err := s.deps.Reader.ListDownloads(r.Context())
|
||
if err != nil {
|
||
s.apiErr(w, r, err, "")
|
||
return
|
||
}
|
||
out := make([]downloadDTO, 0, len(downloads))
|
||
for _, d := range downloads {
|
||
out = append(out, toDTO(d))
|
||
}
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
|
||
func (s *server) handleAPIGet(w http.ResponseWriter, r *http.Request) {
|
||
id, err := pathID(r)
|
||
if err != nil {
|
||
// Синтаксически невалидный id = несуществующая сущность (404), в БД не ходим.
|
||
writeJSON(w, http.StatusNotFound, errBody(r, "не найдено", ""))
|
||
return
|
||
}
|
||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||
if err != nil {
|
||
// ErrNotFound → 404, реальный сбой БД → 500 (не маскируем под 404).
|
||
s.apiErr(w, r, err, id)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, toDTO(*d))
|
||
}
|
||
|
||
func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
|
||
var req addRequest
|
||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный запрос", ""))
|
||
return
|
||
}
|
||
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context})
|
||
if err != nil {
|
||
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit) —
|
||
// тогда коррелируем по download_id, иначе (ранний разбор источника) по
|
||
// request_id.
|
||
s.apiErr(w, r, err, res.DownloadID)
|
||
return
|
||
}
|
||
status := http.StatusCreated
|
||
if res.Deduplicated {
|
||
status = http.StatusOK
|
||
}
|
||
writeJSON(w, status, addResponse{
|
||
ID: res.DownloadID,
|
||
Infohashes: res.Infohashes,
|
||
State: string(res.State),
|
||
Deduplicated: res.Deduplicated,
|
||
})
|
||
}
|
||
|
||
func (s *server) handleAPICancel(w http.ResponseWriter, r *http.Request) {
|
||
s.apiCommand(w, r, s.deps.Commander.Cancel)
|
||
}
|
||
|
||
func (s *server) handleAPIRetry(w http.ResponseWriter, r *http.Request) {
|
||
s.apiCommand(w, r, s.deps.Commander.Retry)
|
||
}
|
||
|
||
func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(context.Context, string) error) {
|
||
id, err := pathID(r)
|
||
if err != nil {
|
||
// Синтаксически невалидный id = несуществующая сущность (404), в БД не ходим.
|
||
writeJSON(w, http.StatusNotFound, errBody(r, "не найдено", ""))
|
||
return
|
||
}
|
||
if err := cmd(r.Context(), id); err != nil {
|
||
// Тонкий транспорт: возвращённую use-case'ом/воркером ошибку переводим в
|
||
// статус+сообщение и не логируем повторно (доменный слой уже залогировал,
|
||
// а невалидный ввод — норма, разбирать команде нечего).
|
||
s.apiErr(w, r, err, id)
|
||
return
|
||
}
|
||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusOK, map[string]string{"id": id})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, toDTO(*d))
|
||
}
|
||
|
||
// --- helpers ---
|
||
|
||
func toDTO(d store.Download) downloadDTO {
|
||
return downloadDTO{
|
||
ID: d.ID,
|
||
SourceType: string(d.SourceType),
|
||
Infohashes: d.HashList(),
|
||
Context: d.Context,
|
||
State: string(d.State),
|
||
ErrorCode: d.ErrorCode.String,
|
||
ErrorMsg: d.ErrorMsg.String,
|
||
CreatedAt: d.CreatedAt,
|
||
UpdatedAt: d.UpdatedAt,
|
||
}
|
||
}
|
||
|
||
func toView(d store.Download) downloadView {
|
||
state := string(d.State)
|
||
return downloadView{
|
||
ID: d.ID,
|
||
Title: downloadTitle(d),
|
||
Context: d.Context,
|
||
State: state,
|
||
Error: d.ErrorMsg.String,
|
||
Terminal: d.State.IsTerminal(),
|
||
IsDownloading: d.State == store.StateDownloading,
|
||
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
||
Undoable: d.State == store.StateDone,
|
||
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
|
||
d.State == store.StateTargetMissing,
|
||
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
|
||
Note: desyncNote(d.State),
|
||
}
|
||
}
|
||
|
||
// downloadTitle — заголовок загрузки для списка: имя раздачи (display_name,
|
||
// то, что ушло в qBittorrent) → распознанное название (RecTitle из листинга) →
|
||
// усечённый до одной строки сырой источник. Сырой magnet не должен занимать
|
||
// несколько строк заголовка.
|
||
func downloadTitle(d store.Download) string {
|
||
if d.DisplayName != "" {
|
||
return d.DisplayName
|
||
}
|
||
if d.RecTitle.Valid && d.RecTitle.String != "" {
|
||
return d.RecTitle.String
|
||
}
|
||
return shorten(oneLine(d.SourceRef), 80)
|
||
}
|
||
|
||
// oneLine схлопывает переводы строк и лишние пробелы — сырой источник в
|
||
// заголовок кладём одной строкой.
|
||
func oneLine(s string) string {
|
||
return strings.Join(strings.Fields(s), " ")
|
||
}
|
||
|
||
// desyncNote — пояснение состояния рассинхрона для UI (см. state-reconciliation).
|
||
func desyncNote(s store.State) string {
|
||
switch s {
|
||
case store.StateTargetMissing:
|
||
return "разложено, но файлов в библиотеке нет — можно привязать заново"
|
||
case store.StateOrphaned:
|
||
return "источник удалён — это последняя копия данных, откат недоступен"
|
||
case store.StateDeleted:
|
||
return "удалены и источник, и файлы в библиотеке"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func shorten(s string, n int) string {
|
||
if len(s) <= n {
|
||
return s
|
||
}
|
||
return s[:n] + "…"
|
||
}
|
||
|
||
// pathID валидирует {id} из URL как ULID и нормализует к lowercase — до
|
||
// любого обращения к БД (сравнение в SQLite побайтовое). Невалидный id
|
||
// трактуется вызывающими как несуществующая сущность (404).
|
||
func pathID(r *http.Request) (string, error) {
|
||
return ident.Parse(chi.URLParam(r, "id"))
|
||
}
|
||
|
||
// liveFor достаёт живую телеметрию по любому из хешей загрузки.
|
||
func (s *server) liveFor(d store.Download) (worker.Live, bool) {
|
||
for _, h := range d.HashList() {
|
||
if l, ok := s.deps.Live.Live(h); ok {
|
||
return l, true
|
||
}
|
||
}
|
||
return worker.Live{}, false
|
||
}
|
||
|
||
func redirectErr(w http.ResponseWriter, r *http.Request, msg string) {
|
||
http.Redirect(w, r, "/?err="+url.QueryEscape(msg), http.StatusSeeOther)
|
||
}
|
||
|
||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(status)
|
||
_ = json.NewEncoder(w).Encode(v)
|
||
}
|
||
|
||
// classifyErr транслирует доменную ошибку в HTTP-статус и нейтральное
|
||
// человекочитаемое сообщение публичного канала (без сырого err.Error() и
|
||
// деталей реализации): ErrNotFound → 404, валидация источника
|
||
// (magnet.ErrNotMagnet) → 400, конфликт состояния (worker.ErrConflict) → 409,
|
||
// прочее → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём
|
||
// только сообщение + корреляционный ключ.
|
||
func classifyErr(err error) (int, string) {
|
||
switch {
|
||
case errors.Is(err, store.ErrNotFound):
|
||
return http.StatusNotFound, "не найдено"
|
||
case errors.Is(err, magnet.ErrNotMagnet):
|
||
return http.StatusBadRequest, "некорректный источник"
|
||
case errors.Is(err, worker.ErrConflict):
|
||
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
|
||
return http.StatusConflict, "действие недоступно в текущем состоянии"
|
||
case errors.Is(err, errManualSource):
|
||
// Ошибка ручного ввода источника — показываем пользователю как есть.
|
||
return http.StatusBadRequest, errManualSource.Error()
|
||
case errors.Is(err, errInvalidCandidate):
|
||
return http.StatusBadRequest, errInvalidCandidate.Error()
|
||
default:
|
||
return http.StatusInternalServerError, "внутренняя ошибка"
|
||
}
|
||
}
|
||
|
||
// errBody — тело ошибки REST API: нейтральное сообщение + корреляционный ключ
|
||
// для владельца (download_id, если операция привязана к загрузке, иначе
|
||
// request_id запроса), по которому он найдёт полную ошибку в логах.
|
||
func errBody(r *http.Request, msg string, downloadID string) map[string]any {
|
||
body := map[string]any{"error": msg}
|
||
if downloadID != "" {
|
||
body["download_id"] = downloadID
|
||
} else {
|
||
body["request_id"] = middleware.GetReqID(r.Context())
|
||
}
|
||
return body
|
||
}
|
||
|
||
// apiErr пишет ответ об ошибке REST API по доменной ошибке (статус + тело).
|
||
func (s *server) apiErr(w http.ResponseWriter, r *http.Request, err error, downloadID string) {
|
||
status, msg := classifyErr(err)
|
||
writeJSON(w, status, errBody(r, msg, downloadID))
|
||
}
|
||
|
||
// userErr — сообщение публичного канала для веб-UI: нейтральный текст по
|
||
// доменной ошибке + корреляционный ключ владельцу (download_id, если операция
|
||
// привязана к загрузке, иначе request_id). Сырой текст ошибки наружу не идёт.
|
||
func userErr(r *http.Request, err error, downloadID string) string {
|
||
_, msg := classifyErr(err)
|
||
if downloadID != "" {
|
||
return fmt.Sprintf("%s (download_id=%s)", msg, downloadID)
|
||
}
|
||
return fmt.Sprintf("%s (request_id=%s)", msg, middleware.GetReqID(r.Context()))
|
||
}
|
||
|
||
// requestLogger пишет структурированный лог по каждому запросу. Служебные и
|
||
// навигационные GET (healthcheck, страницы веб-UI, статика) пишем на DEBUG,
|
||
// чтобы не зашумлять INFO; мутации и REST API остаются на INFO.
|
||
func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
||
return func(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||
start := time.Now()
|
||
|
||
next.ServeHTTP(ww, r)
|
||
|
||
// http.route — низкокардинальный шаблон маршрута (chi), а не
|
||
// конкретный путь; при отсутствии шаблона падаем на путь.
|
||
route := chi.RouteContext(r.Context()).RoutePattern()
|
||
if route == "" {
|
||
route = r.URL.Path
|
||
}
|
||
logger.Log(r.Context(), requestLogLevel(r), "http request",
|
||
"transport", "http",
|
||
"http.method", r.Method,
|
||
"http.route", route,
|
||
"http.status_code", ww.Status(),
|
||
"bytes", ww.BytesWritten(),
|
||
"duration_ms", time.Since(start).Milliseconds(),
|
||
"request_id", middleware.GetReqID(r.Context()),
|
||
)
|
||
})
|
||
}
|
||
}
|
||
|
||
// requestLogLevel понижает уровень для служебных и навигационных запросов:
|
||
// healthcheck и GET-страницы веб-UI (включая статику). Мутации и REST
|
||
// API (`/api/...`) остаются на INFO.
|
||
func requestLogLevel(r *http.Request) slog.Level {
|
||
switch {
|
||
case r.URL.Path == "/healthz":
|
||
return slog.LevelDebug
|
||
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api"):
|
||
return slog.LevelDebug
|
||
default:
|
||
return slog.LevelInfo
|
||
}
|
||
}
|