Files
brain/internal/preview/preview.go
T
Andrew S Erwin 4e421f6ea2
release gate / check (push) Failing after 6m43s
Address code review findings across API, web and deployment
High severity:
- wire TRUSTED_PROXY through Compose/deploy/docs and warn at web startup
- bucket public rate limits on effective client IP, add per-bookmark budget,
  and forward the signed browser identity from the share page
- disable first-run bootstrap in server deployments and document seeding
- serialize last-admin demotion in one advisory-locked transaction

Medium/low:
- cap and batch share recipient inserts
- add a shared outbound budget for preview/CardDAV/IMAP/signal work
- pin CardDAV dials to 443 and bound IMAP logout
- fix rightward kanban column drags and normalize cards on column delete
- classify backend outages in auth guards, add request timeouts and an
  error boundary, remove the dead login route, surface throttling
- enforce pinned/unpinned page reorder grouping server-side
- avoid preview work for stale bookmark versions; drop duplicate dialog fetch
- sequence-guard collection/tag/calendar/quick-find loads
- validate property config and fix MULTI_SELECT/DATE filter inputs
- document index maintenance; apply same-origin check to sign-out
- trim signup names, unique shared dates and kanban keys (migration 010),
  reset reference cache on identity changes, hide OIDC issuer, return 413
  for oversized direct bodies, escape Markdown link destinations
- remove dead template files and duplicate test helper

Verified with go build/vet, full PostgreSQL race suite, db convergence,
tsc, both ESLint configs and a Next production build.
2026-10-20 00:13:30 -05:00

229 lines
6.9 KiB
Go

