A community based topic aggregation platform built on atproto
1package user
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "log/slog"
9 "net/http"
10
11 "Coves/internal/api/middleware"
12 "Coves/internal/atproto/pds"
13
14 "github.com/bluesky-social/indigo/atproto/auth/oauth"
15)
16
17// CovesProfileCollection is the atProto collection for Coves user profiles.
18// NOTE: This constant is intentionally duplicated in internal/atproto/jetstream/user_consumer.go
19// to avoid circular dependencies between packages. Keep both definitions in sync.
20const CovesProfileCollection = "social.coves.actor.profile"
21
22// PDSClientFactory creates PDS clients from session data.
23// Used to allow injection of different auth mechanisms (OAuth for production, password for E2E tests).
24type PDSClientFactory func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error)
25
26const (
27 // MaxDisplayNameLength is the maximum allowed length for display names (per atProto lexicon)
28 MaxDisplayNameLength = 64
29 // MaxBioLength is the maximum allowed length for bio/description (per atProto lexicon)
30 MaxBioLength = 256
31 // MaxAvatarBlobSize is the maximum allowed avatar size in bytes (1MB per lexicon)
32 MaxAvatarBlobSize = 1_000_000
33 // MaxBannerBlobSize is the maximum allowed banner size in bytes (2MB per lexicon)
34 MaxBannerBlobSize = 2_000_000
35 // MaxRequestBodySize is the maximum request body size (10MB to accommodate base64 overhead)
36 MaxRequestBodySize = 10_000_000
37)
38
39// UpdateProfileRequest represents the request body for updating a user profile
40type UpdateProfileRequest struct {
41 DisplayName *string `json:"displayName,omitempty"`
42 Bio *string `json:"bio,omitempty"`
43 AvatarBlob []byte `json:"avatarBlob,omitempty"`
44 AvatarMimeType string `json:"avatarMimeType,omitempty"`
45 BannerBlob []byte `json:"bannerBlob,omitempty"`
46 BannerMimeType string `json:"bannerMimeType,omitempty"`
47}
48
49// UpdateProfileResponse represents the response from updating a profile
50type UpdateProfileResponse struct {
51 URI string `json:"uri"`
52 CID string `json:"cid"`
53}
54
55// UpdateProfileHandler handles POST /xrpc/social.coves.actor.updateProfile
56// This endpoint allows authenticated users to update their Coves profile on their PDS.
57// It validates inputs, uploads any provided blobs, and writes the profile record.
58type UpdateProfileHandler struct {
59 oauthClient *oauth.ClientApp // For creating authenticated PDS clients (production)
60 pdsClientFactory PDSClientFactory // Optional: custom factory for testing
61}
62
63// NewUpdateProfileHandler creates a new update profile handler.
64// Panics if oauthClient is nil - use NewUpdateProfileHandlerWithFactory for testing.
65func NewUpdateProfileHandler(oauthClient *oauth.ClientApp) *UpdateProfileHandler {
66 if oauthClient == nil {
67 panic("NewUpdateProfileHandler: oauthClient is required")
68 }
69 return &UpdateProfileHandler{
70 oauthClient: oauthClient,
71 }
72}
73
74// NewUpdateProfileHandlerWithFactory creates a new update profile handler with a custom PDS client factory.
75// This is primarily for E2E testing with password-based authentication instead of OAuth.
76// Panics if factory is nil.
77func NewUpdateProfileHandlerWithFactory(factory PDSClientFactory) *UpdateProfileHandler {
78 if factory == nil {
79 panic("NewUpdateProfileHandlerWithFactory: factory is required")
80 }
81 return &UpdateProfileHandler{
82 pdsClientFactory: factory,
83 }
84}
85
86// getPDSClient creates a PDS client from an OAuth session.
87// If a custom factory was provided (for testing), uses that.
88// Otherwise, uses DPoP authentication via indigo's ClientApp for proper OAuth token handling.
89func (h *UpdateProfileHandler) getPDSClient(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) {
90 // Use custom factory if provided (e.g., for E2E testing with password auth)
91 if h.pdsClientFactory != nil {
92 return h.pdsClientFactory(ctx, session)
93 }
94
95 // Production path: use OAuth with DPoP
96 if h.oauthClient == nil {
97 return nil, fmt.Errorf("OAuth client not configured")
98 }
99
100 return pds.NewFromOAuthSession(ctx, h.oauthClient, session)
101}
102
103// ServeHTTP handles the update profile request
104func (h *UpdateProfileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
105 ctx := r.Context()
106
107 // Check HTTP method
108 if r.Method != http.MethodPost {
109 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
110 return
111 }
112
113 // 1. Get authenticated user from context
114 userDID := middleware.GetUserDID(r)
115 if userDID == "" {
116 writeUpdateProfileError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
117 return
118 }
119
120 // Get OAuth session for PDS URL and access token
121 session := middleware.GetOAuthSession(r)
122 if session == nil {
123 writeUpdateProfileError(w, http.StatusUnauthorized, "MissingSession", "Missing PDS credentials")
124 return
125 }
126
127 if session.HostURL == "" {
128 writeUpdateProfileError(w, http.StatusUnauthorized, "MissingCredentials", "Missing PDS credentials")
129 return
130 }
131
132 // 2. Parse request
133 r.Body = http.MaxBytesReader(w, r.Body, MaxRequestBodySize)
134 var req UpdateProfileRequest
135 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
136 writeUpdateProfileError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
137 return
138 }
139
140 // Validate displayName length
141 if req.DisplayName != nil && len(*req.DisplayName) > MaxDisplayNameLength {
142 writeUpdateProfileError(w, http.StatusBadRequest, "DisplayNameTooLong",
143 fmt.Sprintf("Display name exceeds %d character limit", MaxDisplayNameLength))
144 return
145 }
146
147 // Validate bio length
148 if req.Bio != nil && len(*req.Bio) > MaxBioLength {
149 writeUpdateProfileError(w, http.StatusBadRequest, "BioTooLong",
150 fmt.Sprintf("Bio exceeds %d character limit", MaxBioLength))
151 return
152 }
153
154 // 3. Validate blob sizes and mime types
155 if len(req.AvatarBlob) > 0 {
156 // Validate mime type is provided when blob is provided
157 if req.AvatarMimeType == "" {
158 writeUpdateProfileError(w, http.StatusBadRequest, "InvalidRequest", "Avatar blob provided without mime type")
159 return
160 }
161 // Validate size (1MB max for avatar per lexicon)
162 if len(req.AvatarBlob) > MaxAvatarBlobSize {
163 writeUpdateProfileError(w, http.StatusBadRequest, "AvatarTooLarge", "Avatar exceeds 1MB limit")
164 return
165 }
166 if !isValidImageMimeType(req.AvatarMimeType) {
167 writeUpdateProfileError(w, http.StatusBadRequest, "InvalidMimeType", "Invalid avatar mime type")
168 return
169 }
170 }
171
172 if len(req.BannerBlob) > 0 {
173 // Validate mime type is provided when blob is provided
174 if req.BannerMimeType == "" {
175 writeUpdateProfileError(w, http.StatusBadRequest, "InvalidRequest", "Banner blob provided without mime type")
176 return
177 }
178 // Validate size (2MB max for banner per lexicon)
179 if len(req.BannerBlob) > MaxBannerBlobSize {
180 writeUpdateProfileError(w, http.StatusBadRequest, "BannerTooLarge", "Banner exceeds 2MB limit")
181 return
182 }
183 if !isValidImageMimeType(req.BannerMimeType) {
184 writeUpdateProfileError(w, http.StatusBadRequest, "InvalidMimeType", "Invalid banner mime type")
185 return
186 }
187 }
188
189 // 4. Create PDS client (uses factory if provided, otherwise OAuth with DPoP)
190 pdsClient, err := h.getPDSClient(ctx, session)
191 if err != nil {
192 slog.Error("failed to create PDS client",
193 slog.String("did", userDID),
194 slog.String("error", err.Error()),
195 )
196 writeUpdateProfileError(w, http.StatusUnauthorized, "SessionError",
197 "Failed to restore session. Please sign in again.")
198 return
199 }
200
201 // 5. Build profile record
202 profile := map[string]interface{}{
203 "$type": CovesProfileCollection,
204 }
205
206 // Add displayName if provided
207 if req.DisplayName != nil {
208 profile["displayName"] = *req.DisplayName
209 }
210
211 // Add bio (description) if provided
212 if req.Bio != nil {
213 profile["description"] = *req.Bio
214 }
215
216 // 6. Upload avatar blob if provided
217 if len(req.AvatarBlob) > 0 {
218 avatarRef, err := pdsClient.UploadBlob(ctx, req.AvatarBlob, req.AvatarMimeType)
219 if err != nil {
220 slog.Error("failed to upload avatar blob",
221 slog.String("did", userDID),
222 slog.String("error", err.Error()),
223 )
224 // Map specific PDS errors to user-friendly messages
225 switch {
226 case errors.Is(err, pds.ErrUnauthorized), errors.Is(err, pds.ErrForbidden):
227 writeUpdateProfileError(w, http.StatusUnauthorized, "AuthExpired", "Your session may have expired. Please re-authenticate.")
228 case errors.Is(err, pds.ErrRateLimited):
229 writeUpdateProfileError(w, http.StatusTooManyRequests, "RateLimited", "Too many requests. Please try again later.")
230 case errors.Is(err, pds.ErrPayloadTooLarge):
231 writeUpdateProfileError(w, http.StatusRequestEntityTooLarge, "AvatarTooLarge", "Avatar exceeds PDS size limit.")
232 default:
233 writeUpdateProfileError(w, http.StatusInternalServerError, "BlobUploadFailed", "Failed to upload avatar")
234 }
235 return
236 }
237 if avatarRef == nil || avatarRef.Ref == nil || avatarRef.Type == "" {
238 slog.Error("invalid blob reference returned from avatar upload", slog.String("did", userDID))
239 writeUpdateProfileError(w, http.StatusInternalServerError, "BlobUploadFailed", "Invalid avatar blob reference")
240 return
241 }
242 profile["avatar"] = map[string]interface{}{
243 "$type": avatarRef.Type,
244 "ref": avatarRef.Ref,
245 "mimeType": avatarRef.MimeType,
246 "size": avatarRef.Size,
247 }
248 }
249
250 // 7. Upload banner blob if provided
251 if len(req.BannerBlob) > 0 {
252 bannerRef, err := pdsClient.UploadBlob(ctx, req.BannerBlob, req.BannerMimeType)
253 if err != nil {
254 slog.Error("failed to upload banner blob",
255 slog.String("did", userDID),
256 slog.String("error", err.Error()),
257 )
258 // Map specific PDS errors to user-friendly messages
259 switch {
260 case errors.Is(err, pds.ErrUnauthorized), errors.Is(err, pds.ErrForbidden):
261 writeUpdateProfileError(w, http.StatusUnauthorized, "AuthExpired", "Your session may have expired. Please re-authenticate.")
262 case errors.Is(err, pds.ErrRateLimited):
263 writeUpdateProfileError(w, http.StatusTooManyRequests, "RateLimited", "Too many requests. Please try again later.")
264 case errors.Is(err, pds.ErrPayloadTooLarge):
265 writeUpdateProfileError(w, http.StatusRequestEntityTooLarge, "BannerTooLarge", "Banner exceeds PDS size limit.")
266 default:
267 writeUpdateProfileError(w, http.StatusInternalServerError, "BlobUploadFailed", "Failed to upload banner")
268 }
269 return
270 }
271 if bannerRef == nil || bannerRef.Ref == nil || bannerRef.Type == "" {
272 slog.Error("invalid blob reference returned from banner upload", slog.String("did", userDID))
273 writeUpdateProfileError(w, http.StatusInternalServerError, "BlobUploadFailed", "Invalid banner blob reference")
274 return
275 }
276 profile["banner"] = map[string]interface{}{
277 "$type": bannerRef.Type,
278 "ref": bannerRef.Ref,
279 "mimeType": bannerRef.MimeType,
280 "size": bannerRef.Size,
281 }
282 }
283
284 // 8. Put profile record to PDS using com.atproto.repo.putRecord
285 uri, cid, err := pdsClient.PutRecord(ctx, CovesProfileCollection, "self", profile, "")
286 if err != nil {
287 slog.Error("failed to put profile record to PDS",
288 slog.String("did", userDID),
289 slog.String("pds_url", session.HostURL),
290 slog.String("error", err.Error()),
291 )
292 // Map PDS errors to user-friendly messages
293 switch {
294 case errors.Is(err, pds.ErrUnauthorized), errors.Is(err, pds.ErrForbidden):
295 writeUpdateProfileError(w, http.StatusUnauthorized, "AuthExpired", "Your session may have expired. Please re-authenticate.")
296 case errors.Is(err, pds.ErrRateLimited):
297 writeUpdateProfileError(w, http.StatusTooManyRequests, "RateLimited", "Too many requests. Please try again later.")
298 case errors.Is(err, pds.ErrPayloadTooLarge):
299 writeUpdateProfileError(w, http.StatusRequestEntityTooLarge, "PayloadTooLarge", "Profile data exceeds PDS size limit.")
300 default:
301 writeUpdateProfileError(w, http.StatusInternalServerError, "PDSError", "Failed to update profile")
302 }
303 return
304 }
305
306 // 9. Return success response
307 resp := UpdateProfileResponse{URI: uri, CID: cid}
308
309 // Marshal to bytes first to catch encoding errors before writing headers
310 responseBytes, err := json.Marshal(resp)
311 if err != nil {
312 slog.Error("failed to marshal update profile response",
313 slog.String("did", userDID),
314 slog.String("error", err.Error()),
315 )
316 writeUpdateProfileError(w, http.StatusInternalServerError, "InternalError", "Failed to encode response")
317 return
318 }
319
320 w.Header().Set("Content-Type", "application/json")
321 w.WriteHeader(http.StatusOK)
322 if _, writeErr := w.Write(responseBytes); writeErr != nil {
323 slog.Warn("failed to write update profile response",
324 slog.String("did", userDID),
325 slog.String("error", writeErr.Error()),
326 )
327 }
328}
329
330// isValidImageMimeType checks if the MIME type is allowed for profile images
331func isValidImageMimeType(mimeType string) bool {
332 switch mimeType {
333 case "image/png", "image/jpeg", "image/webp":
334 return true
335 default:
336 return false
337 }
338}
339
340// writeUpdateProfileError writes a JSON error response for update profile failures
341func writeUpdateProfileError(w http.ResponseWriter, statusCode int, errorType, message string) {
342 responseBytes, err := json.Marshal(map[string]interface{}{
343 "error": errorType,
344 "message": message,
345 })
346 if err != nil {
347 // Fallback to plain text if JSON encoding fails
348 slog.Error("failed to marshal error response", slog.String("error", err.Error()))
349 w.Header().Set("Content-Type", "text/plain")
350 w.WriteHeader(statusCode)
351 _, _ = w.Write([]byte(message))
352 return
353 }
354
355 w.Header().Set("Content-Type", "application/json")
356 w.WriteHeader(statusCode)
357 if _, writeErr := w.Write(responseBytes); writeErr != nil {
358 slog.Warn("failed to write error response", slog.String("error", writeErr.Error()))
359 }
360}