1package api
2
3import (
4 "bytes"
5 _ "embed"
6 "encoding/json"
7 "fmt"
8 "html"
9 "image"
10 "image/color"
11 "image/draw"
12 _ "image/jpeg"
13 "image/png"
14 "log"
15 "net/http"
16 "net/url"
17 "os"
18 "strings"
19
20 "golang.org/x/image/font"
21 "golang.org/x/image/font/opentype"
22 "golang.org/x/image/math/fixed"
23
24 "margin.at/internal/db"
25)
26
27//go:embed fonts/Inter-Regular.ttf
28var interRegularTTF []byte
29
30//go:embed fonts/Inter-Bold.ttf
31var interBoldTTF []byte
32
33//go:embed assets/logo.png
34var logoPNG []byte
35
36var (
37 fontRegular *opentype.Font
38 fontBold *opentype.Font
39 logoImage image.Image
40)
41
42func init() {
43 var err error
44 fontRegular, err = opentype.Parse(interRegularTTF)
45 if err != nil {
46 log.Printf("Warning: failed to parse Inter-Regular font: %v", err)
47 }
48 fontBold, err = opentype.Parse(interBoldTTF)
49 if err != nil {
50 log.Printf("Warning: failed to parse Inter-Bold font: %v", err)
51 }
52
53 if len(logoPNG) > 0 {
54 img, _, err := image.Decode(bytes.NewReader(logoPNG))
55 if err != nil {
56 log.Printf("Warning: failed to decode logo PNG: %v", err)
57 } else {
58 logoImage = img
59 }
60 }
61}
62
63type OGHandler struct {
64 db *db.DB
65 baseURL string
66 staticDir string
67}
68
69func NewOGHandler(database *db.DB) *OGHandler {
70 baseURL := os.Getenv("BASE_URL")
71 if baseURL == "" {
72 baseURL = "https://margin.at"
73 }
74 staticDir := os.Getenv("STATIC_DIR")
75 if staticDir == "" {
76 staticDir = "../web/dist"
77 }
78 return &OGHandler{
79 db: database,
80 baseURL: strings.TrimSuffix(baseURL, "/"),
81 staticDir: staticDir,
82 }
83}
84
85var crawlerUserAgents = []string{
86 "facebookexternalhit",
87 "Facebot",
88 "Twitterbot",
89 "LinkedInBot",
90 "WhatsApp",
91 "Slackbot",
92 "TelegramBot",
93 "Discordbot",
94 "applebot",
95 "bot",
96 "crawler",
97 "spider",
98 "preview",
99 "Cardyb",
100 "Bluesky",
101}
102
103var lucideToEmoji = map[string]string{
104 "folder": "📁",
105 "star": "⭐",
106 "heart": "❤️",
107 "bookmark": "🔖",
108 "lightbulb": "💡",
109 "zap": "⚡",
110 "coffee": "☕",
111 "music": "🎵",
112 "camera": "📷",
113 "code": "💻",
114 "globe": "🌍",
115 "flag": "🚩",
116 "tag": "🏷️",
117 "box": "📦",
118 "archive": "🗄️",
119 "file": "📄",
120 "image": "🖼️",
121 "video": "🎬",
122 "mail": "✉️",
123 "pin": "📍",
124 "calendar": "📅",
125 "clock": "🕐",
126 "search": "🔍",
127 "settings": "⚙️",
128 "user": "👤",
129 "users": "👥",
130 "home": "🏠",
131 "briefcase": "💼",
132 "gift": "🎁",
133 "award": "🏆",
134 "target": "🎯",
135 "trending": "📈",
136 "activity": "📊",
137 "cpu": "🔲",
138 "database": "🗃️",
139 "cloud": "☁️",
140 "sun": "☀️",
141 "moon": "🌙",
142 "flame": "🔥",
143 "leaf": "🍃",
144}
145
146func iconToEmoji(icon string) string {
147 if strings.HasPrefix(icon, "icon:") {
148 name := strings.TrimPrefix(icon, "icon:")
149 if emoji, ok := lucideToEmoji[name]; ok {
150 return emoji
151 }
152 return "📁"
153 }
154 return icon
155}
156
157func isCrawler(userAgent string) bool {
158 ua := strings.ToLower(userAgent)
159 for _, bot := range crawlerUserAgents {
160 if strings.Contains(ua, strings.ToLower(bot)) {
161 return true
162 }
163 }
164 return false
165}
166
167func (h *OGHandler) resolveHandle(handle string) (string, error) {
168 resp, err := http.Get(fmt.Sprintf("https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=%s", url.QueryEscape(handle)))
169 if err == nil && resp.StatusCode == http.StatusOK {
170 var result struct {
171 Did string `json:"did"`
172 }
173 if err := json.NewDecoder(resp.Body).Decode(&result); err == nil && result.Did != "" {
174 return result.Did, nil
175 }
176 }
177 defer resp.Body.Close()
178
179 return "", fmt.Errorf("failed to resolve handle")
180}
181
182func (h *OGHandler) HandleAnnotationPage(w http.ResponseWriter, r *http.Request) {
183 path := r.URL.Path
184 var did, rkey, collectionType string
185
186 parts := strings.Split(strings.Trim(path, "/"), "/")
187 if len(parts) >= 2 {
188 firstPart, _ := url.QueryUnescape(parts[0])
189
190 if firstPart == "at" || firstPart == "annotation" {
191 if len(parts) >= 3 {
192 did, _ = url.QueryUnescape(parts[1])
193 rkey = parts[2]
194 }
195 } else {
196 if len(parts) >= 3 {
197 var err error
198 did, err = h.resolveHandle(firstPart)
199 if err != nil {
200 h.serveIndexHTML(w, r)
201 return
202 }
203
204 switch parts[1] {
205 case "highlight":
206 collectionType = "at.margin.highlight"
207 case "bookmark":
208 collectionType = "at.margin.bookmark"
209 case "annotation":
210 collectionType = "at.margin.annotation"
211 }
212 rkey = parts[2]
213 }
214 }
215 }
216
217 if did == "" || rkey == "" {
218 h.serveIndexHTML(w, r)
219 return
220 }
221
222 if !isCrawler(r.UserAgent()) {
223 h.serveIndexHTML(w, r)
224 return
225 }
226
227 if collectionType != "" {
228 uri := fmt.Sprintf("at://%s/%s/%s", did, collectionType, rkey)
229 if h.tryServeType(w, uri, collectionType) {
230 return
231 }
232 } else {
233 types := []string{
234 "at.margin.annotation",
235 "at.margin.bookmark",
236 "at.margin.highlight",
237 }
238 for _, t := range types {
239 uri := fmt.Sprintf("at://%s/%s/%s", did, t, rkey)
240 if h.tryServeType(w, uri, t) {
241 return
242 }
243 }
244
245 colURI := fmt.Sprintf("at://%s/at.margin.collection/%s", did, rkey)
246 if h.tryServeType(w, colURI, "at.margin.collection") {
247 return
248 }
249 }
250
251 h.serveIndexHTML(w, r)
252}
253
254func (h *OGHandler) tryServeType(w http.ResponseWriter, uri, colType string) bool {
255 switch colType {
256 case "at.margin.annotation":
257 if item, err := h.db.GetAnnotationByURI(uri); err == nil && item != nil {
258 h.serveAnnotationOG(w, item)
259 return true
260 }
261 case "at.margin.highlight":
262 if item, err := h.db.GetHighlightByURI(uri); err == nil && item != nil {
263 h.serveHighlightOG(w, item)
264 return true
265 }
266 case "at.margin.bookmark":
267 if item, err := h.db.GetBookmarkByURI(uri); err == nil && item != nil {
268 h.serveBookmarkOG(w, item)
269 return true
270 }
271 case "at.margin.collection":
272 if item, err := h.db.GetCollectionByURI(uri); err == nil && item != nil {
273 h.serveCollectionOG(w, item)
274 return true
275 }
276 }
277 return false
278}
279
280func (h *OGHandler) HandleCollectionPage(w http.ResponseWriter, r *http.Request) {
281 path := r.URL.Path
282 var did, rkey string
283
284 if strings.Contains(path, "/collection/") {
285 parts := strings.Split(strings.Trim(path, "/"), "/")
286 if len(parts) == 3 && parts[1] == "collection" {
287 handle, _ := url.QueryUnescape(parts[0])
288 rkey = parts[2]
289 var err error
290 did, err = h.resolveHandle(handle)
291 if err != nil {
292 h.serveIndexHTML(w, r)
293 return
294 }
295 } else if strings.HasPrefix(path, "/collection/") {
296 uriParam := strings.TrimPrefix(path, "/collection/")
297 if uriParam != "" {
298 uri, err := url.QueryUnescape(uriParam)
299 if err == nil {
300 parts := strings.Split(uri, "/")
301 if len(parts) >= 3 && strings.HasPrefix(uri, "at://") {
302 did = parts[2]
303 rkey = parts[len(parts)-1]
304 }
305 }
306 }
307 }
308 }
309
310 if did == "" && rkey == "" {
311 h.serveIndexHTML(w, r)
312 return
313 } else if did != "" && rkey != "" {
314 uri := fmt.Sprintf("at://%s/at.margin.collection/%s", did, rkey)
315
316 if !isCrawler(r.UserAgent()) {
317 h.serveIndexHTML(w, r)
318 return
319 }
320
321 collection, err := h.db.GetCollectionByURI(uri)
322 if err == nil && collection != nil {
323 h.serveCollectionOG(w, collection)
324 return
325 }
326 }
327
328 h.serveIndexHTML(w, r)
329}
330
331func (h *OGHandler) serveBookmarkOG(w http.ResponseWriter, bookmark *db.Bookmark) {
332 title := "Bookmark on Margin"
333 if bookmark.Title != nil && *bookmark.Title != "" {
334 title = *bookmark.Title
335 }
336
337 description := ""
338 if bookmark.Description != nil && *bookmark.Description != "" {
339 description = *bookmark.Description
340 } else {
341 description = "A saved bookmark on Margin"
342 }
343
344 sourceDomain := ""
345 if bookmark.Source != "" {
346 if parsed, err := url.Parse(bookmark.Source); err == nil {
347 sourceDomain = parsed.Host
348 }
349 }
350
351 if sourceDomain != "" {
352 description += " from " + sourceDomain
353 }
354
355 authorHandle := bookmark.AuthorDID
356 profiles := fetchProfilesForDIDs([]string{bookmark.AuthorDID})
357 if profile, ok := profiles[bookmark.AuthorDID]; ok && profile.Handle != "" {
358 authorHandle = "@" + profile.Handle
359 }
360
361 pageURL := fmt.Sprintf("%s/at/%s", h.baseURL, url.PathEscape(bookmark.URI[5:]))
362 ogImageURL := fmt.Sprintf("%s/og-image?uri=%s", h.baseURL, url.QueryEscape(bookmark.URI))
363
364 htmlContent := fmt.Sprintf(`<!DOCTYPE html>
365<html lang="en">
366<head>
367 <meta charset="UTF-8">
368 <meta name="viewport" content="width=device-width, initial-scale=1.0">
369 <title>%s - Margin</title>
370 <meta name="description" content="%s">
371
372 <!-- Open Graph -->
373 <meta property="og:type" content="article">
374 <meta property="og:title" content="%s">
375 <meta property="og:description" content="%s">
376 <meta property="og:url" content="%s">
377 <meta property="og:image" content="%s">
378 <meta property="og:image:width" content="1200">
379 <meta property="og:image:height" content="630">
380 <meta property="og:site_name" content="Margin">
381
382 <!-- Twitter Card -->
383 <meta name="twitter:card" content="summary_large_image">
384 <meta name="twitter:title" content="%s">
385 <meta name="twitter:description" content="%s">
386 <meta name="twitter:image" content="%s">
387
388 <!-- Author -->
389 <meta property="article:author" content="%s">
390
391 <meta http-equiv="refresh" content="0; url=%s">
392</head>
393<body>
394 <p>Redirecting to <a href="%s">%s</a>...</p>
395</body>
396</html>`,
397 html.EscapeString(title),
398 html.EscapeString(description),
399 html.EscapeString(title),
400 html.EscapeString(description),
401 html.EscapeString(pageURL),
402 html.EscapeString(ogImageURL),
403 html.EscapeString(title),
404 html.EscapeString(description),
405 html.EscapeString(ogImageURL),
406 html.EscapeString(authorHandle),
407 html.EscapeString(pageURL),
408 html.EscapeString(pageURL),
409 html.EscapeString(title),
410 )
411
412 w.Header().Set("Content-Type", "text/html; charset=utf-8")
413 w.Write([]byte(htmlContent))
414}
415
416func (h *OGHandler) serveHighlightOG(w http.ResponseWriter, highlight *db.Highlight) {
417 title := "Highlight on Margin"
418 description := ""
419
420 if highlight.SelectorJSON != nil && *highlight.SelectorJSON != "" {
421 var selector struct {
422 Exact string `json:"exact"`
423 }
424 if err := json.Unmarshal([]byte(*highlight.SelectorJSON), &selector); err == nil && selector.Exact != "" {
425 description = fmt.Sprintf("\"%s\"", selector.Exact)
426 if len(description) > 200 {
427 description = description[:197] + "...\""
428 }
429 }
430 }
431
432 if highlight.TargetTitle != nil && *highlight.TargetTitle != "" {
433 title = fmt.Sprintf("Highlight on: %s", *highlight.TargetTitle)
434 if len(title) > 60 {
435 title = title[:57] + "..."
436 }
437 }
438
439 sourceDomain := ""
440 if highlight.TargetSource != "" {
441 if parsed, err := url.Parse(highlight.TargetSource); err == nil {
442 sourceDomain = parsed.Host
443 }
444 }
445
446 authorHandle := highlight.AuthorDID
447 profiles := fetchProfilesForDIDs([]string{highlight.AuthorDID})
448 if profile, ok := profiles[highlight.AuthorDID]; ok && profile.Handle != "" {
449 authorHandle = "@" + profile.Handle
450 }
451
452 if description == "" {
453 description = fmt.Sprintf("A highlight by %s", authorHandle)
454 if sourceDomain != "" {
455 description += fmt.Sprintf(" on %s", sourceDomain)
456 }
457 }
458
459 pageURL := fmt.Sprintf("%s/at/%s", h.baseURL, url.PathEscape(highlight.URI[5:]))
460 ogImageURL := fmt.Sprintf("%s/og-image?uri=%s", h.baseURL, url.QueryEscape(highlight.URI))
461
462 htmlContent := fmt.Sprintf(`<!DOCTYPE html>
463<html lang="en">
464<head>
465 <meta charset="UTF-8">
466 <meta name="viewport" content="width=device-width, initial-scale=1.0">
467 <title>%s - Margin</title>
468 <meta name="description" content="%s">
469
470 <!-- Open Graph -->
471 <meta property="og:type" content="article">
472 <meta property="og:title" content="%s">
473 <meta property="og:description" content="%s">
474 <meta property="og:url" content="%s">
475 <meta property="og:image" content="%s">
476 <meta property="og:image:width" content="1200">
477 <meta property="og:image:height" content="630">
478 <meta property="og:site_name" content="Margin">
479
480 <!-- Twitter Card -->
481 <meta name="twitter:card" content="summary_large_image">
482 <meta name="twitter:title" content="%s">
483 <meta name="twitter:description" content="%s">
484 <meta name="twitter:image" content="%s">
485
486 <!-- Author -->
487 <meta property="article:author" content="%s">
488
489 <meta http-equiv="refresh" content="0; url=%s">
490</head>
491<body>
492 <p>Redirecting to <a href="%s">%s</a>...</p>
493</body>
494</html>`,
495 html.EscapeString(title),
496 html.EscapeString(description),
497 html.EscapeString(title),
498 html.EscapeString(description),
499 html.EscapeString(pageURL),
500 html.EscapeString(ogImageURL),
501 html.EscapeString(title),
502 html.EscapeString(description),
503 html.EscapeString(ogImageURL),
504 html.EscapeString(authorHandle),
505 html.EscapeString(pageURL),
506 html.EscapeString(pageURL),
507 html.EscapeString(title),
508 )
509
510 w.Header().Set("Content-Type", "text/html; charset=utf-8")
511 w.Write([]byte(htmlContent))
512}
513
514func (h *OGHandler) serveCollectionOG(w http.ResponseWriter, collection *db.Collection) {
515 icon := "📁"
516 if collection.Icon != nil && *collection.Icon != "" {
517 icon = iconToEmoji(*collection.Icon)
518 }
519
520 title := fmt.Sprintf("%s %s", icon, collection.Name)
521 description := ""
522 if collection.Description != nil && *collection.Description != "" {
523 description = *collection.Description
524 if len(description) > 200 {
525 description = description[:197] + "..."
526 }
527 }
528
529 authorHandle := collection.AuthorDID
530 var avatarURL string
531 profiles := fetchProfilesForDIDs([]string{collection.AuthorDID})
532 if profile, ok := profiles[collection.AuthorDID]; ok {
533 if profile.Handle != "" {
534 authorHandle = "@" + profile.Handle
535 }
536 if profile.Avatar != "" {
537 avatarURL = profile.Avatar
538 }
539 }
540
541 if description == "" {
542 description = fmt.Sprintf("A collection by %s", authorHandle)
543 } else {
544 description = fmt.Sprintf("By %s • %s", authorHandle, description)
545 }
546
547 pageURL := fmt.Sprintf("%s/collection/%s", h.baseURL, url.PathEscape(collection.URI))
548 ogImageURL := fmt.Sprintf("%s/og-image?uri=%s", h.baseURL, url.QueryEscape(collection.URI))
549
550 _ = avatarURL
551
552 htmlContent := fmt.Sprintf(`<!DOCTYPE html>
553<html lang="en">
554<head>
555 <meta charset="UTF-8">
556 <meta name="viewport" content="width=device-width, initial-scale=1.0">
557 <title>%s - Margin</title>
558 <meta name="description" content="%s">
559
560 <!-- Open Graph -->
561 <meta property="og:type" content="article">
562 <meta property="og:title" content="%s">
563 <meta property="og:description" content="%s">
564 <meta property="og:url" content="%s">
565 <meta property="og:image" content="%s">
566 <meta property="og:image:width" content="1200">
567 <meta property="og:image:height" content="630">
568 <meta property="og:site_name" content="Margin">
569
570 <!-- Twitter Card -->
571 <meta name="twitter:card" content="summary_large_image">
572 <meta name="twitter:title" content="%s">
573 <meta name="twitter:description" content="%s">
574 <meta name="twitter:image" content="%s">
575
576 <!-- Author -->
577 <meta property="article:author" content="%s">
578
579 <meta http-equiv="refresh" content="0; url=%s">
580</head>
581<body>
582 <p>Redirecting to <a href="%s">%s</a>...</p>
583</body>
584</html>`,
585 html.EscapeString(title),
586 html.EscapeString(description),
587 html.EscapeString(title),
588 html.EscapeString(description),
589 html.EscapeString(pageURL),
590 html.EscapeString(ogImageURL),
591 html.EscapeString(title),
592 html.EscapeString(description),
593 html.EscapeString(ogImageURL),
594 html.EscapeString(authorHandle),
595 html.EscapeString(pageURL),
596 html.EscapeString(pageURL),
597 html.EscapeString(title),
598 )
599
600 w.Header().Set("Content-Type", "text/html; charset=utf-8")
601 w.Write([]byte(htmlContent))
602}
603
604func (h *OGHandler) serveAnnotationOG(w http.ResponseWriter, annotation *db.Annotation) {
605 title := "Annotation on Margin"
606 description := ""
607
608 if annotation.BodyValue != nil && *annotation.BodyValue != "" {
609 description = *annotation.BodyValue
610 if len(description) > 200 {
611 description = description[:197] + "..."
612 }
613 }
614
615 if annotation.TargetTitle != nil && *annotation.TargetTitle != "" {
616 title = fmt.Sprintf("Comment on: %s", *annotation.TargetTitle)
617 if len(title) > 60 {
618 title = title[:57] + "..."
619 }
620 }
621
622 sourceDomain := ""
623 if annotation.TargetSource != "" {
624 if parsed, err := url.Parse(annotation.TargetSource); err == nil {
625 sourceDomain = parsed.Host
626 }
627 }
628
629 authorHandle := annotation.AuthorDID
630 profiles := fetchProfilesForDIDs([]string{annotation.AuthorDID})
631 if profile, ok := profiles[annotation.AuthorDID]; ok && profile.Handle != "" {
632 authorHandle = "@" + profile.Handle
633 }
634
635 pageURL := fmt.Sprintf("%s/at/%s", h.baseURL, url.PathEscape(annotation.URI[5:]))
636
637 var selectorText string
638 if annotation.SelectorJSON != nil && *annotation.SelectorJSON != "" {
639 var selector struct {
640 Exact string `json:"exact"`
641 }
642 if err := json.Unmarshal([]byte(*annotation.SelectorJSON), &selector); err == nil && selector.Exact != "" {
643 selectorText = selector.Exact
644 if len(selectorText) > 100 {
645 selectorText = selectorText[:97] + "..."
646 }
647 }
648 }
649
650 if selectorText != "" && description != "" {
651 description = fmt.Sprintf("\"%s\"\n\n%s", selectorText, description)
652 } else if selectorText != "" {
653 description = fmt.Sprintf("Highlighted: \"%s\"", selectorText)
654 }
655
656 if description == "" {
657 description = fmt.Sprintf("An annotation by %s", authorHandle)
658 if sourceDomain != "" {
659 description += fmt.Sprintf(" on %s", sourceDomain)
660 }
661 }
662
663 ogImageURL := fmt.Sprintf("%s/og-image?uri=%s", h.baseURL, url.QueryEscape(annotation.URI))
664
665 htmlContent := fmt.Sprintf(`<!DOCTYPE html>
666<html lang="en">
667<head>
668 <meta charset="UTF-8">
669 <meta name="viewport" content="width=device-width, initial-scale=1.0">
670 <title>%s - Margin</title>
671 <meta name="description" content="%s">
672
673 <!-- Open Graph -->
674 <meta property="og:type" content="article">
675 <meta property="og:title" content="%s">
676 <meta property="og:description" content="%s">
677 <meta property="og:url" content="%s">
678 <meta property="og:image" content="%s">
679 <meta property="og:image:width" content="1200">
680 <meta property="og:image:height" content="630">
681 <meta property="og:site_name" content="Margin">
682
683 <!-- Twitter Card -->
684 <meta name="twitter:card" content="summary_large_image">
685 <meta name="twitter:title" content="%s">
686 <meta name="twitter:description" content="%s">
687 <meta name="twitter:image" content="%s">
688
689 <!-- Author -->
690 <meta property="article:author" content="%s">
691
692 <meta http-equiv="refresh" content="0; url=%s">
693</head>
694<body>
695 <p>Redirecting to <a href="%s">%s</a>...</p>
696</body>
697</html>`,
698 html.EscapeString(title),
699 html.EscapeString(description),
700 html.EscapeString(title),
701 html.EscapeString(description),
702 html.EscapeString(pageURL),
703 html.EscapeString(ogImageURL),
704 html.EscapeString(title),
705 html.EscapeString(description),
706 html.EscapeString(ogImageURL),
707 html.EscapeString(authorHandle),
708 html.EscapeString(pageURL),
709 html.EscapeString(pageURL),
710 html.EscapeString(title),
711 )
712
713 w.Header().Set("Content-Type", "text/html; charset=utf-8")
714 w.Write([]byte(htmlContent))
715}
716
717func (h *OGHandler) serveIndexHTML(w http.ResponseWriter, r *http.Request) {
718 http.ServeFile(w, r, h.staticDir+"/index.html")
719}
720
721func (h *OGHandler) HandleOGImage(w http.ResponseWriter, r *http.Request) {
722 uri := r.URL.Query().Get("uri")
723 if uri == "" {
724 http.Error(w, "uri parameter required", http.StatusBadRequest)
725 return
726 }
727
728 var authorHandle, text, quote, sourceDomain, avatarURL string
729
730 annotation, err := h.db.GetAnnotationByURI(uri)
731 if err == nil && annotation != nil {
732 authorHandle = annotation.AuthorDID
733 profiles := fetchProfilesForDIDs([]string{annotation.AuthorDID})
734 if profile, ok := profiles[annotation.AuthorDID]; ok {
735 if profile.Handle != "" {
736 authorHandle = "@" + profile.Handle
737 }
738 if profile.Avatar != "" {
739 avatarURL = profile.Avatar
740 }
741 }
742
743 if annotation.BodyValue != nil {
744 text = *annotation.BodyValue
745 }
746
747 if annotation.SelectorJSON != nil && *annotation.SelectorJSON != "" {
748 var selector struct {
749 Exact string `json:"exact"`
750 }
751 if err := json.Unmarshal([]byte(*annotation.SelectorJSON), &selector); err == nil {
752 quote = selector.Exact
753 }
754 }
755
756 if annotation.TargetSource != "" {
757 if parsed, err := url.Parse(annotation.TargetSource); err == nil {
758 sourceDomain = parsed.Host
759 }
760 }
761 } else {
762 bookmark, err := h.db.GetBookmarkByURI(uri)
763 if err == nil && bookmark != nil {
764 authorHandle = bookmark.AuthorDID
765 profiles := fetchProfilesForDIDs([]string{bookmark.AuthorDID})
766 if profile, ok := profiles[bookmark.AuthorDID]; ok {
767 if profile.Handle != "" {
768 authorHandle = "@" + profile.Handle
769 }
770 if profile.Avatar != "" {
771 avatarURL = profile.Avatar
772 }
773 }
774
775 text = "Bookmark"
776 if bookmark.Description != nil {
777 quote = *bookmark.Description
778 }
779 if bookmark.Title != nil {
780 text = *bookmark.Title
781 }
782
783 if bookmark.Source != "" {
784 if parsed, err := url.Parse(bookmark.Source); err == nil {
785 sourceDomain = parsed.Host
786 }
787 }
788 } else {
789 highlight, err := h.db.GetHighlightByURI(uri)
790 if err == nil && highlight != nil {
791 authorHandle = highlight.AuthorDID
792 profiles := fetchProfilesForDIDs([]string{highlight.AuthorDID})
793 if profile, ok := profiles[highlight.AuthorDID]; ok {
794 if profile.Handle != "" {
795 authorHandle = "@" + profile.Handle
796 }
797 if profile.Avatar != "" {
798 avatarURL = profile.Avatar
799 }
800 }
801
802 targetTitle := ""
803 if highlight.TargetTitle != nil {
804 targetTitle = *highlight.TargetTitle
805 }
806
807 if highlight.SelectorJSON != nil && *highlight.SelectorJSON != "" {
808 var selector struct {
809 Exact string `json:"exact"`
810 }
811 if err := json.Unmarshal([]byte(*highlight.SelectorJSON), &selector); err == nil && selector.Exact != "" {
812 quote = selector.Exact
813 }
814 }
815
816 if highlight.TargetSource != "" {
817 if parsed, err := url.Parse(highlight.TargetSource); err == nil {
818 sourceDomain = parsed.Host
819 }
820 }
821
822 img := generateHighlightOGImagePNG(authorHandle, targetTitle, quote, sourceDomain, avatarURL)
823
824 w.Header().Set("Content-Type", "image/png")
825 w.Header().Set("Cache-Control", "public, max-age=86400")
826 png.Encode(w, img)
827 return
828 } else {
829 collection, err := h.db.GetCollectionByURI(uri)
830 if err == nil && collection != nil {
831 authorHandle = collection.AuthorDID
832 profiles := fetchProfilesForDIDs([]string{collection.AuthorDID})
833 if profile, ok := profiles[collection.AuthorDID]; ok {
834 if profile.Handle != "" {
835 authorHandle = "@" + profile.Handle
836 }
837 if profile.Avatar != "" {
838 avatarURL = profile.Avatar
839 }
840 }
841
842 icon := "📁"
843 if collection.Icon != nil && *collection.Icon != "" {
844 icon = iconToEmoji(*collection.Icon)
845 }
846
847 description := ""
848 if collection.Description != nil && *collection.Description != "" {
849 description = *collection.Description
850 }
851
852 img := generateCollectionOGImagePNG(authorHandle, collection.Name, description, icon, avatarURL)
853
854 w.Header().Set("Content-Type", "image/png")
855 w.Header().Set("Cache-Control", "public, max-age=86400")
856 png.Encode(w, img)
857 return
858 } else {
859 http.Error(w, "Record not found", http.StatusNotFound)
860 return
861 }
862 }
863 }
864 }
865
866 img := generateOGImagePNG(authorHandle, text, quote, sourceDomain, avatarURL)
867
868 w.Header().Set("Content-Type", "image/png")
869 w.Header().Set("Cache-Control", "public, max-age=86400")
870 png.Encode(w, img)
871}
872
873func generateOGImagePNG(author, text, quote, source, avatarURL string) image.Image {
874 width := 1200
875 height := 630
876 padding := 100
877
878 bgPrimary := color.RGBA{12, 10, 20, 255}
879 accent := color.RGBA{168, 85, 247, 255}
880 textPrimary := color.RGBA{244, 240, 255, 255}
881 textSecondary := color.RGBA{168, 158, 200, 255}
882 border := color.RGBA{45, 38, 64, 255}
883
884 img := image.NewRGBA(image.Rect(0, 0, width, height))
885
886 draw.Draw(img, img.Bounds(), &image.Uniform{bgPrimary}, image.Point{}, draw.Src)
887 draw.Draw(img, image.Rect(0, 0, width, 12), &image.Uniform{accent}, image.Point{}, draw.Src)
888
889 avatarSize := 64
890 avatarX := padding
891 avatarY := padding
892
893 avatarImg := fetchAvatarImage(avatarURL)
894 if avatarImg != nil {
895 drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize)
896 } else {
897 drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent)
898 }
899 drawText(img, author, avatarX+avatarSize+24, avatarY+42, textSecondary, 28, false)
900
901 contentWidth := width - (padding * 2)
902 yPos := 220
903
904 if text != "" {
905 textLen := len(text)
906 textSize := 32.0
907 textLineHeight := 42
908 maxTextLines := 5
909
910 if textLen > 200 {
911 textSize = 28.0
912 textLineHeight = 36
913 maxTextLines = 6
914 }
915
916 lines := wrapTextToWidth(text, contentWidth, int(textSize))
917 numLines := min(len(lines), maxTextLines)
918
919 for i := 0; i < numLines; i++ {
920 line := lines[i]
921 if i == numLines-1 && len(lines) > numLines {
922 line += "..."
923 }
924 drawText(img, line, padding, yPos+(i*textLineHeight), textPrimary, textSize, false)
925 }
926 yPos += (numLines * textLineHeight) + 40
927 }
928
929 if quote != "" {
930 quoteLen := len(quote)
931 quoteSize := 24.0
932 quoteLineHeight := 32
933 maxQuoteLines := 3
934
935 if quoteLen > 150 {
936 quoteSize = 20.0
937 quoteLineHeight = 28
938 maxQuoteLines = 4
939 }
940
941 lines := wrapTextToWidth(quote, contentWidth-30, int(quoteSize))
942 numLines := min(len(lines), maxQuoteLines)
943 barHeight := numLines * quoteLineHeight
944
945 draw.Draw(img, image.Rect(padding, yPos, padding+6, yPos+barHeight), &image.Uniform{accent}, image.Point{}, draw.Src)
946
947 for i := 0; i < numLines; i++ {
948 line := lines[i]
949 if i == numLines-1 && len(lines) > numLines {
950 line += "..."
951 }
952 drawText(img, line, padding+24, yPos+24+(i*quoteLineHeight), textSecondary, quoteSize, true)
953 }
954 yPos += barHeight + 40
955 }
956
957 draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src)
958 yPos += 40
959 drawText(img, source, padding, yPos+32, textSecondary, 24, false)
960
961 return img
962}
963
964func drawScaledImage(dst *image.RGBA, src image.Image, x, y, w, h int) {
965 bounds := src.Bounds()
966 srcW := bounds.Dx()
967 srcH := bounds.Dy()
968
969 for dy := 0; dy < h; dy++ {
970 for dx := 0; dx < w; dx++ {
971 srcX := bounds.Min.X + (dx * srcW / w)
972 srcY := bounds.Min.Y + (dy * srcH / h)
973 c := src.At(srcX, srcY)
974 _, _, _, a := c.RGBA()
975 if a > 0 {
976 dst.Set(x+dx, y+dy, c)
977 }
978 }
979 }
980}
981
982func fetchAvatarImage(avatarURL string) image.Image {
983 if avatarURL == "" {
984 return nil
985 }
986
987 resp, err := http.Get(avatarURL)
988 if err != nil {
989 return nil
990 }
991 defer resp.Body.Close()
992
993 if resp.StatusCode != 200 {
994 return nil
995 }
996
997 img, _, err := image.Decode(resp.Body)
998 if err != nil {
999 return nil
1000 }
1001
1002 return img
1003}
1004
1005func drawCircularAvatar(dst *image.RGBA, src image.Image, x, y, size int) {
1006 bounds := src.Bounds()
1007 srcW := bounds.Dx()
1008 srcH := bounds.Dy()
1009
1010 centerX := size / 2
1011 centerY := size / 2
1012 radius := size / 2
1013
1014 for dy := 0; dy < size; dy++ {
1015 for dx := 0; dx < size; dx++ {
1016 distX := dx - centerX
1017 distY := dy - centerY
1018 if distX*distX+distY*distY <= radius*radius {
1019 srcX := bounds.Min.X + (dx * srcW / size)
1020 srcY := bounds.Min.Y + (dy * srcH / size)
1021 dst.Set(x+dx, y+dy, src.At(srcX, srcY))
1022 }
1023 }
1024 }
1025}
1026
1027func drawDefaultAvatar(dst *image.RGBA, author string, x, y, size int, accentColor color.RGBA) {
1028 centerX := size / 2
1029 centerY := size / 2
1030 radius := size / 2
1031
1032 for dy := 0; dy < size; dy++ {
1033 for dx := 0; dx < size; dx++ {
1034 distX := dx - centerX
1035 distY := dy - centerY
1036 if distX*distX+distY*distY <= radius*radius {
1037 dst.Set(x+dx, y+dy, accentColor)
1038 }
1039 }
1040 }
1041
1042 initial := "?"
1043 if len(author) > 1 {
1044 if author[0] == '@' && len(author) > 1 {
1045 initial = strings.ToUpper(string(author[1]))
1046 } else {
1047 initial = strings.ToUpper(string(author[0]))
1048 }
1049 }
1050 drawText(dst, initial, x+size/2-10, y+size/2+12, color.RGBA{255, 255, 255, 255}, 32, true)
1051}
1052
1053func min(a, b int) int {
1054 if a < b {
1055 return a
1056 }
1057 return b
1058}
1059
1060func drawText(img *image.RGBA, text string, x, y int, c color.Color, size float64, bold bool) {
1061 if fontRegular == nil || fontBold == nil {
1062 return
1063 }
1064
1065 selectedFont := fontRegular
1066 if bold {
1067 selectedFont = fontBold
1068 }
1069
1070 face, err := opentype.NewFace(selectedFont, &opentype.FaceOptions{
1071 Size: size,
1072 DPI: 72,
1073 Hinting: font.HintingFull,
1074 })
1075 if err != nil {
1076 return
1077 }
1078 defer face.Close()
1079
1080 d := &font.Drawer{
1081 Dst: img,
1082 Src: image.NewUniform(c),
1083 Face: face,
1084 Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)},
1085 }
1086 d.DrawString(text)
1087}
1088
1089func wrapTextToWidth(text string, maxWidth int, fontSize int) []string {
1090 words := strings.Fields(text)
1091 var lines []string
1092 var currentLine string
1093
1094 charWidth := fontSize * 6 / 10
1095
1096 for _, word := range words {
1097 testLine := currentLine
1098 if testLine != "" {
1099 testLine += " "
1100 }
1101 testLine += word
1102
1103 if len(testLine)*charWidth > maxWidth && currentLine != "" {
1104 lines = append(lines, currentLine)
1105 currentLine = word
1106 } else {
1107 currentLine = testLine
1108 }
1109 }
1110 if currentLine != "" {
1111 lines = append(lines, currentLine)
1112 }
1113 return lines
1114}
1115
1116func generateCollectionOGImagePNG(author, collectionName, description, icon, avatarURL string) image.Image {
1117 width := 1200
1118 height := 630
1119 padding := 120
1120
1121 bgPrimary := color.RGBA{12, 10, 20, 255}
1122 accent := color.RGBA{168, 85, 247, 255}
1123 textPrimary := color.RGBA{244, 240, 255, 255}
1124 textSecondary := color.RGBA{168, 158, 200, 255}
1125 textTertiary := color.RGBA{107, 95, 138, 255}
1126 border := color.RGBA{45, 38, 64, 255}
1127
1128 img := image.NewRGBA(image.Rect(0, 0, width, height))
1129
1130 draw.Draw(img, img.Bounds(), &image.Uniform{bgPrimary}, image.Point{}, draw.Src)
1131 draw.Draw(img, image.Rect(0, 0, width, 12), &image.Uniform{accent}, image.Point{}, draw.Src)
1132
1133 iconY := 120
1134 var iconWidth int
1135 if icon != "" {
1136 emojiImg := fetchTwemojiImage(icon)
1137 if emojiImg != nil {
1138 iconSize := 96
1139 drawScaledImage(img, emojiImg, padding, iconY, iconSize, iconSize)
1140 iconWidth = iconSize + 32
1141 } else {
1142 drawText(img, icon, padding, iconY+70, textPrimary, 80, true)
1143 iconWidth = 100
1144 }
1145 }
1146
1147 drawText(img, collectionName, padding+iconWidth, iconY+65, textPrimary, 64, true)
1148
1149 yPos := 280
1150 contentWidth := width - (padding * 2)
1151
1152 if description != "" {
1153 if len(description) > 200 {
1154 description = description[:197] + "..."
1155 }
1156 lines := wrapTextToWidth(description, contentWidth, 32)
1157 for i, line := range lines {
1158 if i >= 4 {
1159 break
1160 }
1161 drawText(img, line, padding, yPos+(i*42), textSecondary, 32, false)
1162 }
1163 } else {
1164 drawText(img, "A collection on Margin", padding, yPos, textTertiary, 32, false)
1165 }
1166
1167 yPos = 480
1168 draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src)
1169
1170 avatarSize := 64
1171 avatarX := padding
1172 avatarY := yPos + 40
1173
1174 avatarImg := fetchAvatarImage(avatarURL)
1175 if avatarImg != nil {
1176 drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize)
1177 } else {
1178 drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent)
1179 }
1180
1181 handleX := avatarX + avatarSize + 24
1182 drawText(img, author, handleX, avatarY+42, textTertiary, 28, false)
1183
1184 return img
1185}
1186
1187func fetchTwemojiImage(emoji string) image.Image {
1188 var codes []string
1189 for _, r := range emoji {
1190 codes = append(codes, fmt.Sprintf("%x", r))
1191 }
1192 hexCode := strings.Join(codes, "-")
1193
1194 url := fmt.Sprintf("https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/%s.png", hexCode)
1195
1196 resp, err := http.Get(url)
1197 if err != nil || resp.StatusCode != 200 {
1198 if strings.Contains(hexCode, "-fe0f") {
1199 simpleHex := strings.ReplaceAll(hexCode, "-fe0f", "")
1200 url = fmt.Sprintf("https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/%s.png", simpleHex)
1201 resp, err = http.Get(url)
1202 if err != nil || resp.StatusCode != 200 {
1203 return nil
1204 }
1205 } else {
1206 return nil
1207 }
1208 }
1209 defer resp.Body.Close()
1210
1211 img, _, err := image.Decode(resp.Body)
1212 if err != nil {
1213 return nil
1214 }
1215 return img
1216}
1217
1218func generateHighlightOGImagePNG(author, pageTitle, quote, source, avatarURL string) image.Image {
1219 width := 1200
1220 height := 630
1221 padding := 100
1222
1223 bgPrimary := color.RGBA{12, 10, 20, 255}
1224 accent := color.RGBA{250, 204, 21, 255}
1225 textPrimary := color.RGBA{244, 240, 255, 255}
1226 textSecondary := color.RGBA{168, 158, 200, 255}
1227 border := color.RGBA{45, 38, 64, 255}
1228
1229 img := image.NewRGBA(image.Rect(0, 0, width, height))
1230
1231 draw.Draw(img, img.Bounds(), &image.Uniform{bgPrimary}, image.Point{}, draw.Src)
1232 draw.Draw(img, image.Rect(0, 0, width, 12), &image.Uniform{accent}, image.Point{}, draw.Src)
1233
1234 avatarSize := 64
1235 avatarX := padding
1236 avatarY := padding
1237
1238 avatarImg := fetchAvatarImage(avatarURL)
1239 if avatarImg != nil {
1240 drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize)
1241 } else {
1242 drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent)
1243 }
1244 drawText(img, author, avatarX+avatarSize+24, avatarY+42, textSecondary, 28, false)
1245
1246 contentWidth := width - (padding * 2)
1247 yPos := 220
1248 if quote != "" {
1249 quoteLen := len(quote)
1250 fontSize := 42.0
1251 lineHeight := 56
1252 maxLines := 4
1253
1254 if quoteLen > 200 {
1255 fontSize = 32.0
1256 lineHeight = 44
1257 maxLines = 6
1258 } else if quoteLen > 100 {
1259 fontSize = 36.0
1260 lineHeight = 48
1261 maxLines = 5
1262 }
1263
1264 lines := wrapTextToWidth(quote, contentWidth-40, int(fontSize))
1265 numLines := min(len(lines), maxLines)
1266 barHeight := numLines * lineHeight
1267
1268 draw.Draw(img, image.Rect(padding, yPos, padding+8, yPos+barHeight), &image.Uniform{accent}, image.Point{}, draw.Src)
1269
1270 for i := 0; i < numLines; i++ {
1271 line := lines[i]
1272 if i == numLines-1 && len(lines) > numLines {
1273 line += "..."
1274 }
1275 drawText(img, line, padding+40, yPos+42+(i*lineHeight), textPrimary, fontSize, false)
1276 }
1277 yPos += barHeight + 40
1278 }
1279
1280 draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src)
1281 yPos += 40
1282
1283 if pageTitle != "" {
1284 if len(pageTitle) > 60 {
1285 pageTitle = pageTitle[:57] + "..."
1286 }
1287 drawText(img, pageTitle, padding, yPos+32, textSecondary, 32, true)
1288 }
1289
1290 if source != "" {
1291 drawText(img, source, padding, yPos+80, textSecondary, 24, false)
1292 }
1293
1294 return img
1295}