package preview
import (
"context"
"fmt"
"github.com/andrew/go-computer/internal/netguard"
"github.com/andrew/go-computer/internal/outbound"
"golang.org/x/net/html"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
type Preview struct {
Title *string `json:"title"`
Description *string `json:"description"`
Favicon *string `json:"favicon"`
OgImage *string `json:"ogImage"`
OgTitle *string `json:"ogTitle"`
OgDescription *string `json:"ogDescription"`
}
// DNS is resolved and checked on each connection, including redirects, so
// URL previews cannot reach another user's internal service or Docker DB.
// The shared range logic lives in netguard (also used by CardDAV and IMAP).
// Preview fetches are additionally pinned to the normal web ports: a public
// host can still expose SSH, SMTP or a database, and a preview has no reason
// to touch them. The fixture/LAN escape hatch keeps loopback test servers
// (and deliberate private deployments) working on arbitrary ports.
func publicDial(ctx context.Context, network, address string) (net.Conn, error) {
if netguard.AllowPrivate() {
return netguard.DialContext(ctx, network, address)
}
return netguard.DialContextPorts(ctx, network, address, 80, 443)
}
// previewClient is reused for all preview fetches (review PERF-1): a new
// Transport per call leaked idle connections because IdleConnTimeout
// defaulted to 0. A single client with bounded idle pool and timeout keeps
// the connection count and file descriptors under control.
var previewClient = &http.Client{
Timeout: 8 * time.Second,
Transport: &http.Transport{
DialContext: publicDial,
IdleConnTimeout: 30 * time.Second,
MaxIdleConns: 32,
MaxIdleConnsPerHost: 8,
},
CheckRedirect: func(r *http.Request, via []*http.Request) error {
if len(via) > 5 {
return fmt.Errorf("too many redirects")
}
if r.URL.Scheme != "http" && r.URL.Scheme != "https" {
return fmt.Errorf("invalid scheme")
}
return nil
},
}
// Shared preview-work budget (review CR-06). EVERY entry point that fetches
// preview metadata — the dedicated /api/preview route, bookmark creation and
// URL-changing updates — reserves against this one budget, so mutations can
// no longer bypass the preview route's throttle and generate unbounded
// outbound work. Two layers:
// - per-identity rate: at most keyLimit reservations per keyWindow;
// - global concurrency: at most maxConcurrent fetches in flight.
//
// Exhaustion returns an empty Preview immediately instead of queueing:
// previews are decorative, and a bookmark save must never be coupled to
// preview availability (the row persists without og metadata).
const (
keyLimit = 20
keyWindow = time.Minute
// maxConcurrent mirrors outbound's global cap; kept for the budget tests
// and documentation.
maxConcurrent = 8
)
// Reserve claims one preview-work slot for key (the requesting identity).
// The returned release function MUST be called when the fetch finishes.
// ok is false when either budget layer is exhausted. Preview shares the
// process-wide outbound budget with CardDAV/IMAP/signal checks (review M-2),
// while keeping its own more generous per-identity rate.
func Reserve(key string) (release func(), ok bool) {
release, _, ok = outbound.Reserve(key, keyLimit, keyWindow)
return release, ok
}
// FetchPreview resolves metadata for raw on behalf of key (the requesting
// identity, e.g. "user:<id>"). The caller's context bounds the fetch (review
// CORR-02): when the incoming request is cancelled — the user closed the tab
// or aborted the save — the outbound I/O and HTML parsing stop instead of
// running to the 8-second client timeout. An exhausted shared budget (review
// CR-06) yields an empty Preview without any outbound work.
func FetchPreview(ctx context.Context, key, raw string) Preview {
release, ok := Reserve(key)
if !ok {
return Preview{}
}
defer release()
u, e := url.Parse(raw)
if e != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil {
return Preview{}
}
req, e := http.NewRequestWithContext(ctx, "GET", raw, nil)
if e != nil {
return Preview{}
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; computer/1.0)")
resp, e := previewClient.Do(req)
if e != nil {
return Preview{}
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return Preview{}
}
// Review CORR-01: resolve relative favicons/og:images against the final
// response URL, not the caller-supplied one — a redirect can change the
// host or base path and break every relative asset.
base := u
if resp.Request != nil && resp.Request.URL != nil {
base = resp.Request.URL
}
return ParseHTML(io.LimitReader(resp.Body, 1<<20), base)
}
// clipRunes keeps remote metadata bounded: a hostile or merely careless page
// can otherwise put a megabyte into a title/description that every bookmark
// list then carries around.
func clipRunes(s string, max int) string {
r := []rune(s)
if len(r) > max {
r = r[:max]
}
return string(r)
}
func ParseHTML(r io.Reader, base *url.URL) Preview {
out := Preview{}
z := html.NewTokenizer(r)
var title strings.Builder
inTitle := false
absolute := func(s string) *string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
u, e := base.Parse(s)
if e != nil || (u.Scheme != "http" && u.Scheme != "https") {
return nil
}
s = clipRunes(u.String(), 2048)
return &s
}
for {
t := z.Next()
if t == html.ErrorToken {
break
}
tok := z.Token()
if t == html.TextToken && inTitle {
title.WriteString(tok.Data)
}
if t == html.EndTagToken && tok.Data == "title" {
inTitle = false
}
if t != html.StartTagToken && t != html.SelfClosingTagToken {
continue
}
if tok.Data == "title" {
inTitle = true
}
attrs := map[string]string{}
for _, a := range tok.Attr {
attrs[a.Key] = a.Val
}
if tok.Data == "meta" {
key := strings.ToLower(attrs["property"])
if key == "" {
key = strings.ToLower(attrs["name"])
}
v := strings.TrimSpace(attrs["content"])
if v != "" {
switch key {
case "og:title":
if out.OgTitle == nil {
c := clipRunes(v, 500)
out.OgTitle = &c
}
case "og:description":
if out.OgDescription == nil {
c := clipRunes(v, 2000)
out.OgDescription = &c
}
case "description":
if out.Description == nil {
c := clipRunes(v, 2000)
out.Description = &c
}
case "og:image":
if out.OgImage == nil {
out.OgImage = absolute(v)
}
}
}
}
if tok.Data == "link" && strings.Contains(strings.ToLower(attrs["rel"]), "icon") && out.Favicon == nil {
out.Favicon = absolute(attrs["href"])
}
}
if out.OgTitle != nil {
out.Title = out.OgTitle
} else if s := clipRunes(strings.TrimSpace(title.String()), 500); s != "" {
out.Title = &s
}
if out.OgDescription != nil {
out.Description = out.OgDescription
}
if out.Favicon == nil {
s := base.Scheme + "://" + base.Host + "/favicon.ico"
out.Favicon = &s
}
return out
}