Files
pad/internal/server/tutorials.go
T
xarmian 4fdc6ed625 feat: in-app tutorials on Pad Cloud, links to getpad.dev/learn elsewhere (TASK-3452) (#1839)
Design and rulings on TASK-3452 (lead, day 89).

- Placement: a first-time card on /console's empty state (Start a new
  project) and in the workspace launchpad's step 2 (Set up your workspace
  with /pad onboard); each vanishes with its screen. The stable way back is
  a Tutorials entry in the user menu's Resources block, right after Docs.
- Dismissal: users.ui_dismissals, a JSON set of keys behind an allow-list
  (models.UIDismissalKeys), read-modify-written under the users row lock,
  so a dismissal holds on every device. GET/PUT /me/ui-dismissals, web only.
- Pad Cloud: the server fetches https://www.getpad.dev/learn/catalog.json
  (a constant; 1h cache, last good copy kept, 5 min back-off) and serves it
  same-origin at /tutorials, with posters proxied at /tutorials/posters/
  {slug} from www.getpad.dev only. The page CSP gains frame-src for
  youtube-nocookie, Cloud only. /console/tutorials hosts the click-to-load
  player and chapters; transcripts link to getpad.dev.
- Self-hosted: both routes 404, no outbound request, no CSP change; every
  surface links to getpad.dev/learn.

Migration 127 / pg 101, after #1837's 126 / 100 (rides rc.7). No CLI or MCP surface.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-06 22:23:29 -04:00

303 lines
9.5 KiB
Go

