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 }} +