package api import ( "bytes" "encoding/base64" "errors" "log/slog" "net/http" "strings" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/bluesky-social/indigo/lex/util" "atmoquest/internal/oauthclient" "atmoquest/internal/profile" ) // profileResponse is the JSON shape for profile endpoints. type profileResponse struct { DID string `json:"did"` Handle string `json:"handle"` DisplayName string `json:"displayName"` AvatarURL string `json:"avatarURL"` Bio string `json:"bio"` Interests []string `json:"interests"` Links []profileLinkPayload `json:"links"` Location string `json:"location"` IsHiring bool `json:"isHiring"` IsLooking bool `json:"isLooking"` } type profileLinkPayload struct { Label string `json:"label"` URL string `json:"url"` } // GetProfile returns the authenticated user's profile. // GET /api/v1/profile func (h *Handlers) GetProfile(w http.ResponseWriter, r *http.Request) { did, sess, ok := h.requireSession(w, r) if !ok { return } pds := sess.Data.HostURL bsky, err := profile.FetchBluesky(r.Context(), pds, did) if err != nil && !errors.Is(err, profile.ErrNotFound) { slog.Warn("api: fetch bsky profile", "did", did, "err", err) } quest, err := profile.FetchQuest(r.Context(), pds, did) if err != nil && !errors.Is(err, profile.ErrNotFound) { slog.Warn("api: fetch quest profile", "did", did, "err", err) } resp := buildProfileResponse(pds, did, h.resolveHandle(r, did.String()), bsky, quest) writeJSON(w, http.StatusOK, resp) } // GetUser returns another user's public profile. // GET /api/v1/users/{did} func (h *Handlers) GetUser(w http.ResponseWriter, r *http.Request) { _, ok := h.requireAuth(w, r) if !ok { return } targetDIDStr := r.PathValue("did") if targetDIDStr == "" { writeError(w, http.StatusBadRequest, "missing did parameter") return } targetDID, err := parseDID(targetDIDStr) if err != nil { writeError(w, http.StatusBadRequest, "invalid DID") return } pds := h.lookupPDS(r, targetDID) bsky, err := profile.FetchBluesky(r.Context(), pds, targetDID) if err != nil && !errors.Is(err, profile.ErrNotFound) { slog.Warn("api: fetch user bsky", "target", targetDID, "err", err) } quest, err := profile.FetchQuest(r.Context(), pds, targetDID) if err != nil && !errors.Is(err, profile.ErrNotFound) { slog.Warn("api: fetch user quest", "target", targetDID, "err", err) } resp := buildProfileResponse(pds, targetDID, h.resolveHandle(r, targetDID.String()), bsky, quest) writeJSON(w, http.StatusOK, resp) } // UpdateProfile updates the authenticated user's quest.atmo.profile record. // PUT /api/v1/profile func (h *Handlers) UpdateProfile(w http.ResponseWriter, r *http.Request) { did, sess, ok := h.requireSession(w, r) if !ok { return } var body struct { DisplayName string `json:"displayName"` Bio string `json:"bio"` Location string `json:"location"` IsHiring bool `json:"isHiring"` IsLooking bool `json:"isLooking"` Interests []string `json:"interests"` Links []profileLinkPayload `json:"links"` AvatarData string `json:"avatarData"` AvatarMime string `json:"avatarMime"` ClearAvatar bool `json:"clearAvatar"` } if err := decodeBody(r, &body); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON") return } // --- Display name + avatar handling --- if oauthclient.HasBskyProfileScope(sess) { displayName := strings.TrimSpace(body.DisplayName) var avatarRef *util.LexBlob if body.AvatarData != "" { mimeType := body.AvatarMime if mimeType == "" { mimeType = "image/jpeg" } if _, ok := profile.AllowedAvatarMimeTypes[mimeType]; !ok { writeError(w, http.StatusBadRequest, "unsupported image type") return } decoded, err := base64.StdEncoding.DecodeString(body.AvatarData) if err != nil { writeError(w, http.StatusBadRequest, "invalid base64 avatar data") return } if len(decoded) > profile.MaxAvatarBytes { writeError(w, http.StatusBadRequest, "avatar too large (max 1 MB)") return } avatarRef, err = profile.UploadAvatar(r.Context(), sess, bytes.NewReader(decoded), mimeType) if err != nil { slog.Warn("api: upload avatar", "did", did, "err", err) writeError(w, http.StatusInternalServerError, "failed to upload avatar") return } } if displayName != "" || avatarRef != nil || body.ClearAvatar { if _, err := profile.PutBluesky(r.Context(), sess, did, displayName, avatarRef, body.ClearAvatar); err != nil { slog.Warn("api: update bsky profile", "did", did, "err", err) writeError(w, http.StatusInternalServerError, "failed to update display name / avatar") return } } } // Build the quest profile record. hiring := body.IsHiring looking := body.IsLooking rec := profile.QuestRecord{ Bio: body.Bio, Location: body.Location, Hiring: &hiring, Looking: &looking, } for _, i := range body.Interests { rec.Interests = append(rec.Interests, i) } for _, l := range body.Links { rec.Links = append(rec.Links, profile.Link{Label: l.Label, URL: l.URL}) } if _, err := profile.PutQuest(r.Context(), sess, did, rec); err != nil { slog.Warn("api: update profile", "did", did, "err", err) writeError(w, http.StatusInternalServerError, "failed to update profile") return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } // buildProfileResponse creates a profileResponse from fetched PDS data. func buildProfileResponse(pdsHost string, did syntax.DID, handle string, bsky *profile.BlueskyRecord, quest *profile.QuestRecord) profileResponse { resp := profileResponse{ DID: did.String(), Handle: handle, Interests: []string{}, Links: []profileLinkPayload{}, } if bsky != nil { resp.DisplayName = bsky.DisplayName resp.AvatarURL = profile.AvatarURL(pdsHost, did, bsky.Avatar.CID()) resp.Bio = bsky.Description } if quest != nil { if quest.Bio != "" { resp.Bio = quest.Bio } resp.Location = quest.Location if quest.Hiring != nil { resp.IsHiring = *quest.Hiring } if quest.Looking != nil { resp.IsLooking = *quest.Looking } for _, i := range quest.Interests { resp.Interests = append(resp.Interests, i) } for _, l := range quest.Links { resp.Links = append(resp.Links, profileLinkPayload{Label: l.Label, URL: l.URL}) } } return resp }