Live video on the AT Protocol
at next 337 lines 8.9 kB view raw
1package linking 2 3import ( 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 18type Linker struct { 19 BaseHTML []byte 20 sdb *statedb.StatefulDB 21 cli *config.CLI 22} 23 24func 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 33type 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 41type MetaTag struct { 42 Type string // "name" or "property" 43 Key string 44 Content string 45} 46 47var 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 60func (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 97func (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") 100 } 101 if lsv == nil { 102 return nil, errors.New("livestream view is nil") 103 } 104 ls, ok := lsv.Record.Val.(*streamplace.Livestream) 105 if !ok { 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" 114 thumbURL.RawQuery = fmt.Sprintf("id=%s", lsv.Author.Did) 115 116 // Define all meta tags 117 metaTags := []MetaTag{ 118 // Basic meta 119 {Type: "name", Key: "description", Content: ls.Title}, 120 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 127 // Twitter Meta Tags 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 }) 177} 178 179func (l *Linker) GenerateDefaultCard(ctx context.Context, u *url.URL, sentryDSN string) ([]byte, error) { 180 if u == nil { 181 return nil, errors.New("url is nil") 182 } 183 184 thumbURL, _ := url.Parse(u.String()) 185 thumbURL.Path = "/linkbanner.png" 186 187 // Define all meta tags 188 metaTags := []MetaTag{ 189 // Basic meta 190 {Type: "name", Key: "description", Content: "Stream.place is open-source livestreaming on the AT Protocol."}, 191 192 // Facebook Meta Tags 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 200 {Type: "name", Key: "twitter:card", Content: "summary_large_image"}, 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 }) 251} 252 253func (l *Linker) GenerateHTML(ctx context.Context, pc *PageConfig) ([]byte, error) { 254 255 root, err := html.Parse(bytes.NewReader(l.BaseHTML)) 256 if err != nil { 257 return nil, err 258 } 259 260 var htmlNode *html.Node 261 for node := range root.ChildNodes() { 262 if node.Type == html.ElementNode && node.Data == "html" { 263 htmlNode = node 264 break 265 } 266 } 267 if htmlNode == nil { 268 return nil, errors.New("html not found") 269 } 270 271 var head *html.Node 272 for node := range htmlNode.ChildNodes() { 273 if node.Data == "head" { 274 head = node 275 break 276 } 277 } 278 if head == nil { 279 return nil, errors.New("head not found") 280 } 281 282 // Title tag (handled separately as it's not a meta tag) 283 284 var oldTitle *html.Node 285 for node := range head.ChildNodes() { 286 if node.Type == html.ElementNode && node.Data == "title" { 287 oldTitle = node 288 break 289 } 290 } 291 if oldTitle != nil { 292 head.RemoveChild(oldTitle) 293 } 294 295 title := &html.Node{ 296 Type: html.ElementNode, 297 Data: "title", 298 } 299 head.AppendChild(title) 300 title.AppendChild(&html.Node{ 301 Type: html.TextNode, 302 Data: pc.Title, 303 }) 304 305 // Add all meta tags in a loop 306 for _, tag := range pc.Metas { 307 head.AppendChild(&html.Node{ 308 Type: html.ElementNode, 309 Data: "meta", 310 Attr: []html.Attribute{ 311 {Key: tag.Type, Val: tag.Key}, 312 {Key: "content", Val: tag.Content}, 313 }, 314 }) 315 } 316 317 // Add Sentry DSN script if configured 318 if pc.SentryDSN != "" { 319 script := &html.Node{ 320 Type: html.ElementNode, 321 Data: "script", 322 } 323 head.AppendChild(script) 324 script.AppendChild(&html.Node{ 325 Type: html.TextNode, 326 Data: `window.SENTRY_DSN = "` + pc.SentryDSN + `";`, 327 }) 328 } 329 330 // Render the HTML to a string 331 var buf bytes.Buffer 332 if err := html.Render(&buf, root); err != nil { 333 return nil, err 334 } 335 336 return buf.Bytes(), nil 337}