Something went wrong. Try again.
An event companion and onboarding experience for the ATmosphere (alpha) atmo.quest
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222package 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/profilefunc (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/profilefunc (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}