// Package httpapi предоставляет HTTP API и веб-UI (server-rendered). // // Тонкий транспорт над ядром: приём идёт в ingest, команды (cancel/retry) — // в worker, чтение — в store. В v1 без авторизации (доверенная LAN). package httpapi import ( "context" "encoding/json" "errors" "html/template" "log/slog" "net/http" "net/url" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "git.vakhrushev.me/av/jellybit/internal/ingest" "git.vakhrushev.me/av/jellybit/internal/store" "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 int64) error Retry(ctx context.Context, id int64) error } // Reader читает задачи (store.Store). type Reader interface { ListDownloads(ctx context.Context) ([]store.Download, error) GetDownload(ctx context.Context, id int64) (*store.Download, error) } // Deps — зависимости транспорта. type Deps struct { Logger *slog.Logger Ingestor Ingestor Commander Commander Reader Reader Reviewer Reviewer } type server struct { deps Deps index *template.Template review *template.Template } // NewRouter собирает HTTP-обработчик сервиса. func NewRouter(d Deps) (http.Handler, error) { index, err := template.ParseFS(web.FS, "templates/index.html") if err != nil { return nil, err } review, err := template.New("review.html"). Funcs(template.FuncMap{"add": func(a, b int) int { return a + b }}). ParseFS(web.FS, "templates/review.html") if err != nil { return nil, err } s := &server{deps: d, index: index, review: review} r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.Recoverer) r.Use(requestLogger(d.Logger)) r.Get("/healthz", handleHealthz) // Веб-UI. r.Get("/", s.handleIndex) r.Post("/ui/downloads", s.handleUIAdd) r.Post("/ui/downloads/{id}/cancel", s.handleUICancel) // Веб-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}/type", s.handleSetType) r.Post("/ui/downloads/{id}/ignore", s.handleIgnore) r.Post("/ui/downloads/{id}/defer", s.handleDefer) r.Post("/ui/downloads/{id}/undo", s.handleUndo) // 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 --- type indexView struct { Error string Downloads []downloadView } type downloadView struct { ID int64 Source string Infohash string Context string State string Error string Terminal bool Reviewable bool // review/deferred — есть экран ревью Undoable bool // done — можно откатить раскладку } func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { downloads, err := s.deps.Reader.ListDownloads(r.Context()) if err != nil { s.deps.Logger.Error("list downloads", "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } view := indexView{Error: r.URL.Query().Get("err")} for _, d := range downloads { view.Downloads = append(view.Downloads, toView(d)) } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.index.Execute(w, view); err != nil { s.deps.Logger.Error("render index", "err", err) } } func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { redirectErr(w, r, "не удалось разобрать форму") return } _, 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, err.Error()) 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, err.Error()) return } http.Redirect(w, r, "/", http.StatusSeeOther) } // --- REST API --- type downloadDTO struct { ID int64 `json:"id"` SourceType string `json:"source_type"` Infohash string `json:"infohash,omitempty"` 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 int64 `json:"id"` Infohash string `json:"infohash"` 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 { writeJSON(w, http.StatusInternalServerError, errJSON(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 { writeJSON(w, http.StatusBadRequest, errJSON(err)) return } d, err := s.deps.Reader.GetDownload(r.Context(), id) if err != nil { writeJSON(w, http.StatusNotFound, errJSON(err)) 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, errJSON(err)) return } res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context}) if err != nil { writeJSON(w, http.StatusBadRequest, errJSON(err)) return } status := http.StatusCreated if res.Deduplicated { status = http.StatusOK } writeJSON(w, status, addResponse{ ID: res.DownloadID, Infohash: res.Infohash, 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, int64) error) { id, err := pathID(r) if err != nil { writeJSON(w, http.StatusBadRequest, errJSON(err)) return } if err := cmd(r.Context(), id); err != nil { writeJSON(w, http.StatusConflict, errJSON(err)) return } d, err := s.deps.Reader.GetDownload(r.Context(), id) if err != nil { writeJSON(w, http.StatusOK, map[string]int64{"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), Infohash: d.Infohash.String, 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 { return downloadView{ ID: d.ID, Source: shorten(d.SourceRef, 64), Infohash: d.Infohash.String, Context: d.Context, State: string(d.State), Error: d.ErrorMsg.String, Terminal: d.State.IsTerminal(), Reviewable: d.State == store.StateReview || d.State == store.StateDeferred, Undoable: d.State == store.StateDone, } } func shorten(s string, n int) string { if len(s) <= n { return s } return s[:n] + "…" } func pathID(r *http.Request) (int64, error) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { return 0, errors.New("invalid id") } return id, nil } 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) } func errJSON(err error) map[string]string { return map[string]string{"error": err.Error()} } // requestLogger пишет структурированный лог по каждому запросу. 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) logger.Info("http request", "method", r.Method, "path", r.URL.Path, "status", ww.Status(), "bytes", ww.BytesWritten(), "duration_ms", time.Since(start).Milliseconds(), "request_id", middleware.GetReqID(r.Context()), ) }) } }