Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

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

+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) {