Live video on the AT Protocol
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}