Files
brain/internal/preview/preview.go
T
Andrew S Erwin b9721ff13f
release gate / check (push) Failing after 8m35s
initial commit on Federted servers
2026-10-19 16:23:55 -05:00

268 lines
7.5 KiB
Go

package preview
import (
"context"
"fmt"
"github.com/andrew/go-computer/internal/netguard"
"golang.org/x/net/html"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"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 = 8
)
type rateWindow struct {
count int
reset time.Time
}
type budgetStore struct {
mu sync.Mutex
inFlight int
keys map[string]*rateWindow
}
var sharedBudget = &budgetStore{keys: map[string]*rateWindow{}}
// 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.
func Reserve(key string) (release func(), ok bool) {
if key == "" {
key = "anonymous"
}
sharedBudget.mu.Lock()
defer sharedBudget.mu.Unlock()
if sharedBudget.inFlight >= maxConcurrent {
return nil, false
}
now := time.Now()
// Opportunistic prune keeps the map bounded by active identities.
if len(sharedBudget.keys) > 4096 {
for k, w := range sharedBudget.keys {
if now.After(w.reset) {
delete(sharedBudget.keys, k)
}
}
}
w := sharedBudget.keys[key]
if w == nil || now.After(w.reset) {
w = &rateWindow{reset: now.Add(keyWindow)}
sharedBudget.keys[key] = w
}
if w.count >= keyLimit {
return nil, false
}
w.count++
sharedBudget.inFlight++
return func() {
sharedBudget.mu.Lock()
sharedBudget.inFlight--
sharedBudget.mu.Unlock()
}, true
}
// 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
}