Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / state / profile.go
38 kB 1339 lines
1package state 2 3import ( 4 "cmp" 5 "context" 6 "errors" 7 "fmt" 8 "net/http" 9 "slices" 10 "strings" 11 "time" 12 13 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 "github.com/bluesky-social/indigo/atproto/identity" 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 "github.com/go-chi/chi/v5" 18 "github.com/gorilla/feeds" 19 "tangled.org/core/api/tangled" 20 "tangled.org/core/appview/cache" 21 "tangled.org/core/appview/db" 22 "tangled.org/core/appview/middleware" 23 "tangled.org/core/appview/models" 24 "tangled.org/core/appview/pages" 25 "tangled.org/core/appview/pagination" 26 "tangled.org/core/appview/searchquery" 27 "tangled.org/core/orm" 28 "tangled.org/core/xrpc" 29) 30 31func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 32 tabVal := r.URL.Query().Get("tab") 33 switch tabVal { 34 case "repos": 35 middleware. 36 Paginate(http.HandlerFunc(s.reposPage)). 37 ServeHTTP(w, r) 38 case "followers": 39 s.followersPage(w, r) 40 case "following": 41 s.followingPage(w, r) 42 case "starred": 43 middleware. 44 Paginate(http.HandlerFunc(s.starredPage)). 45 ServeHTTP(w, r) 46 case "strings": 47 s.stringsPage(w, r) 48 case "vouches": 49 middleware. 50 Paginate(http.HandlerFunc(s.vouchesPage)). 51 ServeHTTP(w, r) 52 default: 53 s.profileOverview(w, r) 54 } 55} 56 57var profileScripts = map[string]string{ 58 "did:plc:3fwecdnvtcscjnrx2p4n7alz": "/static/profile-fx/orrery.js", 59 "did:plc:qfpnj4og54vl56wngdriaxug": "/static/profile-fx/oppili.js", 60 "did:plc:oxdlsmnvpk2riyyuvq5jtdkd": "/static/profile-fx/wilb.me.js", 61 "did:plc:lrphxvv25aibthe7xoc2eeyy": "/static/profile-fx/kandake.js", 62 "did:plc:vzl336yxfrftoc23ygcqklzr": "/static/profile-fx/gdorsi.bsky.social.js", 63 "did:plc:laqygfbyvnkyuhsuaxmp6ez3": "/static/profile-fx/punchcard-td.js", 64 "did:plc:dfkjiu36xs6ogt7pux7i7o2b": "/static/profile-fx/willow.sh.js", 65 "did:plc:doe7nkqeodssh6uc5jcq5iyw": "/static/profile-fx/luisstd.js", 66 "did:plc:f2ablw5m3ashhpydt6u7h2rx": "/static/profile-fx/chancey.dev.js", 67 "did:plc:4aah6pidgmlp36cvtispnwhu": "/static/profile-fx/matthewlipski.tngl.sh.js", 68 "did:plc:7qubw2z53qzfturlblaozz4a": "/static/profile-fx/mihaizaurus.at.js", 69 "did:plc:xmyx5qvyzd4fm77oehni7pvh": "/static/profile-fx/danschmidt.js", 70} 71 72func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) { 73 didOrHandle := chi.URLParam(r, "user") 74 if didOrHandle == "" { 75 return nil, fmt.Errorf("empty DID or handle") 76 } 77 78 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 79 if !ok { 80 return nil, fmt.Errorf("failed to resolve ID") 81 } 82 did := ident.DID.String() 83 84 profile, err := db.GetProfile(s.db, did) 85 if err != nil { 86 return nil, fmt.Errorf("failed to get profile: %w", err) 87 } 88 89 hasProfile := profile != nil 90 if !hasProfile { 91 profile = &models.Profile{Did: did} 92 } 93 94 isTangledUser, err := db.IsTangledUser(s.db, did) 95 if err != nil { 96 return nil, fmt.Errorf("failed to determine tangled user status: %w", err) 97 } 98 99 repoCount, err := db.CountRepos(s.db, orm.FilterEq("did", did)) 100 if err != nil { 101 return nil, fmt.Errorf("failed to get repo count: %w", err) 102 } 103 104 stringCount, err := db.CountStrings(s.db, orm.FilterEq("did", did)) 105 if err != nil { 106 return nil, fmt.Errorf("failed to get string count: %w", err) 107 } 108 109 starredCount, err := db.CountStars(s.db, orm.FilterEq("did", did)) 110 if err != nil { 111 return nil, fmt.Errorf("failed to get starred repo count: %w", err) 112 } 113 114 followStats, err := db.GetFollowerFollowingCount(s.db, did) 115 if err != nil { 116 return nil, fmt.Errorf("failed to get follower stats: %w", err) 117 } 118 119 loggedInUser := s.oauth.GetMultiAccountUser(r) 120 followStatus := models.IsNotFollowing 121 var loggedInDid string 122 var vouchRelationship *models.VouchRelationship 123 124 if loggedInUser != nil { 125 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 126 loggedInDid = loggedInUser.Did 127 vouchRelationship, err = db.GetVouchRelationship(s.db, syntax.DID(loggedInUser.Did), syntax.DID(did)) 128 } 129 130 showPunchcard := s.shouldShowPunchcard(did, loggedInDid) 131 132 var punchcard *models.Punchcard 133 if showPunchcard { 134 now := time.Now() 135 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 136 punchcard, err = db.MakePunchcard( 137 s.db, 138 orm.FilterEq("did", did), 139 orm.FilterGte("date", startOfYear.Format(time.DateOnly)), 140 orm.FilterLte("date", now.Format(time.DateOnly)), 141 ) 142 if err != nil { 143 return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err) 144 } 145 } 146 147 return &pages.ProfileCard{ 148 UserDid: did, 149 HasProfile: hasProfile, 150 IsTangledUser: isTangledUser, 151 Profile: profile, 152 FollowStatus: followStatus, 153 VouchRelationship: vouchRelationship, 154 Stats: pages.ProfileStats{ 155 RepoCount: repoCount, 156 StringCount: stringCount, 157 StarredCount: starredCount, 158 FollowersCount: followStats.Followers, 159 FollowingCount: followStats.Following, 160 }, 161 Punchcard: punchcard, 162 ProfileScript: profileScripts[did], 163 }, nil 164} 165 166func (s *State) profileOverview(w http.ResponseWriter, r *http.Request) { 167 l := s.logger.With("handler", "profileHomePage") 168 169 profile, err := s.profile(r) 170 if err != nil { 171 l.Error("failed to build profile card", "err", err) 172 s.pages.Error500(w) 173 return 174 } 175 l = l.With("profileDid", profile.UserDid) 176 177 repos, err := db.GetRepos( 178 s.db, 179 orm.FilterEq("did", profile.UserDid), 180 ) 181 if err != nil { 182 l.Error("failed to fetch repos", "err", err) 183 } 184 185 // filter out ones that are pinned 186 pinnedRepos := []models.Repo{} 187 for i, r := range repos { 188 if profile.Profile.MatchesPinnedRepo(r) { 189 pinnedRepos = append(pinnedRepos, r) 190 } else if profile.Profile.IsPinnedReposEmpty() && i < 4 { 191 pinnedRepos = append(pinnedRepos, r) 192 } 193 } 194 195 collaboratingRepos, err := db.CollaboratingIn(s.db, profile.UserDid) 196 if err != nil { 197 l.Error("failed to fetch collaborating repos", "err", err) 198 } 199 200 pinnedCollaboratingRepos := []models.Repo{} 201 for _, r := range collaboratingRepos { 202 if profile.Profile.MatchesPinnedRepo(r) { 203 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) 204 } 205 } 206 207 timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid) 208 if err != nil { 209 l.Error("failed to create timeline", "err", err) 210 } 211 212 err = s.pages.ProfileOverview(w, pages.ProfileOverviewParams{ 213 BaseParams: pages.BaseParamsFromContext(r.Context()), 214 Card: profile, 215 Repos: pinnedRepos, 216 CollaboratingRepos: pinnedCollaboratingRepos, 217 ProfileTimeline: timeline, 218 }) 219 if err != nil { 220 l.Error("failed to render template", "err", err) 221 } 222} 223 224func (s *State) shouldShowPunchcard(targetDid, requesterDid string) bool { 225 l := s.logger.With("helper", "shouldShowPunchcard") 226 227 targetPunchcardPreferences, err := db.GetPunchcardPreference(s.db, targetDid) 228 if err != nil { 229 l.Error("failed to get target users punchcard preferences", "err", err) 230 return true 231 } 232 233 requesterPunchcardPreferences, err := db.GetPunchcardPreference(s.db, requesterDid) 234 if err != nil { 235 l.Error("failed to get requester users punchcard preferences", "err", err) 236 return true 237 } 238 239 showPunchcard := true 240 241 // looking at their own profile 242 if targetDid == requesterDid { 243 if targetPunchcardPreferences.HideMine { 244 return false 245 } 246 return true 247 } 248 249 if targetPunchcardPreferences.HideMine || requesterPunchcardPreferences.HideOthers { 250 showPunchcard = false 251 } 252 return showPunchcard 253} 254 255func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 256 l := s.logger.With("handler", "reposPage") 257 258 profile, err := s.profile(r) 259 if err != nil { 260 l.Error("failed to build profile card", "err", err) 261 s.pages.Error500(w) 262 return 263 } 264 l = l.With("profileDid", profile.UserDid) 265 266 params := r.URL.Query() 267 page := pagination.FromContext(r.Context()) 268 269 query := searchquery.Parse(params.Get("q")) 270 271 var language string 272 if lang := cmp.Or(query.Get("language"), query.Get("lang")); lang != nil { 273 language = *lang 274 } 275 276 tf := searchquery.ExtractTextFilters(query) 277 278 searchOpts := models.RepoSearchOptions{ 279 Keywords: tf.Keywords, 280 Phrases: tf.Phrases, 281 NegatedKeywords: tf.NegatedKeywords, 282 NegatedPhrases: tf.NegatedPhrases, 283 Did: profile.UserDid, 284 Language: language, 285 Page: page, 286 } 287 288 var repos []models.Repo 289 var totalRepos int64 290 loggedInUser := s.oauth.GetMultiAccountUser(r) 291 292 if searchOpts.HasSearchFilters() { 293 res, err := s.indexer.Repos.Search(r.Context(), searchOpts) 294 if err != nil { 295 l.Error("failed to search repos", "err", err) 296 s.pages.Error500(w) 297 return 298 } 299 300 if len(res.Hits) > 0 { 301 repos, err = db.GetRepos(s.db, orm.FilterIn("id", res.Hits)) 302 if err != nil { 303 l.Error("failed to get repos by IDs", "err", err) 304 s.pages.Error500(w) 305 return 306 } 307 308 // sort repos to match search result order (by relevance) 309 repoMap := make(map[int64]models.Repo, len(repos)) 310 for _, repo := range repos { 311 repoMap[repo.Id] = repo 312 } 313 repos = make([]models.Repo, 0, len(res.Hits)) 314 for _, id := range res.Hits { 315 if repo, ok := repoMap[id]; ok { 316 repos = append(repos, repo) 317 } 318 } 319 } 320 totalRepos = int64(res.Total) 321 } else { 322 repos, err = db.GetReposPaginated( 323 s.db, 324 page, 325 orm.FilterEq("did", profile.UserDid), 326 ) 327 if err != nil { 328 l.Error("failed to get repos", "err", err) 329 s.pages.Error500(w) 330 return 331 } 332 333 totalRepos, err = db.CountRepos( 334 s.db, 335 orm.FilterEq("did", profile.UserDid), 336 ) 337 if err != nil { 338 l.Error("failed to count repos", "err", err) 339 s.pages.Error500(w) 340 return 341 } 342 } 343 344 starStatuses := map[string]bool{} 345 if loggedInUser != nil { 346 repoDids := make([]string, 0, len(repos)) 347 for _, repo := range repos { 348 if repo.RepoDid != "" { 349 repoDids = append(repoDids, repo.RepoDid) 350 } 351 } 352 353 starStatuses, err = db.GetStarStatuses(s.db, loggedInUser.Did, repoDids) 354 if err != nil { 355 l.Error("failed to get repo star statuses", "err", err) 356 s.pages.Error500(w) 357 return 358 } 359 } 360 361 err = s.pages.ProfileRepos(w, pages.ProfileReposParams{ 362 BaseParams: pages.BaseParamsFromContext(r.Context()), 363 Repos: repos, 364 StarStatuses: starStatuses, 365 Card: profile, 366 Page: page, 367 RepoCount: int(totalRepos), 368 FilterQuery: query.String(), 369 }) 370 if err != nil { 371 l.Error("failed to render page", "err", err) 372 } 373} 374 375func (s *State) starredPage(w http.ResponseWriter, r *http.Request) { 376 l := s.logger.With("handler", "starredPage") 377 378 page := pagination.FromContext(r.Context()) 379 l = l.With("page", page) 380 381 profile, err := s.profile(r) 382 if err != nil { 383 l.Error("failed to build profile card", "err", err) 384 s.pages.Error500(w) 385 return 386 } 387 l = l.With("profileDid", profile.UserDid) 388 389 stars, err := db.GetRepoStars(s.db, page, orm.FilterEq("did", profile.UserDid)) 390 if err != nil { 391 l.Error("failed to get stars", "err", err) 392 s.pages.Error500(w) 393 return 394 } 395 var repos []models.Repo 396 for _, s := range stars { 397 repos = append(repos, *s.Repo) 398 } 399 400 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ 401 BaseParams: pages.BaseParamsFromContext(r.Context()), 402 Repos: repos, 403 Total: int(profile.Stats.StarredCount), 404 Card: profile, 405 Page: page, 406 }) 407 if err != nil { 408 l.Error("failed to render", "err", err) 409 } 410} 411 412func (s *State) stringsPage(w http.ResponseWriter, r *http.Request) { 413 l := s.logger.With("handler", "stringsPage") 414 415 profile, err := s.profile(r) 416 if err != nil { 417 l.Error("failed to build profile card", "err", err) 418 s.pages.Error500(w) 419 return 420 } 421 l = l.With("profileDid", profile.UserDid) 422 423 strings, err := db.GetStrings(s.db, 0, orm.FilterEq("did", profile.UserDid)) 424 if err != nil { 425 l.Error("failed to get strings", "err", err) 426 s.pages.Error500(w) 427 return 428 } 429 430 err = s.pages.ProfileStrings(w, pages.ProfileStringsParams{ 431 BaseParams: pages.BaseParamsFromContext(r.Context()), 432 Strings: strings, 433 Card: profile, 434 }) 435} 436 437func (s *State) vouchesPage(w http.ResponseWriter, r *http.Request) { 438 l := s.logger.With("handler", "vouchesPage") 439 440 profile, err := s.profile(r) 441 if err != nil { 442 l.Error("failed to build profile card", "err", err) 443 s.pages.Error500(w) 444 return 445 } 446 l = l.With("profileDid", profile.UserDid) 447 448 loggedInUser := s.oauth.GetMultiAccountUser(r) 449 page := pagination.FromContext(r.Context()) 450 451 var vouches []models.Vouch 452 var vouchCount int 453 if loggedInUser != nil { 454 vouches, err = db.GetNetworkVouchTimeline(s.db, loggedInUser.Did, profile.UserDid, page) 455 if err != nil { 456 l.Error("failed to get vouch timeline", "err", err) 457 s.pages.Error500(w) 458 return 459 } 460 vouchCount, err = db.CountNetworkVouchTimeline(s.db, loggedInUser.Did, profile.UserDid) 461 if err != nil { 462 l.Error("failed to count vouch timeline", "err", err) 463 s.pages.Error500(w) 464 return 465 } 466 } 467 468 var suggestions []models.VouchSuggestion 469 if loggedInUser != nil && loggedInUser.Did == profile.UserDid { 470 suggestions, err = db.GetVouchSuggestions(s.db, profile.UserDid, 5) 471 if err != nil { 472 l.Error("failed to get vouch suggestions", "err", err) 473 } 474 475 if len(suggestions) > 0 { 476 suggestionDids := make([]syntax.DID, len(suggestions)) 477 for i, s := range suggestions { 478 suggestionDids[i] = syntax.DID(s.Did) 479 } 480 relationships, err := db.GetVouchRelationshipsBatch(s.db, syntax.DID(loggedInUser.Did), suggestionDids) 481 if err != nil { 482 l.Error("failed to get vouch relationships for suggestions", "err", err) 483 } else { 484 for i := range suggestions { 485 suggestions[i].VouchRelationship = relationships[suggestions[i].Did] 486 } 487 } 488 } 489 } 490 491 var pullAts, issueAts []syntax.ATURI 492 for _, v := range vouches { 493 for _, ev := range v.Evidences { 494 switch ev.Collection().String() { 495 case tangled.RepoPullNSID: 496 pullAts = append(pullAts, ev) 497 case tangled.RepoIssueNSID: 498 issueAts = append(issueAts, ev) 499 } 500 } 501 } 502 503 evidencePulls := make(map[syntax.ATURI]*models.Pull) 504 if len(pullAts) > 0 { 505 pulls, err := db.GetPulls(s.db, orm.FilterIn("at_uri", pullAts)) 506 if err != nil { 507 l.Error("failed to get evidence pulls", "err", err) 508 } else { 509 for _, p := range pulls { 510 evidencePulls[p.AtUri()] = p 511 } 512 } 513 } 514 515 evidenceIssues := make(map[syntax.ATURI]*models.Issue) 516 if len(issueAts) > 0 { 517 issues, err := db.GetIssues(s.db, orm.FilterIn("at_uri", issueAts)) 518 if err != nil { 519 l.Error("failed to get evidence issues", "err", err) 520 } else { 521 for i := range issues { 522 evidenceIssues[issues[i].AtUri()] = &issues[i] 523 } 524 } 525 } 526 527 err = s.pages.ProfileVouches(w, pages.ProfileVouchesParams{ 528 BaseParams: pages.BaseParamsFromContext(r.Context()), 529 Vouches: vouches, 530 Suggestions: suggestions, 531 Card: profile, 532 Page: page, 533 VouchCount: vouchCount, 534 EvidencePulls: evidencePulls, 535 EvidenceIssues: evidenceIssues, 536 }) 537 if err != nil { 538 l.Error("failed to render page", "err", err) 539 } 540} 541 542type FollowsPageParams struct { 543 Follows []pages.FollowCard 544 Card *pages.ProfileCard 545} 546 547func (s *State) followPage( 548 r *http.Request, 549 fetchFollows func(db.Execer, string) ([]models.Follow, error), 550 extractDid func(models.Follow) string, 551) (*FollowsPageParams, error) { 552 l := s.logger.With("handler", "reposPage") 553 554 profile, err := s.profile(r) 555 if err != nil { 556 return nil, err 557 } 558 l = l.With("profileDid", profile.UserDid) 559 560 loggedInUser := s.oauth.GetMultiAccountUser(r) 561 params := FollowsPageParams{ 562 Card: profile, 563 } 564 565 follows, err := fetchFollows(s.db, profile.UserDid) 566 if err != nil { 567 l.Error("failed to fetch follows", "err", err) 568 return &params, err 569 } 570 571 if len(follows) == 0 { 572 return &params, nil 573 } 574 575 followDids := make([]string, 0, len(follows)) 576 for _, follow := range follows { 577 followDids = append(followDids, extractDid(follow)) 578 } 579 580 profiles, err := db.GetProfiles(s.db, orm.FilterIn("did", followDids)) 581 if err != nil { 582 l.Error("failed to get profiles", "followDids", followDids, "err", err) 583 return &params, err 584 } 585 586 followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) 587 if err != nil { 588 l.Error("getting follow counts", "followDids", followDids, "err", err) 589 } 590 591 loggedInUserFollowing := make(map[string]struct{}) 592 if loggedInUser != nil { 593 following, err := db.GetFollowing(s.db, loggedInUser.Did) 594 if err != nil { 595 l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did) 596 return &params, err 597 } 598 loggedInUserFollowing = make(map[string]struct{}, len(following)) 599 for _, follow := range following { 600 loggedInUserFollowing[follow.SubjectDid] = struct{}{} 601 } 602 } 603 604 followCards := make([]pages.FollowCard, len(follows)) 605 for i, did := range followDids { 606 followStats := followStatsMap[did] 607 followStatus := models.IsNotFollowing 608 if _, exists := loggedInUserFollowing[did]; exists { 609 followStatus = models.IsFollowing 610 } else if loggedInUser != nil && loggedInUser.Did == did { 611 followStatus = models.IsSelf 612 } 613 614 var profile *models.Profile 615 if p, exists := profiles[did]; exists { 616 profile = p 617 } else { 618 profile = &models.Profile{} 619 profile.Did = did 620 } 621 followCards[i] = pages.FollowCard{ 622 BaseParams: pages.BaseParamsFromContext(r.Context()), 623 UserDid: did, 624 FollowStatus: followStatus, 625 FollowersCount: followStats.Followers, 626 FollowingCount: followStats.Following, 627 Profile: profile, 628 } 629 } 630 631 params.Follows = followCards 632 633 return &params, nil 634} 635 636func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 637 followPage, err := s.followPage(r, db.GetFollowers, func(f models.Follow) string { return f.UserDid }) 638 if err != nil { 639 s.pages.Notice(w, "all-followers", "Failed to load followers") 640 return 641 } 642 643 s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{ 644 BaseParams: pages.BaseParamsFromContext(r.Context()), 645 Followers: followPage.Follows, 646 Card: followPage.Card, 647 }) 648} 649 650func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 651 followPage, err := s.followPage(r, db.GetFollowing, func(f models.Follow) string { return f.SubjectDid }) 652 if err != nil { 653 s.pages.Notice(w, "all-following", "Failed to load following") 654 return 655 } 656 657 s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{ 658 BaseParams: pages.BaseParamsFromContext(r.Context()), 659 Following: followPage.Follows, 660 Card: followPage.Card, 661 }) 662} 663 664func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { 665 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 666 if !ok { 667 s.pages.Error404(w) 668 return 669 } 670 671 feed, err := s.getProfileFeed(r.Context(), &ident) 672 if err != nil { 673 s.pages.Error500(w) 674 return 675 } 676 677 if feed == nil { 678 return 679 } 680 681 atom, err := feed.ToAtom() 682 if err != nil { 683 s.pages.Error500(w) 684 return 685 } 686 687 w.Header().Set("content-type", "application/atom+xml") 688 w.Write([]byte(atom)) 689} 690 691func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) { 692 timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 693 if err != nil { 694 return nil, err 695 } 696 697 author := &feeds.Author{ 698 Name: fmt.Sprintf("@%s", id.Handle), 699 } 700 701 feed := feeds.Feed{ 702 Title: fmt.Sprintf("%s's timeline", author.Name), 703 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.BaseUrl(), id.Handle), Type: "text/html", Rel: "alternate"}, 704 Items: make([]*feeds.Item, 0), 705 Updated: time.UnixMilli(0), 706 Author: author, 707 } 708 709 for _, byMonth := range timeline.ByMonth { 710 if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil { 711 return nil, err 712 } 713 if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil { 714 return nil, err 715 } 716 if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil { 717 return nil, err 718 } 719 } 720 721 slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int { 722 return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli()) 723 }) 724 725 if len(feed.Items) > 0 { 726 feed.Updated = feed.Items[0].Created 727 } 728 729 return &feed, nil 730} 731 732func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*models.Pull, author *feeds.Author) error { 733 for _, pull := range pulls { 734 owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 735 if err != nil { 736 return err 737 } 738 739 // Add pull request creation item 740 feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author)) 741 } 742 return nil 743} 744 745func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*models.Issue, author *feeds.Author) error { 746 for _, issue := range issues { 747 owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did) 748 if err != nil { 749 return err 750 } 751 752 feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author)) 753 } 754 return nil 755} 756 757func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []models.RepoEvent, author *feeds.Author) error { 758 for _, repo := range repos { 759 item, err := s.createRepoItem(ctx, repo, author) 760 if err != nil { 761 return err 762 } 763 feed.Items = append(feed.Items, item) 764 } 765 return nil 766} 767 768func (s *State) createPullRequestItem(pull *models.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 769 return &feeds.Item{ 770 Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 771 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.BaseUrl(), owner.Handle, pull.Repo.Slug(), pull.PullId), Type: "text/html", Rel: "alternate"}, 772 Created: pull.Created, 773 Author: author, 774 } 775} 776 777func (s *State) createIssueItem(issue *models.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 778 return &feeds.Item{ 779 Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name), 780 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.BaseUrl(), owner.Handle, issue.Repo.Slug(), issue.IssueId), Type: "text/html", Rel: "alternate"}, 781 Created: issue.Created, 782 Author: author, 783 } 784} 785 786func (s *State) createRepoItem(ctx context.Context, repo models.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 787 var title string 788 if repo.Source != nil { 789 sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) 790 if err != nil { 791 return nil, err 792 } 793 title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name) 794 } else { 795 title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) 796 } 797 798 return &feeds.Item{ 799 Title: title, 800 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.BaseUrl(), author.Name[1:], repo.Repo.Slug()), Type: "text/html", Rel: "alternate"}, // Remove @ prefix 801 Created: repo.Repo.Created, 802 Author: author, 803 }, nil 804} 805 806func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { 807 l := s.logger.With("handler", "UpdateProfileBio") 808 809 if err := r.ParseForm(); err != nil { 810 l.Error("invalid profile update form", "err", err) 811 s.pages.Notice(w, "update-profile", "Invalid form.") 812 return 813 } 814 815 profile, err := s.bioFormToProfile(r) 816 if err != nil { 817 s.pages.Notice(w, "update-profile", err.Error()) 818 return 819 } 820 821 s.updateProfile(profile, w, r) 822} 823 824// bioFormToProfile builds a validated Profile from an form, callers must have 825// called r.ParseForm. 826func (s *State) bioFormToProfile(r *http.Request) (*models.Profile, error) { 827 user := s.oauth.GetMultiAccountUser(r) 828 829 profile, err := db.GetProfile(s.db, user.Did) 830 if err != nil { 831 s.logger.Error("getting profile data", "did", user.Did, "err", err) 832 } 833 if profile == nil { 834 profile = &models.Profile{Did: user.Did} 835 } 836 837 profile.Description = r.FormValue("description") 838 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" 839 profile.Location = r.FormValue("location") 840 profile.Pronouns = r.FormValue("pronouns") 841 rawPreferredHandle := strings.TrimSpace(r.FormValue("preferredHandle")) 842 if rawPreferredHandle != "" { 843 h, err := syntax.ParseHandle(rawPreferredHandle) 844 if err != nil { 845 return nil, errors.New("Invalid handle format.") 846 } 847 848 ident, err := s.idResolver.ResolveIdent(r.Context(), user.Did) 849 if err != nil || !slices.Contains(ident.AlsoKnownAs, "at://"+rawPreferredHandle) { 850 return nil, errors.New("Handle not found in your DID document.") 851 } 852 profile.PreferredHandle = h 853 } else { 854 profile.PreferredHandle = "" 855 } 856 857 var links [5]string 858 for i := range 5 { 859 links[i] = r.FormValue(fmt.Sprintf("link%d", i)) 860 } 861 profile.Links = links 862 863 // Parse stats (exactly 2) 864 profile.Stats[0].Kind = models.ParseVanityStatKind(r.FormValue("stat0")) 865 profile.Stats[1].Kind = models.ParseVanityStatKind(r.FormValue("stat1")) 866 867 if err := db.ValidateProfile(s.db, profile); err != nil { 868 s.logger.Error("invalid profile", "err", err) 869 return nil, err 870 } 871 872 return profile, nil 873} 874 875func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { 876 l := s.logger.With("handler", "UpdateProfilePins") 877 user := s.oauth.GetMultiAccountUser(r) 878 879 err := r.ParseForm() 880 if err != nil { 881 l.Error("invalid profile update form", "err", err) 882 s.pages.Notice(w, "update-profile", "Invalid form.") 883 return 884 } 885 886 profile, err := db.GetProfile(s.db, user.Did) 887 if err != nil { 888 l.Error("getting profile data", "did", user.Did, "err", err) 889 } 890 if profile == nil { 891 profile = &models.Profile{Did: user.Did} 892 } 893 894 i := 0 895 var pinnedRepos [6]string 896 for key, values := range r.Form { 897 if i >= 6 { 898 l.Warn("too many pinned repos") 899 s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.") 900 return 901 } 902 if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 { 903 pinnedRepos[i] = values[0] 904 i++ 905 } 906 } 907 profile.PinnedRepos = pinnedRepos 908 909 s.updateProfile(profile, w, r) 910} 911 912func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) { 913 user := s.oauth.GetMultiAccountUser(r) 914 915 if err := s.writeProfile(r, profile); err != nil { 916 s.logger.With("handler", "updateProfile").Error("failed to write profile", "err", err) 917 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 918 return 919 } 920 921 s.pages.HxRedirect(w, "/"+user.Did) 922} 923 924// writeProfile persists a profile to the PDS and local DB and updates caches. 925// It is shared by the profile settings handler and the onboarding flow; the 926// response (notice/redirect) is left to the caller. 927func (s *State) writeProfile(r *http.Request, profile *models.Profile) error { 928 user := s.oauth.GetMultiAccountUser(r) 929 l := s.logger.With("handler", "writeProfile") 930 931 client, err := s.oauth.AuthorizedClient(r) 932 if err != nil { 933 return fmt.Errorf("failed to get authorized client: %w", err) 934 } 935 936 var pinnedRepoStrings []string 937 for _, r := range profile.PinnedRepos { 938 if r != "" { 939 pinnedRepoStrings = append(pinnedRepoStrings, r) 940 } 941 } 942 943 var vanityStats []string 944 for _, v := range profile.Stats { 945 vanityStats = append(vanityStats, string(v.Kind)) 946 } 947 948 ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 949 var cid *string 950 var existingAvatar *lexutil.LexBlob 951 if ex != nil { 952 cid = ex.Cid 953 if rec, ok := ex.Value.Val.(*tangled.ActorProfile); ok { 954 existingAvatar = rec.Avatar 955 } 956 } 957 958 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 959 Collection: tangled.ActorProfileNSID, 960 Repo: user.Did, 961 Rkey: "self", 962 Record: &lexutil.LexiconTypeDecoder{ 963 Val: &tangled.ActorProfile{ 964 Avatar: existingAvatar, 965 Bluesky: profile.IncludeBluesky, 966 Description: &profile.Description, 967 Links: profile.Links[:], 968 Location: &profile.Location, 969 PinnedRepositories: pinnedRepoStrings, 970 Stats: vanityStats[:], 971 Pronouns: &profile.Pronouns, 972 PreferredHandle: (*string)(&profile.PreferredHandle), 973 }}, 974 SwapRecord: cid, 975 }) 976 if err != nil { 977 return fmt.Errorf("failed to update profile on PDS: %w", err) 978 } 979 980 if err := db.UpsertProfile(s.db, profile); err != nil { 981 // db failed, but PDS operation succeed. 982 // log error and continue 983 l.Error("failed to update profile in DB", "err", err) 984 } 985 986 if s.rdb != nil { 987 ctx := r.Context() 988 pipe := s.rdb.Pipeline() 989 didKey := fmt.Sprintf(cache.PreferredHandleByDid, profile.Did) 990 if profile.PreferredHandle != "" { 991 pipe.Set(ctx, didKey, string(profile.PreferredHandle), cache.PreferredHandleTTL) 992 pipe.Set(ctx, fmt.Sprintf(cache.PreferredHandleByHandle, string(profile.PreferredHandle)), profile.Did, cache.PreferredHandleTTL) 993 } else { 994 pipe.Del(ctx, didKey) 995 } 996 if _, execErr := pipe.Exec(ctx); execErr != nil { 997 s.logger.Warn("failed to update preferred handle cache", "err", execErr) 998 } 999 } 1000 1001 s.notifier.UpdateProfile(r.Context(), profile) 1002 1003 return nil 1004} 1005 1006func (s *State) ProfilePopover(w http.ResponseWriter, r *http.Request) { 1007 l := s.logger.With("handler", "ProfilePopover") 1008 1009 did := r.URL.Query().Get("did") 1010 if did == "" { 1011 l.Warn("missing did param") 1012 w.WriteHeader(http.StatusBadRequest) 1013 return 1014 } 1015 1016 profile, err := db.GetProfile(s.db, did) 1017 if err != nil { 1018 l.Error("failed to get profile", "did", did, "err", err) 1019 w.WriteHeader(http.StatusInternalServerError) 1020 return 1021 } 1022 if profile == nil { 1023 profile = &models.Profile{Did: did} 1024 } 1025 1026 followStats, err := db.GetFollowerFollowingCount(s.db, did) 1027 if err != nil { 1028 l.Error("failed to get follower stats", "did", did, "err", err) 1029 w.WriteHeader(http.StatusInternalServerError) 1030 return 1031 } 1032 1033 loggedInUser := s.oauth.GetMultiAccountUser(r) 1034 followStatus := models.IsNotFollowing 1035 var vouchRelationship *models.VouchRelationship 1036 1037 if loggedInUser != nil { 1038 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 1039 vouchRelationship, _ = db.GetVouchRelationship(s.db, syntax.DID(loggedInUser.Did), syntax.DID(did)) 1040 } 1041 1042 s.pages.ProfilePopoverFragment(w, pages.ProfilePopoverParams{ 1043 BaseParams: pages.BaseParamsFromContext(r.Context()), 1044 UserDid: did, 1045 Profile: profile, 1046 FollowStatus: followStatus, 1047 VouchRelationship: vouchRelationship, 1048 Stats: pages.ProfilePopoverStats{ 1049 FollowersCount: followStats.Followers, 1050 FollowingCount: followStats.Following, 1051 }, 1052 }) 1053} 1054 1055func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) { 1056 l := s.logger.With("handler", "EditBioFragment") 1057 user := s.oauth.GetMultiAccountUser(r) 1058 1059 profile, err := db.GetProfile(s.db, user.Did) 1060 if err != nil { 1061 l.Error("getting profile data", "did", user.Did, "err", err) 1062 } 1063 if profile == nil { 1064 profile = &models.Profile{Did: user.Did} 1065 } 1066 1067 var alsoKnownAs []string 1068 ident, err := s.idResolver.ResolveIdent(r.Context(), user.Did) 1069 if err == nil { 1070 alsoKnownAs = ident.AlsoKnownAs 1071 } 1072 1073 s.pages.EditBioFragment(w, pages.EditBioParams{ 1074 BaseParams: pages.BaseParamsFromContext(r.Context()), 1075 Profile: profile, 1076 AlsoKnownAs: alsoKnownAs, 1077 }) 1078} 1079 1080func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) { 1081 l := s.logger.With("handler", "EditPinsFragment") 1082 user := s.oauth.GetMultiAccountUser(r) 1083 1084 profile, err := db.GetProfile(s.db, user.Did) 1085 if err != nil { 1086 l.Error("getting profile data", "did", user.Did, "err", err) 1087 } 1088 if profile == nil { 1089 profile = &models.Profile{Did: user.Did} 1090 } 1091 1092 repos, err := db.GetRepos(s.db, orm.FilterEq("did", user.Did)) 1093 if err != nil { 1094 l.Error("getting repos", "did", user.Did, "err", err) 1095 } 1096 1097 collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did) 1098 if err != nil { 1099 l.Error("getting collaborating repos", "did", user.Did, "err", err) 1100 } 1101 1102 allRepos := []pages.PinnedRepo{} 1103 1104 for _, r := range repos { 1105 allRepos = append(allRepos, pages.PinnedRepo{ 1106 IsPinned: profile.MatchesPinnedRepo(r), 1107 Repo: r, 1108 }) 1109 } 1110 for _, r := range collaboratingRepos { 1111 allRepos = append(allRepos, pages.PinnedRepo{ 1112 IsPinned: profile.MatchesPinnedRepo(r), 1113 Repo: r, 1114 }) 1115 } 1116 1117 s.pages.EditPinsFragment(w, pages.EditPinsParams{ 1118 BaseParams: pages.BaseParamsFromContext(r.Context()), 1119 Profile: profile, 1120 AllRepos: allRepos, 1121 }) 1122} 1123 1124func (s *State) UploadProfileAvatar(w http.ResponseWriter, r *http.Request) { 1125 l := s.logger.With("handler", "UploadProfileAvatar") 1126 user := s.oauth.GetMultiAccountUser(r) 1127 l = l.With("did", user.Did) 1128 1129 // Parse multipart form (10MB max) 1130 if err := r.ParseMultipartForm(10 << 20); err != nil { 1131 l.Error("failed to parse form", "err", err) 1132 s.pages.Notice(w, "avatar-error", "Failed to parse form") 1133 return 1134 } 1135 1136 file, header, err := r.FormFile("avatar") 1137 if err != nil { 1138 l.Error("failed to read avatar file", "err", err) 1139 s.pages.Notice(w, "avatar-error", "Failed to read avatar file") 1140 return 1141 } 1142 defer file.Close() 1143 1144 if header.Size > 5000000 { 1145 l.Warn("avatar file too large", "size", header.Size) 1146 s.pages.Notice(w, "avatar-error", "Avatar file too large (max 5MB)") 1147 return 1148 } 1149 1150 contentType := header.Header.Get("Content-Type") 1151 if contentType != "image/png" && contentType != "image/jpeg" { 1152 l.Warn("invalid image type", "contentType", contentType) 1153 s.pages.Notice(w, "avatar-error", "Invalid image type (only PNG and JPEG allowed)") 1154 return 1155 } 1156 1157 client, err := s.oauth.AuthorizedClient(r) 1158 if err != nil { 1159 l.Error("failed to get PDS client", "err", err) 1160 s.pages.Notice(w, "avatar-error", "Failed to connect to your PDS") 1161 return 1162 } 1163 1164 uploadBlobResp, err := xrpc.RepoUploadBlob(r.Context(), client, file, header.Header.Get("Content-Type")) 1165 if err != nil { 1166 l.Error("failed to upload avatar blob", "err", err) 1167 s.pages.Notice(w, "avatar-error", "Failed to upload avatar to your PDS") 1168 return 1169 } 1170 1171 l.Info("uploaded avatar blob", "cid", uploadBlobResp.Blob.Ref.String()) 1172 1173 // get current profile record from PDS to get its CID for swap. A new user 1174 // (e.g. mid-onboarding) may not have a profile record yet; treat any error 1175 // as "no existing record" and create a fresh one with no swap. 1176 getRecordResp, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 1177 1178 var profileRecord *tangled.ActorProfile 1179 var swapCid *string 1180 switch { 1181 case err != nil: 1182 l.Warn("no existing profile record, creating new record", "err", err) 1183 profileRecord = &tangled.ActorProfile{} 1184 case getRecordResp.Value != nil: 1185 if val, ok := getRecordResp.Value.Val.(*tangled.ActorProfile); ok { 1186 profileRecord = val 1187 } else { 1188 l.Warn("profile record type assertion failed, creating new record") 1189 profileRecord = &tangled.ActorProfile{} 1190 } 1191 swapCid = getRecordResp.Cid 1192 default: 1193 l.Warn("no existing profile record, creating new record") 1194 profileRecord = &tangled.ActorProfile{} 1195 swapCid = getRecordResp.Cid 1196 } 1197 1198 profileRecord.Avatar = uploadBlobResp.Blob 1199 1200 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1201 Collection: tangled.ActorProfileNSID, 1202 Repo: user.Did, 1203 Rkey: "self", 1204 Record: &lexutil.LexiconTypeDecoder{Val: profileRecord}, 1205 SwapRecord: swapCid, 1206 }) 1207 1208 if err != nil { 1209 l.Error("failed to update profile record", "err", err) 1210 s.pages.Notice(w, "avatar-error", "Failed to update profile on your PDS") 1211 return 1212 } 1213 1214 l.Info("successfully updated profile with avatar") 1215 1216 profile, err := db.GetProfile(s.db, user.Did) 1217 if err != nil { 1218 l.Warn("getting profile data from DB", "err", err) 1219 } 1220 if profile == nil { 1221 profile = &models.Profile{Did: user.Did} 1222 } 1223 profile.Avatar = uploadBlobResp.Blob.Ref.String() 1224 1225 err = db.UpsertProfile(s.db, profile) 1226 if err != nil { 1227 l.Error("failed to update profile in DB", "err", err) 1228 s.pages.HxRefresh(w) 1229 w.WriteHeader(http.StatusOK) 1230 return 1231 } 1232 1233 // inline uploads (e.g. the onboarding step) manage their own preview 1234 // client-side; skip the full reload so unsaved form fields aren't cleared. 1235 if r.FormValue("inline") == "true" { 1236 w.WriteHeader(http.StatusOK) 1237 return 1238 } 1239 1240 s.pages.HxRedirect(w, r.Header.Get("Referer")) 1241} 1242 1243func (s *State) RemoveProfileAvatar(w http.ResponseWriter, r *http.Request) { 1244 l := s.logger.With("handler", "RemoveProfileAvatar") 1245 user := s.oauth.GetMultiAccountUser(r) 1246 l = l.With("did", user.Did) 1247 1248 client, err := s.oauth.AuthorizedClient(r) 1249 if err != nil { 1250 l.Error("failed to get PDS client", "err", err) 1251 s.pages.Notice(w, "avatar-error", "Failed to connect to your PDS") 1252 return 1253 } 1254 1255 getRecordResp, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 1256 if err != nil { 1257 l.Error("failed to get current profile record", "err", err) 1258 s.pages.Notice(w, "avatar-error", "Failed to get current profile from your PDS") 1259 return 1260 } 1261 1262 var profileRecord *tangled.ActorProfile 1263 if getRecordResp.Value != nil { 1264 if val, ok := getRecordResp.Value.Val.(*tangled.ActorProfile); ok { 1265 profileRecord = val 1266 } else { 1267 l.Warn("profile record type assertion failed") 1268 profileRecord = &tangled.ActorProfile{} 1269 } 1270 } else { 1271 l.Warn("no existing profile record") 1272 profileRecord = &tangled.ActorProfile{} 1273 } 1274 1275 profileRecord.Avatar = nil 1276 1277 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1278 Collection: tangled.ActorProfileNSID, 1279 Repo: user.Did, 1280 Rkey: "self", 1281 Record: &lexutil.LexiconTypeDecoder{Val: profileRecord}, 1282 SwapRecord: getRecordResp.Cid, 1283 }) 1284 1285 if err != nil { 1286 l.Error("failed to update profile record", "err", err) 1287 s.pages.Notice(w, "avatar-error", "Failed to remove avatar from your PDS") 1288 return 1289 } 1290 1291 l.Info("successfully removed avatar from PDS") 1292 1293 profile, err := db.GetProfile(s.db, user.Did) 1294 if err != nil { 1295 l.Warn("getting profile data from DB", "err", err) 1296 } 1297 if profile == nil { 1298 profile = &models.Profile{Did: user.Did} 1299 } 1300 profile.Avatar = "" 1301 1302 err = db.UpsertProfile(s.db, profile) 1303 if err != nil { 1304 l.Error("failed to update profile in DB", "err", err) 1305 s.pages.HxRefresh(w) 1306 w.WriteHeader(http.StatusOK) 1307 return 1308 } 1309 1310 s.pages.HxRedirect(w, r.Header.Get("Referer")) 1311} 1312 1313func (s *State) UpdateProfilePunchcardSetting(w http.ResponseWriter, r *http.Request) { 1314 l := s.logger.With("handler", "UpdateProfilePunchcardSetting") 1315 err := r.ParseForm() 1316 if err != nil { 1317 l.Error("invalid profile update form", "err", err) 1318 return 1319 } 1320 user := s.oauth.GetMultiAccountUser(r) 1321 1322 hideOthers := false 1323 hideMine := false 1324 1325 if r.Form.Get("hideMine") == "on" { 1326 hideMine = true 1327 } 1328 if r.Form.Get("hideOthers") == "on" { 1329 hideOthers = true 1330 } 1331 1332 err = db.UpsertPunchcardPreference(s.db, user.Did, hideMine, hideOthers) 1333 if err != nil { 1334 l.Error("failed to update punchcard preferences", "err", err) 1335 return 1336 } 1337 1338 s.pages.HxRefresh(w) 1339}