// Package profile reads and writes the user's atmoquest profile and the // underlying app.bsky.actor.profile record on their PDS. // // Design: // // - Reads of public records (both app.bsky.actor.profile and // quest.atmo.profile) go through an unauthenticated atclient pointed at // the user's PDS. No scope required. // - Writes go through the user's OAuth session via sess.APIClient(), which // handles DPoP signing + access-token + auto-refresh. Requires the // corresponding `repo:` scope (see oauthclient.DefaultScopes). // - Avatars are served as blobs from the PDS via com.atproto.sync.getBlob, // a public endpoint. This works for any ATProto provider, not just bsky's // CDN. package profile import ( "context" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/bluesky-social/indigo/lex/util" ) const ( // NSIDs we read and write. bskyProfileNSID = "app.bsky.actor.profile" questProfileNSID = "quest.atmo.profile" // getRecord / putRecord live in the com.atproto.repo namespace. nsidGetRecord = "com.atproto.repo.getRecord" nsidPutRecord = "com.atproto.repo.putRecord" // MaxLinks mirrors lexicons/quest/atmo/profile.json#links.maxLength. // Form parsing trims to this cap defensively. MaxLinks = 5 // MaxInterests mirrors lexicons/quest/atmo/profile.json#interests.maxLength. MaxInterests = 30 // MaxBioRunes mirrors profile.json#bio.maxGraphemes (close enough — we // count runes, not graphemes; this is a UI-side belt-and-braces check // before the lexicon validator runs on the PDS). MaxBioRunes = 256 // MaxAvatarBytes is the maximum allowed avatar upload size (1 MB). MaxAvatarBytes = 1 * 1024 * 1024 ) // AllowedAvatarMimeTypes are the image formats we accept for avatar upload. var AllowedAvatarMimeTypes = map[string]string{ "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", } // ErrNotFound is returned by Fetch* when the record doesn't exist yet. // Callers should treat this as "no profile yet, render defaults". var ErrNotFound = errors.New("profile: record not found") // Link mirrors quest.atmo.profile#link. type Link struct { Label string `json:"label"` URL string `json:"url"` } // QuestRecord is the decoded value of a quest.atmo.profile record. // Fields not yet surfaced in the UI are still parsed so a future read/write // cycle round-trips them. type QuestRecord struct { Bio string `json:"bio,omitempty"` Links []Link `json:"links,omitempty"` Interests []string `json:"interests,omitempty"` Location string `json:"location,omitempty"` WorksAt string `json:"worksAt,omitempty"` ContactMethod string `json:"contactMethod,omitempty"` Hiring *bool `json:"hiring,omitempty"` Looking *bool `json:"looking,omitempty"` UpdatedAt time.Time `json:"updatedAt,omitempty"` } // BlueskyRecord is the subset of app.bsky.actor.profile we read. // We deliberately ignore banner, labels, joinedViaStarterPack, etc — they // aren't shown on the atmoquest profile. type BlueskyRecord struct { DisplayName string `json:"displayName,omitempty"` Description string `json:"description,omitempty"` Avatar *BlobRef `json:"avatar,omitempty"` } // BlobRef is the wire shape of an atproto blob reference. The Ref.Link field // holds the CID; MimeType is the original upload mime. // // We only need the CID and mime for rendering, so we don't model the full // CBOR/JSON blob ref struct (which carries size, $type=blob, etc). type BlobRef struct { Ref BlobRefLink `json:"ref"` MimeType string `json:"mimeType,omitempty"` } // BlobRefLink is the {"$link": ""} sub-object inside a blob ref. type BlobRefLink struct { Link string `json:"$link"` } // CID returns the avatar blob's CID string, or empty if missing. func (b *BlobRef) CID() string { if b == nil { return "" } return b.Ref.Link } // getRecordResponse is the wire shape of com.atproto.repo.getRecord output. // The "value" field shape depends on the collection — we unmarshal it into // the caller-provided target via a second decode pass. type getRecordResponse struct { URI string `json:"uri"` CID string `json:"cid"` Value interface{} `json:"value"` } // FetchBluesky reads app.bsky.actor.profile/self from the given PDS for the // given DID. Public, unauthenticated. Returns ErrNotFound if the user has no // Bluesky profile record (rare but valid for non-bsky-onboarded accounts). func FetchBluesky(ctx context.Context, pdsHost string, did syntax.DID) (*BlueskyRecord, error) { var out BlueskyRecord if err := fetchRecord(ctx, pdsHost, did, bskyProfileNSID, "self", &out); err != nil { return nil, err } return &out, nil } // FetchQuest reads quest.atmo.profile/self from the given PDS for the given // DID. Public, unauthenticated. Returns ErrNotFound if no record exists yet // (typical for a freshly-signed-in user). func FetchQuest(ctx context.Context, pdsHost string, did syntax.DID) (*QuestRecord, error) { var out QuestRecord if err := fetchRecord(ctx, pdsHost, did, questProfileNSID, "self", &out); err != nil { return nil, err } return &out, nil } func fetchRecord(ctx context.Context, pdsHost string, did syntax.DID, collection, rkey string, value any) error { c := atclient.NewAPIClient(pdsHost) params := map[string]any{ "repo": did.String(), "collection": collection, "rkey": rkey, } // Two-pass: first decode the envelope, then re-decode the inner value. // atclient.Get does one JSON pass into the target, but we need to map the // envelope's `value` field onto our struct, so use a generic envelope // shape and re-marshal. var env struct { URI string `json:"uri"` CID string `json:"cid"` Value any `json:"value"` } err := c.Get(ctx, syntax.NSID(nsidGetRecord), params, &env) if err != nil { // atclient surfaces 4xx via atclient.APIError; treat // RecordNotFound + 400 InvalidRequest both as ErrNotFound to be // resilient across PDS implementations. if isRecordMissing(err) { return ErrNotFound } return fmt.Errorf("getRecord %s: %w", collection, err) } if env.Value == nil { return ErrNotFound } // Re-marshal the generic value into the typed target. This is cheap (a // single small record) and keeps us decoupled from any JSON tag magic. return remarshal(env.Value, value) } func isRecordMissing(err error) bool { if err == nil { return false } var apiErr *atclient.APIError if errors.As(err, &apiErr) { switch apiErr.StatusCode { case http.StatusNotFound: return true case http.StatusBadRequest: // PDSes surface a missing record as 400 InvalidRequest with a // name like "RecordNotFound" — match on either. n := strings.ToLower(apiErr.Name) if strings.Contains(n, "notfound") || strings.Contains(n, "not_found") { return true } } } // Fallback: some clients flatten the error to its message. msg := strings.ToLower(err.Error()) return strings.Contains(msg, "could not locate record") || strings.Contains(msg, "recordnotfound") || strings.Contains(msg, "record not found") } // PutQuest writes (creates or overwrites) the user's quest.atmo.profile/self // record via the authenticated OAuth session. Sets UpdatedAt to now if it's // zero. Returns the new record CID. // // Caller must have the `repo:quest.atmo.profile` scope on the session. func PutQuest(ctx context.Context, sess *oauth.ClientSession, did syntax.DID, rec QuestRecord) (string, error) { if sess == nil { return "", errors.New("profile: nil oauth session") } if rec.UpdatedAt.IsZero() { rec.UpdatedAt = time.Now().UTC() } // Build the record value with $type set so the PDS can validate it // against the right lexicon. value := map[string]any{ "$type": questProfileNSID, "updatedAt": rec.UpdatedAt.UTC().Format(time.RFC3339), } if rec.Bio != "" { value["bio"] = rec.Bio } if len(rec.Interests) > 0 { value["interests"] = rec.Interests } if len(rec.Links) > 0 { links := make([]map[string]any, 0, len(rec.Links)) for _, l := range rec.Links { links = append(links, map[string]any{ "label": l.Label, "url": l.URL, }) } value["links"] = links } if rec.Location != "" { value["location"] = rec.Location } if rec.WorksAt != "" { value["worksAt"] = rec.WorksAt } if rec.ContactMethod != "" { value["contactMethod"] = rec.ContactMethod } if rec.Hiring != nil { value["hiring"] = *rec.Hiring } if rec.Looking != nil { value["looking"] = *rec.Looking } input := map[string]any{ "repo": did.String(), "collection": questProfileNSID, "rkey": "self", // `validate` is intentionally omitted — when set to `true` PDSes // that haven't cached our (custom) quest.atmo.profile lexicon // reject the write with `InvalidRequest: Unknown lexicon type`. // Leaving it unset asks the PDS to validate only against lexicons // it already knows, which is what we want. "record": value, } var out struct { URI string `json:"uri"` CID string `json:"cid"` } if err := sess.APIClient().Post(ctx, syntax.NSID(nsidPutRecord), input, &out); err != nil { return "", fmt.Errorf("putRecord %s: %w", questProfileNSID, err) } return out.CID, nil } // AvatarURL builds the public blob URL on the user's PDS for the avatar CID. // Returns empty string if either input is missing. // // We use com.atproto.sync.getBlob — a public, unauthenticated endpoint on // every PDS. This works for any provider (bsky.social, custom PDSes, etc.) // and never depends on bsky's CDN. func AvatarURL(pdsHost string, did syntax.DID, cid string) string { if pdsHost == "" || did == "" || cid == "" { return "" } host := strings.TrimRight(pdsHost, "/") q := url.Values{ "did": []string{did.String()}, "cid": []string{cid}, } return host + "/xrpc/com.atproto.sync.getBlob?" + q.Encode() } // EffectiveBio picks the bio to show: the atmoquest override if set, else // the Bluesky description, else empty. Whitespace-only counts as unset. func EffectiveBio(quest *QuestRecord, bsky *BlueskyRecord) string { if quest != nil { if b := strings.TrimSpace(quest.Bio); b != "" { return b } } if bsky != nil { return strings.TrimSpace(bsky.Description) } return "" } // UploadAvatar uploads an image blob to the user's PDS and returns a LexBlob // reference suitable for including in app.bsky.actor.profile. func UploadAvatar(ctx context.Context, sess *oauth.ClientSession, data io.Reader, mimeType string) (*util.LexBlob, error) { resp, err := atproto.RepoUploadBlob(ctx, sess.APIClient(), data) if err != nil { return nil, fmt.Errorf("uploadBlob: %w", err) } if resp.Blob == nil { return nil, errors.New("uploadBlob: nil response") } if resp.Blob.MimeType == "" { resp.Blob.MimeType = mimeType } return resp.Blob, nil } // FetchBlueskyMap is like FetchBluesky but returns the raw record as a map, // preserving all fields for read-modify-write merge. func FetchBlueskyMap(ctx context.Context, pdsHost string, did syntax.DID) (map[string]any, error) { var value map[string]any if err := fetchRecord(ctx, pdsHost, did, bskyProfileNSID, "self", &value); err != nil { return nil, err } return value, nil } // mergeBskyRecord merges updates into the existing record for a // read-modify-write of app.bsky.actor.profile. func mergeBskyRecord(existing map[string]any, displayName string, avatar *util.LexBlob, clearAvatar bool) map[string]any { value := make(map[string]any, len(existing)+1) for k, v := range existing { value[k] = v } value["$type"] = bskyProfileNSID if displayName != "" { value["displayName"] = displayName } if clearAvatar { delete(value, "avatar") } else if avatar != nil { value["avatar"] = avatar } return value } // PutBluesky writes (creates or overwrites) the user's app.bsky.actor.profile/self // record via the authenticated OAuth session. This is a read-modify-write: we // fetch the current record, merge in the caller's changes, and write back so // fields the caller doesn't care about (description, banner, labels, etc.) are // preserved. // // Caller must have the `repo:app.bsky.actor.profile` scope on the session. // // displayName: new display name, or "" to leave unchanged. // avatar: new avatar blob ref, or nil to leave unchanged. // clearAvatar: set to true to explicitly remove the avatar. // Returns the new record CID. func PutBluesky(ctx context.Context, sess *oauth.ClientSession, did syntax.DID, displayName string, avatar *util.LexBlob, clearAvatar bool) (string, error) { if sess == nil { return "", errors.New("profile: nil oauth session") } // Fetch existing record (public, unauthenticated). existing, err := FetchBlueskyMap(ctx, sess.Data.HostURL, did) if err != nil && !errors.Is(err, ErrNotFound) { return "", fmt.Errorf("fetch existing bsky profile: %w", err) } if existing == nil { existing = make(map[string]any) } value := mergeBskyRecord(existing, displayName, avatar, clearAvatar) input := map[string]any{ "repo": did.String(), "collection": bskyProfileNSID, "rkey": "self", "record": value, } var out struct { URI string `json:"uri"` CID string `json:"cid"` } if err := sess.APIClient().Post(ctx, syntax.NSID(nsidPutRecord), input, &out); err != nil { return "", fmt.Errorf("putRecord %s: %w", bskyProfileNSID, err) } return out.CID, nil }