package handler import ( "context" "errors" "io" "net" "net/http" "net/url" "regexp" "strings" "sync" "time" "github.com/flowy-live/llink/internal/utils/flog" ) // LinkMetadata mirrors the TS shape in `js/desktop/src/lib/link-metadata.ts`. // All clients (web and Electron) call this endpoint — the Electron main-process // fetcher was retired so both clients share one parser and the server-side cache. type LinkMetadata struct { URL string `json:"url"` Title *string `json:"title"` Description *string `json:"description"` Image *string `json:"image"` Favicon *string `json:"favicon"` Domain string `json:"domain"` } const ( metadataFetchTimeout = 5 * time.Second metadataMaxBytes = 50 * 1024 metadataUserAgent = "Mozilla/5.0 (compatible; llink/1.0)" ) // In-process cache, unbounded but only successful results are stored. // URL space is bounded in practice. var metadataCache sync.Map // map[string]LinkMetadata func (h *Handler) GetLinkMetadata(w http.ResponseWriter, r *http.Request) { raw := r.URL.Query().Get("url") if raw == "" { http.Error(w, "url is required", http.StatusBadRequest) return } parsed, err := url.Parse(raw) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { http.Error(w, "invalid url", http.StatusBadRequest) return } if cached, ok := metadataCache.Load(raw); ok { writeJSON(w, cached) return } if err := guardSSRF(parsed.Hostname()); err != nil { // Treat unresolvable / private hosts as "no metadata" rather than 4xx — the // client treats a null body as a progressive-enhancement miss. writeJSON(w, nil) return } meta, err := fetchLinkMetadata(r.Context(), parsed) if err != nil { flog.Warn("link metadata fetch failed", "url", raw, "error", err) writeJSON(w, nil) return } metadataCache.Store(raw, *meta) writeJSON(w, meta) } // guardSSRF resolves the host and rejects loopback, private, link-local, and // unspecified addresses so the endpoint can't be turned into an internal-network // probe. func guardSSRF(host string) error { ips, err := net.LookupIP(host) if err != nil { return err } if len(ips) == 0 { return errors.New("no addresses for host") } for _, ip := range ips { if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() { return errors.New("private or local address") } } return nil } func fetchLinkMetadata(ctx context.Context, target *url.URL) (*LinkMetadata, error) { ctx, cancel := context.WithTimeout(ctx, metadataFetchTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil) if err != nil { return nil, err } req.Header.Set("User-Agent", metadataUserAgent) req.Header.Set("Accept", "text/html") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, errors.New("upstream non-2xx") } body, err := io.ReadAll(io.LimitReader(resp.Body, metadataMaxBytes)) if err != nil { return nil, err } html := string(body) domain := strings.TrimPrefix(target.Hostname(), "www.") title := firstNonNil(metaContent(html, "og:title"), parseTitle(html)) description := firstNonNil( metaContent(html, "og:description"), metaContent(html, "description"), ) image := resolveURL(metaContent(html, "og:image"), target) favicon := parseFavicon(html, target) return &LinkMetadata{ URL: target.String(), Title: title, Description: description, Image: image, Favicon: favicon, Domain: domain, }, nil } var titleRe = regexp.MustCompile(`(?i)]*>([^<]*)`) func parseTitle(html string) *string { m := titleRe.FindStringSubmatch(html) if len(m) < 2 { return nil } s := strings.TrimSpace(m[1]) if s == "" { return nil } return &s } // metaContent finds in either attribute order. func metaContent(html, prop string) *string { escaped := regexp.QuoteMeta(prop) re := regexp.MustCompile( `(?i)]*(?:property|name)=["']` + escaped + `["'][^>]*content=["']([^"']*)["']` + `|]*content=["']([^"']*)["'][^>]*(?:property|name)=["']` + escaped + `["']`, ) m := re.FindStringSubmatch(html) if len(m) == 0 { return nil } for _, g := range m[1:] { if g != "" { return &g } } return nil } var ( faviconRe1 = regexp.MustCompile(`(?i)]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']*)["']`) faviconRe2 = regexp.MustCompile(`(?i)]*href=["']([^"']*)["'][^>]*rel=["'](?:shortcut )?icon["']`) ) func parseFavicon(html string, base *url.URL) *string { for _, re := range []*regexp.Regexp{faviconRe1, faviconRe2} { if m := re.FindStringSubmatch(html); len(m) >= 2 { return resolveURL(&m[1], base) } } // Fall back to /favicon.ico fallback := (&url.URL{Scheme: base.Scheme, Host: base.Host, Path: "/favicon.ico"}).String() return &fallback } func resolveURL(src *string, base *url.URL) *string { if src == nil || *src == "" { return nil } parsed, err := url.Parse(*src) if err != nil { return src } resolved := base.ResolveReference(parsed).String() return &resolved } func firstNonNil(values ...*string) *string { for _, v := range values { if v != nil && *v != "" { return v } } return nil }