mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 22:10:25 +00:00
4fdc6ed625
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
303 lines
9.5 KiB
Go
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)
|
|
}
|