(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement profile display name and avatar editing in Margin using at.margin.profile record.

scanash00 d1a13809 2c4e898c

+639 -157
+25 -7
avatar/worker.js
··· 56 56 } 57 57 58 58 try { 59 - const profileResponse = await fetch( 60 - `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${decodedActor}`, 61 - ); 62 - 63 59 let avatarUrl = null; 64 - if (profileResponse.ok) { 65 - const profile = await profileResponse.json(); 66 - avatarUrl = profile.avatar; 60 + const marginApiUrl = env.MARGIN_API_URL || "https://margin.at"; 61 + 62 + try { 63 + const marginResponse = await fetch( 64 + `${marginApiUrl}/api/profile/${decodedActor}`, 65 + ); 66 + if (marginResponse.ok) { 67 + const marginProfile = await marginResponse.json(); 68 + if (marginProfile.avatar) { 69 + if (typeof marginProfile.avatar === "string") { 70 + avatarUrl = marginProfile.avatar; 71 + } 72 + } 73 + } 74 + } catch (e) {} 75 + 76 + if (!avatarUrl) { 77 + const profileResponse = await fetch( 78 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${decodedActor}`, 79 + ); 80 + 81 + if (profileResponse.ok) { 82 + const profile = await profileResponse.json(); 83 + avatarUrl = profile.avatar; 84 + } 67 85 } 68 86 69 87 if (!avatarUrl) {
+1
backend/cmd/server/main.go
··· 112 112 r.Get("/api/tags/trending", handler.HandleGetTrendingTags) 113 113 r.Put("/api/profile", handler.UpdateProfile) 114 114 r.Get("/api/profile/{did}", handler.GetProfile) 115 + r.Post("/api/profile/avatar", handler.UploadAvatar) 115 116 116 117 r.Get("/collection/{uri}", ogHandler.HandleCollectionPage) 117 118 r.Get("/{handle}/collection/{rkey}", ogHandler.HandleCollectionPage)
+2 -2
backend/internal/api/collections.go
··· 225 225 return 226 226 } 227 227 228 - profiles := fetchProfilesForDIDs([]string{authorDID}) 228 + profiles := fetchProfilesForDIDs(s.db, []string{authorDID}) 229 229 creator := profiles[authorDID] 230 230 231 231 apiCollections := make([]APICollection, len(collections)) ··· 469 469 return 470 470 } 471 471 472 - profiles := fetchProfilesForDIDs([]string{collection.AuthorDID}) 472 + profiles := fetchProfilesForDIDs(s.db, []string{collection.AuthorDID}) 473 473 creator := profiles[collection.AuthorDID] 474 474 475 475 icon := ""
backend/internal/api/fonts/DroidSansFallback.ttf

This is a binary file and will not be displayed.

+1 -1
backend/internal/api/handler.go
··· 982 982 return 983 983 } 984 984 985 - enriched, _ := hydrateReplies(replies) 985 + enriched, _ := hydrateReplies(h.db, replies) 986 986 987 987 w.Header().Set("Content-Type", "application/json") 988 988 json.NewEncoder(w).Encode(map[string]interface{}{
+54 -33
backend/internal/api/hydration.go
··· 194 194 return []APIAnnotation{}, nil 195 195 } 196 196 197 - profiles := fetchProfilesForDIDs(collectDIDs(annotations, func(a db.Annotation) string { return a.AuthorDID })) 197 + profiles := fetchProfilesForDIDs(database, collectDIDs(annotations, func(a db.Annotation) string { return a.AuthorDID })) 198 198 199 199 uris := make([]string, len(annotations)) 200 200 for i, a := range annotations { ··· 279 279 return []APIHighlight{}, nil 280 280 } 281 281 282 - profiles := fetchProfilesForDIDs(collectDIDs(highlights, func(h db.Highlight) string { return h.AuthorDID })) 282 + profiles := fetchProfilesForDIDs(database, collectDIDs(highlights, func(h db.Highlight) string { return h.AuthorDID })) 283 283 284 284 uris := make([]string, len(highlights)) 285 285 for i, h := range highlights { ··· 348 348 return []APIBookmark{}, nil 349 349 } 350 350 351 - profiles := fetchProfilesForDIDs(collectDIDs(bookmarks, func(b db.Bookmark) string { return b.AuthorDID })) 351 + profiles := fetchProfilesForDIDs(database, collectDIDs(bookmarks, func(b db.Bookmark) string { return b.AuthorDID })) 352 352 353 353 uris := make([]string, len(bookmarks)) 354 354 for i, b := range bookmarks { ··· 402 402 return result, nil 403 403 } 404 404 405 - func hydrateReplies(replies []db.Reply) ([]APIReply, error) { 405 + func hydrateReplies(database *db.DB, replies []db.Reply) ([]APIReply, error) { 406 406 if len(replies) == 0 { 407 407 return []APIReply{}, nil 408 408 } 409 409 410 - profiles := fetchProfilesForDIDs(collectDIDs(replies, func(r db.Reply) string { return r.AuthorDID })) 410 + profiles := fetchProfilesForDIDs(database, collectDIDs(replies, func(r db.Reply) string { return r.AuthorDID })) 411 411 412 412 result := make([]APIReply, len(replies)) 413 413 for i, r := range replies { ··· 449 449 return dids 450 450 } 451 451 452 - func fetchProfilesForDIDs(dids []string) map[string]Author { 452 + func fetchProfilesForDIDs(database *db.DB, dids []string) map[string]Author { 453 453 profiles := make(map[string]Author) 454 454 missingDIDs := make([]string, 0) 455 455 ··· 461 461 } 462 462 } 463 463 464 - if len(missingDIDs) == 0 { 465 - return profiles 466 - } 464 + if len(missingDIDs) > 0 { 465 + batchSize := 25 466 + var wg sync.WaitGroup 467 + var mu sync.Mutex 467 468 468 - batchSize := 25 469 - var wg sync.WaitGroup 470 - var mu sync.Mutex 469 + for i := 0; i < len(missingDIDs); i += batchSize { 470 + end := i + batchSize 471 + if end > len(missingDIDs) { 472 + end = len(missingDIDs) 473 + } 474 + batch := missingDIDs[i:end] 471 475 472 - for i := 0; i < len(missingDIDs); i += batchSize { 473 - end := i + batchSize 474 - if end > len(missingDIDs) { 475 - end = len(missingDIDs) 476 + wg.Add(1) 477 + go func(actors []string) { 478 + defer wg.Done() 479 + fetched, err := fetchProfiles(actors) 480 + if err == nil { 481 + mu.Lock() 482 + defer mu.Unlock() 483 + for k, v := range fetched { 484 + profiles[k] = v 485 + } 486 + } 487 + }(batch) 476 488 } 477 - batch := missingDIDs[i:end] 489 + wg.Wait() 490 + } 478 491 479 - wg.Add(1) 480 - go func(actors []string) { 481 - defer wg.Done() 482 - fetched, err := fetchProfiles(actors) 483 - if err == nil { 484 - mu.Lock() 485 - defer mu.Unlock() 486 - for k, v := range fetched { 487 - profiles[k] = v 488 - Cache.Set(k, v) 492 + if database != nil && len(dids) > 0 { 493 + marginProfiles, err := database.GetProfilesByDIDs(dids) 494 + if err == nil { 495 + for did, mp := range marginProfiles { 496 + author, exists := profiles[did] 497 + if !exists { 498 + author = Author{ 499 + DID: did, 500 + } 501 + } 502 + 503 + if mp.DisplayName != nil && *mp.DisplayName != "" { 504 + author.DisplayName = *mp.DisplayName 505 + } 506 + if mp.Avatar != nil && *mp.Avatar != "" { 507 + author.Avatar = getProxiedAvatarURL(did, *mp.Avatar) 489 508 } 509 + profiles[did] = author 510 + 511 + Cache.Set(did, author) 490 512 } 491 - }(batch) 513 + } 492 514 } 493 - wg.Wait() 494 515 495 516 return profiles 496 517 } ··· 548 569 return []APICollectionItem{}, nil 549 570 } 550 571 551 - profiles := fetchProfilesForDIDs(collectDIDs(items, func(i db.CollectionItem) string { return i.AuthorDID })) 572 + profiles := fetchProfilesForDIDs(database, collectDIDs(items, func(i db.CollectionItem) string { return i.AuthorDID })) 552 573 553 574 var collectionURIs []string 554 575 var annotationURIs []string ··· 573 594 if len(collectionURIs) > 0 { 574 595 colls, err := database.GetCollectionsByURIs(collectionURIs) 575 596 if err == nil { 576 - collProfiles := fetchProfilesForDIDs(collectDIDs(colls, func(c db.Collection) string { return c.AuthorDID })) 597 + collProfiles := fetchProfilesForDIDs(database, collectDIDs(colls, func(c db.Collection) string { return c.AuthorDID })) 577 598 for _, coll := range colls { 578 599 icon := "" 579 600 if coll.Icon != nil { ··· 686 707 } 687 708 } 688 709 689 - profiles := fetchProfilesForDIDs(dids) 710 + profiles := fetchProfilesForDIDs(database, dids) 690 711 691 712 replyURIs := make([]string, 0) 692 713 for _, n := range notifications { ··· 699 720 if len(replyURIs) > 0 { 700 721 replies, err := database.GetRepliesByURIs(replyURIs) 701 722 if err == nil { 702 - hydratedReplies, _ := hydrateReplies(replies) 723 + hydratedReplies, _ := hydrateReplies(database, replies) 703 724 for _, r := range hydratedReplies { 704 725 replyMap[r.ID] = r 705 726 }
+84 -47
backend/internal/api/og.go
··· 19 19 20 20 "golang.org/x/image/font" 21 21 "golang.org/x/image/font/opentype" 22 + "golang.org/x/image/font/sfnt" 22 23 "golang.org/x/image/math/fixed" 23 24 24 25 "margin.at/internal/db" ··· 30 31 //go:embed fonts/Inter-Bold.ttf 31 32 var interBoldTTF []byte 32 33 34 + //go:embed fonts/DroidSansFallback.ttf 35 + var droidSansFallbackTTF []byte 36 + 33 37 //go:embed assets/logo.png 34 38 var logoPNG []byte 35 39 36 40 var ( 37 - fontRegular *opentype.Font 38 - fontBold *opentype.Font 39 - logoImage image.Image 41 + fontRegular *opentype.Font 42 + fontBold *opentype.Font 43 + fontFallback *opentype.Font 44 + logoImage image.Image 40 45 ) 41 46 42 47 func init() { ··· 49 54 if err != nil { 50 55 log.Printf("Warning: failed to parse Inter-Bold font: %v", err) 51 56 } 57 + fontFallback, err = opentype.Parse(droidSansFallbackTTF) 58 + if err != nil { 59 + log.Printf("Warning: failed to parse DroidSansFallback font: %v", err) 60 + } 52 61 53 62 if len(logoPNG) > 0 { 54 63 img, _, err := image.Decode(bytes.NewReader(logoPNG)) ··· 60 69 } 61 70 } 62 71 72 + func drawText(img *image.RGBA, text string, x, y int, c color.Color, size float64, bold bool) { 73 + if fontRegular == nil || fontBold == nil { 74 + return 75 + } 76 + 77 + primaryFont := fontRegular 78 + if bold { 79 + primaryFont = fontBold 80 + } 81 + 82 + opts := &opentype.FaceOptions{ 83 + Size: size, 84 + DPI: 72, 85 + Hinting: font.HintingFull, 86 + } 87 + 88 + facePrimary, _ := opentype.NewFace(primaryFont, opts) 89 + defer facePrimary.Close() 90 + 91 + var faceFallback font.Face 92 + if fontFallback != nil { 93 + faceFallback, _ = opentype.NewFace(fontFallback, opts) 94 + defer faceFallback.Close() 95 + } 96 + 97 + dPrimary := &font.Drawer{ 98 + Dst: img, 99 + Src: image.NewUniform(c), 100 + Face: facePrimary, 101 + Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)}, 102 + } 103 + 104 + var dFallback *font.Drawer 105 + if faceFallback != nil { 106 + dFallback = &font.Drawer{ 107 + Dst: img, 108 + Src: image.NewUniform(c), 109 + Face: faceFallback, 110 + Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)}, 111 + } 112 + } 113 + 114 + var buf sfnt.Buffer 115 + for _, r := range text { 116 + useFallback := false 117 + if fontFallback != nil { 118 + idx, err := primaryFont.GlyphIndex(&buf, r) 119 + if err != nil || idx == 0 { 120 + useFallback = true 121 + } 122 + } 123 + 124 + if useFallback { 125 + dFallback.Dot = dPrimary.Dot 126 + 127 + dFallback.DrawString(string(r)) 128 + 129 + dPrimary.Dot = dFallback.Dot 130 + } else { 131 + dPrimary.DrawString(string(r)) 132 + } 133 + } 134 + } 135 + 63 136 type OGHandler struct { 64 137 db *db.DB 65 138 baseURL string ··· 353 426 } 354 427 355 428 authorHandle := bookmark.AuthorDID 356 - profiles := fetchProfilesForDIDs([]string{bookmark.AuthorDID}) 429 + profiles := fetchProfilesForDIDs(h.db, []string{bookmark.AuthorDID}) 357 430 if profile, ok := profiles[bookmark.AuthorDID]; ok && profile.Handle != "" { 358 431 authorHandle = "@" + profile.Handle 359 432 } ··· 444 517 } 445 518 446 519 authorHandle := highlight.AuthorDID 447 - profiles := fetchProfilesForDIDs([]string{highlight.AuthorDID}) 520 + profiles := fetchProfilesForDIDs(h.db, []string{highlight.AuthorDID}) 448 521 if profile, ok := profiles[highlight.AuthorDID]; ok && profile.Handle != "" { 449 522 authorHandle = "@" + profile.Handle 450 523 } ··· 528 601 529 602 authorHandle := collection.AuthorDID 530 603 var avatarURL string 531 - profiles := fetchProfilesForDIDs([]string{collection.AuthorDID}) 604 + profiles := fetchProfilesForDIDs(h.db, []string{collection.AuthorDID}) 532 605 if profile, ok := profiles[collection.AuthorDID]; ok { 533 606 if profile.Handle != "" { 534 607 authorHandle = "@" + profile.Handle ··· 627 700 } 628 701 629 702 authorHandle := annotation.AuthorDID 630 - profiles := fetchProfilesForDIDs([]string{annotation.AuthorDID}) 703 + profiles := fetchProfilesForDIDs(h.db, []string{annotation.AuthorDID}) 631 704 if profile, ok := profiles[annotation.AuthorDID]; ok && profile.Handle != "" { 632 705 authorHandle = "@" + profile.Handle 633 706 } ··· 730 803 annotation, err := h.db.GetAnnotationByURI(uri) 731 804 if err == nil && annotation != nil { 732 805 authorHandle = annotation.AuthorDID 733 - profiles := fetchProfilesForDIDs([]string{annotation.AuthorDID}) 806 + profiles := fetchProfilesForDIDs(h.db, []string{annotation.AuthorDID}) 734 807 if profile, ok := profiles[annotation.AuthorDID]; ok { 735 808 if profile.Handle != "" { 736 809 authorHandle = "@" + profile.Handle ··· 762 835 bookmark, err := h.db.GetBookmarkByURI(uri) 763 836 if err == nil && bookmark != nil { 764 837 authorHandle = bookmark.AuthorDID 765 - profiles := fetchProfilesForDIDs([]string{bookmark.AuthorDID}) 838 + profiles := fetchProfilesForDIDs(h.db, []string{bookmark.AuthorDID}) 766 839 if profile, ok := profiles[bookmark.AuthorDID]; ok { 767 840 if profile.Handle != "" { 768 841 authorHandle = "@" + profile.Handle ··· 789 862 highlight, err := h.db.GetHighlightByURI(uri) 790 863 if err == nil && highlight != nil { 791 864 authorHandle = highlight.AuthorDID 792 - profiles := fetchProfilesForDIDs([]string{highlight.AuthorDID}) 865 + profiles := fetchProfilesForDIDs(h.db, []string{highlight.AuthorDID}) 793 866 if profile, ok := profiles[highlight.AuthorDID]; ok { 794 867 if profile.Handle != "" { 795 868 authorHandle = "@" + profile.Handle ··· 829 902 collection, err := h.db.GetCollectionByURI(uri) 830 903 if err == nil && collection != nil { 831 904 authorHandle = collection.AuthorDID 832 - profiles := fetchProfilesForDIDs([]string{collection.AuthorDID}) 905 + profiles := fetchProfilesForDIDs(h.db, []string{collection.AuthorDID}) 833 906 if profile, ok := profiles[collection.AuthorDID]; ok { 834 907 if profile.Handle != "" { 835 908 authorHandle = "@" + profile.Handle ··· 1048 1121 } 1049 1122 } 1050 1123 drawText(dst, initial, x+size/2-10, y+size/2+12, color.RGBA{255, 255, 255, 255}, 32, true) 1051 - } 1052 - 1053 - func min(a, b int) int { 1054 - if a < b { 1055 - return a 1056 - } 1057 - return b 1058 - } 1059 - 1060 - func 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 1124 } 1088 1125 1089 1126 func wrapTextToWidth(text string, maxWidth int, fontSize int) []string {
+95 -22
backend/internal/api/profile.go
··· 3 3 import ( 4 4 "encoding/json" 5 5 "fmt" 6 + "io" 6 7 "net/http" 7 8 "net/url" 8 9 "strings" ··· 15 16 ) 16 17 17 18 type UpdateProfileRequest struct { 18 - Bio string `json:"bio"` 19 - Website string `json:"website"` 20 - Links []string `json:"links"` 19 + DisplayName string `json:"displayName"` 20 + Avatar *xrpc.BlobRef `json:"avatar"` 21 + Bio string `json:"bio"` 22 + Website string `json:"website"` 23 + Links []string `json:"links"` 21 24 } 22 25 23 26 func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) { ··· 34 37 } 35 38 36 39 record := &xrpc.MarginProfileRecord{ 37 - Type: xrpc.CollectionProfile, 38 - Bio: req.Bio, 39 - Website: req.Website, 40 - Links: req.Links, 41 - CreatedAt: time.Now().UTC().Format(time.RFC3339), 40 + Type: xrpc.CollectionProfile, 41 + DisplayName: req.DisplayName, 42 + Avatar: req.Avatar, 43 + Bio: req.Bio, 44 + Website: req.Website, 45 + Links: req.Links, 46 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 42 47 } 43 48 44 49 if err := record.Validate(); err != nil { ··· 46 51 return 47 52 } 48 53 54 + var pdsURL string 49 55 err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 56 + pdsURL = client.PDS 50 57 _, err := client.PutRecord(r.Context(), did, xrpc.CollectionProfile, "self", record) 51 58 return err 52 59 }) ··· 54 61 if err != nil { 55 62 http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError) 56 63 return 64 + } 65 + 66 + var avatarURL *string 67 + if req.Avatar != nil && req.Avatar.Ref.Link != "" { 68 + url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 69 + pdsURL, session.DID, req.Avatar.Ref.Link) 70 + avatarURL = &url 57 71 } 58 72 59 73 linksJSON, _ := json.Marshal(req.Links) 60 74 profile := &db.Profile{ 61 - URI: fmt.Sprintf("at://%s/%s/self", session.DID, xrpc.CollectionProfile), 62 - AuthorDID: session.DID, 63 - Bio: &req.Bio, 64 - Website: &req.Website, 65 - LinksJSON: stringPtr(string(linksJSON)), 66 - CreatedAt: time.Now(), 67 - IndexedAt: time.Now(), 75 + URI: fmt.Sprintf("at://%s/%s/self", session.DID, xrpc.CollectionProfile), 76 + AuthorDID: session.DID, 77 + DisplayName: stringPtr(req.DisplayName), 78 + Avatar: avatarURL, 79 + Bio: stringPtr(req.Bio), 80 + Website: stringPtr(req.Website), 81 + LinksJSON: stringPtr(string(linksJSON)), 82 + CreatedAt: time.Now(), 83 + IndexedAt: time.Now(), 68 84 } 69 85 h.db.UpsertProfile(profile) 70 86 ··· 74 90 } 75 91 76 92 func stringPtr(s string) *string { 93 + if s == "" { 94 + return nil 95 + } 77 96 return &s 78 97 } 79 98 ··· 118 137 } 119 138 120 139 resp := struct { 121 - URI string `json:"uri"` 122 - DID string `json:"did"` 123 - Bio string `json:"bio"` 124 - Website string `json:"website"` 125 - Links []string `json:"links"` 126 - CreatedAt string `json:"createdAt"` 127 - IndexedAt string `json:"indexedAt"` 140 + URI string `json:"uri"` 141 + DID string `json:"did"` 142 + DisplayName string `json:"displayName,omitempty"` 143 + Avatar string `json:"avatar,omitempty"` 144 + Bio string `json:"bio"` 145 + Website string `json:"website"` 146 + Links []string `json:"links"` 147 + CreatedAt string `json:"createdAt"` 148 + IndexedAt string `json:"indexedAt"` 128 149 }{ 129 150 URI: profile.URI, 130 151 DID: profile.AuthorDID, ··· 132 153 IndexedAt: profile.IndexedAt.Format(time.RFC3339), 133 154 } 134 155 156 + if profile.DisplayName != nil { 157 + resp.DisplayName = *profile.DisplayName 158 + } 159 + if profile.Avatar != nil { 160 + resp.Avatar = *profile.Avatar 161 + } 135 162 if profile.Bio != nil { 136 163 resp.Bio = *profile.Bio 137 164 } ··· 148 175 w.Header().Set("Content-Type", "application/json") 149 176 json.NewEncoder(w).Encode(resp) 150 177 } 178 + 179 + func (h *Handler) UploadAvatar(w http.ResponseWriter, r *http.Request) { 180 + session, err := h.refresher.GetSessionWithAutoRefresh(r) 181 + if err != nil { 182 + http.Error(w, err.Error(), http.StatusUnauthorized) 183 + return 184 + } 185 + 186 + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) 187 + 188 + file, header, err := r.FormFile("avatar") 189 + if err != nil { 190 + http.Error(w, "Failed to read avatar file: "+err.Error(), http.StatusBadRequest) 191 + return 192 + } 193 + defer file.Close() 194 + 195 + contentType := header.Header.Get("Content-Type") 196 + if contentType != "image/jpeg" && contentType != "image/png" { 197 + http.Error(w, "Invalid image type. Must be JPEG or PNG.", http.StatusBadRequest) 198 + return 199 + } 200 + 201 + data, err := io.ReadAll(file) 202 + if err != nil { 203 + http.Error(w, "Failed to read file", http.StatusInternalServerError) 204 + return 205 + } 206 + 207 + var blobRef *xrpc.BlobRef 208 + err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 209 + var uploadErr error 210 + blobRef, uploadErr = client.UploadBlob(r.Context(), data, contentType) 211 + return uploadErr 212 + }) 213 + 214 + if err != nil { 215 + http.Error(w, "Failed to upload avatar: "+err.Error(), http.StatusInternalServerError) 216 + return 217 + } 218 + 219 + w.Header().Set("Content-Type", "application/json") 220 + json.NewEncoder(w).Encode(map[string]interface{}{ 221 + "blob": blobRef, 222 + }) 223 + }
+65 -13
backend/internal/db/db.go
··· 130 130 } 131 131 132 132 type Profile struct { 133 - URI string `json:"uri"` 134 - AuthorDID string `json:"authorDid"` 135 - Bio *string `json:"bio,omitempty"` 136 - Website *string `json:"website,omitempty"` 137 - LinksJSON *string `json:"links,omitempty"` 138 - CreatedAt time.Time `json:"createdAt"` 139 - IndexedAt time.Time `json:"indexedAt"` 140 - CID *string `json:"cid,omitempty"` 133 + URI string `json:"uri"` 134 + AuthorDID string `json:"authorDid"` 135 + DisplayName *string `json:"displayName,omitempty"` 136 + Avatar *string `json:"avatar,omitempty"` 137 + Bio *string `json:"bio,omitempty"` 138 + Website *string `json:"website,omitempty"` 139 + LinksJSON *string `json:"links,omitempty"` 140 + CreatedAt time.Time `json:"createdAt"` 141 + IndexedAt time.Time `json:"indexedAt"` 142 + CID *string `json:"cid,omitempty"` 141 143 } 142 144 143 145 func New(dsn string) (*DB, error) { ··· 342 344 db.Exec(`CREATE TABLE IF NOT EXISTS profiles ( 343 345 uri TEXT PRIMARY KEY, 344 346 author_did TEXT NOT NULL, 347 + display_name TEXT, 348 + avatar TEXT, 345 349 bio TEXT, 346 350 website TEXT, 347 351 links_json TEXT, ··· 364 368 return nil 365 369 } 366 370 371 + func (db *DB) GetProfilesByDIDs(dids []string) (map[string]*Profile, error) { 372 + if len(dids) == 0 { 373 + return nil, nil 374 + } 375 + 376 + query := `SELECT uri, author_did, display_name, bio, avatar, website, links_json, created_at, indexed_at FROM profiles WHERE author_did IN (` 377 + args := make([]interface{}, len(dids)) 378 + placeholders := make([]string, len(dids)) 379 + 380 + for i, did := range dids { 381 + placeholders[i] = fmt.Sprintf("$%d", i+1) 382 + args[i] = did 383 + } 384 + 385 + query += strings.Join(placeholders, ",") + ")" 386 + 387 + if db.driver == "sqlite3" { 388 + query = strings.ReplaceAll(query, "$", "?") 389 + 390 + placeholders = make([]string, len(dids)) 391 + for i := range dids { 392 + placeholders[i] = "?" 393 + } 394 + query = `SELECT uri, author_did, display_name, bio, avatar, website, links_json, created_at, indexed_at FROM profiles WHERE author_did IN (` + strings.Join(placeholders, ",") + ")" 395 + } 396 + 397 + rows, err := db.Query(query, args...) 398 + if err != nil { 399 + return nil, err 400 + } 401 + defer rows.Close() 402 + 403 + profiles := make(map[string]*Profile) 404 + for rows.Next() { 405 + var p Profile 406 + if err := rows.Scan(&p.URI, &p.AuthorDID, &p.DisplayName, &p.Bio, &p.Avatar, &p.Website, &p.LinksJSON, &p.CreatedAt, &p.IndexedAt); err != nil { 407 + continue 408 + } 409 + profiles[p.AuthorDID] = &p 410 + } 411 + 412 + return profiles, nil 413 + } 414 + 367 415 func (db *DB) GetCursor(id string) (int64, error) { 368 416 var cursor int64 369 417 err := db.QueryRow("SELECT last_cursor FROM cursors WHERE id = $1", id).Scan(&cursor) ··· 390 438 391 439 func (db *DB) GetProfile(did string) (*Profile, error) { 392 440 var p Profile 393 - err := db.QueryRow("SELECT uri, author_did, bio, website, links_json, created_at, indexed_at FROM profiles WHERE author_did = $1", did).Scan( 394 - &p.URI, &p.AuthorDID, &p.Bio, &p.Website, &p.LinksJSON, &p.CreatedAt, &p.IndexedAt, 441 + err := db.QueryRow("SELECT uri, author_did, display_name, avatar, bio, website, links_json, created_at, indexed_at FROM profiles WHERE author_did = $1", did).Scan( 442 + &p.URI, &p.AuthorDID, &p.DisplayName, &p.Avatar, &p.Bio, &p.Website, &p.LinksJSON, &p.CreatedAt, &p.IndexedAt, 395 443 ) 396 444 if err == sql.ErrNoRows { 397 445 return nil, nil ··· 404 452 405 453 func (db *DB) UpsertProfile(p *Profile) error { 406 454 query := ` 407 - INSERT INTO profiles (uri, author_did, bio, website, links_json, created_at, indexed_at) 408 - VALUES ($1, $2, $3, $4, $5, $6, $7) 455 + INSERT INTO profiles (uri, author_did, display_name, avatar, bio, website, links_json, created_at, indexed_at) 456 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) 409 457 ON CONFLICT(uri) DO UPDATE SET 458 + display_name = EXCLUDED.display_name, 459 + avatar = EXCLUDED.avatar, 410 460 bio = EXCLUDED.bio, 411 461 website = EXCLUDED.website, 412 462 links_json = EXCLUDED.links_json, 413 463 indexed_at = EXCLUDED.indexed_at 414 464 ` 415 - _, err := db.Exec(db.Rebind(query), p.URI, p.AuthorDID, p.Bio, p.Website, p.LinksJSON, p.CreatedAt, p.IndexedAt) 465 + _, err := db.Exec(db.Rebind(query), p.URI, p.AuthorDID, p.DisplayName, p.Avatar, p.Bio, p.Website, p.LinksJSON, p.CreatedAt, p.IndexedAt) 416 466 return err 417 467 } 418 468 ··· 443 493 db.Exec(`UPDATE annotations SET motivation = 'commenting' WHERE motivation IS NULL`) 444 494 445 495 db.Exec(`ALTER TABLE profiles ADD COLUMN website TEXT`) 496 + db.Exec(`ALTER TABLE profiles ADD COLUMN display_name TEXT`) 497 + db.Exec(`ALTER TABLE profiles ADD COLUMN avatar TEXT`) 446 498 447 499 if db.driver == "postgres" { 448 500 db.Exec(`ALTER TABLE cursors ALTER COLUMN last_cursor TYPE BIGINT`)
+17 -12
backend/internal/firehose/ingester.go
··· 687 687 } 688 688 689 689 var record struct { 690 - Bio string `json:"bio"` 691 - Website string `json:"website"` 692 - Links []string `json:"links"` 693 - CreatedAt string `json:"createdAt"` 690 + DisplayName string `json:"displayName"` 691 + Bio string `json:"bio"` 692 + Website string `json:"website"` 693 + Links []string `json:"links"` 694 + CreatedAt string `json:"createdAt"` 694 695 } 695 696 696 697 if err := json.Unmarshal(event.Record, &record); err != nil { ··· 704 705 createdAt = time.Now() 705 706 } 706 707 707 - var bioPtr, websitePtr, linksJSONPtr *string 708 + var displayNamePtr, bioPtr, websitePtr, linksJSONPtr *string 709 + if record.DisplayName != "" { 710 + displayNamePtr = &record.DisplayName 711 + } 708 712 if record.Bio != "" { 709 713 bioPtr = &record.Bio 710 714 } ··· 718 722 } 719 723 720 724 profile := &db.Profile{ 721 - URI: uri, 722 - AuthorDID: event.Repo, 723 - Bio: bioPtr, 724 - Website: websitePtr, 725 - LinksJSON: linksJSONPtr, 726 - CreatedAt: createdAt, 727 - IndexedAt: time.Now(), 725 + URI: uri, 726 + AuthorDID: event.Repo, 727 + DisplayName: displayNamePtr, 728 + Bio: bioPtr, 729 + Website: websitePtr, 730 + LinksJSON: linksJSONPtr, 731 + CreatedAt: createdAt, 732 + IndexedAt: time.Now(), 728 733 } 729 734 730 735 if err := i.db.UpsertProfile(profile); err != nil {
+4 -4
backend/internal/oauth/handler.go
··· 144 144 145 145 pkceVerifier, pkceChallenge := client.GeneratePKCE() 146 146 147 - scope := "atproto offline_access blob:* include:at.margin.authFull" 147 + scope := "atproto offline_access blob:* blob:image/jpeg blob:image/png include:at.margin.authFull" 148 148 149 149 parResp, state, dpopNonce, err := client.SendPAR(meta, handle, scope, dpopKey, pkceChallenge) 150 150 if err != nil { ··· 244 244 } 245 245 246 246 pkceVerifier, pkceChallenge := client.GeneratePKCE() 247 - scope := "atproto offline_access blob:* include:at.margin.authFull" 247 + scope := "atproto offline_access blob:* blob:image/jpeg blob:image/png include:at.margin.authFull" 248 248 249 249 parResp, state, dpopNonce, err := client.SendPAR(meta, req.Handle, scope, dpopKey, pkceChallenge) 250 250 if err != nil { ··· 324 324 } 325 325 326 326 pkceVerifier, pkceChallenge := client.GeneratePKCE() 327 - scope := "atproto offline_access blob:* include:at.margin.authFull" 327 + scope := "atproto offline_access blob:* blob:image/jpeg blob:image/png include:at.margin.authFull" 328 328 329 329 parResp, state, dpopNonce, err := client.SendPARWithPrompt(meta, "", scope, dpopKey, pkceChallenge, "create") 330 330 if err != nil { ··· 600 600 "redirect_uris": []string{client.RedirectURI}, 601 601 "grant_types": []string{"authorization_code", "refresh_token"}, 602 602 "response_types": []string{"code"}, 603 - "scope": "atproto offline_access blob:* include:at.margin.authFull", 603 + "scope": "atproto offline_access blob:* blob:image/jpeg blob:image/png include:at.margin.authFull", 604 604 "token_endpoint_auth_method": "private_key_jwt", 605 605 "token_endpoint_auth_signing_alg": "ES256", 606 606 "dpop_bound_access_tokens": true,
+53
backend/internal/xrpc/client.go
··· 304 304 305 305 return output.Did, nil 306 306 } 307 + 308 + type UploadBlobOutput struct { 309 + Blob BlobRef `json:"blob"` 310 + } 311 + 312 + func (c *Client) UploadBlob(ctx context.Context, data []byte, contentType string) (*BlobRef, error) { 313 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", c.PDS) 314 + 315 + maxRetries := 2 316 + for i := 0; i < maxRetries; i++ { 317 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data)) 318 + if err != nil { 319 + return nil, err 320 + } 321 + 322 + req.Header.Set("Content-Type", contentType) 323 + 324 + dpopProof, err := c.createDPoPProof("POST", url) 325 + if err != nil { 326 + return nil, fmt.Errorf("failed to create DPoP proof: %w", err) 327 + } 328 + 329 + req.Header.Set("Authorization", "DPoP "+c.AccessToken) 330 + req.Header.Set("DPoP", dpopProof) 331 + 332 + resp, err := http.DefaultClient.Do(req) 333 + if err != nil { 334 + return nil, err 335 + } 336 + defer resp.Body.Close() 337 + 338 + if nonce := resp.Header.Get("DPoP-Nonce"); nonce != "" { 339 + c.DPoPNonce = nonce 340 + } 341 + 342 + if resp.StatusCode < 400 { 343 + var output UploadBlobOutput 344 + if err := json.NewDecoder(resp.Body).Decode(&output); err != nil { 345 + return nil, err 346 + } 347 + return &output.Blob, nil 348 + } 349 + 350 + bodyBytes, _ := io.ReadAll(resp.Body) 351 + if resp.StatusCode == 401 && (bytes.Contains(bodyBytes, []byte("use_dpop_nonce")) || bytes.Contains(bodyBytes, []byte("UseDpopNonce"))) { 352 + continue 353 + } 354 + 355 + return nil, fmt.Errorf("XRPC error %d: %s", resp.StatusCode, string(bodyBytes)) 356 + } 357 + 358 + return nil, fmt.Errorf("upload blob failed after retries") 359 + }
+21 -5
backend/internal/xrpc/records.go
··· 382 382 } 383 383 384 384 type MarginProfileRecord struct { 385 - Type string `json:"$type"` 386 - Bio string `json:"bio,omitempty"` 387 - Website string `json:"website,omitempty"` 388 - Links []string `json:"links,omitempty"` 389 - CreatedAt string `json:"createdAt"` 385 + Type string `json:"$type"` 386 + DisplayName string `json:"displayName,omitempty"` 387 + Avatar *BlobRef `json:"avatar,omitempty"` 388 + Bio string `json:"bio,omitempty"` 389 + Website string `json:"website,omitempty"` 390 + Links []string `json:"links,omitempty"` 391 + CreatedAt string `json:"createdAt"` 392 + } 393 + 394 + type BlobRef struct { 395 + Type string `json:"$type"` 396 + Ref RefObj `json:"ref"` 397 + MimeType string `json:"mimeType"` 398 + Size int64 `json:"size"` 399 + } 400 + 401 + type RefObj struct { 402 + Link string `json:"$link"` 390 403 } 391 404 392 405 func (r *MarginProfileRecord) Validate() error { 406 + if len(r.DisplayName) > 640 { 407 + return fmt.Errorf("displayName too long") 408 + } 393 409 if len(r.Bio) > 5000 { 394 410 return fmt.Errorf("bio too long") 395 411 }
+11
lexicons/at/margin/profile.json
··· 10 10 "type": "object", 11 11 "required": ["createdAt"], 12 12 "properties": { 13 + "displayName": { 14 + "type": "string", 15 + "maxLength": 640, 16 + "description": "Display name for the user." 17 + }, 18 + "avatar": { 19 + "type": "blob", 20 + "accept": ["image/png", "image/jpeg"], 21 + "maxSize": 1000000, 22 + "description": "User avatar image." 23 + }, 13 24 "bio": { 14 25 "type": "string", 15 26 "maxLength": 5000,
+26 -2
web/src/api/client.js
··· 176 176 }); 177 177 } 178 178 179 - export async function updateProfile({ bio, website, links }) { 179 + export async function updateProfile({ 180 + displayName, 181 + avatar, 182 + bio, 183 + website, 184 + links, 185 + }) { 180 186 return request(`${API_BASE}/profile`, { 181 187 method: "PUT", 182 - body: JSON.stringify({ bio, website, links }), 188 + body: JSON.stringify({ displayName, avatar, bio, website, links }), 183 189 }); 190 + } 191 + 192 + export async function uploadAvatar(file) { 193 + const formData = new FormData(); 194 + formData.append("avatar", file); 195 + 196 + const response = await fetch(`${API_BASE}/profile/avatar`, { 197 + method: "POST", 198 + credentials: "include", 199 + body: formData, 200 + }); 201 + 202 + if (!response.ok) { 203 + const error = await response.text(); 204 + throw new Error(error || `HTTP ${response.status}`); 205 + } 206 + 207 + return response.json(); 184 208 } 185 209 186 210 export async function createCollection(name, description, icon) {
+118 -5
web/src/components/EditProfileModal.jsx
··· 1 - import { useState } from "react"; 2 - import { updateProfile } from "../api/client"; 1 + import { useState, useRef } from "react"; 2 + import { updateProfile, uploadAvatar } from "../api/client"; 3 3 4 4 export default function EditProfileModal({ profile, onClose, onUpdate }) { 5 + const [displayName, setDisplayName] = useState(profile?.displayName || ""); 6 + const [avatarBlob, setAvatarBlob] = useState(null); 7 + const [avatarPreview, setAvatarPreview] = useState(null); 5 8 const [bio, setBio] = useState(profile?.bio || ""); 6 9 const [website, setWebsite] = useState(profile?.website || ""); 7 10 const [links, setLinks] = useState(profile?.links || []); 8 11 const [newLink, setNewLink] = useState(""); 9 12 const [saving, setSaving] = useState(false); 13 + const [uploading, setUploading] = useState(false); 10 14 const [error, setError] = useState(null); 15 + const fileInputRef = useRef(null); 16 + 17 + const handleAvatarChange = async (e) => { 18 + const file = e.target.files?.[0]; 19 + if (!file) return; 20 + 21 + if (!["image/jpeg", "image/png"].includes(file.type)) { 22 + setError("Please select a JPEG or PNG image"); 23 + return; 24 + } 25 + 26 + if (file.size > 1024 * 1024) { 27 + setError("Image must be under 1MB"); 28 + return; 29 + } 30 + 31 + setAvatarPreview(URL.createObjectURL(file)); 32 + setUploading(true); 33 + setError(null); 34 + 35 + try { 36 + const result = await uploadAvatar(file); 37 + setAvatarBlob(result.blob); 38 + } catch (err) { 39 + setError("Failed to upload avatar: " + err.message); 40 + setAvatarPreview(null); 41 + } finally { 42 + setUploading(false); 43 + } 44 + }; 11 45 12 46 const handleSubmit = async (e) => { 13 47 e.preventDefault(); ··· 15 49 setError(null); 16 50 17 51 try { 18 - await updateProfile({ bio, website, links }); 52 + await updateProfile({ 53 + displayName, 54 + avatar: avatarBlob, 55 + bio, 56 + website, 57 + links, 58 + }); 19 59 onUpdate(); 20 60 onClose(); 21 61 } catch (err) { ··· 39 79 setLinks(links.filter((_, i) => i !== index)); 40 80 }; 41 81 82 + const currentAvatar = 83 + avatarPreview || (profile?.did ? `/api/avatar/${profile.did}` : null); 84 + 42 85 return ( 43 86 <div className="modal-overlay" onClick={onClose}> 44 87 <div className="modal-container" onClick={(e) => e.stopPropagation()}> ··· 64 107 {error && <div className="error-message">{error}</div>} 65 108 66 109 <div className="form-group"> 110 + <label>Avatar</label> 111 + <div className="avatar-upload-container"> 112 + <div 113 + className="avatar-preview" 114 + onClick={() => fileInputRef.current?.click()} 115 + style={{ cursor: "pointer" }} 116 + > 117 + {currentAvatar ? ( 118 + <img 119 + src={currentAvatar} 120 + alt="Avatar preview" 121 + className="avatar-preview-img" 122 + /> 123 + ) : ( 124 + <div className="avatar-placeholder"> 125 + <svg 126 + width="32" 127 + height="32" 128 + viewBox="0 0 24 24" 129 + fill="none" 130 + stroke="currentColor" 131 + strokeWidth="2" 132 + > 133 + <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /> 134 + <circle cx="12" cy="7" r="4" /> 135 + </svg> 136 + </div> 137 + )} 138 + {uploading && ( 139 + <div className="avatar-uploading"> 140 + <span>Uploading...</span> 141 + </div> 142 + )} 143 + </div> 144 + <input 145 + ref={fileInputRef} 146 + type="file" 147 + accept="image/jpeg,image/png" 148 + onChange={handleAvatarChange} 149 + style={{ display: "none" }} 150 + /> 151 + <button 152 + type="button" 153 + className="btn btn-secondary btn-sm" 154 + onClick={() => fileInputRef.current?.click()} 155 + disabled={uploading} 156 + > 157 + {uploading ? "Uploading..." : "Change Avatar"} 158 + </button> 159 + </div> 160 + </div> 161 + 162 + <div className="form-group"> 163 + <label>Display Name</label> 164 + <input 165 + type="text" 166 + className="input" 167 + value={displayName} 168 + onChange={(e) => setDisplayName(e.target.value)} 169 + placeholder="Your name" 170 + maxLength={64} 171 + /> 172 + <div className="char-count">{displayName.length}/64</div> 173 + </div> 174 + 175 + <div className="form-group"> 67 176 <label>Bio</label> 68 177 <textarea 69 178 className="input" ··· 130 239 type="button" 131 240 className="btn btn-secondary" 132 241 onClick={onClose} 133 - disabled={saving} 242 + disabled={saving || uploading} 134 243 > 135 244 Cancel 136 245 </button> 137 - <button type="submit" className="btn btn-primary" disabled={saving}> 246 + <button 247 + type="submit" 248 + className="btn btn-primary" 249 + disabled={saving || uploading} 250 + > 138 251 {saving ? "Saving..." : "Save Profile"} 139 252 </button> 140 253 </div>
+50
web/src/css/modals.css
··· 578 578 color: var(--text-tertiary); 579 579 margin-top: 4px; 580 580 } 581 + 582 + .avatar-upload-container { 583 + display: flex; 584 + align-items: center; 585 + gap: var(--spacing-md); 586 + } 587 + 588 + .avatar-preview { 589 + width: 72px; 590 + height: 72px; 591 + border-radius: var(--radius-full); 592 + background: var(--bg-tertiary); 593 + border: 2px solid var(--border); 594 + overflow: hidden; 595 + display: flex; 596 + align-items: center; 597 + justify-content: center; 598 + position: relative; 599 + transition: border-color 0.15s ease; 600 + } 601 + 602 + .avatar-preview:hover { 603 + border-color: var(--accent); 604 + } 605 + 606 + .avatar-preview-img { 607 + width: 100%; 608 + height: 100%; 609 + object-fit: cover; 610 + } 611 + 612 + .avatar-placeholder { 613 + color: var(--text-tertiary); 614 + } 615 + 616 + .avatar-uploading { 617 + position: absolute; 618 + inset: 0; 619 + background: rgba(0, 0, 0, 0.6); 620 + display: flex; 621 + align-items: center; 622 + justify-content: center; 623 + color: white; 624 + font-size: 0.7rem; 625 + } 626 + 627 + .btn-sm { 628 + padding: 6px 12px; 629 + font-size: 0.8rem; 630 + }
+12 -4
web/src/pages/Profile.jsx
··· 121 121 122 122 const bskyData = await bskyPromise; 123 123 if (bskyData || marginData) { 124 - setProfile((prev) => ({ 124 + const merged = { 125 125 ...(bskyData || {}), 126 - ...prev, 127 - ...(marginData || {}), 128 - })); 126 + }; 127 + if (marginData) { 128 + merged.did = marginData.did || merged.did; 129 + if (marginData.displayName) 130 + merged.displayName = marginData.displayName; 131 + if (marginData.avatar) merged.avatar = marginData.avatar; 132 + if (marginData.bio) merged.bio = marginData.bio; 133 + if (marginData.website) merged.website = marginData.website; 134 + if (marginData.links?.length) merged.links = marginData.links; 135 + } 136 + setProfile(merged); 129 137 } 130 138 } 131 139 } catch (err) {