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:"). 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 }