YouTube channels that 404 on playlist discovery (deleted/private) are now: 1. Wrapped in domain.ErrChannelUnavailable by the YouTube adapter (instead of a generic error), so the runner can identify them without string-matching. 2. Stored per-user in channel_errors (migration 013, RLS-guarded) via runner's new UpsertChannelError path — removed from the generic Errors counter, counted separately as ChannelUnavailable. 3. Shown on the account page under "Unavailable channels" with name, chip-warn badge, and first-seen date, so users know why some subscribed channels produce no videos.
406 lines
15 KiB
Go
406 lines
15 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/a-h/templ"
|
|
|
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
|
)
|
|
|
|
// 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
|
|
|
|
// 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)
|
|
|
|
// 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
|
|
// 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
|
|
// Invitations validates and consumes email-invite tokens for the public
|
|
// /invite/{token} flow. Nil = the invite routes report "invalid" (the flow is
|
|
// effectively off). *store.Store satisfies it.
|
|
Invitations InvitationStore
|
|
// Dex creates the Dex local-password account when an invite is claimed. Nil =
|
|
// not in-cluster (dev): the submit handler degrades to a clear "deployed-only"
|
|
// message instead of creating an account. *dex.PasswordClient satisfies it.
|
|
Dex DexPasswordCreator
|
|
}
|
|
|
|
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())
|
|
|
|
// Email invitation claim (public — the visitor has no Dex session yet, so this
|
|
// sits OUTSIDE Auth.Middleware). The token in the path is the capability.
|
|
root.HandleFunc("GET /invite/{token}", a.handleInviteForm)
|
|
root.HandleFunc("POST /invite/{token}", a.handleInviteSubmit)
|
|
|
|
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("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"),
|
|
}
|
|
|
|
rows, err := a.Store.ListVideos(r.Context(), userID, 0)
|
|
if err != nil {
|
|
a.serverError(w, r, "list videos", err)
|
|
return
|
|
}
|
|
rows = f.apply(rows)
|
|
|
|
// hasConnected drives the empty state: a fresh account with a connection but
|
|
// no `tapir run` yet has zero rows, and we want it to read "connected, run
|
|
// tapir" rather than "nothing here". Only needed when the list is empty.
|
|
hasConnected := false
|
|
if len(rows) == 0 {
|
|
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(rows, hasConnected))
|
|
return
|
|
}
|
|
a.render(w, r, ListPage(rows, f, 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))
|
|
}
|
|
|
|
// 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"
|
|
}
|