Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}