From d3de6118d9b15ab50acf8a48faf0fdb09680befd Mon Sep 17 00:00:00 2001 From: Lewis Date: Mon, 30 Mar 2026 19:05:25 -0700 Subject: [PATCH] appview/profile: save and use preferred user handle Lewis: May this revision serve well! --- api/tangled/actorprofile.go | 2 + api/tangled/cbor_gen.go | 59 ++++++++++++++++++- appview/db/db.go | 7 +++ appview/db/profile.go | 48 +++++++++++++-- appview/ingester.go | 33 +++++++---- appview/middleware/middleware.go | 10 +++- appview/models/profile.go | 17 +++--- appview/pages/funcmap.go | 6 +- appview/pages/pages.go | 1 + .../templates/user/fragments/editBio.html | 19 ++++++ appview/state/login.go | 11 +++- appview/state/profile.go | 30 ++++++++++ idresolver/resolver.go | 33 ++++++----- lexicons/actor/profile.json | 6 ++ 14 files changed, 237 insertions(+), 45 deletions(-) diff --git a/api/tangled/actorprofile.go b/api/tangled/actorprofile.go index ee5ee864..4cac5283 100644 --- a/api/tangled/actorprofile.go +++ b/api/tangled/actorprofile.go @@ -29,6 +29,8 @@ type ActorProfile struct { Location *string `json:"location,omitempty" cborgen:"location,omitempty"` // pinnedRepositories: Any ATURI, it is up to appviews to validate these fields. PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"` + // preferredHandle: A handle the user prefers to be displayed as. + PreferredHandle *string `json:"preferredHandle,omitempty" cborgen:"preferredHandle,omitempty"` // pronouns: Preferred gender pronouns. Pronouns *string `json:"pronouns,omitempty" cborgen:"pronouns,omitempty"` Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"` diff --git a/api/tangled/cbor_gen.go b/api/tangled/cbor_gen.go index a4016bf5..a1c67800 100644 --- a/api/tangled/cbor_gen.go +++ b/api/tangled/cbor_gen.go @@ -26,7 +26,7 @@ func (t *ActorProfile) MarshalCBOR(w io.Writer) error { } cw := cbg.NewCborWriter(w) - fieldCount := 9 + fieldCount := 10 if t.Avatar == nil { fieldCount-- @@ -48,6 +48,10 @@ func (t *ActorProfile) MarshalCBOR(w io.Writer) error { fieldCount-- } + if t.PreferredHandle == nil { + fieldCount-- + } + if t.Pronouns == nil { fieldCount-- } @@ -282,6 +286,38 @@ func (t *ActorProfile) MarshalCBOR(w io.Writer) error { } } + // t.PreferredHandle (string) (string) + if t.PreferredHandle != nil { + + if len("preferredHandle") > 1000000 { + return xerrors.Errorf("Value in field \"preferredHandle\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("preferredHandle"))); err != nil { + return err + } + if _, err := cw.WriteString(string("preferredHandle")); err != nil { + return err + } + + if t.PreferredHandle == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.PreferredHandle) > 1000000 { + return xerrors.Errorf("Value in field t.PreferredHandle was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.PreferredHandle))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.PreferredHandle)); err != nil { + return err + } + } + } + // t.PinnedRepositories ([]string) (slice) if t.PinnedRepositories != nil { @@ -553,6 +589,27 @@ func (t *ActorProfile) UnmarshalCBOR(r io.Reader) (err error) { t.Description = (*string)(&sval) } } + // t.PreferredHandle (string) (string) + case "preferredHandle": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.PreferredHandle = (*string)(&sval) + } + } // t.PinnedRepositories ([]string) (slice) case "pinnedRepositories": diff --git a/appview/db/db.go b/appview/db/db.go index e46cf334..a3968785 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -1288,6 +1288,13 @@ func Make(ctx context.Context, dbPath string) (*DB, error) { return err }) + orm.RunMigration(conn, logger, "add-preferred-handle-profile", func(tx *sql.Tx) error { + _, err := tx.Exec(` + alter table profile add column preferred_handle text; + `) + return err + }) + return &DB{ db, logger, diff --git a/appview/db/profile.go b/appview/db/profile.go index 88cd8ffc..99d42ced 100644 --- a/appview/db/profile.go +++ b/appview/db/profile.go @@ -162,15 +162,17 @@ func UpsertProfile(tx *sql.Tx, profile *models.Profile) error { description, include_bluesky, location, - pronouns + pronouns, + preferred_handle ) - values (?, ?, ?, ?, ?, ?)`, + values (?, ?, ?, ?, ?, ?, ?)`, profile.Did, profile.Avatar, profile.Description, includeBskyValue, profile.Location, profile.Pronouns, + string(profile.PreferredHandle), ) if err != nil { @@ -252,7 +254,8 @@ func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, e description, include_bluesky, location, - pronouns + pronouns, + preferred_handle from profile %s`, @@ -269,8 +272,9 @@ func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, e var profile models.Profile var includeBluesky int var pronouns sql.Null[string] + var preferredHandle sql.Null[string] - err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns) + err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns, &preferredHandle) if err != nil { return nil, err } @@ -283,6 +287,10 @@ func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, e profile.Pronouns = pronouns.V } + if preferredHandle.Valid { + profile.PreferredHandle = syntax.Handle(preferredHandle.V) + } + profileMap[profile.Did] = &profile } if err = rows.Err(); err != nil { @@ -346,19 +354,32 @@ func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, e return profileMap, nil } +func GetDidByPreferredHandle(e Execer, handle syntax.Handle) (syntax.DID, error) { + var did string + err := e.QueryRow( + `select did from profile where preferred_handle = ?`, + string(handle), + ).Scan(&did) + if err != nil { + return "", err + } + return syntax.DID(did), nil +} + func GetProfile(e Execer, did string) (*models.Profile, error) { var profile models.Profile var pronouns sql.Null[string] var avatar sql.Null[string] + var preferredHandle sql.Null[string] profile.Did = did includeBluesky := 0 err := e.QueryRow( - `select avatar, description, include_bluesky, location, pronouns from profile where did = ?`, + `select avatar, description, include_bluesky, location, pronouns, preferred_handle from profile where did = ?`, did, - ).Scan(&avatar, &profile.Description, &includeBluesky, &profile.Location, &pronouns) + ).Scan(&avatar, &profile.Description, &includeBluesky, &profile.Location, &pronouns, &preferredHandle) if err == sql.ErrNoRows { return nil, nil } @@ -379,6 +400,10 @@ func GetProfile(e Execer, did string) (*models.Profile, error) { profile.Avatar = avatar.V } + if preferredHandle.Valid { + profile.PreferredHandle = syntax.Handle(preferredHandle.V) + } + rows, err := e.Query(`select link from profile_links where did = ?`, did) if err != nil { return nil, err @@ -482,6 +507,17 @@ func ValidateProfile(e Execer, profile *models.Profile) error { return fmt.Errorf("Entered pronouns are too long.") } + if profile.PreferredHandle != "" { + if _, err := syntax.ParseHandle(string(profile.PreferredHandle)); err != nil { + return fmt.Errorf("Invalid preferred handle format.") + } + + claimant, err := GetDidByPreferredHandle(e, profile.PreferredHandle) + if err == nil && string(claimant) != profile.Did { + return fmt.Errorf("Preferred handle is already claimed by another user.") + } + } + // ensure links are in order err := validateLinks(profile) if err != nil { diff --git a/appview/ingester.go b/appview/ingester.go index 73fa8520..174b8687 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -66,7 +66,7 @@ func (i *Ingester) Ingest() processFunc { case tangled.RepoArtifactNSID: err = i.ingestArtifact(e) case tangled.ActorProfileNSID: - err = i.ingestProfile(e) + err = i.ingestProfile(ctx, e) case tangled.SpindleMemberNSID: err = i.ingestSpindleMember(ctx, e) case tangled.SpindleNSID: @@ -264,7 +264,7 @@ func (i *Ingester) ingestArtifact(e *jmodels.Event) error { return nil } -func (i *Ingester) ingestProfile(e *jmodels.Event) error { +func (i *Ingester) ingestProfile(ctx context.Context, e *jmodels.Event) error { did := e.Did var err error @@ -328,16 +328,27 @@ func (i *Ingester) ingestProfile(e *jmodels.Event) error { } } + var preferredHandle syntax.Handle + if record.PreferredHandle != nil { + if h, err := syntax.ParseHandle(*record.PreferredHandle); err == nil { + ident, identErr := i.IdResolver.ResolveIdent(ctx, did) + if identErr == nil && slices.Contains(ident.AlsoKnownAs, "at://"+string(h)) { + preferredHandle = h + } + } + } + profile := models.Profile{ - Did: did, - Avatar: avatar, - Description: description, - IncludeBluesky: includeBluesky, - Location: location, - Links: links, - Stats: stats, - PinnedRepos: pinned, - Pronouns: pronouns, + Did: did, + Avatar: avatar, + Description: description, + IncludeBluesky: includeBluesky, + Location: location, + Links: links, + Stats: stats, + PinnedRepos: pinned, + Pronouns: pronouns, + PreferredHandle: preferredHandle, } ddb, ok := i.Db.Execer.(*db.DB) diff --git a/appview/middleware/middleware.go b/appview/middleware/middleware.go index 46e0f307..8b2d0610 100644 --- a/appview/middleware/middleware.go +++ b/appview/middleware/middleware.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" "github.com/go-chi/chi/v5" "tangled.org/core/appview/db" "tangled.org/core/appview/oauth" @@ -188,7 +189,14 @@ func (mw Middleware) ResolveIdent() middlewareFunc { id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) if err != nil { - // invalid did or handle + if h, parseErr := syntax.ParseHandle(didOrHandle); parseErr == nil { + if did, lookupErr := db.GetDidByPreferredHandle(mw.db, h); lookupErr == nil { + id, err = mw.idResolver.ResolveIdent(req.Context(), string(did)) + } + } + } + // invalid did or handle + if err != nil { log.Printf("failed to resolve did/handle '%s': %s\n", didOrHandle, err) mw.pages.Error404(w) return diff --git a/appview/models/profile.go b/appview/models/profile.go index 83dc2607..aac5858a 100644 --- a/appview/models/profile.go +++ b/appview/models/profile.go @@ -13,14 +13,15 @@ type Profile struct { Did string // data - Avatar string // CID of the avatar blob - Description string - IncludeBluesky bool - Location string - Links [5]string - Stats [2]VanityStat - PinnedRepos [6]syntax.ATURI - Pronouns string + Avatar string // CID of the avatar blob + Description string + IncludeBluesky bool + Location string + Links [5]string + Stats [2]VanityStat + PinnedRepos [6]syntax.ATURI + Pronouns string + PreferredHandle syntax.Handle } func (p Profile) IsLinksEmpty() bool { diff --git a/appview/pages/funcmap.go b/appview/pages/funcmap.go index cff60bea..2b1e2597 100644 --- a/appview/pages/funcmap.go +++ b/appview/pages/funcmap.go @@ -65,8 +65,12 @@ func (p *Pages) funcMap() template.FuncMap { return mapValue.MapIndex(keyValue).IsValid() }, "resolve": func(s string) string { - identity, err := p.resolver.ResolveIdent(context.Background(), s) + profile, err := db.GetProfile(p.db, s) + if err == nil && profile != nil && profile.PreferredHandle != "" { + return string(profile.PreferredHandle) + } + identity, err := p.resolver.ResolveIdent(context.Background(), s) if err != nil { return s } diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 4bdcfcdb..2344675e 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -731,6 +731,7 @@ func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { type EditBioParams struct { LoggedInUser *oauth.MultiAccountUser Profile *models.Profile + AlsoKnownAs []string } func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error { diff --git a/appview/pages/templates/user/fragments/editBio.html b/appview/pages/templates/user/fragments/editBio.html index e79ef168..25dd6a41 100644 --- a/appview/pages/templates/user/fragments/editBio.html +++ b/appview/pages/templates/user/fragments/editBio.html @@ -36,6 +36,25 @@ + {{ if gt (len .AlsoKnownAs) 1 }} +
+ +
+ {{ $preferredHandle := "" }} + {{ if and .Profile .Profile.PreferredHandle }} + {{ $preferredHandle = .Profile.PreferredHandle }} + {{ end }} + {{ i "at-sign" "size-4" }} + +
+
+ {{ end }} +
diff --git a/appview/state/login.go b/appview/state/login.go index c68501fd..c9174252 100644 --- a/appview/state/login.go +++ b/appview/state/login.go @@ -8,6 +8,8 @@ import ( "time" comatproto "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" "github.com/bluesky-social/indigo/xrpc" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" @@ -69,6 +71,13 @@ func (s *State) Login(w http.ResponseWriter, r *http.Request) { } ident, err := s.idResolver.ResolveIdent(r.Context(), handle) + if err != nil && errors.Is(err, identity.ErrHandleMismatch) { + if h, parseErr := syntax.ParseHandle(handle); parseErr == nil { + if did, resolveErr := s.idResolver.ResolveHandle(r.Context(), h); resolveErr == nil { + ident, err = s.idResolver.ResolveIdent(r.Context(), did.String()) + } + } + } if err != nil { l.Warn("handle resolution failed", "handle", handle, "err", err) s.pages.Notice(w, "login-msg", fmt.Sprintf("Could not resolve handle \"%s\". The account may not exist.", handle)) @@ -101,7 +110,7 @@ func (s *State) Login(w http.ResponseWriter, r *http.Request) { l.Error("failed to set auth return", "err", err) } - redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) + redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), ident.DID.String()) if err != nil { l.Error("failed to start auth", "err", err) s.pages.Notice( diff --git a/appview/state/profile.go b/appview/state/profile.go index 342eee24..13a92f0d 100644 --- a/appview/state/profile.go +++ b/appview/state/profile.go @@ -663,6 +663,23 @@ func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" profile.Location = r.FormValue("location") profile.Pronouns = r.FormValue("pronouns") + rawPreferredHandle := strings.TrimSpace(r.FormValue("preferredHandle")) + if rawPreferredHandle != "" { + h, err := syntax.ParseHandle(rawPreferredHandle) + if err != nil { + s.pages.Notice(w, "update-profile", "Invalid handle format.") + return + } + + ident, err := s.idResolver.ResolveIdent(r.Context(), user.Active.Did) + if err != nil || !slices.Contains(ident.AlsoKnownAs, "at://"+rawPreferredHandle) { + s.pages.Notice(w, "update-profile", "Handle not found in your DID document.") + return + } + profile.PreferredHandle = h + } else { + profile.PreferredHandle = "" + } var links [5]string for i := range 5 { @@ -759,8 +776,12 @@ func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Active.Did, "self") var cid *string + var existingAvatar *lexutil.LexBlob if ex != nil { cid = ex.Cid + if rec, ok := ex.Value.Val.(*tangled.ActorProfile); ok { + existingAvatar = rec.Avatar + } } _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ @@ -769,6 +790,7 @@ func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r Rkey: "self", Record: &lexutil.LexiconTypeDecoder{ Val: &tangled.ActorProfile{ + Avatar: existingAvatar, Bluesky: profile.IncludeBluesky, Description: &profile.Description, Links: profile.Links[:], @@ -776,6 +798,7 @@ func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r PinnedRepositories: pinnedRepoStrings, Stats: vanityStats[:], Pronouns: &profile.Pronouns, + PreferredHandle: (*string)(&profile.PreferredHandle), }}, SwapRecord: cid, }) @@ -808,9 +831,16 @@ func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) { profile = &models.Profile{Did: user.Active.Did} } + var alsoKnownAs []string + ident, err := s.idResolver.ResolveIdent(r.Context(), user.Active.Did) + if err == nil { + alsoKnownAs = ident.AlsoKnownAs + } + s.pages.EditBioFragment(w, pages.EditBioParams{ LoggedInUser: user, Profile: profile, + AlsoKnownAs: alsoKnownAs, }) } diff --git a/idresolver/resolver.go b/idresolver/resolver.go index c8b8c003..3440f296 100644 --- a/idresolver/resolver.go +++ b/idresolver/resolver.go @@ -15,9 +15,10 @@ import ( type Resolver struct { directory identity.Directory + base *identity.BaseDirectory } -func BaseDirectory(plcUrl string) identity.Directory { +func BaseDirectory(plcUrl string) *identity.BaseDirectory { base := identity.BaseDirectory{ PLCURL: plcUrl, HTTPClient: http.Client{ @@ -42,38 +43,38 @@ func BaseDirectory(plcUrl string) identity.Directory { return &base } -func RedisDirectory(url, plcUrl string) (identity.Directory, error) { - hitTTL := time.Hour * 24 - errTTL := time.Second * 30 - invalidHandleTTL := time.Minute * 5 - return redisdir.NewRedisDirectory( - BaseDirectory(plcUrl), - url, - hitTTL, - errTTL, - invalidHandleTTL, - 10000, - ) -} - func DefaultResolver(plcUrl string) *Resolver { base := BaseDirectory(plcUrl) cached := identity.NewCacheDirectory(base, 250_000, time.Hour*24, time.Minute*2, time.Minute*5) return &Resolver{ directory: cached, + base: base, } } +func RedisDirectory(base *identity.BaseDirectory, url string) (identity.Directory, error) { + hitTTL := time.Hour * 24 + errTTL := time.Second * 30 + invalidHandleTTL := time.Minute * 5 + return redisdir.NewRedisDirectory(base, url, hitTTL, errTTL, invalidHandleTTL, 10000) +} + func RedisResolver(redisUrl, plcUrl string) (*Resolver, error) { - directory, err := RedisDirectory(redisUrl, plcUrl) + base := BaseDirectory(plcUrl) + directory, err := RedisDirectory(base, redisUrl) if err != nil { return nil, err } return &Resolver{ directory: directory, + base: base, }, nil } +func (r *Resolver) ResolveHandle(ctx context.Context, handle syntax.Handle) (syntax.DID, error) { + return r.base.ResolveHandle(ctx, handle) +} + func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) { id, err := syntax.ParseAtIdentifier(arg) if err != nil { diff --git a/lexicons/actor/profile.json b/lexicons/actor/profile.json index d7127214..0b862589 100644 --- a/lexicons/actor/profile.json +++ b/lexicons/actor/profile.json @@ -74,6 +74,12 @@ "type": "string", "description": "Preferred gender pronouns.", "maxLength": 40 + }, + "preferredHandle": { + "type": "string", + "description": "A handle the user prefers to be displayed as.", + "format": "handle", + "maxLength": 253 } } } -- 2.51.2