package linking import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "net/url" "regexp" "strings" "stream.place/streamplace/pkg/log" "golang.org/x/net/html" "stream.place/streamplace/pkg/appbsky" "stream.place/streamplace/pkg/branding" "stream.place/streamplace/pkg/config" "stream.place/streamplace/pkg/placestream" "stream.place/streamplace/pkg/statedb" ) type Linker struct { BaseHTML []byte sdb *statedb.StatefulDB cli *config.CLI } func NewLinker(ctx context.Context, baseHTML []byte, sdb *statedb.StatefulDB, cli *config.CLI) (*Linker, error) { _, err := html.Parse(bytes.NewReader(baseHTML)) if err != nil { return nil, err } return &Linker{BaseHTML: baseHTML, sdb: sdb, cli: cli}, nil } type PageConfig struct { Title string Metas []MetaTag SentryDSN string Branding []string } // Define all meta tags in a structured way type MetaTag struct { Type string // "name" or "property" Key string Content string } var BrandingAssetList = func() []string { keys := make([]string, 0, len(branding.Specs)) for _, spec := range branding.Specs { keys = append(keys, spec.Key) } return keys }() // inlineBrandingImageLimit caps the image assets embedded as data URLs in // the internal-brand meta tags. const inlineBrandingImageLimit = 96 * 1024 // hexColor accepts #rgb / #rrggbb / #rrggbbaa, the only forms the app's // theme accepts, so a stored value can be dropped straight into a style. var hexColor = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`) // bodyBackground returns the node's branded dark background color, if any, // so the page can paint it before the bundle loads instead of the default // then re-painting (the "flash of unbranded content"). func (l *Linker) bodyBackground() string { v := l.brandingText("backgroundColor") if !hexColor.MatchString(v) { return "" } return v } // brandingText reads a text branding key for this node, "" when unset. func (l *Linker) brandingText(key string) string { if l.sdb == nil || l.cli == nil { return "" } blob, err := l.sdb.GetBrandingBlob("did:web:"+l.cli.BroadcasterHost, key) if err != nil || blob == nil { return "" } return strings.TrimSpace(string(blob.Data)) } func findChild(parent *html.Node, name string) *html.Node { for node := range parent.ChildNodes() { if node.Type == html.ElementNode && node.Data == name { return node } } return nil } // isAppBannerMeta reports whether a is the iOS Smart App Banner // (apple-itunes-app), which the template ships for the first-party app. func isAppBannerMeta(node *html.Node) bool { for _, attr := range node.Attr { if attr.Key == "name" && attr.Val == "apple-itunes-app" { return true } } return false } // atTags returns meta tags implementing the at-tags proposal // (https://tangled.org/chrisshank.com/at-tags), which maps web pages back to // atproto identities and records via tags in the page // : // // - at:canonical — the AT URI of the record this page canonically maps to // (pass "" to omit, e.g. for pages that don't represent a record) // - at:author — the atproto identity of the page's author, as an // at:// URI (pass "" to omit) // - at:me — the identity of the overall website, i.e. this node's // did:web, when a broadcaster host is configured func (l *Linker) atTags(canonicalURI string, authorDID string) []MetaTag { tags := make([]MetaTag, 0, 3) if strings.HasPrefix(canonicalURI, "at://") { tags = append(tags, MetaTag{Type: "name", Key: "at:canonical", Content: canonicalURI}) } if authorDID != "" { tags = append(tags, MetaTag{Type: "name", Key: "at:author", Content: "at://" + authorDID}) } if l.cli != nil && l.cli.BroadcasterHost != "" { tags = append(tags, MetaTag{Type: "name", Key: "at:me", Content: "at://did:web:" + l.cli.BroadcasterHost}) } return tags } // fetch branding assets for a given broadcaster DID func (l *Linker) getBrandingAssets(broadcasterDid string) ([]placestream.BrandingGetBranding_BrandingAsset, error) { ret := make([]placestream.BrandingGetBranding_BrandingAsset, 0) for _, asset := range BrandingAssetList { blob, err := l.sdb.GetBrandingBlob(broadcasterDid, asset) if err != nil { // this can probably include a 'record not found' error, in which case we skip log.Debug(context.Background(), "error fetching branding asset for broadcaster", "asset", asset, "broadcaster", broadcasterDid, "error", err) continue } asset := placestream.BrandingGetBranding_BrandingAsset{ Key: blob.Key, MimeType: blob.MimeType, } if blob.Width != nil { w := int64(*blob.Width) asset.Width = &w } if blob.Height != nil { h := int64(*blob.Height) asset.Height = &h } // process based on mime type if blob.MimeType == "text/plain" { str := string(blob.Data) asset.Data = &str } else { url := fmt.Sprintf("/xrpc/place.stream.branding.getBlob?key=%s&broadcaster=%s", blob.Key, broadcasterDid) asset.Url = &url // Small images ride along inline so the very first paint can draw // the node's logo instead of the default mark while the blob // fetch is in flight. Large ones (sidebar backgrounds) stay // URL-only; the meta tag would dwarf the page. if len(blob.Data) <= inlineBrandingImageLimit { dataURL := "data:" + blob.MimeType + ";base64," + base64.StdEncoding.EncodeToString(blob.Data) asset.Data = &dataURL } } ret = append(ret, asset) } return ret, nil } // brandMetas fetches the node's branding once for a card: the // internal-brand:* meta tags the app hydrates from, plus every text value by // key so the card's own title and description can be branded. func (l *Linker) brandMetas(ctx context.Context) ([]MetaTag, map[string]string) { values := map[string]string{} if l.sdb == nil || l.cli == nil { return nil, values } assets, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) if err != nil { // log but we should not block rendering log.Error(ctx, "error fetching branding assets", "error", err) return nil, values } var metas []MetaTag for i := range assets { val := assets[i] if val.MimeType == branding.TextMime && val.Data != nil { values[val.Key] = strings.TrimSpace(*val.Data) } marshalledJson, err := json.Marshal(val) if err != nil { log.Error(ctx, "error marshalling branding asset", "key", val.Key, "error", err) continue } metas = append(metas, MetaTag{ Type: "name", Key: "internal-brand:" + val.Key, Content: string(marshalledJson), }) } return metas, values } // The card text when a node has no siteTitle / siteDescription branding. const ( defaultSiteTitle = "streamplace node" defaultSiteDescription = "Open-source livestreaming on the AT Protocol." ) // cardText resolves one of the card template keys: the node's value when // set, else the vocabulary's default. func cardText(values map[string]string, key string) string { if v := values[key]; v != "" { return v } if spec, ok := branding.Lookup(key); ok { return spec.Default } return "" } // siteTitle is the node's name for og:site_name and the page title fallback; // siteName is how the card templates refer to it ({site}), which a node can // shorten with cardSiteName ("W" for a "W Social" site). func siteTitle(values map[string]string) string { if v := values["siteTitle"]; v != "" { return v } return defaultSiteTitle } func siteName(values map[string]string) string { if v := values["cardSiteName"]; v != "" { return v } return siteTitle(values) } // expandCard fills a card template's placeholders: {site}, and for a page // about one account {name} (display name, falling back to the handle) and // {handle}. func expandCard(tmpl string, values map[string]string, author *appbsky.ActorDefs_ProfileViewBasic) string { r := []string{"{site}", siteName(values)} if author != nil { name := author.Handle if author.DisplayName != nil && strings.TrimSpace(*author.DisplayName) != "" { name = strings.TrimSpace(*author.DisplayName) } r = append(r, "{name}", name, "{handle}", author.Handle) } return strings.TrimSpace(strings.NewReplacer(r...).Replace(tmpl)) } // cardTags builds the description, OpenGraph and Twitter tags every card // shares. func cardTags(u *url.URL, ogType, title, description, image, site string) []MetaTag { tags := []MetaTag{ // Basic meta {Type: "name", Key: "description", Content: description}, // Facebook Meta Tags {Type: "property", Key: "og:url", Content: u.String()}, {Type: "property", Key: "og:type", Content: ogType}, {Type: "property", Key: "og:site_name", Content: site}, {Type: "property", Key: "og:title", Content: title}, {Type: "property", Key: "og:description", Content: description}, {Type: "property", Key: "og:image", Content: image}, // Twitter Meta Tags {Type: "name", Key: "twitter:card", Content: "summary_large_image"}, {Type: "property", Key: "twitter:domain", Content: u.Host}, {Type: "property", Key: "twitter:url", Content: u.String()}, {Type: "name", Key: "twitter:title", Content: title}, {Type: "name", Key: "twitter:description", Content: description}, {Type: "name", Key: "twitter:image", Content: image}, } return tags } // GenerateStreamerCard is the card for a stream page: " is live on // " (cardLiveTitle) over the stream's post text, with the streamer's // profile card as the image. func (l *Linker) GenerateStreamerCard(ctx context.Context, u *url.URL, lsv *placestream.Livestream_LivestreamView, sentryDSN string) ([]byte, error) { if u == nil { return nil, errors.New("url is nil") } if lsv == nil { return nil, errors.New("livestream view is nil") } ls, ok := lsv.Record.Val.(*placestream.Livestream) if !ok { return nil, errors.New("livestream view is not a livestream") } thumbURL, _ := url.Parse(u.String()) thumbURL.Path = "/xrpc/place.stream.live.getProfileCard" thumbURL.RawQuery = fmt.Sprintf("id=%s", lsv.Author.Did) brandMetas, values := l.brandMetas(ctx) title := expandCard(cardText(values, "cardLiveTitle"), values, &lsv.Author) metaTags := cardTags(u, "website", title, ls.Title, thumbURL.String(), siteTitle(values)) metaTags = append(metaTags, brandMetas...) // at-tags: this page canonically maps to the livestream record, authored // by the streamer metaTags = append(metaTags, l.atTags(lsv.Uri, lsv.Author.Did)...) return l.GenerateHTML(ctx, &PageConfig{ Title: title, Metas: metaTags, SentryDSN: sentryDSN, }) } // GenerateVideoCard is the card for a video page: "'s video on " // (cardVideoTitle) over the video's own title. func (l *Linker) GenerateVideoCard(ctx context.Context, u *url.URL, vv *placestream.MediaGetVideo_VideoView, sentryDSN string) ([]byte, error) { if u == nil { return nil, errors.New("url is nil") } if vv == nil { return nil, errors.New("video view is nil") } video, ok := vv.Record.Val.(*placestream.Video) if !ok { return nil, errors.New("video view record is not a video") } authorDid := vv.Author.Did // og:image is the VOD's own thumbnail, served through the Bluesky image // CDN (which fetches the blob from the author's PDS — the same trick used // for game cover art). When the video has no thumbnail we fall back to the // author's generated profile card so the link still carries an image. var imageURL string if video.Thumb != nil && authorDid != "" { imageURL = fmt.Sprintf( "https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s@jpeg", authorDid, video.Thumb.Ref.String(), ) } else { cardURL, _ := url.Parse(u.String()) cardURL.Path = "/xrpc/place.stream.live.getProfileCard" cardURL.RawQuery = fmt.Sprintf("id=%s", authorDid) imageURL = cardURL.String() } brandMetas, values := l.brandMetas(ctx) title := expandCard(cardText(values, "cardVideoTitle"), values, &vv.Author) // The description is the video's title, the post text it was published // with; a video without one falls back to its description. description := strings.TrimSpace(video.Title) if description == "" && video.Description != nil { description = strings.TrimSpace(*video.Description) } metaTags := cardTags(u, "video.other", title, description, imageURL, siteTitle(values)) metaTags = append(metaTags, brandMetas...) // at-tags: this page canonically maps to the place.stream.video record, // authored by the streamer metaTags = append(metaTags, l.atTags(vv.Uri, authorDid)...) return l.GenerateHTML(ctx, &PageConfig{ Title: title, Metas: metaTags, SentryDSN: sentryDSN, }) } // LandingPage names the app's top-level tabs, each with its own link card. type LandingPage int const ( // LandingDefault is any page without a card of its own: the node's // siteTitle and siteDescription. LandingDefault LandingPage = iota // LandingHome is the front page, the live tab (/): cardHomeTitle and // cardHomeDescription, falling back to the site's own. LandingHome // LandingVideos is the on-demand tab (/video). LandingVideos // LandingGoLive is the go-live tab (/live, /go-live). LandingGoLive ) // LandingPageFor maps a request path to the tab it shows, LandingDefault for // anything else. func LandingPageFor(path string) LandingPage { switch strings.Trim(path, "/") { case "": return LandingHome case "video": return LandingVideos case "live", "go-live": return LandingGoLive } return LandingDefault } // GenerateDefaultCard is the card for a page without one of its own: the // node's siteTitle and siteDescription with its link banner. func (l *Linker) GenerateDefaultCard(ctx context.Context, u *url.URL, sentryDSN string) ([]byte, error) { return l.GenerateLandingCard(ctx, u, LandingDefault, sentryDSN) } // GenerateLandingCard is the card for one of the app's tabs: its own title // and description from the card templates (cardHomeTitle, cardVideosTitle, // cardGoLiveTitle and their descriptions), the node's link banner as the // image. func (l *Linker) GenerateLandingCard(ctx context.Context, u *url.URL, page LandingPage, sentryDSN string) ([]byte, error) { if u == nil { return nil, errors.New("url is nil") } // /linkbanner.png serves the uploaded linkBanner asset when there is one // and the bundled brand banner otherwise. thumbURL, _ := url.Parse(u.String()) thumbURL.Path = "/linkbanner.png" brandMetas, values := l.brandMetas(ctx) title := siteTitle(values) description := values["siteDescription"] if description == "" { description = defaultSiteDescription } switch page { case LandingHome: // The front page falls back to the site's own title and description; // a node phrases its live tab ("Live streams on W") by setting the // home templates. if t := expandCard(cardText(values, "cardHomeTitle"), values, nil); t != "" { title = t } if d := expandCard(cardText(values, "cardHomeDescription"), values, nil); d != "" { description = d } case LandingVideos: title = expandCard(cardText(values, "cardVideosTitle"), values, nil) description = expandCard(cardText(values, "cardVideosDescription"), values, nil) case LandingGoLive: title = expandCard(cardText(values, "cardGoLiveTitle"), values, nil) description = expandCard(cardText(values, "cardGoLiveDescription"), values, nil) } metaTags := cardTags(u, "website", title, description, thumbURL.String(), siteTitle(values)) metaTags = append(metaTags, brandMetas...) // at-tags: the site itself is identified by this node's did:web; there's // no single author or canonical record for the front page metaTags = append(metaTags, l.atTags("", "")...) return l.GenerateHTML(ctx, &PageConfig{ Title: title, Metas: metaTags, SentryDSN: sentryDSN, }) } // isLinkPreviewMeta reports whether a is one the cards own: the page // description and every og: / twitter: tag. func isLinkPreviewMeta(node *html.Node) bool { for _, attr := range node.Attr { switch attr.Key { case "property": if strings.HasPrefix(attr.Val, "og:") || strings.HasPrefix(attr.Val, "twitter:") { return true } case "name": if attr.Val == "description" || strings.HasPrefix(attr.Val, "twitter:") { return true } } } return false } func (l *Linker) GenerateHTML(ctx context.Context, pc *PageConfig) ([]byte, error) { root, err := html.Parse(bytes.NewReader(l.BaseHTML)) if err != nil { return nil, err } var htmlNode *html.Node for node := range root.ChildNodes() { if node.Type == html.ElementNode && node.Data == "html" { htmlNode = node break } } if htmlNode == nil { return nil, errors.New("html not found") } var head *html.Node for node := range htmlNode.ChildNodes() { if node.Data == "head" { head = node break } } if head == nil { return nil, errors.New("head not found") } // The template ships its own title, description and link-preview tags // (the first-party brand, for a static host). Every card replaces them, // and crawlers honour the first tag they meet, so the template's go. // A node that is its own product can also drop the template's iOS Smart // App Banner (branding key mobileAppBanner=off). dropAppBanner := l.brandingText("mobileAppBanner") == "off" var stale []*html.Node for node := range head.ChildNodes() { if node.Type != html.ElementNode { continue } if node.Data == "title" || (node.Data == "meta" && isLinkPreviewMeta(node)) { stale = append(stale, node) } if dropAppBanner && node.Data == "meta" && isAppBannerMeta(node) { stale = append(stale, node) } } for _, node := range stale { head.RemoveChild(node) } title := &html.Node{ Type: html.ElementNode, Data: "title", } head.AppendChild(title) title.AppendChild(&html.Node{ Type: html.TextNode, Data: pc.Title, }) // Add all meta tags in a loop for _, tag := range pc.Metas { head.AppendChild(&html.Node{ Type: html.ElementNode, Data: "meta", Attr: []html.Attribute{ {Key: tag.Type, Val: tag.Key}, {Key: "content", Val: tag.Content}, }, }) } // Paint the branded background before any script runs: on the root // element too, since that is what mobile browsers show behind their // translucent bars and in overscroll, and as theme-color for the bars // themselves. if bg := l.bodyBackground(); bg != "" { style := &html.Node{Type: html.ElementNode, Data: "style"} head.AppendChild(style) style.AppendChild(&html.Node{Type: html.TextNode, Data: "html,body{background-color:" + bg + "}"}) head.AppendChild(&html.Node{ Type: html.ElementNode, Data: "meta", Attr: []html.Attribute{ {Key: "name", Val: "theme-color"}, {Key: "content", Val: bg}, }, }) } // Add Sentry DSN script if configured if pc.SentryDSN != "" { script := &html.Node{ Type: html.ElementNode, Data: "script", } head.AppendChild(script) script.AppendChild(&html.Node{ Type: html.TextNode, Data: `window.SENTRY_DSN = "` + pc.SentryDSN + `";`, }) } // Render the HTML to a string var buf bytes.Buffer if err := html.Render(&buf, root); err != nil { return nil, err } return buf.Bytes(), nil }