diff --git a/features/api/profile.go b/features/api/profile.go
index 0074d98..8d42d70 100644
--- a/features/api/profile.go
+++ b/features/api/profile.go
@@ -1,10 +1,17 @@
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"
)
@@ -13,6 +20,7 @@ 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"`
@@ -45,7 +53,7 @@ func (h *Handlers) GetProfile(w http.ResponseWriter, r *http.Request) {
slog.Warn("api: fetch quest profile", "did", did, "err", err)
}
- resp := buildProfileResponse(did.String(), h.resolveHandle(r, did.String()), bsky, quest)
+ resp := buildProfileResponse(pds, did, h.resolveHandle(r, did.String()), bsky, quest)
writeJSON(w, http.StatusOK, resp)
}
@@ -81,7 +89,7 @@ func (h *Handlers) GetUser(w http.ResponseWriter, r *http.Request) {
slog.Warn("api: fetch user quest", "target", targetDID, "err", err)
}
- resp := buildProfileResponse(targetDID.String(), h.resolveHandle(r, targetDID.String()), bsky, quest)
+ resp := buildProfileResponse(pds, targetDID, h.resolveHandle(r, targetDID.String()), bsky, quest)
writeJSON(w, http.StatusOK, resp)
}
@@ -101,12 +109,55 @@ func (h *Handlers) UpdateProfile(w http.ResponseWriter, r *http.Request) {
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
@@ -133,9 +184,9 @@ func (h *Handlers) UpdateProfile(w http.ResponseWriter, r *http.Request) {
}
// buildProfileResponse creates a profileResponse from fetched PDS data.
-func buildProfileResponse(didStr, handle string, bsky *profile.BlueskyRecord, quest *profile.QuestRecord) profileResponse {
+func buildProfileResponse(pdsHost string, did syntax.DID, handle string, bsky *profile.BlueskyRecord, quest *profile.QuestRecord) profileResponse {
resp := profileResponse{
- DID: didStr,
+ DID: did.String(),
Handle: handle,
Interests: []string{},
Links: []profileLinkPayload{},
@@ -143,6 +194,7 @@ func buildProfileResponse(didStr, handle string, bsky *profile.BlueskyRecord, qu
if bsky != nil {
resp.DisplayName = bsky.DisplayName
+ resp.AvatarURL = profile.AvatarURL(pdsHost, did, bsky.Avatar.CID())
resp.Bio = bsky.Description
}
diff --git a/features/auth/link.go b/features/auth/link.go
index b685b5b..ed6356e 100644
--- a/features/auth/link.go
+++ b/features/auth/link.go
@@ -133,6 +133,13 @@ func (h *Handlers) LinkLocalToATProto(w http.ResponseWriter, r *http.Request) {
if _, err := profile.PutQuest(ctx, sess, did, rec); err != nil {
slog.Warn("link: migrate profile", "err", err)
}
+
+ // 3b. Migrate display name to Bluesky profile
+ if displayName != "" {
+ if _, err := profile.PutBluesky(ctx, sess, did, displayName, nil, false); err != nil {
+ slog.Warn("link: migrate bluesky display name", "err", err)
+ }
+ }
}
// 4. Migrate badges to PDS
diff --git a/features/profile/handlers.go b/features/profile/handlers.go
index 13cd911..7acaea8 100644
--- a/features/profile/handlers.go
+++ b/features/profile/handlers.go
@@ -7,18 +7,22 @@
package profile
import (
+ "bytes"
"database/sql"
"encoding/json"
"errors"
+ "io"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
+ "github.com/bluesky-social/indigo/lex/util"
"atmoquest/features/auth"
"atmoquest/features/profile/pages"
"atmoquest/internal/badge"
+ "atmoquest/internal/oauthclient"
"atmoquest/internal/profile"
)
@@ -205,14 +209,17 @@ func (h *Handlers) renderLocalProfileEdit(w http.ResponseWriter, r *http.Request
// Build profile view for edit form
view := pages.ProfileEditView{
- DID: localID,
- DisplayName: displayName,
- Bio: bio,
- Location: location,
- WorksAt: worksAt,
- ContactMethod: contactMethod,
- Hiring: hiring,
- Looking: looking,
+ DID: localID,
+ DisplayName: displayName,
+ DisplayNameDisabled: false, // local users can edit their display name
+ AvatarDisabled: true, // no avatar support for local accounts
+ AvatarURL: "",
+ Bio: bio,
+ Location: location,
+ WorksAt: worksAt,
+ ContactMethod: contactMethod,
+ Hiring: hiring,
+ Looking: looking,
}
// Parse interests and links from JSON
@@ -254,6 +261,7 @@ func (h *Handlers) ProfileEdit(w http.ResponseWriter, r *http.Request) {
return
}
+ missingScope := !oauthclient.HasBskyProfileScope(sess)
pds := sess.Data.HostURL
bsky, err := profile.FetchBluesky(r.Context(), pds, did)
@@ -267,7 +275,7 @@ func (h *Handlers) ProfileEdit(w http.ResponseWriter, r *http.Request) {
quest = nil
}
- view := buildProfileEditView(did, pds, bsky, quest, "")
+ view := buildProfileEditView(did, pds, bsky, quest, "", missingScope)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := pages.ProfileEdit(view).Render(r.Context(), w); err != nil {
slog.Error("render profile edit", "err", err)
@@ -293,25 +301,103 @@ func (h *Handlers) ProfileSave(w http.ResponseWriter, r *http.Request) {
return
}
- if err := r.ParseForm(); err != nil {
+ missingScope := !oauthclient.HasBskyProfileScope(sess)
+
+ if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
+ displayName := strings.TrimSpace(r.FormValue("display_name"))
+ clearAvatar := r.FormValue("clear_avatar") == "1"
+
rec, formErr := parseProfileForm(r)
if formErr != "" {
bsky, _ := profile.FetchBluesky(r.Context(), sess.Data.HostURL, did)
- view := buildProfileEditViewFromRec(did, sess.Data.HostURL, bsky, rec, formErr)
+ view := buildProfileEditViewFromRec(did, sess.Data.HostURL, bsky, rec, formErr, missingScope, displayName)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_ = pages.ProfileEdit(view).Render(r.Context(), w)
return
}
+ // --- Display name + avatar handling ---
+ if !missingScope {
+ var avatarRef *util.LexBlob
+ file, header, err := r.FormFile("avatar")
+ if err == nil {
+ defer file.Close()
+ mimeType := header.Header.Get("Content-Type")
+ if _, ok := profile.AllowedAvatarMimeTypes[mimeType]; !ok {
+ bsky, _ := profile.FetchBluesky(r.Context(), sess.Data.HostURL, did)
+ view := buildProfileEditViewFromRec(did, sess.Data.HostURL, bsky, rec, "unsupported file type — use JPEG, PNG, GIF, or WebP", missingScope, displayName)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusBadRequest)
+ _ = pages.ProfileEdit(view).Render(r.Context(), w)
+ return
+ }
+ if header.Size > profile.MaxAvatarBytes {
+ bsky, _ := profile.FetchBluesky(r.Context(), sess.Data.HostURL, did)
+ view := buildProfileEditViewFromRec(did, sess.Data.HostURL, bsky, rec, "file too large — max 1 MB", missingScope, displayName)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusBadRequest)
+ _ = pages.ProfileEdit(view).Render(r.Context(), w)
+ return
+ }
+ data, err := io.ReadAll(file)
+ if err != nil {
+ bsky, _ := profile.FetchBluesky(r.Context(), sess.Data.HostURL, did)
+ view := buildProfileEditViewFromRec(did, sess.Data.HostURL, bsky, rec, "couldn't read uploaded file", missingScope, displayName)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusBadRequest)
+ _ = pages.ProfileEdit(view).Render(r.Context(), w)
+ return
+ }
+ avatarRef, err = profile.UploadAvatar(r.Context(), sess, bytes.NewReader(data), mimeType)
+ if err != nil {
+ slog.Warn("profile save: upload avatar", "did", did.String(), "err", err)
+ bsky, _ := profile.FetchBluesky(r.Context(), sess.Data.HostURL, did)
+ view := buildProfileEditViewFromRec(did, sess.Data.HostURL, bsky, rec, "couldn't upload avatar — please try again", missingScope, displayName)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusBadGateway)
+ _ = pages.ProfileEdit(view).Render(r.Context(), w)
+ return
+ }
+ } else if err != http.ErrMissingFile {
+ slog.Warn("profile save: form file", "did", did.String(), "err", err)
+ bsky, _ := profile.FetchBluesky(r.Context(), sess.Data.HostURL, did)
+ view := buildProfileEditViewFromRec(did, sess.Data.HostURL, bsky, rec, "couldn't read file upload", missingScope, displayName)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusBadRequest)
+ _ = pages.ProfileEdit(view).Render(r.Context(), w)
+ return
+ }
+
+ if displayName != "" || avatarRef != nil || clearAvatar {
+ if _, err := profile.PutBluesky(r.Context(), sess, did, displayName, avatarRef, clearAvatar); err != nil {
+ slog.Warn("profile save: put bluesky", "did", did.String(), "err", err)
+ bsky, _ := profile.FetchBluesky(r.Context(), sess.Data.HostURL, did)
+ view := buildProfileEditViewFromRec(did, sess.Data.HostURL, bsky, rec, "couldn't save display name / avatar — please try again", missingScope, displayName)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusBadGateway)
+ _ = pages.ProfileEdit(view).Render(r.Context(), w)
+ return
+ }
+ }
+ } else if r.FormValue("display_name") != "" || r.FormValue("clear_avatar") == "1" || len(r.MultipartForm.File["avatar"]) > 0 {
+ // User tried to edit display name/avatar without the scope.
+ bsky, _ := profile.FetchBluesky(r.Context(), sess.Data.HostURL, did)
+ view := buildProfileEditViewFromRec(did, sess.Data.HostURL, bsky, rec, "Missing required permission. Sign out and sign back in to edit your display name and avatar.", missingScope, displayName)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusForbidden)
+ _ = pages.ProfileEdit(view).Render(r.Context(), w)
+ return
+ }
+
if _, err := profile.PutQuest(r.Context(), sess, did, rec); err != nil {
slog.Warn("profile save", "did", did.String(), "err", err)
bsky, _ := profile.FetchBluesky(r.Context(), sess.Data.HostURL, did)
- view := buildProfileEditViewFromRec(did, sess.Data.HostURL, bsky, rec, "couldn't save to your PDS — please try again")
+ view := buildProfileEditViewFromRec(did, sess.Data.HostURL, bsky, rec, "couldn't save to your PDS — please try again", missingScope, displayName)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadGateway)
_ = pages.ProfileEdit(view).Render(r.Context(), w)
@@ -326,12 +412,13 @@ func (h *Handlers) ProfileSave(w http.ResponseWriter, r *http.Request) {
func (h *Handlers) saveLocalProfile(w http.ResponseWriter, r *http.Request, localID string) {
ctx := r.Context()
- if err := r.ParseForm(); err != nil {
+ if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
// Parse form data
+ displayName := strings.TrimSpace(r.FormValue("display_name"))
bio := r.FormValue("bio")
interestsStr := r.FormValue("interests")
location := r.FormValue("location")
@@ -358,10 +445,10 @@ func (h *Handlers) saveLocalProfile(w http.ResponseWriter, r *http.Request, loca
// Update users table
_, err := h.DB.ExecContext(ctx, `
- UPDATE users SET bio = ?, location = ?, works_at = ?, contact_method = ?,
+ UPDATE users SET display_name = ?, bio = ?, location = ?, works_at = ?, contact_method = ?,
hiring = ?, "looking" = ?, hide_badges = ?, interests = ?, links = ?
WHERE did = ?
- `, bio, location, worksAt, contactMethod, hiring, looking, hideBadges, string(interestsJSON), string(linksJSON), localID)
+ `, displayName, bio, location, worksAt, contactMethod, hiring, looking, hideBadges, string(interestsJSON), string(linksJSON), localID)
if err != nil {
slog.Error("save local profile", "err", err)
http.Error(w, "failed to save", http.StatusInternalServerError)
diff --git a/features/profile/pages/profile_edit.templ b/features/profile/pages/profile_edit.templ
index f5ca188..91127ad 100644
--- a/features/profile/pages/profile_edit.templ
+++ b/features/profile/pages/profile_edit.templ
@@ -6,8 +6,8 @@ import (
)
// ProfileEditView is the data the edit form template renders. Reuses
-// ProfileLink from profile.templ. All Bluesky fields are read-only display
-// hints; the only editable atmo.quest fields are Bio, Interests, Links.
+// ProfileLink from profile.templ. Display name and avatar are now editable
+// and written to app.bsky.actor.profile.
type ProfileEditView struct {
DID string
DisplayName string
@@ -28,6 +28,14 @@ type ProfileEditView struct {
// (standard checkbox semantics) — so a plain bool here is fine.
Hiring bool
Looking bool
+ // DisplayNameDisabled is true when the session lacks the
+ // repo:app.bsky.actor.profile scope.
+ DisplayNameDisabled bool
+ // AvatarDisabled is true when the session lacks the scope.
+ AvatarDisabled bool
+ // MissingScope is true when the user needs to re-authenticate to edit
+ // display name and avatar.
+ MissingScope bool
// Error is rendered above the form when set (e.g. validation failure).
Error string
}
@@ -68,22 +76,62 @@ templ ProfileEdit(v ProfileEditView) {
profile --edit
+
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -454,98 +505,98 @@ func profileLinkRow(idx int, l ProfileLink) templ.Component {
templ_7745c5c3_Var19 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "#")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
#")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(idx + 1))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 277, Col: 75}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 323, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\">
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/features/profile/view.go b/features/profile/view.go
index 1836825..714c563 100644
--- a/features/profile/view.go
+++ b/features/profile/view.go
@@ -38,13 +38,16 @@ func buildProfileView(did syntax.DID, pds string, scopes []string, bsky *profile
}
// buildProfileEditView pre-populates the edit form from the existing records.
-func buildProfileEditView(did syntax.DID, pds string, bsky *profile.BlueskyRecord, quest *profile.QuestRecord, errMsg string) pages.ProfileEditView {
+func buildProfileEditView(did syntax.DID, pds string, bsky *profile.BlueskyRecord, quest *profile.QuestRecord, errMsg string, missingScope bool) pages.ProfileEditView {
v := pages.ProfileEditView{
- DID: did.String(),
- DisplayName: blueskyDisplayName(bsky),
- AvatarURL: blueskyAvatarURL(pds, did, bsky),
- BlueskyBio: blueskyBio(bsky),
- Error: errMsg,
+ DID: did.String(),
+ DisplayName: blueskyDisplayName(bsky),
+ DisplayNameDisabled: missingScope,
+ AvatarDisabled: missingScope,
+ MissingScope: missingScope,
+ AvatarURL: blueskyAvatarURL(pds, did, bsky),
+ BlueskyBio: blueskyBio(bsky),
+ Error: errMsg,
}
if quest != nil {
v.Bio = quest.Bio
@@ -64,20 +67,25 @@ func buildProfileEditView(did syntax.DID, pds string, bsky *profile.BlueskyRecor
// buildProfileEditViewFromRec rebuilds the edit view from in-flight form
// values rather than the PDS — used when re-rendering after a validation /
// save error so the user doesn't lose what they typed.
-func buildProfileEditViewFromRec(did syntax.DID, pds string, bsky *profile.BlueskyRecord, rec profile.QuestRecord, errMsg string) pages.ProfileEditView {
+// formDisplayName, if non-empty, overrides the display name from the bsky record
+// so the user's typed value is preserved on error.
+func buildProfileEditViewFromRec(did syntax.DID, pds string, bsky *profile.BlueskyRecord, rec profile.QuestRecord, errMsg string, missingScope bool, formDisplayName string) pages.ProfileEditView {
v := pages.ProfileEditView{
- DID: did.String(),
- DisplayName: blueskyDisplayName(bsky),
- AvatarURL: blueskyAvatarURL(pds, did, bsky),
- BlueskyBio: blueskyBio(bsky),
- Bio: rec.Bio,
- Interests: append([]string(nil), rec.Interests...),
- Hiring: boolDeref(rec.Hiring),
- Looking: boolDeref(rec.Looking),
- Location: rec.Location,
- WorksAt: rec.WorksAt,
- ContactMethod: rec.ContactMethod,
- Error: errMsg,
+ DID: did.String(),
+ DisplayName: displayNameOrDefault(blueskyDisplayName(bsky), formDisplayName),
+ DisplayNameDisabled: missingScope,
+ AvatarDisabled: missingScope,
+ MissingScope: missingScope,
+ AvatarURL: blueskyAvatarURL(pds, did, bsky),
+ BlueskyBio: blueskyBio(bsky),
+ Bio: rec.Bio,
+ Interests: append([]string(nil), rec.Interests...),
+ Hiring: boolDeref(rec.Hiring),
+ Looking: boolDeref(rec.Looking),
+ Location: rec.Location,
+ WorksAt: rec.WorksAt,
+ ContactMethod: rec.ContactMethod,
+ Error: errMsg,
}
for _, l := range rec.Links {
v.Links = append(v.Links, pages.ProfileLink{Label: l.Label, URL: l.URL})
@@ -115,6 +123,14 @@ func blueskyAvatarURL(pds string, did syntax.DID, b *profile.BlueskyRecord) stri
return profile.AvatarURL(pds, did, b.Avatar.CID())
}
+// displayNameOrDefault returns formDisplayName if non-empty, otherwise fallback.
+func displayNameOrDefault(fallback, formDisplayName string) string {
+ if formDisplayName != "" {
+ return formDisplayName
+ }
+ return fallback
+}
+
func questBioIsEmpty(q *profile.QuestRecord) bool {
if q == nil {
return true
diff --git a/go.mod b/go.mod
index 1fb0660..8da4af2 100644
--- a/go.mod
+++ b/go.mod
@@ -14,9 +14,11 @@ require (
github.com/delaneyj/toolbelt v0.9.1
github.com/evanw/esbuild v0.28.0
github.com/go-chi/httplog/v3 v3.3.0
+ github.com/google/uuid v1.6.0
github.com/gorilla/sessions v1.4.0
github.com/joho/godotenv v1.5.1
github.com/nats-io/nats-server/v2 v2.14.0
+ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/starfederation/datastar-go v1.2.1
modernc.org/sqlite v1.40.1
)
@@ -74,11 +76,11 @@ require (
github.com/google/go-dap v0.12.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
- github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/ipfs/go-cid v0.4.1 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
@@ -86,12 +88,17 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
- github.com/mattn/go-sqlite3 v1.14.44 // indirect
github.com/mattn/go-zglob v0.0.6 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
+ github.com/minio/sha256-simd v1.0.1 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
+ github.com/multiformats/go-base32 v0.1.0 // indirect
+ github.com/multiformats/go-base36 v0.2.0 // indirect
+ github.com/multiformats/go-multibase v0.2.0 // indirect
+ github.com/multiformats/go-multihash v0.2.3 // indirect
+ github.com/multiformats/go-varint v0.0.7 // indirect
github.com/natefinch/atomic v1.0.1 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/nats.go v1.52.0 // indirect
@@ -112,13 +119,14 @@ require (
github.com/sajari/fuzzy v1.0.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
- github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
+ github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/tdewolff/parse/v2 v2.7.15 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
@@ -135,9 +143,11 @@ require (
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.44.0 // indirect
+ golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+ lukechampine.com/blake3 v1.2.1 // indirect
modernc.org/libc v1.67.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
diff --git a/go.sum b/go.sum
index d8e2d2b..3391a2d 100644
--- a/go.sum
+++ b/go.sum
@@ -285,8 +285,6 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
-github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-zglob v0.0.6 h1:mP8RnmCgho4oaUYDIDn6GNxYk+qJGUs8fJLn+twYj2A=
github.com/mattn/go-zglob v0.0.6/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
diff --git a/internal/oauthclient/oauthclient.go b/internal/oauthclient/oauthclient.go
index 6be514d..9d9f51c 100644
--- a/internal/oauthclient/oauthclient.go
+++ b/internal/oauthclient/oauthclient.go
@@ -34,7 +34,7 @@ import (
// that's the legacy blanket scope, and a quest-tracking app has no business
// holding it.
//
-// Reading records (including `app.bsky.actor.profile` for display name /
+// Reading public records (e.g. `app.bsky.actor.profile` for display name /
// avatar) is unauthenticated — public records on a PDS need no scope. If we
// ever need to call AppView XRPCs (e.g. `app.bsky.actor.getProfile` for a
// hydrated profile), add the corresponding `rpc:
?aud=`
@@ -44,6 +44,7 @@ import (
// create + update + delete on that collection.
var DefaultScopes = []string{
"atproto",
+ "repo:app.bsky.actor.profile", // write displayName + avatar
"repo:quest.atmo.profile",
"repo:quest.atmo.event",
"repo:quest.atmo.checkin",
@@ -77,3 +78,17 @@ func Build(cfg *config.Config, store oauth.ClientAuthStore) (*oauth.ClientApp, s
}
return app, ocfg.ClientID, nil
}
+
+// HasBskyProfileScope returns true when the session includes the
+// repo:app.bsky.actor.profile scope needed to write displayName/avatar.
+func HasBskyProfileScope(sess *oauth.ClientSession) bool {
+ if sess == nil {
+ return false
+ }
+ for _, s := range sess.Data.Scopes {
+ if s == "repo:app.bsky.actor.profile" {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/oauthclient/oauthclient_test.go b/internal/oauthclient/oauthclient_test.go
index 9954b36..395a45d 100644
--- a/internal/oauthclient/oauthclient_test.go
+++ b/internal/oauthclient/oauthclient_test.go
@@ -89,6 +89,7 @@ func TestDefaultScopes(t *testing.T) {
// repo: write scope, otherwise the feature that needs it will silently
// fail at write time. Update this list when adding a new lexicon.
wantRepoScopes := []string{
+ "repo:app.bsky.actor.profile",
"repo:quest.atmo.profile",
"repo:quest.atmo.event",
"repo:quest.atmo.checkin",
diff --git a/internal/profile/profile.go b/internal/profile/profile.go
index 3f6e34d..ff1a942 100644
--- a/internal/profile/profile.go
+++ b/internal/profile/profile.go
@@ -8,7 +8,7 @@
// 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
-// `repo:quest.atmo.profile` scope (see oauthclient.DefaultScopes).
+// 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.
@@ -18,14 +18,17 @@ 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 (
@@ -48,8 +51,19 @@ const (
// 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")
@@ -300,3 +314,93 @@ func EffectiveBio(quest *QuestRecord, bsky *BlueskyRecord) string {
}
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
+}
diff --git a/web/resources/static/css/terminal.css b/web/resources/static/css/terminal.css
index 4907280..d6ae426 100644
--- a/web/resources/static/css/terminal.css
+++ b/web/resources/static/css/terminal.css
@@ -3544,3 +3544,38 @@ footer a:hover { color: var(--lavender); }
content: '@';
color: var(--peach);
}
+
+/* avatar upload edit area on profile edit page */
+.profile-avatar-edit {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+}
+.profile-avatar-edit-img {
+ width: 128px;
+ height: 128px;
+ flex-shrink: 0;
+ border-radius: 18px;
+ border: 3px solid var(--peach);
+ background: var(--surface);
+ overflow: hidden;
+}
+.profile-avatar-edit-img .profile-avatar,
+.profile-avatar-edit-img .profile-avatar-empty {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+}
+.avatar-input-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
+ pointer-events: none;
+}
+.btn-small {
+ font-size: 12px;
+ padding: 6px 12px;
+ min-height: 32px;
+}