A vibe coded tangled fork which supports pijul.

appview/profile: save and use preferred user handle

Lewis: May this revision serve well! <lewis@tangled.org>

authored by

Lewis and committed by tangled.org d3de6118 6b0bc344

+237 -45
+2
api/tangled/actorprofile.go
··· 29 29 Location *string `json:"location,omitempty" cborgen:"location,omitempty"` 30 30 // pinnedRepositories: Any ATURI, it is up to appviews to validate these fields. 31 31 PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"` 32 + // preferredHandle: A handle the user prefers to be displayed as. 33 + PreferredHandle *string `json:"preferredHandle,omitempty" cborgen:"preferredHandle,omitempty"` 32 34 // pronouns: Preferred gender pronouns. 33 35 Pronouns *string `json:"pronouns,omitempty" cborgen:"pronouns,omitempty"` 34 36 Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"`
+58 -1
api/tangled/cbor_gen.go
··· 26 26 } 27 27 28 28 cw := cbg.NewCborWriter(w) 29 - fieldCount := 9 29 + fieldCount := 10 30 30 31 31 if t.Avatar == nil { 32 32 fieldCount-- ··· 45 45 } 46 46 47 47 if t.PinnedRepositories == nil { 48 + fieldCount-- 49 + } 50 + 51 + if t.PreferredHandle == nil { 48 52 fieldCount-- 49 53 } 50 54 ··· 282 286 } 283 287 } 284 288 289 + // t.PreferredHandle (string) (string) 290 + if t.PreferredHandle != nil { 291 + 292 + if len("preferredHandle") > 1000000 { 293 + return xerrors.Errorf("Value in field \"preferredHandle\" was too long") 294 + } 295 + 296 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("preferredHandle"))); err != nil { 297 + return err 298 + } 299 + if _, err := cw.WriteString(string("preferredHandle")); err != nil { 300 + return err 301 + } 302 + 303 + if t.PreferredHandle == nil { 304 + if _, err := cw.Write(cbg.CborNull); err != nil { 305 + return err 306 + } 307 + } else { 308 + if len(*t.PreferredHandle) > 1000000 { 309 + return xerrors.Errorf("Value in field t.PreferredHandle was too long") 310 + } 311 + 312 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.PreferredHandle))); err != nil { 313 + return err 314 + } 315 + if _, err := cw.WriteString(string(*t.PreferredHandle)); err != nil { 316 + return err 317 + } 318 + } 319 + } 320 + 285 321 // t.PinnedRepositories ([]string) (slice) 286 322 if t.PinnedRepositories != nil { 287 323 ··· 551 587 } 552 588 553 589 t.Description = (*string)(&sval) 590 + } 591 + } 592 + // t.PreferredHandle (string) (string) 593 + case "preferredHandle": 594 + 595 + { 596 + b, err := cr.ReadByte() 597 + if err != nil { 598 + return err 599 + } 600 + if b != cbg.CborNull[0] { 601 + if err := cr.UnreadByte(); err != nil { 602 + return err 603 + } 604 + 605 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 606 + if err != nil { 607 + return err 608 + } 609 + 610 + t.PreferredHandle = (*string)(&sval) 554 611 } 555 612 } 556 613 // t.PinnedRepositories ([]string) (slice)
+7
appview/db/db.go
··· 1288 1288 return err 1289 1289 }) 1290 1290 1291 + orm.RunMigration(conn, logger, "add-preferred-handle-profile", func(tx *sql.Tx) error { 1292 + _, err := tx.Exec(` 1293 + alter table profile add column preferred_handle text; 1294 + `) 1295 + return err 1296 + }) 1297 + 1291 1298 return &DB{ 1292 1299 db, 1293 1300 logger,
+42 -6
appview/db/profile.go
··· 162 162 description, 163 163 include_bluesky, 164 164 location, 165 - pronouns 165 + pronouns, 166 + preferred_handle 166 167 ) 167 - values (?, ?, ?, ?, ?, ?)`, 168 + values (?, ?, ?, ?, ?, ?, ?)`, 168 169 profile.Did, 169 170 profile.Avatar, 170 171 profile.Description, 171 172 includeBskyValue, 172 173 profile.Location, 173 174 profile.Pronouns, 175 + string(profile.PreferredHandle), 174 176 ) 175 177 176 178 if err != nil { ··· 252 254 description, 253 255 include_bluesky, 254 256 location, 255 - pronouns 257 + pronouns, 258 + preferred_handle 256 259 from 257 260 profile 258 261 %s`, ··· 269 272 var profile models.Profile 270 273 var includeBluesky int 271 274 var pronouns sql.Null[string] 275 + var preferredHandle sql.Null[string] 272 276 273 - err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns) 277 + err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns, &preferredHandle) 274 278 if err != nil { 275 279 return nil, err 276 280 } ··· 283 287 profile.Pronouns = pronouns.V 284 288 } 285 289 290 + if preferredHandle.Valid { 291 + profile.PreferredHandle = syntax.Handle(preferredHandle.V) 292 + } 293 + 286 294 profileMap[profile.Did] = &profile 287 295 } 288 296 if err = rows.Err(); err != nil { ··· 346 354 return profileMap, nil 347 355 } 348 356 357 + func GetDidByPreferredHandle(e Execer, handle syntax.Handle) (syntax.DID, error) { 358 + var did string 359 + err := e.QueryRow( 360 + `select did from profile where preferred_handle = ?`, 361 + string(handle), 362 + ).Scan(&did) 363 + if err != nil { 364 + return "", err 365 + } 366 + return syntax.DID(did), nil 367 + } 368 + 349 369 func GetProfile(e Execer, did string) (*models.Profile, error) { 350 370 var profile models.Profile 351 371 var pronouns sql.Null[string] 352 372 var avatar sql.Null[string] 373 + var preferredHandle sql.Null[string] 353 374 354 375 profile.Did = did 355 376 356 377 includeBluesky := 0 357 378 358 379 err := e.QueryRow( 359 - `select avatar, description, include_bluesky, location, pronouns from profile where did = ?`, 380 + `select avatar, description, include_bluesky, location, pronouns, preferred_handle from profile where did = ?`, 360 381 did, 361 - ).Scan(&avatar, &profile.Description, &includeBluesky, &profile.Location, &pronouns) 382 + ).Scan(&avatar, &profile.Description, &includeBluesky, &profile.Location, &pronouns, &preferredHandle) 362 383 if err == sql.ErrNoRows { 363 384 return nil, nil 364 385 } ··· 377 398 378 399 if avatar.Valid { 379 400 profile.Avatar = avatar.V 401 + } 402 + 403 + if preferredHandle.Valid { 404 + profile.PreferredHandle = syntax.Handle(preferredHandle.V) 380 405 } 381 406 382 407 rows, err := e.Query(`select link from profile_links where did = ?`, did) ··· 480 505 // ensure pronouns are not too long 481 506 if len(profile.Pronouns) > 40 { 482 507 return fmt.Errorf("Entered pronouns are too long.") 508 + } 509 + 510 + if profile.PreferredHandle != "" { 511 + if _, err := syntax.ParseHandle(string(profile.PreferredHandle)); err != nil { 512 + return fmt.Errorf("Invalid preferred handle format.") 513 + } 514 + 515 + claimant, err := GetDidByPreferredHandle(e, profile.PreferredHandle) 516 + if err == nil && string(claimant) != profile.Did { 517 + return fmt.Errorf("Preferred handle is already claimed by another user.") 518 + } 483 519 } 484 520 485 521 // ensure links are in order
+22 -11
appview/ingester.go
··· 66 66 case tangled.RepoArtifactNSID: 67 67 err = i.ingestArtifact(e) 68 68 case tangled.ActorProfileNSID: 69 - err = i.ingestProfile(e) 69 + err = i.ingestProfile(ctx, e) 70 70 case tangled.SpindleMemberNSID: 71 71 err = i.ingestSpindleMember(ctx, e) 72 72 case tangled.SpindleNSID: ··· 264 264 return nil 265 265 } 266 266 267 - func (i *Ingester) ingestProfile(e *jmodels.Event) error { 267 + func (i *Ingester) ingestProfile(ctx context.Context, e *jmodels.Event) error { 268 268 did := e.Did 269 269 var err error 270 270 ··· 328 328 } 329 329 } 330 330 331 + var preferredHandle syntax.Handle 332 + if record.PreferredHandle != nil { 333 + if h, err := syntax.ParseHandle(*record.PreferredHandle); err == nil { 334 + ident, identErr := i.IdResolver.ResolveIdent(ctx, did) 335 + if identErr == nil && slices.Contains(ident.AlsoKnownAs, "at://"+string(h)) { 336 + preferredHandle = h 337 + } 338 + } 339 + } 340 + 331 341 profile := models.Profile{ 332 - Did: did, 333 - Avatar: avatar, 334 - Description: description, 335 - IncludeBluesky: includeBluesky, 336 - Location: location, 337 - Links: links, 338 - Stats: stats, 339 - PinnedRepos: pinned, 340 - Pronouns: pronouns, 342 + Did: did, 343 + Avatar: avatar, 344 + Description: description, 345 + IncludeBluesky: includeBluesky, 346 + Location: location, 347 + Links: links, 348 + Stats: stats, 349 + PinnedRepos: pinned, 350 + Pronouns: pronouns, 351 + PreferredHandle: preferredHandle, 341 352 } 342 353 343 354 ddb, ok := i.Db.Execer.(*db.DB)
+9 -1
appview/middleware/middleware.go
··· 11 11 "strings" 12 12 13 13 "github.com/bluesky-social/indigo/atproto/identity" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 14 15 "github.com/go-chi/chi/v5" 15 16 "tangled.org/core/appview/db" 16 17 "tangled.org/core/appview/oauth" ··· 188 189 189 190 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 190 191 if err != nil { 191 - // invalid did or handle 192 + if h, parseErr := syntax.ParseHandle(didOrHandle); parseErr == nil { 193 + if did, lookupErr := db.GetDidByPreferredHandle(mw.db, h); lookupErr == nil { 194 + id, err = mw.idResolver.ResolveIdent(req.Context(), string(did)) 195 + } 196 + } 197 + } 198 + // invalid did or handle 199 + if err != nil { 192 200 log.Printf("failed to resolve did/handle '%s': %s\n", didOrHandle, err) 193 201 mw.pages.Error404(w) 194 202 return
+9 -8
appview/models/profile.go
··· 13 13 Did string 14 14 15 15 // data 16 - Avatar string // CID of the avatar blob 17 - Description string 18 - IncludeBluesky bool 19 - Location string 20 - Links [5]string 21 - Stats [2]VanityStat 22 - PinnedRepos [6]syntax.ATURI 23 - Pronouns string 16 + Avatar string // CID of the avatar blob 17 + Description string 18 + IncludeBluesky bool 19 + Location string 20 + Links [5]string 21 + Stats [2]VanityStat 22 + PinnedRepos [6]syntax.ATURI 23 + Pronouns string 24 + PreferredHandle syntax.Handle 24 25 } 25 26 26 27 func (p Profile) IsLinksEmpty() bool {
+5 -1
appview/pages/funcmap.go
··· 65 65 return mapValue.MapIndex(keyValue).IsValid() 66 66 }, 67 67 "resolve": func(s string) string { 68 - identity, err := p.resolver.ResolveIdent(context.Background(), s) 68 + profile, err := db.GetProfile(p.db, s) 69 + if err == nil && profile != nil && profile.PreferredHandle != "" { 70 + return string(profile.PreferredHandle) 71 + } 69 72 73 + identity, err := p.resolver.ResolveIdent(context.Background(), s) 70 74 if err != nil { 71 75 return s 72 76 }
+1
appview/pages/pages.go
··· 731 731 type EditBioParams struct { 732 732 LoggedInUser *oauth.MultiAccountUser 733 733 Profile *models.Profile 734 + AlsoKnownAs []string 734 735 } 735 736 736 737 func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error {
+19
appview/pages/templates/user/fragments/editBio.html
··· 36 36 </div> 37 37 </div> 38 38 39 + {{ if gt (len .AlsoKnownAs) 1 }} 40 + <div class="flex flex-col gap-1"> 41 + <label class="m-0 p-0" for="preferredHandle">preferred handle</label> 42 + <div class="flex items-center gap-2 w-full"> 43 + {{ $preferredHandle := "" }} 44 + {{ if and .Profile .Profile.PreferredHandle }} 45 + {{ $preferredHandle = .Profile.PreferredHandle }} 46 + {{ end }} 47 + <span class="flex-shrink-0">{{ i "at-sign" "size-4" }}</span> 48 + <select name="preferredHandle" class="py-1 px-1 w-full border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 49 + {{ range .AlsoKnownAs }} 50 + {{ $h := trimPrefix . "at://" }} 51 + <option value="{{ $h }}" {{ if eq $h $preferredHandle }}selected{{ end }}>{{ $h }}</option> 52 + {{ end }} 53 + </select> 54 + </div> 55 + </div> 56 + {{ end }} 57 + 39 58 <div class="flex flex-col gap-1"> 40 59 <label class="m-0 p-0" for="location">location</label> 41 60 <div class="flex items-center gap-2 w-full">
+10 -1
appview/state/login.go
··· 8 8 "time" 9 9 10 10 comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/atproto/identity" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 11 13 "github.com/bluesky-social/indigo/xrpc" 12 14 "tangled.org/core/appview/oauth" 13 15 "tangled.org/core/appview/pages" ··· 69 71 } 70 72 71 73 ident, err := s.idResolver.ResolveIdent(r.Context(), handle) 74 + if err != nil && errors.Is(err, identity.ErrHandleMismatch) { 75 + if h, parseErr := syntax.ParseHandle(handle); parseErr == nil { 76 + if did, resolveErr := s.idResolver.ResolveHandle(r.Context(), h); resolveErr == nil { 77 + ident, err = s.idResolver.ResolveIdent(r.Context(), did.String()) 78 + } 79 + } 80 + } 72 81 if err != nil { 73 82 l.Warn("handle resolution failed", "handle", handle, "err", err) 74 83 s.pages.Notice(w, "login-msg", fmt.Sprintf("Could not resolve handle \"%s\". The account may not exist.", handle)) ··· 101 110 l.Error("failed to set auth return", "err", err) 102 111 } 103 112 104 - redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 113 + redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), ident.DID.String()) 105 114 if err != nil { 106 115 l.Error("failed to start auth", "err", err) 107 116 s.pages.Notice(
+30
appview/state/profile.go
··· 663 663 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" 664 664 profile.Location = r.FormValue("location") 665 665 profile.Pronouns = r.FormValue("pronouns") 666 + rawPreferredHandle := strings.TrimSpace(r.FormValue("preferredHandle")) 667 + if rawPreferredHandle != "" { 668 + h, err := syntax.ParseHandle(rawPreferredHandle) 669 + if err != nil { 670 + s.pages.Notice(w, "update-profile", "Invalid handle format.") 671 + return 672 + } 673 + 674 + ident, err := s.idResolver.ResolveIdent(r.Context(), user.Active.Did) 675 + if err != nil || !slices.Contains(ident.AlsoKnownAs, "at://"+rawPreferredHandle) { 676 + s.pages.Notice(w, "update-profile", "Handle not found in your DID document.") 677 + return 678 + } 679 + profile.PreferredHandle = h 680 + } else { 681 + profile.PreferredHandle = "" 682 + } 666 683 667 684 var links [5]string 668 685 for i := range 5 { ··· 759 776 760 777 ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Active.Did, "self") 761 778 var cid *string 779 + var existingAvatar *lexutil.LexBlob 762 780 if ex != nil { 763 781 cid = ex.Cid 782 + if rec, ok := ex.Value.Val.(*tangled.ActorProfile); ok { 783 + existingAvatar = rec.Avatar 784 + } 764 785 } 765 786 766 787 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ ··· 769 790 Rkey: "self", 770 791 Record: &lexutil.LexiconTypeDecoder{ 771 792 Val: &tangled.ActorProfile{ 793 + Avatar: existingAvatar, 772 794 Bluesky: profile.IncludeBluesky, 773 795 Description: &profile.Description, 774 796 Links: profile.Links[:], ··· 776 798 PinnedRepositories: pinnedRepoStrings, 777 799 Stats: vanityStats[:], 778 800 Pronouns: &profile.Pronouns, 801 + PreferredHandle: (*string)(&profile.PreferredHandle), 779 802 }}, 780 803 SwapRecord: cid, 781 804 }) ··· 808 831 profile = &models.Profile{Did: user.Active.Did} 809 832 } 810 833 834 + var alsoKnownAs []string 835 + ident, err := s.idResolver.ResolveIdent(r.Context(), user.Active.Did) 836 + if err == nil { 837 + alsoKnownAs = ident.AlsoKnownAs 838 + } 839 + 811 840 s.pages.EditBioFragment(w, pages.EditBioParams{ 812 841 LoggedInUser: user, 813 842 Profile: profile, 843 + AlsoKnownAs: alsoKnownAs, 814 844 }) 815 845 } 816 846
+17 -16
idresolver/resolver.go
··· 15 15 16 16 type Resolver struct { 17 17 directory identity.Directory 18 + base *identity.BaseDirectory 18 19 } 19 20 20 - func BaseDirectory(plcUrl string) identity.Directory { 21 + func BaseDirectory(plcUrl string) *identity.BaseDirectory { 21 22 base := identity.BaseDirectory{ 22 23 PLCURL: plcUrl, 23 24 HTTPClient: http.Client{ ··· 40 41 UserAgent: "indigo-identity/" + versioninfo.Short(), 41 42 } 42 43 return &base 43 - } 44 - 45 - func RedisDirectory(url, plcUrl string) (identity.Directory, error) { 46 - hitTTL := time.Hour * 24 47 - errTTL := time.Second * 30 48 - invalidHandleTTL := time.Minute * 5 49 - return redisdir.NewRedisDirectory( 50 - BaseDirectory(plcUrl), 51 - url, 52 - hitTTL, 53 - errTTL, 54 - invalidHandleTTL, 55 - 10000, 56 - ) 57 44 } 58 45 59 46 func DefaultResolver(plcUrl string) *Resolver { ··· 61 48 cached := identity.NewCacheDirectory(base, 250_000, time.Hour*24, time.Minute*2, time.Minute*5) 62 49 return &Resolver{ 63 50 directory: cached, 51 + base: base, 64 52 } 53 + } 54 + 55 + func RedisDirectory(base *identity.BaseDirectory, url string) (identity.Directory, error) { 56 + hitTTL := time.Hour * 24 57 + errTTL := time.Second * 30 58 + invalidHandleTTL := time.Minute * 5 59 + return redisdir.NewRedisDirectory(base, url, hitTTL, errTTL, invalidHandleTTL, 10000) 65 60 } 66 61 67 62 func RedisResolver(redisUrl, plcUrl string) (*Resolver, error) { 68 - directory, err := RedisDirectory(redisUrl, plcUrl) 63 + base := BaseDirectory(plcUrl) 64 + directory, err := RedisDirectory(base, redisUrl) 69 65 if err != nil { 70 66 return nil, err 71 67 } 72 68 return &Resolver{ 73 69 directory: directory, 70 + base: base, 74 71 }, nil 72 + } 73 + 74 + func (r *Resolver) ResolveHandle(ctx context.Context, handle syntax.Handle) (syntax.DID, error) { 75 + return r.base.ResolveHandle(ctx, handle) 75 76 } 76 77 77 78 func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) {
+6
lexicons/actor/profile.json
··· 74 74 "type": "string", 75 75 "description": "Preferred gender pronouns.", 76 76 "maxLength": 40 77 + }, 78 + "preferredHandle": { 79 + "type": "string", 80 + "description": "A handle the user prefers to be displayed as.", 81 + "format": "handle", 82 + "maxLength": 253 77 83 } 78 84 } 79 85 }