package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
)
// In-app tutorials (TASK-3452, PLAN-3442).
//
// The catalog lives on getpad.dev (pad-web's /learn/catalog.json, built from
// the same file as getpad.dev/learn), so publishing a tutorial needs no Pad
// release. On Pad CLOUD the server fetches it and serves it same-origin: the
// app's CSP keeps connect-src and img-src to itself, so the browser never
// reaches getpad.dev, and the posters are proxied here for the same reason.
// A SELF-HOSTED server never fetches anything: both routes answer 404 and the
// web client links out to getpad.dev/learn instead (a self-hoster's instance
// makes no third-party calls). Lead rulings on TASK-3452: server-side fetch
// gated on cloud mode, the catalog URL a server constant, posters proxied.
// tutorialsCatalogURL is the catalog's address. www, not the apex: the apex
// answers 307 (measured on TASK-3452 U1).
const tutorialsCatalogURL = "https://www.getpad.dev/learn/catalog.json"
// tutorialsPosterOrigin is the only origin a poster is fetched from, whatever
// the catalog says, so a bad catalog cannot point this server at anything else.
const tutorialsPosterOrigin = "https://www.getpad.dev/"
const (
tutorialsTTL = time.Hour
tutorialsRetryAfterFail = 5 * time.Minute
tutorialsFetchTimeout = 5 * time.Second
tutorialsCatalogMaxSize = 1 << 20
tutorialsPosterMaxSize = 2 << 20
)
type tutorialChapter struct {
T float64 `json:"t"`
Title string `json:"title"`
}
type tutorialEntry struct {
Slug string `json:"slug"`
Title string `json:"title"`
Summary string `json:"summary,omitempty"`
Path string `json:"path,omitempty"`
YouTubeID *string `json:"youtube_id"`
Seconds *float64 `json:"seconds"`
Poster *string `json:"poster"`
Chapters []tutorialChapter `json:"chapters,omitempty"`
URL string `json:"url,omitempty"`
Next *string `json:"next,omitempty"`
}
type tutorialPath struct {
ID string `json:"id"`
Title string `json:"title"`
Blurb string `json:"blurb"`
}
type tutorialCatalog struct {
Version int `json:"version"`
Paths []tutorialPath `json:"paths"`
Tutorials []tutorialEntry `json:"tutorials"`
Demos []tutorialEntry `json:"demos"`
}
type tutorialPoster struct {
data []byte
contentType string
}
// tutorialsCache holds the last good catalog and the posters fetched so far.
type tutorialsCache struct {
mu sync.Mutex
catalog *tutorialCatalog
fetchedAt time.Time
failedAt time.Time
// inflight is closed when the refresh in progress finishes; nil when none.
inflight chan struct{}
posters map[string]tutorialPoster
}
// tutorialsClient is the outbound client, replaceable in tests. A dedicated
// client with its own timeout; a nil Transport is http.DefaultTransport.
func (s *Server) tutorialsClient() *http.Client {
if s.tutorialsHTTP != nil {
return s.tutorialsHTTP
}
return &http.Client{Timeout: tutorialsFetchTimeout}
}
func (s *Server) fetchTutorialBytes(ctx context.Context, url string, limit int64) ([]byte, string, error) {
ctx, cancel := context.WithTimeout(ctx, tutorialsFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", err
}
// Never follow a redirect: the origin check is on the URL asked for, and a
// redirect would let an allowed getpad.dev URL send this server anywhere
// (codex round 1). A 3xx is then just a non-200, and the fetch fails.
client := *s.tutorialsClient()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
resp, err := client.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("tutorials: %s answered %d", url, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return nil, "", err
}
if int64(len(body)) > limit {
return nil, "", fmt.Errorf("tutorials: %s is larger than %d bytes", url, limit)
}
return body, resp.Header.Get("Content-Type"), nil
}
// tutorialCatalogNow returns the catalog, refreshing it when it is older than
// the TTL. One refresh runs at a time and never holds the lock while it waits
// on the network (codex round 1): a caller with a copy in hand gets that copy
// at once, and only a caller with NO copy waits for the refresh in progress,
// bounded by its own request context. A failed refresh keeps the last good
// copy; with none, the result is nil and the client falls back to links.
// Failures are not retried more often than tutorialsRetryAfterFail, so an
// outage does not cost every request a timeout.
func (s *Server) tutorialCatalogNow(ctx context.Context) *tutorialCatalog {
c := &s.tutorials
c.mu.Lock()
now := time.Now()
fresh := c.catalog != nil && now.Sub(c.fetchedAt) < tutorialsTTL
backingOff := !c.failedAt.IsZero() && now.Sub(c.failedAt) < tutorialsRetryAfterFail
if fresh || backingOff {
cat := c.catalog
c.mu.Unlock()
return cat
}
wait := c.inflight
if wait == nil {
wait = make(chan struct{})
c.inflight = wait
go s.refreshTutorialCatalog(wait)
}
cat := c.catalog
c.mu.Unlock()
if cat != nil {
return cat // stale but usable; the refresh lands for the next caller
}
select {
case <-wait:
case <-ctx.Done():
}
c.mu.Lock()
defer c.mu.Unlock()
return c.catalog
}
// refreshTutorialCatalog fetches the catalog and publishes the result, then
// closes done. It runs detached from any one request, so a client that goes
// away does not cancel the refresh everyone else is waiting on.
func (s *Server) refreshTutorialCatalog(done chan struct{}) {
body, _, err := s.fetchTutorialBytes(context.Background(), tutorialsCatalogURL, tutorialsCatalogMaxSize)
var cat tutorialCatalog
if err == nil {
err = json.Unmarshal(body, &cat)
}
if err == nil && cat.Version != 1 {
err = fmt.Errorf("tutorials: catalog version %d is not 1", cat.Version)
}
c := &s.tutorials
c.mu.Lock()
if err != nil {
c.failedAt = time.Now()
} else {
c.catalog = &cat
c.fetchedAt = time.Now()
c.failedAt = time.Time{}
}
c.inflight = nil
c.mu.Unlock()
close(done)
}
func tutorialPosterPath(slug string) string {
return "/api/v1/tutorials/posters/" + slug
}
// sameOriginEntries rewrites each poster to this server's proxy path.
func sameOriginEntries(in []tutorialEntry) []tutorialEntry {
out := make([]tutorialEntry, len(in))
for i, e := range in {
if e.Poster != nil && *e.Poster != "" {
p := tutorialPosterPath(e.Slug)
e.Poster = &p
} else {
e.Poster = nil
}
out[i] = e
}
return out
}
// handleListTutorials: GET /tutorials. Cloud only (404 on a self-hosted
// server). {available, paths, tutorials, demos}; available=false when the
// catalog could not be fetched and no earlier copy exists, and the client then
// links to getpad.dev/learn.
func (s *Server) handleListTutorials(w http.ResponseWriter, r *http.Request) {
if !s.cloudMode {
writeError(w, http.StatusNotFound, "not_found", "Not found")
return
}
cat := s.tutorialCatalogNow(r.Context())
if cat == nil {
writeJSON(w, http.StatusOK, map[string]any{
"available": false, "paths": []tutorialPath{}, "tutorials": []tutorialEntry{}, "demos": []tutorialEntry{},
})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"available": true,
"paths": cat.Paths,
"tutorials": sameOriginEntries(cat.Tutorials),
"demos": sameOriginEntries(cat.Demos),
})
}
var errTutorialPosterOrigin = errors.New("tutorials: poster is not on getpad.dev")
// handleTutorialPoster: GET /tutorials/posters/{slug}. Cloud only. Serves the
// poster the catalog names for slug, fetched once from getpad.dev and kept in
// memory. Only an https://www.getpad.dev/ image is ever fetched.
func (s *Server) handleTutorialPoster(w http.ResponseWriter, r *http.Request) {
if !s.cloudMode {
writeError(w, http.StatusNotFound, "not_found", "Not found")
return
}
slug := chi.URLParam(r, "slug")
cat := s.tutorialCatalogNow(r.Context())
var src string
if cat != nil {
for _, e := range append(append([]tutorialEntry{}, cat.Tutorials...), cat.Demos...) {
if e.Slug == slug && e.Poster != nil {
src = *e.Poster
break
}
}
}
if src == "" {
writeError(w, http.StatusNotFound, "not_found", "Not found")
return
}
c := &s.tutorials
c.mu.Lock()
p, ok := c.posters[src]
c.mu.Unlock()
if !ok {
data, ctype, err := s.fetchTutorialPoster(r.Context(), src)
if err != nil {
writeError(w, http.StatusBadGateway, "upstream_unavailable", "Poster unavailable")
return
}
p = tutorialPoster{data: data, contentType: ctype}
c.mu.Lock()
if c.posters == nil {
c.posters = map[string]tutorialPoster{}
}
c.posters[src] = p
c.mu.Unlock()
}
w.Header().Set("Content-Type", p.contentType)
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Header().Set("X-Content-Type-Options", "nosniff")
_, _ = w.Write(p.data)
}
func (s *Server) fetchTutorialPoster(ctx context.Context, src string) ([]byte, string, error) {
if !strings.HasPrefix(src, tutorialsPosterOrigin) {
return nil, "", errTutorialPosterOrigin
}
data, ctype, err := s.fetchTutorialBytes(ctx, src, tutorialsPosterMaxSize)
if err != nil {
return nil, "", err
}
switch ctype = strings.ToLower(strings.TrimSpace(strings.Split(ctype, ";")[0])); ctype {
case "image/jpeg", "image/png", "image/webp":
return data, ctype, nil
}
return nil, "", fmt.Errorf("tutorials: poster content type %q", ctype)
}