Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 273 lines 7.3 kB view raw
1package api 2 3import ( 4 "encoding/json" 5 "fmt" 6 "io" 7 "net/http" 8 "net/url" 9 "strings" 10 "time" 11 12 "github.com/go-chi/chi/v5" 13 14 "margin.at/internal/config" 15 "margin.at/internal/db" 16 "margin.at/internal/xrpc" 17) 18 19type UpdateProfileRequest struct { 20 DisplayName string `json:"displayName"` 21 Avatar *xrpc.BlobRef `json:"avatar"` 22 Bio string `json:"bio"` 23 Website string `json:"website"` 24 Links []string `json:"links"` 25} 26 27func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) { 28 session, err := h.refresher.GetSessionWithAutoRefresh(r) 29 if err != nil { 30 http.Error(w, err.Error(), http.StatusUnauthorized) 31 return 32 } 33 34 var req UpdateProfileRequest 35 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 36 http.Error(w, "Invalid request body", http.StatusBadRequest) 37 return 38 } 39 40 record := &xrpc.MarginProfileRecord{ 41 Type: xrpc.CollectionProfile, 42 DisplayName: req.DisplayName, 43 Avatar: req.Avatar, 44 Bio: req.Bio, 45 Website: req.Website, 46 Links: req.Links, 47 CreatedAt: time.Now().UTC().Format(time.RFC3339), 48 } 49 50 if err := record.Validate(); err != nil { 51 http.Error(w, err.Error(), http.StatusBadRequest) 52 return 53 } 54 55 var pdsURL string 56 err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 57 pdsURL = client.PDS 58 _, err := client.PutRecord(r.Context(), did, xrpc.CollectionProfile, "self", record) 59 return err 60 }) 61 62 if err != nil { 63 HandleAPIError(w, r, err, "Failed to update profile: ", http.StatusInternalServerError) 64 return 65 } 66 67 var avatarURL *string 68 if req.Avatar != nil && req.Avatar.Ref.Link != "" { 69 url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 70 pdsURL, session.DID, req.Avatar.Ref.Link) 71 avatarURL = &url 72 } 73 74 linksJSON, _ := json.Marshal(req.Links) 75 profile := &db.Profile{ 76 URI: fmt.Sprintf("at://%s/%s/self", session.DID, xrpc.CollectionProfile), 77 AuthorDID: session.DID, 78 DisplayName: stringPtr(req.DisplayName), 79 Avatar: avatarURL, 80 Bio: stringPtr(req.Bio), 81 Website: stringPtr(req.Website), 82 LinksJSON: stringPtr(string(linksJSON)), 83 CreatedAt: time.Now(), 84 IndexedAt: time.Now(), 85 } 86 h.db.UpsertProfile(profile) 87 88 w.Header().Set("Content-Type", "application/json") 89 w.WriteHeader(http.StatusOK) 90 json.NewEncoder(w).Encode(req) 91} 92 93func stringPtr(s string) *string { 94 if s == "" { 95 return nil 96 } 97 return &s 98} 99 100func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) { 101 did := chi.URLParam(r, "did") 102 if decoded, err := url.QueryUnescape(did); err == nil { 103 did = decoded 104 } 105 106 if did == "" { 107 http.Error(w, "DID required", http.StatusBadRequest) 108 return 109 } 110 111 if !strings.HasPrefix(did, "did:") { 112 var resolvedDID string 113 err := h.db.QueryRow("SELECT did FROM sessions WHERE handle = $1 LIMIT 1", did).Scan(&resolvedDID) 114 if err == nil { 115 did = resolvedDID 116 } else { 117 resolvedDID, err = xrpc.ResolveHandle(did) 118 if err == nil { 119 did = resolvedDID 120 } 121 } 122 } 123 124 profile, err := h.db.GetProfile(did) 125 if err != nil { 126 http.Error(w, "Failed to fetch profile", http.StatusInternalServerError) 127 return 128 } 129 130 if profile == nil { 131 w.Header().Set("Content-Type", "application/json") 132 if did != "" && strings.HasPrefix(did, "did:") { 133 json.NewEncoder(w).Encode(map[string]string{"did": did}) 134 } else { 135 w.Write([]byte("{}")) 136 } 137 return 138 } 139 140 resp := struct { 141 URI string `json:"uri"` 142 DID string `json:"did"` 143 Handle string `json:"handle,omitempty"` 144 DisplayName string `json:"displayName,omitempty"` 145 Avatar string `json:"avatar,omitempty"` 146 Description string `json:"description,omitempty"` 147 Website string `json:"website,omitempty"` 148 Links []string `json:"links"` 149 CreatedAt string `json:"createdAt"` 150 IndexedAt string `json:"indexedAt"` 151 Labels []struct { 152 Val string `json:"val"` 153 Src string `json:"src"` 154 } `json:"labels,omitempty"` 155 Viewer *struct { 156 Blocking bool `json:"blocking"` 157 Muting bool `json:"muting"` 158 BlockedBy bool `json:"blockedBy"` 159 } `json:"viewer,omitempty"` 160 }{ 161 URI: profile.URI, 162 DID: profile.AuthorDID, 163 CreatedAt: profile.CreatedAt.Format(time.RFC3339), 164 IndexedAt: profile.IndexedAt.Format(time.RFC3339), 165 } 166 167 var handle string 168 if err := h.db.QueryRow("SELECT handle FROM sessions WHERE did = $1 LIMIT 1", profile.AuthorDID).Scan(&handle); err == nil { 169 resp.Handle = handle 170 } 171 172 if profile.DisplayName != nil { 173 resp.DisplayName = *profile.DisplayName 174 } 175 if profile.Avatar != nil { 176 resp.Avatar = *profile.Avatar 177 } 178 if profile.Bio != nil { 179 resp.Description = *profile.Bio 180 } 181 if profile.Website != nil { 182 resp.Website = *profile.Website 183 } 184 if profile.LinksJSON != nil && *profile.LinksJSON != "" { 185 _ = json.Unmarshal([]byte(*profile.LinksJSON), &resp.Links) 186 } 187 if resp.Links == nil { 188 resp.Links = []string{} 189 } 190 191 viewerDID := h.getViewerDID(r) 192 if viewerDID != "" && viewerDID != profile.AuthorDID { 193 blocking, muting, blockedBy, err := h.db.GetViewerRelationship(viewerDID, profile.AuthorDID) 194 if err == nil { 195 resp.Viewer = &struct { 196 Blocking bool `json:"blocking"` 197 Muting bool `json:"muting"` 198 BlockedBy bool `json:"blockedBy"` 199 }{ 200 Blocking: blocking, 201 Muting: muting, 202 BlockedBy: blockedBy, 203 } 204 } 205 } 206 207 subscribedLabelers := getSubscribedLabelers(h.db, viewerDID) 208 if subscribedLabelers == nil { 209 serviceDID := config.Get().ServiceDID 210 if serviceDID != "" { 211 subscribedLabelers = []string{serviceDID} 212 } 213 } 214 if didLabels, err := h.db.GetContentLabelsForDIDs([]string{profile.AuthorDID}, subscribedLabelers); err == nil { 215 if labels, ok := didLabels[profile.AuthorDID]; ok { 216 for _, l := range labels { 217 resp.Labels = append(resp.Labels, struct { 218 Val string `json:"val"` 219 Src string `json:"src"` 220 }{Val: l.Val, Src: l.Src}) 221 } 222 } 223 } 224 225 w.Header().Set("Content-Type", "application/json") 226 json.NewEncoder(w).Encode(resp) 227} 228 229func (h *Handler) UploadAvatar(w http.ResponseWriter, r *http.Request) { 230 session, err := h.refresher.GetSessionWithAutoRefresh(r) 231 if err != nil { 232 http.Error(w, err.Error(), http.StatusUnauthorized) 233 return 234 } 235 236 r.Body = http.MaxBytesReader(w, r.Body, 1<<20) 237 238 file, header, err := r.FormFile("avatar") 239 if err != nil { 240 http.Error(w, "Failed to read avatar file: "+err.Error(), http.StatusBadRequest) 241 return 242 } 243 defer file.Close() 244 245 contentType := header.Header.Get("Content-Type") 246 if contentType != "image/jpeg" && contentType != "image/png" { 247 http.Error(w, "Invalid image type. Must be JPEG or PNG.", http.StatusBadRequest) 248 return 249 } 250 251 data, err := io.ReadAll(file) 252 if err != nil { 253 http.Error(w, "Failed to read file", http.StatusInternalServerError) 254 return 255 } 256 257 var blobRef *xrpc.BlobRef 258 err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 259 var uploadErr error 260 blobRef, uploadErr = client.UploadBlob(r.Context(), data, contentType) 261 return uploadErr 262 }) 263 264 if err != nil { 265 http.Error(w, "Failed to upload avatar: "+err.Error(), http.StatusInternalServerError) 266 return 267 } 268 269 w.Header().Set("Content-Type", "application/json") 270 json.NewEncoder(w).Encode(map[string]interface{}{ 271 "blob": blobRef, 272 }) 273}