From d3de6118d9b15ab50acf8a48faf0fdb09680befd Mon Sep 17 00:00:00 2001 From: Lewis Date: Tue, 31 Mar 2026 02:05:25 +0000 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 + appview/pages/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 file(s) changed, 237 insertion(s)(+), 45 deletion(s)(-) diff --git a/api/tangled/actorprofile.go b/api/tangled/actorprofile.go --- a/api/tangled/actorprofile.go +++ b/api/tangled/actorprofile.go @@ -29,6 +29,8 @@ // location: Free-form location text. 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 --- a/api/tangled/cbor_gen.go +++ b/api/tangled/cbor_gen.go @@ -26,7 +26,7 @@ return err } cw := cbg.NewCborWriter(w) - fieldCount := 9 + fieldCount := 10 if t.Avatar == nil { fieldCount-- @@ -45,6 +45,10 @@ fieldCount-- } if t.PinnedRepositories == nil { + fieldCount-- + } + + if t.PreferredHandle == nil { fieldCount-- } @@ -282,6 +286,38 @@ } } } + // 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 { @@ -551,6 +587,27 @@ return err } 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) diff --git a/appview/db/db.go b/appview/db/db.go --- a/appview/db/db.go +++ b/appview/db/db.go @@ -1288,6 +1288,13 @@ `) 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 --- a/appview/db/profile.go +++ b/appview/db/profile.go @@ -162,15 +162,17 @@ avatar, 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 @@ did, description, include_bluesky, location, - pronouns + pronouns, + preferred_handle from profile %s`, @@ -269,8 +272,9 @@ for rows.Next() { 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 @@ if pronouns.Valid { 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 @@ 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 } @@ -377,6 +398,10 @@ } if avatar.Valid { 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) @@ -480,6 +505,17 @@ // ensure pronouns are not too long if len(profile.Pronouns) > 40 { 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 diff --git a/appview/ingester.go b/appview/ingester.go --- a/appview/ingester.go +++ b/appview/ingester.go @@ -66,7 +66,7 @@ err = i.ingestPublicKey(e) 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 @@ 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 @@ pinned[i] = syntax.ATURI(r) } } + 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 --- a/appview/middleware/middleware.go +++ b/appview/middleware/middleware.go @@ -11,6 +11,7 @@ "strconv" "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 @@ } 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 --- a/appview/models/profile.go +++ b/appview/models/profile.go @@ -13,14 +13,15 @@ ID int 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 --- a/appview/pages/funcmap.go +++ b/appview/pages/funcmap.go @@ -65,8 +65,12 @@ keyValue := reflect.ValueOf(key) 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 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -731,6 +731,7 @@ 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 --- 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 --- a/appview/state/login.go +++ b/appview/state/login.go @@ -8,6 +8,8 @@ "strings" "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 @@ return } 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 @@ if err := s.oauth.SetAuthReturn(w, r, returnURL, addAccount); err != nil { 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 --- a/appview/state/profile.go +++ b/appview/state/profile.go @@ -663,6 +663,23 @@ profile.Description = r.FormValue("description") 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 @@ } 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 @@ Repo: user.Active.Did, Rkey: "self", Record: &lexutil.LexiconTypeDecoder{ Val: &tangled.ActorProfile{ + Avatar: existingAvatar, Bluesky: profile.IncludeBluesky, Description: &profile.Description, Links: profile.Links[:], @@ -776,6 +798,7 @@ Location: &profile.Location, PinnedRepositories: pinnedRepoStrings, Stats: vanityStats[:], Pronouns: &profile.Pronouns, + PreferredHandle: (*string)(&profile.PreferredHandle), }}, SwapRecord: cid, }) @@ -808,9 +831,16 @@ if profile == nil { 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 --- a/idresolver/resolver.go +++ b/idresolver/resolver.go @@ -15,9 +15,10 @@ ) 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{ @@ -40,20 +41,6 @@ SkipDNSDomainSuffixes: []string{".bsky.social"}, UserAgent: "indigo-identity/" + versioninfo.Short(), } 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 { @@ -61,17 +48,31 @@ 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) { diff --git a/lexicons/actor/profile.json b/lexicons/actor/profile.json --- a/lexicons/actor/profile.json +++ b/lexicons/actor/profile.json @@ -74,6 +74,12 @@ "pronouns": { "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 } } } -- tangled.sh