Live video on the AT Protocol

Merge pull request #897 from streamplace/natb/embed-branding-web

feat: add branding tags on web

authored by Eli Mallon and committed by GitHub d00dfdf3 b75098de

+206 -19
+60 -1
js/components/src/streamplace-store/branding.tsx
··· 25 }); 26 }; 27 28 // hook to fetch broadcaster DID (unauthenticated) 29 export function useFetchBroadcasterDID() { 30 const streamplaceAgent = usePossiblyUnauthedPDSAgent(); 31 const store = getStreamplaceStoreFromContext(); 32 33 return useCallback(async () => { 34 try { ··· 140 141 // hook to get a specific branding asset by key 142 export function useBrandingAsset(key: string): BrandingAsset | undefined { 143 - return useStreamplaceStore((state) => state.branding?.[key]); 144 } 145 146 // convenience hook for main logo
··· 25 }); 26 }; 27 28 + const PropsInHeader = [ 29 + "siteTitle", 30 + "siteDescription", 31 + "primaryColor", 32 + "accentColor", 33 + "defaultStreamer", 34 + "mainLogo", 35 + "favicon", 36 + "sidebarBg", 37 + "legalLinks", 38 + ]; 39 + 40 + function getMetaContent(key: string): BrandingAsset | null { 41 + if (typeof window === "undefined" || !window.document) return null; 42 + const meta = document.querySelector(`meta[name="internal-brand:${key}`); 43 + if (meta && meta.getAttribute("content")) { 44 + let content = meta.getAttribute("content"); 45 + if (content) return JSON.parse(content) as BrandingAsset; 46 + } 47 + 48 + return null; 49 + } 50 + 51 // hook to fetch broadcaster DID (unauthenticated) 52 export function useFetchBroadcasterDID() { 53 const streamplaceAgent = usePossiblyUnauthedPDSAgent(); 54 const store = getStreamplaceStoreFromContext(); 55 + 56 + // prefetch from meta records, if on web 57 + useEffect(() => { 58 + if (typeof window !== "undefined" && window.document) { 59 + try { 60 + const metaRecords = PropsInHeader.reduce( 61 + (acc, key) => { 62 + const meta = document.querySelector( 63 + `meta[name="internal-brand:${key}`, 64 + ); 65 + // hrmmmmmmmmmmmm 66 + if (meta && meta.getAttribute("content")) { 67 + let content = meta.getAttribute("content"); 68 + if (content) acc[key] = JSON.parse(content) as BrandingAsset; 69 + } 70 + return acc; 71 + }, 72 + {} as Record<string, BrandingAsset>, 73 + ); 74 + 75 + console.log("Found meta records for broadcaster DID:", metaRecords); 76 + // filter out all non-text values, can get on second fetch? 77 + for (const key of Object.keys(metaRecords)) { 78 + if (metaRecords[key].mimeType != "text/plain") { 79 + delete metaRecords[key]; 80 + } 81 + } 82 + } catch (e) { 83 + console.warn("Failed to parse broadcaster DID from meta tags", e); 84 + } 85 + } 86 + }, []); 87 88 return useCallback(async () => { 89 try { ··· 195 196 // hook to get a specific branding asset by key 197 export function useBrandingAsset(key: string): BrandingAsset | undefined { 198 + return ( 199 + useStreamplaceStore((state) => state.branding?.[key]) || 200 + getMetaContent(key) || 201 + undefined 202 + ); 203 } 204 205 // convenience hook for main logo
+1 -1
pkg/api/api.go
··· 272 if err != nil { 273 return nil, err 274 } 275 - linker, err := linking.NewLinker(ctx, bs) 276 if err != nil { 277 return nil, err 278 }
··· 272 if err != nil { 273 return nil, err 274 } 275 + linker, err := linking.NewLinker(ctx, bs, a.StatefulDB, a.CLI) 276 if err != nil { 277 return nil, err 278 }
+139 -11
pkg/linking/linking.go
··· 3 import ( 4 "bytes" 5 "context" 6 "errors" 7 "fmt" 8 "net/url" 9 10 "golang.org/x/net/html" 11 "stream.place/streamplace/pkg/streamplace" 12 ) 13 14 type Linker struct { 15 BaseHTML []byte 16 } 17 18 - func NewLinker(ctx context.Context, baseHTML []byte) (*Linker, error) { 19 _, err := html.Parse(bytes.NewReader(baseHTML)) 20 if err != nil { 21 return nil, err 22 } 23 24 - return &Linker{BaseHTML: baseHTML}, nil 25 } 26 27 type PageConfig struct { 28 Title string 29 Metas []MetaTag 30 SentryDSN string 31 } 32 33 // Define all meta tags in a structured way ··· 37 Content string 38 } 39 40 func (l *Linker) GenerateStreamerCard(ctx context.Context, u *url.URL, lsv *streamplace.Livestream_LivestreamView, sentryDSN string) ([]byte, error) { 41 if u == nil { 42 return nil, errors.New("url is nil") ··· 49 return nil, errors.New("livestream view is not a livestream") 50 } 51 52 - titleStr := fmt.Sprintf("@%s's livestream on %s", lsv.Author.Handle, u.Host) 53 outURL := u.String() 54 - 55 - pageTitle := fmt.Sprintf("@%s | %s", lsv.Author.Handle, u.Host) 56 57 thumbURL, _ := url.Parse(u.String()) 58 thumbURL.Path = "/xrpc/place.stream.live.getProfileCard" ··· 66 // Facebook Meta Tags 67 {Type: "property", Key: "og:url", Content: u.String()}, 68 {Type: "property", Key: "og:type", Content: "website"}, 69 - {Type: "property", Key: "og:title", Content: titleStr}, 70 {Type: "property", Key: "og:description", Content: ls.Title}, 71 {Type: "property", Key: "og:image", Content: thumbURL.String()}, 72 ··· 74 {Type: "name", Key: "twitter:card", Content: "summary_large_image"}, 75 {Type: "property", Key: "twitter:domain", Content: u.Host}, 76 {Type: "property", Key: "twitter:url", Content: outURL}, 77 - {Type: "name", Key: "twitter:title", Content: titleStr}, 78 {Type: "name", Key: "twitter:description", Content: ls.Title}, 79 {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, 80 } 81 82 return l.GenerateHTML(ctx, &PageConfig{ 83 - Title: pageTitle, 84 Metas: metaTags, 85 SentryDSN: sentryDSN, 86 }) ··· 103 {Type: "property", Key: "og:url", Content: u.String()}, 104 {Type: "property", Key: "og:type", Content: "website"}, 105 {Type: "property", Key: "og:title", Content: "Stream.place"}, 106 - {Type: "property", Key: "og:description", Content: "Stream.place is open-source livestreaming on the AT Protocol."}, 107 {Type: "property", Key: "og:image", Content: thumbURL.String()}, 108 109 // Twitter Meta Tags ··· 111 {Type: "property", Key: "twitter:domain", Content: u.Host}, 112 {Type: "property", Key: "twitter:url", Content: u.String()}, 113 {Type: "name", Key: "twitter:title", Content: "Stream.place"}, 114 - {Type: "name", Key: "twitter:description", Content: "Stream.place is open-source livestreaming on the AT Protocol."}, 115 {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, 116 } 117 118 return l.GenerateHTML(ctx, &PageConfig{ 119 - Title: "Stream.place", 120 Metas: metaTags, 121 SentryDSN: sentryDSN, 122 })
··· 3 import ( 4 "bytes" 5 "context" 6 + "encoding/json" 7 "errors" 8 "fmt" 9 + "log" 10 "net/url" 11 12 "golang.org/x/net/html" 13 + "stream.place/streamplace/pkg/config" 14 + "stream.place/streamplace/pkg/statedb" 15 "stream.place/streamplace/pkg/streamplace" 16 ) 17 18 type Linker struct { 19 BaseHTML []byte 20 + sdb *statedb.StatefulDB 21 + cli *config.CLI 22 } 23 24 + func NewLinker(ctx context.Context, baseHTML []byte, sdb *statedb.StatefulDB, cli *config.CLI) (*Linker, error) { 25 _, err := html.Parse(bytes.NewReader(baseHTML)) 26 if err != nil { 27 return nil, err 28 } 29 30 + return &Linker{BaseHTML: baseHTML, sdb: sdb, cli: cli}, nil 31 } 32 33 type PageConfig struct { 34 Title string 35 Metas []MetaTag 36 SentryDSN string 37 + Branding []string 38 } 39 40 // Define all meta tags in a structured way ··· 44 Content string 45 } 46 47 + var BrandingAssetList = [...]string{ 48 + "siteTitle", 49 + "siteDescription", 50 + "primaryColor", 51 + "accentColor", 52 + "defaultStreamer", 53 + "mainLogo", 54 + "favicon", 55 + "sidebarBg", 56 + "legalLinks", 57 + } 58 + 59 + // fetch branding assets for a given broadcaster DID 60 + func (l *Linker) getBrandingAssets(broadcasterDid string) ([]streamplace.BrandingGetBranding_BrandingAsset, error) { 61 + ret := make([]streamplace.BrandingGetBranding_BrandingAsset, 0) 62 + for _, asset := range BrandingAssetList { 63 + blob, err := l.sdb.GetBrandingBlob(broadcasterDid, asset) 64 + if err != nil { 65 + // this can probably include a 'record not found' error, in which case we skip 66 + log.Printf("error fetching branding asset %s for broadcaster %s: %v", asset, broadcasterDid, err) 67 + continue 68 + } 69 + asset := streamplace.BrandingGetBranding_BrandingAsset{ 70 + Key: blob.Key, 71 + MimeType: blob.MimeType, 72 + } 73 + 74 + if blob.Width != nil { 75 + w := int64(*blob.Width) 76 + asset.Width = &w 77 + } 78 + if blob.Height != nil { 79 + h := int64(*blob.Height) 80 + asset.Height = &h 81 + } 82 + 83 + // process based on mime type 84 + if blob.MimeType == "text/plain" { 85 + str := string(blob.Data) 86 + asset.Data = &str 87 + } else { 88 + url := fmt.Sprintf("/xrpc/place.stream.branding.getBlob?key=%s&broadcaster=%s", blob.Key, broadcasterDid) 89 + asset.Url = &url 90 + } 91 + ret = append(ret, asset) 92 + } 93 + 94 + return ret, nil 95 + } 96 + 97 func (l *Linker) GenerateStreamerCard(ctx context.Context, u *url.URL, lsv *streamplace.Livestream_LivestreamView, sentryDSN string) ([]byte, error) { 98 if u == nil { 99 return nil, errors.New("url is nil") ··· 106 return nil, errors.New("livestream view is not a livestream") 107 } 108 109 + titleStr := fmt.Sprintf("@%s's livestream on ", lsv.Author.Handle) 110 outURL := u.String() 111 112 thumbURL, _ := url.Parse(u.String()) 113 thumbURL.Path = "/xrpc/place.stream.live.getProfileCard" ··· 121 // Facebook Meta Tags 122 {Type: "property", Key: "og:url", Content: u.String()}, 123 {Type: "property", Key: "og:type", Content: "website"}, 124 {Type: "property", Key: "og:description", Content: ls.Title}, 125 {Type: "property", Key: "og:image", Content: thumbURL.String()}, 126 ··· 128 {Type: "name", Key: "twitter:card", Content: "summary_large_image"}, 129 {Type: "property", Key: "twitter:domain", Content: u.Host}, 130 {Type: "property", Key: "twitter:url", Content: outURL}, 131 {Type: "name", Key: "twitter:description", Content: ls.Title}, 132 {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, 133 } 134 + brandingTitle := "streamplace node" 135 + if l.sdb != nil && l.cli != nil { 136 + branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) 137 + if err == nil { 138 + for i := range branding { 139 + val := branding[i] 140 + if val.Key == "siteTitle" && val.Data != nil { 141 + brandingTitle = *val.Data 142 + } 143 + marshalledJson, err := json.Marshal(val) 144 + if err != nil { 145 + fmt.Printf("error marshalling branding asset %s: %v\n", val.Key, err) 146 + continue 147 + } 148 + metaTags = append(metaTags, MetaTag{ 149 + Type: "name", 150 + Key: "internal-brand:" + val.Key, 151 + Content: string(marshalledJson), 152 + }) 153 + } 154 + } else { 155 + // log but we should not block rendering 156 + fmt.Printf("error fetching branding assets: %v\n", err) 157 + } 158 + } 159 + 160 + // do twitter/og title after 161 + metaTags = append(metaTags, MetaTag{ 162 + Type: "property", 163 + Key: "og:title", 164 + Content: fmt.Sprintf("%s%s", titleStr, brandingTitle), 165 + }) 166 + metaTags = append(metaTags, MetaTag{ 167 + Type: "name", 168 + Key: "twitter:title", 169 + Content: fmt.Sprintf("%s%s", titleStr, brandingTitle), 170 + }) 171 172 return l.GenerateHTML(ctx, &PageConfig{ 173 + Title: fmt.Sprintf("%s%s", titleStr, brandingTitle), 174 Metas: metaTags, 175 SentryDSN: sentryDSN, 176 }) ··· 193 {Type: "property", Key: "og:url", Content: u.String()}, 194 {Type: "property", Key: "og:type", Content: "website"}, 195 {Type: "property", Key: "og:title", Content: "Stream.place"}, 196 + {Type: "property", Key: "og:description", Content: "Open-source livestreaming on the AT Protocol."}, 197 {Type: "property", Key: "og:image", Content: thumbURL.String()}, 198 199 // Twitter Meta Tags ··· 201 {Type: "property", Key: "twitter:domain", Content: u.Host}, 202 {Type: "property", Key: "twitter:url", Content: u.String()}, 203 {Type: "name", Key: "twitter:title", Content: "Stream.place"}, 204 + {Type: "name", Key: "twitter:description", Content: "Open-source livestreaming on the AT Protocol."}, 205 {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, 206 } 207 208 + brandingTitle := "streamplace node" 209 + if l.sdb != nil && l.cli != nil { 210 + branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) 211 + if err == nil { 212 + for i := range branding { 213 + val := branding[i] 214 + if val.Key == "siteTitle" && val.Data != nil { 215 + brandingTitle = *val.Data 216 + } 217 + marshalledJson, err := json.Marshal(val) 218 + if err != nil { 219 + fmt.Printf("error marshalling branding asset %s: %v\n", val.Key, err) 220 + continue 221 + } 222 + metaTags = append(metaTags, MetaTag{ 223 + Type: "name", 224 + Key: "internal-brand:" + val.Key, 225 + Content: string(marshalledJson), 226 + }) 227 + } 228 + } else { 229 + // log but we should not block rendering 230 + fmt.Printf("error fetching branding assets: %v\n", err) 231 + } 232 + } 233 + 234 + // do twitter/og title after 235 + metaTags = append(metaTags, MetaTag{ 236 + Type: "property", 237 + Key: "og:title", 238 + Content: brandingTitle, 239 + }) 240 + metaTags = append(metaTags, MetaTag{ 241 + Type: "name", 242 + Key: "twitter:title", 243 + Content: brandingTitle, 244 + }) 245 + 246 return l.GenerateHTML(ctx, &PageConfig{ 247 + Title: brandingTitle, 248 Metas: metaTags, 249 SentryDSN: sentryDSN, 250 })
+2 -2
pkg/linking/linking_test.go
··· 29 30 func TestNewLinker(t *testing.T) { 31 index := IndexHTML(t) 32 - linker, err := NewLinker(context.Background(), index) 33 require.NoError(t, err) 34 require.NotNil(t, linker) 35 } 36 37 func TestGenerateLinkCard(t *testing.T) { 38 index := IndexHTML(t) 39 - linker, err := NewLinker(context.Background(), index) 40 require.NoError(t, err) 41 require.NotNil(t, linker) 42
··· 29 30 func TestNewLinker(t *testing.T) { 31 index := IndexHTML(t) 32 + linker, err := NewLinker(context.Background(), index, nil, nil) 33 require.NoError(t, err) 34 require.NotNil(t, linker) 35 } 36 37 func TestGenerateLinkCard(t *testing.T) { 38 index := IndexHTML(t) 39 + linker, err := NewLinker(context.Background(), index, nil, nil) 40 require.NoError(t, err) 41 require.NotNil(t, linker) 42
+4 -4
pkg/spxrpc/place_stream_branding.go
··· 38 return s.cli.BroadcasterHost 39 } 40 41 - func (s *Server) getBrandingBlob(ctx context.Context, broadcasterID, key string) ([]byte, string, *int, *int, error) { 42 // cache miss - fetch from db 43 blob, err := s.statefulDB.GetBrandingBlob(broadcasterID, key) 44 if err == gorm.ErrRecordNotFound { ··· 61 // HandlePlaceStreamBrandingGetBlobDirect is the exported version for direct calls 62 func (s *Server) HandlePlaceStreamBrandingGetBlobDirect(ctx context.Context, broadcasterDID string, key string) (io.Reader, error) { 63 broadcasterID := s.getBroadcasterID(ctx, broadcasterDID) 64 - data, _, _, _, err := s.getBrandingBlob(ctx, broadcasterID, key) 65 if err != nil { 66 return nil, err 67 } ··· 94 // build output 95 assets := make([]*placestreamtypes.BrandingGetBranding_BrandingAsset, 0, len(allKeys)) 96 for key := range allKeys { 97 - data, mimeType, width, height, err := s.getBrandingBlob(ctx, broadcasterID, key) 98 if err != nil { 99 continue // skip if error 100 } ··· 238 239 broadcasterID := s.cli.BroadcasterHost 240 log.Log(ctx, "fetching favicon", "broadcasterID", broadcasterID) 241 - data, mimeType, _, _, err := s.getBrandingBlob(ctx, "did:web:"+broadcasterID, "favicon") 242 243 if err != nil || data == nil { 244 log.Log(ctx, "using fallback favicon", "err", err, "data_nil", data == nil)
··· 38 return s.cli.BroadcasterHost 39 } 40 41 + func (s *Server) GetBrandingBlob(ctx context.Context, broadcasterID, key string) ([]byte, string, *int, *int, error) { 42 // cache miss - fetch from db 43 blob, err := s.statefulDB.GetBrandingBlob(broadcasterID, key) 44 if err == gorm.ErrRecordNotFound { ··· 61 // HandlePlaceStreamBrandingGetBlobDirect is the exported version for direct calls 62 func (s *Server) HandlePlaceStreamBrandingGetBlobDirect(ctx context.Context, broadcasterDID string, key string) (io.Reader, error) { 63 broadcasterID := s.getBroadcasterID(ctx, broadcasterDID) 64 + data, _, _, _, err := s.GetBrandingBlob(ctx, broadcasterID, key) 65 if err != nil { 66 return nil, err 67 } ··· 94 // build output 95 assets := make([]*placestreamtypes.BrandingGetBranding_BrandingAsset, 0, len(allKeys)) 96 for key := range allKeys { 97 + data, mimeType, width, height, err := s.GetBrandingBlob(ctx, broadcasterID, key) 98 if err != nil { 99 continue // skip if error 100 } ··· 238 239 broadcasterID := s.cli.BroadcasterHost 240 log.Log(ctx, "fetching favicon", "broadcasterID", broadcasterID) 241 + data, mimeType, _, _, err := s.GetBrandingBlob(ctx, "did:web:"+broadcasterID, "favicon") 242 243 if err != nil || data == nil { 244 log.Log(ctx, "using fallback favicon", "err", err, "data_nil", data == nil)