From 09cd0fbc4ed6b9abd7d6ee3aa210340b7d3ea499 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Tue, 3 Feb 2026 17:23:22 +0100 Subject: [PATCH 1/3] feat: add branding to header (meta tag) for preloading on web --- pkg/api/api.go | 2 +- pkg/linking/linking.go | 90 ++++++++++++++++++++++++++++- pkg/linking/linking_test.go | 4 +- pkg/spxrpc/place_stream_branding.go | 8 +-- 4 files changed, 95 insertions(+), 9 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 035cbcca7..8c3450308 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -272,7 +272,7 @@ func (a *StreamplaceAPI) Handler(ctx context.Context) (http.Handler, error) { if err != nil { return nil, err } - linker, err := linking.NewLinker(ctx, bs) + linker, err := linking.NewLinker(ctx, bs, a.StatefulDB, a.CLI) if err != nil { return nil, err } diff --git a/pkg/linking/linking.go b/pkg/linking/linking.go index 4b9d2fed7..72b444769 100644 --- a/pkg/linking/linking.go +++ b/pkg/linking/linking.go @@ -3,31 +3,38 @@ package linking import ( "bytes" "context" + "encoding/json" "errors" "fmt" + "log" "net/url" "golang.org/x/net/html" + "stream.place/streamplace/pkg/config" + "stream.place/streamplace/pkg/statedb" "stream.place/streamplace/pkg/streamplace" ) type Linker struct { BaseHTML []byte + sdb *statedb.StatefulDB + cli *config.CLI } -func NewLinker(ctx context.Context, baseHTML []byte) (*Linker, error) { +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}, nil + 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 @@ -37,6 +44,56 @@ type MetaTag struct { Content string } +var BrandingAssetList = [...]string{ + "siteTitle", + "siteDescription", + "primaryColor", + "accentColor", + "defaultStreamer", + "mainLogo", + "favicon", + "sidebarBg", + "legalLinks", +} + +// fetch branding assets for a given broadcaster DID +func (l *Linker) getBrandingAssets(broadcasterDid string) ([]streamplace.BrandingGetBranding_BrandingAsset, error) { + ret := make([]streamplace.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.Printf("error fetching branding asset %s for broadcaster %s: %v", asset, broadcasterDid, err) + continue + } + asset := streamplace.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 + } + ret = append(ret, asset) + } + + return ret, nil +} + func (l *Linker) GenerateStreamerCard(ctx context.Context, u *url.URL, lsv *streamplace.Livestream_LivestreamView, sentryDSN string) ([]byte, error) { if u == nil { return nil, errors.New("url is nil") @@ -199,6 +256,35 @@ func (l *Linker) GenerateHTML(ctx context.Context, pc *PageConfig) ([]byte, erro }) } + if l.sdb != nil && l.cli != nil { + + branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) + + if err == nil { + + for i := range branding { + val := branding[i] + // + marshalledJson, err := json.Marshal(val) + if err != nil { + fmt.Printf("error marshalling branding asset %s: %v\n", val.Key, err) + continue + } + head.AppendChild(&html.Node{ + Type: html.ElementNode, + Data: "meta", + Attr: []html.Attribute{ + {Key: "name", Val: "internal-brand:" + val.Key}, + {Key: "content", Val: string(marshalledJson)}, + }, + }) + + } + } else { + // log but we should not block rendering + fmt.Printf("error fetching branding assets: %v\n", err) + } + } // Render the HTML to a string var buf bytes.Buffer if err := html.Render(&buf, root); err != nil { diff --git a/pkg/linking/linking_test.go b/pkg/linking/linking_test.go index 8b5c9907b..d442f7920 100644 --- a/pkg/linking/linking_test.go +++ b/pkg/linking/linking_test.go @@ -29,14 +29,14 @@ func IndexHTML(t *testing.T) []byte { func TestNewLinker(t *testing.T) { index := IndexHTML(t) - linker, err := NewLinker(context.Background(), index) + linker, err := NewLinker(context.Background(), index, nil, nil) require.NoError(t, err) require.NotNil(t, linker) } func TestGenerateLinkCard(t *testing.T) { index := IndexHTML(t) - linker, err := NewLinker(context.Background(), index) + linker, err := NewLinker(context.Background(), index, nil, nil) require.NoError(t, err) require.NotNil(t, linker) diff --git a/pkg/spxrpc/place_stream_branding.go b/pkg/spxrpc/place_stream_branding.go index d5a74a418..4edcc82d9 100644 --- a/pkg/spxrpc/place_stream_branding.go +++ b/pkg/spxrpc/place_stream_branding.go @@ -38,7 +38,7 @@ func (s *Server) getBroadcasterID(ctx context.Context, broadcasterDID string) st return s.cli.BroadcasterHost } -func (s *Server) getBrandingBlob(ctx context.Context, broadcasterID, key string) ([]byte, string, *int, *int, error) { +func (s *Server) GetBrandingBlob(ctx context.Context, broadcasterID, key string) ([]byte, string, *int, *int, error) { // cache miss - fetch from db blob, err := s.statefulDB.GetBrandingBlob(broadcasterID, key) if err == gorm.ErrRecordNotFound { @@ -61,7 +61,7 @@ func (s *Server) handlePlaceStreamBrandingGetBlob(ctx context.Context, broadcast // HandlePlaceStreamBrandingGetBlobDirect is the exported version for direct calls func (s *Server) HandlePlaceStreamBrandingGetBlobDirect(ctx context.Context, broadcasterDID string, key string) (io.Reader, error) { broadcasterID := s.getBroadcasterID(ctx, broadcasterDID) - data, _, _, _, err := s.getBrandingBlob(ctx, broadcasterID, key) + data, _, _, _, err := s.GetBrandingBlob(ctx, broadcasterID, key) if err != nil { return nil, err } @@ -94,7 +94,7 @@ func (s *Server) HandlePlaceStreamBrandingGetBrandingDirect(ctx context.Context, // build output assets := make([]*placestreamtypes.BrandingGetBranding_BrandingAsset, 0, len(allKeys)) for key := range allKeys { - data, mimeType, width, height, err := s.getBrandingBlob(ctx, broadcasterID, key) + data, mimeType, width, height, err := s.GetBrandingBlob(ctx, broadcasterID, key) if err != nil { continue // skip if error } @@ -238,7 +238,7 @@ func (s *Server) HandleFaviconICO(c echo.Context) error { broadcasterID := s.cli.BroadcasterHost log.Log(ctx, "fetching favicon", "broadcasterID", broadcasterID) - data, mimeType, _, _, err := s.getBrandingBlob(ctx, "did:web:"+broadcasterID, "favicon") + data, mimeType, _, _, err := s.GetBrandingBlob(ctx, "did:web:"+broadcasterID, "favicon") if err != nil || data == nil { log.Log(ctx, "using fallback favicon", "err", err, "data_nil", data == nil) -- 2.51.2 From 4db669ea9038346204e0f6ed8bcd036a5995c94e Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Tue, 3 Feb 2026 17:24:20 +0100 Subject: [PATCH 2/3] fall back to branding in header if we haven't gotten it yet --- .../src/streamplace-store/branding.tsx | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/js/components/src/streamplace-store/branding.tsx b/js/components/src/streamplace-store/branding.tsx index af01143f8..dc882cea1 100644 --- a/js/components/src/streamplace-store/branding.tsx +++ b/js/components/src/streamplace-store/branding.tsx @@ -25,11 +25,66 @@ const blobToBase64 = (blob: Blob): Promise => { }); }; +const PropsInHeader = [ + "siteTitle", + "siteDescription", + "primaryColor", + "accentColor", + "defaultStreamer", + "mainLogo", + "favicon", + "sidebarBg", + "legalLinks", +]; + +function getMetaContent(key: string): BrandingAsset | null { + if (typeof window === "undefined" || !window.document) return null; + const meta = document.querySelector(`meta[name="internal-brand:${key}`); + if (meta && meta.getAttribute("content")) { + let content = meta.getAttribute("content"); + if (content) return JSON.parse(content) as BrandingAsset; + } + + return null; +} + // hook to fetch broadcaster DID (unauthenticated) export function useFetchBroadcasterDID() { const streamplaceAgent = usePossiblyUnauthedPDSAgent(); const store = getStreamplaceStoreFromContext(); + // prefetch from meta records, if on web + useEffect(() => { + if (typeof window !== "undefined" && window.document) { + try { + const metaRecords = PropsInHeader.reduce( + (acc, key) => { + const meta = document.querySelector( + `meta[name="internal-brand:${key}`, + ); + // hrmmmmmmmmmmmm + if (meta && meta.getAttribute("content")) { + let content = meta.getAttribute("content"); + if (content) acc[key] = JSON.parse(content) as BrandingAsset; + } + return acc; + }, + {} as Record, + ); + + console.log("Found meta records for broadcaster DID:", metaRecords); + // filter out all non-text values, can get on second fetch? + for (const key of Object.keys(metaRecords)) { + if (metaRecords[key].mimeType != "text/plain") { + delete metaRecords[key]; + } + } + } catch (e) { + console.warn("Failed to parse broadcaster DID from meta tags", e); + } + } + }, []); + return useCallback(async () => { try { if (!streamplaceAgent) { @@ -140,7 +195,11 @@ export function useFetchBranding() { // hook to get a specific branding asset by key export function useBrandingAsset(key: string): BrandingAsset | undefined { - return useStreamplaceStore((state) => state.branding?.[key]); + return ( + useStreamplaceStore((state) => state.branding?.[key]) || + getMetaContent(key) || + undefined + ); } // convenience hook for main logo -- 2.51.2 From b8df69149031ec0043cf17f2ba3e836f9b954889 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:31:45 +0100 Subject: [PATCH 3/3] add branding to page title el --- pkg/linking/linking.go | 118 ++++++++++++++++++++++++++++------------- 1 file changed, 80 insertions(+), 38 deletions(-) diff --git a/pkg/linking/linking.go b/pkg/linking/linking.go index 72b444769..b89dd9f66 100644 --- a/pkg/linking/linking.go +++ b/pkg/linking/linking.go @@ -106,11 +106,9 @@ func (l *Linker) GenerateStreamerCard(ctx context.Context, u *url.URL, lsv *stre return nil, errors.New("livestream view is not a livestream") } - titleStr := fmt.Sprintf("@%s's livestream on %s", lsv.Author.Handle, u.Host) + titleStr := fmt.Sprintf("@%s's livestream on ", lsv.Author.Handle) outURL := u.String() - pageTitle := fmt.Sprintf("@%s | %s", lsv.Author.Handle, u.Host) - thumbURL, _ := url.Parse(u.String()) thumbURL.Path = "/xrpc/place.stream.live.getProfileCard" thumbURL.RawQuery = fmt.Sprintf("id=%s", lsv.Author.Did) @@ -123,7 +121,6 @@ func (l *Linker) GenerateStreamerCard(ctx context.Context, u *url.URL, lsv *stre // Facebook Meta Tags {Type: "property", Key: "og:url", Content: u.String()}, {Type: "property", Key: "og:type", Content: "website"}, - {Type: "property", Key: "og:title", Content: titleStr}, {Type: "property", Key: "og:description", Content: ls.Title}, {Type: "property", Key: "og:image", Content: thumbURL.String()}, @@ -131,13 +128,49 @@ func (l *Linker) GenerateStreamerCard(ctx context.Context, u *url.URL, lsv *stre {Type: "name", Key: "twitter:card", Content: "summary_large_image"}, {Type: "property", Key: "twitter:domain", Content: u.Host}, {Type: "property", Key: "twitter:url", Content: outURL}, - {Type: "name", Key: "twitter:title", Content: titleStr}, {Type: "name", Key: "twitter:description", Content: ls.Title}, {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, } + brandingTitle := "streamplace node" + if l.sdb != nil && l.cli != nil { + branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) + if err == nil { + for i := range branding { + val := branding[i] + if val.Key == "siteTitle" && val.Data != nil { + brandingTitle = *val.Data + } + marshalledJson, err := json.Marshal(val) + if err != nil { + fmt.Printf("error marshalling branding asset %s: %v\n", val.Key, err) + continue + } + metaTags = append(metaTags, MetaTag{ + Type: "name", + Key: "internal-brand:" + val.Key, + Content: string(marshalledJson), + }) + } + } else { + // log but we should not block rendering + fmt.Printf("error fetching branding assets: %v\n", err) + } + } + + // do twitter/og title after + metaTags = append(metaTags, MetaTag{ + Type: "property", + Key: "og:title", + Content: fmt.Sprintf("%s%s", titleStr, brandingTitle), + }) + metaTags = append(metaTags, MetaTag{ + Type: "name", + Key: "twitter:title", + Content: fmt.Sprintf("%s%s", titleStr, brandingTitle), + }) return l.GenerateHTML(ctx, &PageConfig{ - Title: pageTitle, + Title: fmt.Sprintf("%s%s", titleStr, brandingTitle), Metas: metaTags, SentryDSN: sentryDSN, }) @@ -160,7 +193,7 @@ func (l *Linker) GenerateDefaultCard(ctx context.Context, u *url.URL, sentryDSN {Type: "property", Key: "og:url", Content: u.String()}, {Type: "property", Key: "og:type", Content: "website"}, {Type: "property", Key: "og:title", Content: "Stream.place"}, - {Type: "property", Key: "og:description", Content: "Stream.place is open-source livestreaming on the AT Protocol."}, + {Type: "property", Key: "og:description", Content: "Open-source livestreaming on the AT Protocol."}, {Type: "property", Key: "og:image", Content: thumbURL.String()}, // Twitter Meta Tags @@ -168,12 +201,50 @@ func (l *Linker) GenerateDefaultCard(ctx context.Context, u *url.URL, sentryDSN {Type: "property", Key: "twitter:domain", Content: u.Host}, {Type: "property", Key: "twitter:url", Content: u.String()}, {Type: "name", Key: "twitter:title", Content: "Stream.place"}, - {Type: "name", Key: "twitter:description", Content: "Stream.place is open-source livestreaming on the AT Protocol."}, + {Type: "name", Key: "twitter:description", Content: "Open-source livestreaming on the AT Protocol."}, {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, } + brandingTitle := "streamplace node" + if l.sdb != nil && l.cli != nil { + branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) + if err == nil { + for i := range branding { + val := branding[i] + if val.Key == "siteTitle" && val.Data != nil { + brandingTitle = *val.Data + } + marshalledJson, err := json.Marshal(val) + if err != nil { + fmt.Printf("error marshalling branding asset %s: %v\n", val.Key, err) + continue + } + metaTags = append(metaTags, MetaTag{ + Type: "name", + Key: "internal-brand:" + val.Key, + Content: string(marshalledJson), + }) + } + } else { + // log but we should not block rendering + fmt.Printf("error fetching branding assets: %v\n", err) + } + } + + // do twitter/og title after + metaTags = append(metaTags, MetaTag{ + Type: "property", + Key: "og:title", + Content: brandingTitle, + }) + metaTags = append(metaTags, MetaTag{ + Type: "name", + Key: "twitter:title", + Content: brandingTitle, + }) + return l.GenerateHTML(ctx, &PageConfig{ - Title: "Stream.place", + Title: brandingTitle, Metas: metaTags, SentryDSN: sentryDSN, }) @@ -256,35 +327,6 @@ func (l *Linker) GenerateHTML(ctx context.Context, pc *PageConfig) ([]byte, erro }) } - if l.sdb != nil && l.cli != nil { - - branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) - - if err == nil { - - for i := range branding { - val := branding[i] - // - marshalledJson, err := json.Marshal(val) - if err != nil { - fmt.Printf("error marshalling branding asset %s: %v\n", val.Key, err) - continue - } - head.AppendChild(&html.Node{ - Type: html.ElementNode, - Data: "meta", - Attr: []html.Attribute{ - {Key: "name", Val: "internal-brand:" + val.Key}, - {Key: "content", Val: string(marshalledJson)}, - }, - }) - - } - } else { - // log but we should not block rendering - fmt.Printf("error fetching branding assets: %v\n", err) - } - } // Render the HTML to a string var buf bytes.Buffer if err := html.Render(&buf, root); err != nil { -- 2.51.2