package web import ( "context" "errors" "html/template" "io" "log/slog" "net/http" "time" "github.com/a-h/templ" "gitea.d-ma.be/mathias/tapir/internal/adapters/store" "gitea.d-ma.be/mathias/tapir/internal/domain" ) // Store is the read/write surface the web handlers depend on — a narrow port over // the Postgres store (Clean Architecture: handlers depend on this interface, not // the concrete *store.Store). *store.Store satisfies it; tests can substitute a // fake without a database. type Store interface { ListVideos(ctx context.Context, userID string, limit int) ([]store.SummaryRow, error) GetSummaryByVideo(ctx context.Context, userID, videoID string) (*store.SummaryRow, error) GetVideoRow(ctx context.Context, userID, videoID string) (*store.SummaryRow, error) ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error) SetAction(ctx context.Context, userID, videoID, action string) error ClearAction(ctx context.Context, userID, videoID, action string) error // Summarization mode: the per-user auto/manual toggle and the per-video // manual queue (the "Summarize" button). The runner consumes the queue. GetAutoSummarize(ctx context.Context, userID string) (bool, error) SetAutoSummarize(ctx context.Context, userID string, enabled bool) error RequestSummarize(ctx context.Context, userID, videoID string) error // UpsertVideo persists a pasted video (idempotent on user+provider+video id, // so it also dedups) and returns its durable store id. UpsertVideo(ctx context.Context, v domain.Video) (string, error) // Account management (the /account page, disconnect, delete-account). ConnectionsForUser(ctx context.Context, userID string) ([]store.Connection, error) DeleteConnection(ctx context.Context, userID, provider string) error DeleteUser(ctx context.Context, userID string) error DisplayName(ctx context.Context, userID string) (string, error) // ListChannelErrors returns channels that returned HTTP 404 (deleted/private) on // the most recent discovery pass, shown on the account page as a warning. ListChannelErrors(ctx context.Context, userID string) ([]store.ChannelError, error) // SetTranscriptStatus clears or updates a video's transcript backoff state. // Used by handleRetryNow to clear rate_limited_at before immediate processing. SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error // StampLogin records (throttled, one row per user per day) that the resolved // user was active on this request — the read-side Stage-0 usage signal the // registration gate stamps for every authenticated request. StampLogin(ctx context.Context, userID string) error } // SecretRemover deletes secret material by its opaque ref. *secrets.FileStore // satisfies it; account tests use a fake. The account handlers depend only on // this narrow capability (not the read-side ports.SecretStore), mirroring how the // connect flow depends on auth.TokenWriter for the write side. type SecretRemover interface { Delete(ref string) error } // App is the Stage-1 web surface: handlers over the store, gated by an Auth // implementation (authentication) and a registration gate (which resolves the // authenticated subject to its tapir user_id and stashes it per request). Every // data handler scopes by that resolved id — CurrentUserID(r) — not by a single // configured user (ADR-012, multi-user with enforced isolation). type App struct { Store Store Identity Identity Auth Auth Log *slog.Logger // Connect runs the web-initiated YouTube OAuth connect flow. Optional: when // nil (e.g. dev without YouTube client credentials), the /oauth/youtube/* // routes are not mounted. Connect *ConnectHandler // Secrets removes a user's OAuth tokens on disconnect / delete-account. The // account routes require it; cmd/tapir wires the file-backed store. Secrets SecretRemover // Processor, when non-nil, summarizes a queued video immediately in a // background goroutine (the "Summarize" button kicks it off). Nil = queue-only: // the button flips the DB flag and the next `tapir run` does the work. Processor Processor // Fetcher, when non-nil, resolves an arbitrary YouTube video id to metadata for // the paste-a-URL flow (Feature 2). Nil = the /paste route is not mounted. Fetcher VideoFetcher // Processing tracks in-flight immediate summarizations so the status endpoint // shows the animation until the summary lands. The zero value is ready to use. Processing ProcessingSet // RecencyWindow mirrors the auto-summarize recency bound: un-summarized videos // published before now-RecencyWindow collapse into the "older videos" // disclosure on the list, so the readable summaries are not buried (B3). Zero // disables the collapse (everything stays inline). RecencyWindow time.Duration // Now is an injectable clock for the recency cutoff (tests fix it). Nil = // time.Now. Now func() time.Time } // now returns the App's clock (time.Now unless overridden for tests). func (a *App) now() time.Time { if a.Now != nil { return a.Now() } return time.Now() } // recencyCutoff is the timestamp before which an un-summarized video counts as // "older" and collapses into the disclosure. A zero RecencyWindow yields the zero // time, which bucketRows treats as "collapse disabled". func (a *App) recencyCutoff() time.Time { if a.RecencyWindow <= 0 { return time.Time{} } return a.now().Add(-a.RecencyWindow) } func (a *App) logger() *slog.Logger { if a.Log != nil { return a.Log } return slog.Default() } // Router wires the routes: /healthz (no auth) and /auth/* (the Auth impl's own // endpoints) sit outside the guard; everything else is wrapped by // Auth.Middleware. Go 1.22+ method+path patterns route directly. func (a *App) Router() http.Handler { root := http.NewServeMux() root.HandleFunc("GET /healthz", a.handleHealthz) root.HandleFunc("GET /welcome", a.handleWelcome) root.Handle("GET /static/", staticHandler()) root.Handle("/auth/", a.Auth.Routes()) app := http.NewServeMux() app.HandleFunc("GET /{$}", a.handleList) app.HandleFunc("GET /v/{videoId}", a.handleDetail) app.HandleFunc("POST /v/{videoId}/action", a.handleAction) app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize) app.HandleFunc("POST /v/{videoId}/retry-now", a.handleRetryNow) if a.Fetcher != nil { app.HandleFunc("POST /paste", a.handlePaste) } app.HandleFunc("GET /v/{videoId}/status", a.handleStatus) app.HandleFunc("GET /register", a.handleRegisterForm) app.HandleFunc("POST /register", a.handleRegister) // Account management: view connections, disconnect a provider, delete the // account. Gated like every app route, so CurrentUserID is set. app.HandleFunc("GET /account", a.handleAccount) app.HandleFunc("POST /account/disconnect/{provider}", a.handleDisconnect) app.HandleFunc("POST /account/delete", a.handleDeleteAccount) app.HandleFunc("POST /account/summarize-mode", a.handleSummarizeMode) // Web-initiated YouTube connect (ADR-006). Gated like every app route, so // CurrentUserID is set and the connection binds to the authenticated user. if a.Connect != nil { app.HandleFunc("GET /oauth/youtube/connect", a.Connect.handleConnect) app.HandleFunc("GET /oauth/youtube/callback", a.Connect.handleCallback) } // Two layers: Auth.Middleware requires a Dex session (you must be logged in); // registrationGate requires a tapir user (else → /register) and stashes the // resolved user_id. /register lives inside the auth guard but is exempt from // the registration gate (you must be able to reach it before you have a user). root.Handle("/", a.Auth.Middleware(a.registrationGate(app))) return root } // handleWelcome renders the public landing page (/welcome). It is mounted outside // Auth.Middleware, so it must not assume a session: CurrentUser peeks the cookie // without redirecting and the page renders the logged-out or logged-in variant // accordingly. func (a *App) handleWelcome(w http.ResponseWriter, r *http.Request) { user, ok := a.Auth.CurrentUser(r) a.render(w, r, WelcomePage(user, ok)) } // handleHealthz is the unauthenticated liveness/readiness probe. func (a *App) handleHealthz(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = w.Write([]byte("ok")) } // handleList renders the summary list, applying the channel/date filters from the // query string. An HTMX request gets only the table fragment so the filter form // can swap #summary-list in place; a plain request gets the full page. func (a *App) handleList(w http.ResponseWriter, r *http.Request) { userID, ok := a.currentUserID(w, r) if !ok { return } q := r.URL.Query() f := Filter{ Channel: q.Get("channel"), From: q.Get("from"), To: q.Get("to"), OnlySummarized: q.Get("summarized") == "1", } allRows, err := a.Store.ListVideos(r.Context(), userID, 0) if err != nil { a.serverError(w, r, "list videos", err) return } stats := pipelineStats(allRows) rows := f.apply(allRows) buckets := bucketRows(rows, a.recencyCutoff()) // hasConnected drives both the paste box (shown to ANY connected user, #2) and // the empty-state copy (a fresh account with a connection but no discovery pass // yet reads "connected, summaries land gradually" rather than "nothing here"). // Computed every render — not only when empty — so a user with videos still // gets the paste box. conns, err := a.Store.ConnectionsForUser(r.Context(), userID) if err != nil { a.serverError(w, r, "connections for user", err) return } hasConnected := len(conns) > 0 if isHTMX(r) { a.render(w, r, summaryList(buckets, hasConnected)) return } a.render(w, r, ListPage(buckets, f, stats, takeFlash(w, r), hasConnected)) } // handleDetail renders one summary in full (highlights, takeaways, action group). func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) { userID, ok := a.currentUserID(w, r) if !ok { return } videoID := r.PathValue("videoId") row, err := a.Store.GetSummaryByVideo(r.Context(), userID, videoID) if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return } if err != nil { a.serverError(w, r, "get summary", err) return } a.render(w, r, DetailPage(*row)) } // handleAction toggles one action: re-clicking an active verb clears it, else it // is set (the store enforces watched↔skipped exclusion atomically). It returns // the refreshed button-group fragment for HTMX; without JS it redirects back to // the detail page (POST→redirect→GET). func (a *App) handleAction(w http.ResponseWriter, r *http.Request) { userID, ok := a.currentUserID(w, r) if !ok { return } videoID := r.PathValue("videoId") action := r.FormValue("action") if !isActionVerb(action) { http.Error(w, "unknown action", http.StatusBadRequest) return } current, err := a.Store.ActionsFor(r.Context(), userID, []string{videoID}) if err != nil { a.serverError(w, r, "read actions", err) return } if actionSet(current[videoID])[action] { err = a.Store.ClearAction(r.Context(), userID, videoID, action) } else { err = a.Store.SetAction(r.Context(), userID, videoID, action) } if err != nil { a.serverError(w, r, "toggle action", err) return } updated, err := a.Store.ActionsFor(r.Context(), userID, []string{videoID}) if err != nil { a.serverError(w, r, "read actions", err) return } if isHTMX(r) { a.render(w, r, ActionButtons(videoID, actionSet(updated[videoID]))) return } http.Redirect(w, r, "/v/"+videoID, http.StatusSeeOther) } // handleRequestSummarize handles the "Summarize" button. It always flips the DB // flag (summarize_requested) so the work is durable. With a Processor wired it // then summarizes immediately in the background and answers with the animated // processing card that polls /status until done; without one (queue-only) it // answers with the "Queued" card — the next `tapir run` does the work. Without // JS it redirects back to the list (POST→redirect→GET). func (a *App) handleRequestSummarize(w http.ResponseWriter, r *http.Request) { userID, ok := a.currentUserID(w, r) if !ok { return } videoID := r.PathValue("videoId") err := a.Store.RequestSummarize(r.Context(), userID, videoID) if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return } if err != nil { a.serverError(w, r, "request summarize", err) return } if !isHTMX(r) { http.Redirect(w, r, "/", http.StatusSeeOther) return } row, err := a.Store.GetVideoRow(r.Context(), userID, videoID) if err != nil { a.serverError(w, r, "get video", err) return } if a.Processor != nil { a.startProcessing(userID, videoID) a.render(w, r, processingCard(*row)) return } a.render(w, r, VideoCard(*row)) } // handlePaste handles "paste a YouTube URL" (Feature 2). It parses the video id, // fetches metadata (Data API — ungated), upserts a subscription-less video row // scoped to the user (idempotent, so it also dedups), and — if the video isn't // already summarized — requests a summary and kicks off immediate processing // through the SAME rate gate as the Summarize button. An explicit paste is a // manual request, so it summarizes regardless of the recency window. A video that // turns out to have no captions resolves to the honest "no transcript" terminal // state via the engine (ADR-010), not an error here. func (a *App) handlePaste(w http.ResponseWriter, r *http.Request) { userID, ok := a.currentUserID(w, r) if !ok { return } videoID, err := parseYouTubeVideoID(r.FormValue("url")) if err != nil { a.pasteFailure(w, http.StatusBadRequest, "That doesn't look like a YouTube video link.") return } v, err := a.Fetcher.FetchVideo(r.Context(), userID, videoID) if errors.Is(err, domain.ErrVideoNotFound) { a.pasteFailure(w, http.StatusNotFound, "That video couldn't be found — it may be private or removed.") return } if err != nil { a.serverError(w, r, "paste fetch", err) return } id, err := a.Store.UpsertVideo(r.Context(), v) if err != nil { a.serverError(w, r, "paste upsert", err) return } row, err := a.Store.GetVideoRow(r.Context(), userID, id) if err != nil { a.serverError(w, r, "paste get video", err) return } // Dedup: already in the feed with a summary — surface the existing entry, // don't re-summarize. if row.Summarized { a.render(w, r, VideoCard(*row)) return } // New or unsummarized: queue + (if a Processor is wired) summarize now, through // the shared gate. RequestSummarize makes it durable even if the process dies. if err := a.Store.RequestSummarize(r.Context(), userID, id); err != nil { a.serverError(w, r, "paste request summarize", err) return } if a.Processor != nil { a.startProcessing(userID, id) a.render(w, r, processingCard(*row)) return } a.render(w, r, VideoCard(*row)) } // pasteFailure renders a minimal inline error fragment for the paste form (HTMX // swaps it in). No templ dependency so it renders even on a bad-input fast path. func (a *App) pasteFailure(w http.ResponseWriter, status int, msg string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) _, _ = io.WriteString(w, ``) } // handleRetryNow handles the "Try now" button on rate-limited video cards. It // clears the rate_limited_at backoff so the scheduler won't skip the video, then // triggers an immediate ProcessVideo — same background path as handleRequestSummarize. // The rate gate (globalFetchGate) still applies, so this is safe under concurrent use. func (a *App) handleRetryNow(w http.ResponseWriter, r *http.Request) { userID, ok := a.currentUserID(w, r) if !ok { return } videoID := r.PathValue("videoId") // Clear the backoff so the scheduler won't skip this video on the next pass. if err := a.Store.SetTranscriptStatus(r.Context(), userID, videoID, "none"); err != nil { a.serverError(w, r, "clear rate limit", err) return } if !isHTMX(r) { http.Redirect(w, r, "/", http.StatusSeeOther) return } row, err := a.Store.GetVideoRow(r.Context(), userID, videoID) if err != nil { a.serverError(w, r, "get video", err) return } if a.Processor != nil { a.startProcessing(userID, videoID) a.render(w, r, processingCard(*row)) return } a.render(w, r, VideoCard(*row)) } // startProcessing marks a video in-flight and summarizes it in the background. // The goroutine uses a detached context — not the request's, which is cancelled // when the handler returns — and clears the in-flight mark on completion. On // error the DB flag stays set, so the video remains queued for the next // `tapir run`; a successful Processor.ProcessVideo clears it itself. func (a *App) startProcessing(userID, videoID string) { key := processingKey(userID, videoID) a.Processing.Add(key) go func() { defer a.Processing.Remove(key) if err := a.Processor.ProcessVideo(context.Background(), userID, videoID); err != nil { a.logger().Error("background summarize", "user", userID, "video", videoID, "err", err) } }() } // handleStatus is the HTMX poll target for an in-flight summarization. It returns // the card in its current state: the full summary card once the summary exists, // otherwise the animated processing card while still in-flight (which keeps // polling), or the queued/button card when neither holds. VideoCard carries no // polling attributes, so HTMX stops polling once it swaps in. func (a *App) handleStatus(w http.ResponseWriter, r *http.Request) { userID, ok := a.currentUserID(w, r) if !ok { return } videoID := r.PathValue("videoId") row, err := a.Store.GetVideoRow(r.Context(), userID, videoID) if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return } if err != nil { a.serverError(w, r, "get video", err) return } if row.Summarized || !a.Processing.Has(processingKey(userID, videoID)) { a.render(w, r, VideoCard(*row)) return } a.render(w, r, processingCard(*row)) } // handleSummarizeMode toggles the user's auto/manual summarization mode. The form // submits the desired new value (enabled=true|false). For HTMX it returns the // refreshed mode control; without JS it redirects back to the account page. func (a *App) handleSummarizeMode(w http.ResponseWriter, r *http.Request) { userID, ok := a.currentUserID(w, r) if !ok { return } enabled := r.FormValue("enabled") == "true" if err := a.Store.SetAutoSummarize(r.Context(), userID, enabled); err != nil { a.serverError(w, r, "set summarize mode", err) return } if !isHTMX(r) { http.Redirect(w, r, "/account", http.StatusSeeOther) return } a.render(w, r, summarizeModeControl(enabled)) } // currentUserID returns the tapir user_id the registration gate resolved for this // request. Behind the gate it is always present; a miss means a handler was // reached without scoping (a wiring bug), so it answers 500 and reports false. func (a *App) currentUserID(w http.ResponseWriter, r *http.Request) (string, bool) { id, ok := CurrentUserID(r) if !ok { a.serverError(w, r, "current user", errNoCurrentUser) } return id, ok } // render writes a templ component as HTML. A render error is logged, not retried: // headers may already be flushed, so there is nothing useful to send the client. func (a *App) render(w http.ResponseWriter, r *http.Request, c templ.Component) { a.renderStatus(w, r, http.StatusOK, c) } // renderStatus writes a templ component as HTML with an explicit status code (the // Content-Type must be set before WriteHeader, so this is the single place that // orders them correctly). func (a *App) renderStatus(w http.ResponseWriter, r *http.Request, status int, c templ.Component) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) if err := c.Render(r.Context(), w); err != nil { a.logger().Error("render", "path", r.URL.Path, "err", err) } } func (a *App) serverError(w http.ResponseWriter, r *http.Request, op string, err error) { a.logger().Error("handler error", "op", op, "path", r.URL.Path, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) } // isHTMX reports whether the request came from HTMX, which sets HX-Request: true. func isHTMX(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" }