release gate / check (push) Failing after 6m43s
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.
229 lines
6.9 KiB
Go
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
|
|
}
|