Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 34 kB view raw
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}