Monorepo for Tangled tangled.org
6

Configure Feed

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

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