Monorepo for Tangled
0

Configure Feed

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

at master 36 kB View raw
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 267 if searchOpts.HasSearchFilters() { 268 res, err := s.indexer.Repos.Search(r.Context(), searchOpts) 269 if err != nil { 270 l.Error("failed to search repos", "err", err) 271 s.pages.Error500(w) 272 return 273 } 274 275 if len(res.Hits) > 0 { 276 repos, err = db.GetRepos(s.db, orm.FilterIn("id", res.Hits)) 277 if err != nil { 278 l.Error("failed to get repos by IDs", "err", err) 279 s.pages.Error500(w) 280 return 281 } 282 283 // sort repos to match search result order (by relevance) 284 repoMap := make(map[int64]models.Repo, len(repos)) 285 for _, repo := range repos { 286 repoMap[repo.Id] = repo 287 } 288 repos = make([]models.Repo, 0, len(res.Hits)) 289 for _, id := range res.Hits { 290 if repo, ok := repoMap[id]; ok { 291 repos = append(repos, repo) 292 } 293 } 294 } 295 totalRepos = int64(res.Total) 296 } else { 297 repos, err = db.GetReposPaginated( 298 s.db, 299 page, 300 orm.FilterEq("did", profile.UserDid), 301 ) 302 if err != nil { 303 l.Error("failed to get repos", "err", err) 304 s.pages.Error500(w) 305 return 306 } 307 308 totalRepos, err = db.CountRepos( 309 s.db, 310 orm.FilterEq("did", profile.UserDid), 311 ) 312 if err != nil { 313 l.Error("failed to count repos", "err", err) 314 s.pages.Error500(w) 315 return 316 } 317 } 318 319 err = s.pages.ProfileRepos(w, pages.ProfileReposParams{ 320 LoggedInUser: s.oauth.GetMultiAccountUser(r), 321 Repos: repos, 322 Card: profile, 323 Page: page, 324 RepoCount: int(totalRepos), 325 FilterQuery: query.String(), 326 }) 327 if err != nil { 328 l.Error("failed to render page", "err", err) 329 } 330} 331 332func (s *State) starredPage(w http.ResponseWriter, r *http.Request) { 333 l := s.logger.With("handler", "starredPage") 334 335 page := pagination.FromContext(r.Context()) 336 l = l.With("page", page) 337 338 profile, err := s.profile(r) 339 if err != nil { 340 l.Error("failed to build profile card", "err", err) 341 s.pages.Error500(w) 342 return 343 } 344 l = l.With("profileDid", profile.UserDid) 345 346 stars, err := db.GetRepoStars(s.db, page, orm.FilterEq("did", profile.UserDid)) 347 if err != nil { 348 l.Error("failed to get stars", "err", err) 349 s.pages.Error500(w) 350 return 351 } 352 var repos []models.Repo 353 for _, s := range stars { 354 repos = append(repos, *s.Repo) 355 } 356 357 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ 358 LoggedInUser: s.oauth.GetMultiAccountUser(r), 359 Repos: repos, 360 Total: int(profile.Stats.StarredCount), 361 Card: profile, 362 Page: page, 363 }) 364 if err != nil { 365 l.Error("failed to render", "err", err) 366 } 367} 368 369func (s *State) stringsPage(w http.ResponseWriter, r *http.Request) { 370 l := s.logger.With("handler", "stringsPage") 371 372 profile, err := s.profile(r) 373 if err != nil { 374 l.Error("failed to build profile card", "err", err) 375 s.pages.Error500(w) 376 return 377 } 378 l = l.With("profileDid", profile.UserDid) 379 380 strings, err := db.GetStrings(s.db, 0, orm.FilterEq("did", profile.UserDid)) 381 if err != nil { 382 l.Error("failed to get strings", "err", err) 383 s.pages.Error500(w) 384 return 385 } 386 387 err = s.pages.ProfileStrings(w, pages.ProfileStringsParams{ 388 LoggedInUser: s.oauth.GetMultiAccountUser(r), 389 Strings: strings, 390 Card: profile, 391 }) 392} 393 394func (s *State) vouchesPage(w http.ResponseWriter, r *http.Request) { 395 l := s.logger.With("handler", "vouchesPage") 396 397 profile, err := s.profile(r) 398 if err != nil { 399 l.Error("failed to build profile card", "err", err) 400 s.pages.Error500(w) 401 return 402 } 403 l = l.With("profileDid", profile.UserDid) 404 405 loggedInUser := s.oauth.GetMultiAccountUser(r) 406 page := pagination.FromContext(r.Context()) 407 408 var vouches []models.Vouch 409 var vouchCount int 410 if loggedInUser != nil { 411 vouches, err = db.GetNetworkVouchTimeline(s.db, loggedInUser.Did, profile.UserDid, page) 412 if err != nil { 413 l.Error("failed to get vouch timeline", "err", err) 414 s.pages.Error500(w) 415 return 416 } 417 vouchCount, err = db.CountNetworkVouchTimeline(s.db, loggedInUser.Did, profile.UserDid) 418 if err != nil { 419 l.Error("failed to count vouch timeline", "err", err) 420 s.pages.Error500(w) 421 return 422 } 423 } 424 425 var suggestions []models.VouchSuggestion 426 if loggedInUser != nil && loggedInUser.Did == profile.UserDid { 427 suggestions, err = db.GetVouchSuggestions(s.db, profile.UserDid, 5) 428 if err != nil { 429 l.Error("failed to get vouch suggestions", "err", err) 430 } 431 432 if len(suggestions) > 0 { 433 suggestionDids := make([]syntax.DID, len(suggestions)) 434 for i, s := range suggestions { 435 suggestionDids[i] = syntax.DID(s.Did) 436 } 437 relationships, err := db.GetVouchRelationshipsBatch(s.db, syntax.DID(loggedInUser.Did), suggestionDids) 438 if err != nil { 439 l.Error("failed to get vouch relationships for suggestions", "err", err) 440 } else { 441 for i := range suggestions { 442 suggestions[i].VouchRelationship = relationships[suggestions[i].Did] 443 } 444 } 445 } 446 } 447 448 var pullAts, issueAts []syntax.ATURI 449 for _, v := range vouches { 450 for _, ev := range v.Evidences { 451 switch ev.Collection().String() { 452 case tangled.RepoPullNSID: 453 pullAts = append(pullAts, ev) 454 case tangled.RepoIssueNSID: 455 issueAts = append(issueAts, ev) 456 } 457 } 458 } 459 460 evidencePulls := make(map[syntax.ATURI]*models.Pull) 461 if len(pullAts) > 0 { 462 pulls, err := db.GetPulls(s.db, orm.FilterIn("at_uri", pullAts)) 463 if err != nil { 464 l.Error("failed to get evidence pulls", "err", err) 465 } else { 466 for _, p := range pulls { 467 evidencePulls[p.AtUri()] = p 468 } 469 } 470 } 471 472 evidenceIssues := make(map[syntax.ATURI]*models.Issue) 473 if len(issueAts) > 0 { 474 issues, err := db.GetIssues(s.db, orm.FilterIn("at_uri", issueAts)) 475 if err != nil { 476 l.Error("failed to get evidence issues", "err", err) 477 } else { 478 for i := range issues { 479 evidenceIssues[issues[i].AtUri()] = &issues[i] 480 } 481 } 482 } 483 484 err = s.pages.ProfileVouches(w, pages.ProfileVouchesParams{ 485 LoggedInUser: loggedInUser, 486 Vouches: vouches, 487 Suggestions: suggestions, 488 Card: profile, 489 Page: page, 490 VouchCount: vouchCount, 491 EvidencePulls: evidencePulls, 492 EvidenceIssues: evidenceIssues, 493 }) 494 if err != nil { 495 l.Error("failed to render page", "err", err) 496 } 497} 498 499type FollowsPageParams struct { 500 Follows []pages.FollowCard 501 Card *pages.ProfileCard 502} 503 504func (s *State) followPage( 505 r *http.Request, 506 fetchFollows func(db.Execer, string) ([]models.Follow, error), 507 extractDid func(models.Follow) string, 508) (*FollowsPageParams, error) { 509 l := s.logger.With("handler", "reposPage") 510 511 profile, err := s.profile(r) 512 if err != nil { 513 return nil, err 514 } 515 l = l.With("profileDid", profile.UserDid) 516 517 loggedInUser := s.oauth.GetMultiAccountUser(r) 518 params := FollowsPageParams{ 519 Card: profile, 520 } 521 522 follows, err := fetchFollows(s.db, profile.UserDid) 523 if err != nil { 524 l.Error("failed to fetch follows", "err", err) 525 return &params, err 526 } 527 528 if len(follows) == 0 { 529 return &params, nil 530 } 531 532 followDids := make([]string, 0, len(follows)) 533 for _, follow := range follows { 534 followDids = append(followDids, extractDid(follow)) 535 } 536 537 profiles, err := db.GetProfiles(s.db, orm.FilterIn("did", followDids)) 538 if err != nil { 539 l.Error("failed to get profiles", "followDids", followDids, "err", err) 540 return &params, err 541 } 542 543 followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) 544 if err != nil { 545 l.Error("getting follow counts", "followDids", followDids, "err", err) 546 } 547 548 loggedInUserFollowing := make(map[string]struct{}) 549 if loggedInUser != nil { 550 following, err := db.GetFollowing(s.db, loggedInUser.Did) 551 if err != nil { 552 l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did) 553 return &params, err 554 } 555 loggedInUserFollowing = make(map[string]struct{}, len(following)) 556 for _, follow := range following { 557 loggedInUserFollowing[follow.SubjectDid] = struct{}{} 558 } 559 } 560 561 followCards := make([]pages.FollowCard, len(follows)) 562 for i, did := range followDids { 563 followStats := followStatsMap[did] 564 followStatus := models.IsNotFollowing 565 if _, exists := loggedInUserFollowing[did]; exists { 566 followStatus = models.IsFollowing 567 } else if loggedInUser != nil && loggedInUser.Did == did { 568 followStatus = models.IsSelf 569 } 570 571 var profile *models.Profile 572 if p, exists := profiles[did]; exists { 573 profile = p 574 } else { 575 profile = &models.Profile{} 576 profile.Did = did 577 } 578 followCards[i] = pages.FollowCard{ 579 LoggedInUser: loggedInUser, 580 UserDid: did, 581 FollowStatus: followStatus, 582 FollowersCount: followStats.Followers, 583 FollowingCount: followStats.Following, 584 Profile: profile, 585 } 586 } 587 588 params.Follows = followCards 589 590 return &params, nil 591} 592 593func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 594 followPage, err := s.followPage(r, db.GetFollowers, func(f models.Follow) string { return f.UserDid }) 595 if err != nil { 596 s.pages.Notice(w, "all-followers", "Failed to load followers") 597 return 598 } 599 600 s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{ 601 LoggedInUser: s.oauth.GetMultiAccountUser(r), 602 Followers: followPage.Follows, 603 Card: followPage.Card, 604 }) 605} 606 607func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 608 followPage, err := s.followPage(r, db.GetFollowing, func(f models.Follow) string { return f.SubjectDid }) 609 if err != nil { 610 s.pages.Notice(w, "all-following", "Failed to load following") 611 return 612 } 613 614 s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{ 615 LoggedInUser: s.oauth.GetMultiAccountUser(r), 616 Following: followPage.Follows, 617 Card: followPage.Card, 618 }) 619} 620 621func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { 622 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 623 if !ok { 624 s.pages.Error404(w) 625 return 626 } 627 628 feed, err := s.getProfileFeed(r.Context(), &ident) 629 if err != nil { 630 s.pages.Error500(w) 631 return 632 } 633 634 if feed == nil { 635 return 636 } 637 638 atom, err := feed.ToAtom() 639 if err != nil { 640 s.pages.Error500(w) 641 return 642 } 643 644 w.Header().Set("content-type", "application/atom+xml") 645 w.Write([]byte(atom)) 646} 647 648func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) { 649 timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 650 if err != nil { 651 return nil, err 652 } 653 654 author := &feeds.Author{ 655 Name: fmt.Sprintf("@%s", id.Handle), 656 } 657 658 feed := feeds.Feed{ 659 Title: fmt.Sprintf("%s's timeline", author.Name), 660 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.BaseUrl(), id.Handle), Type: "text/html", Rel: "alternate"}, 661 Items: make([]*feeds.Item, 0), 662 Updated: time.UnixMilli(0), 663 Author: author, 664 } 665 666 for _, byMonth := range timeline.ByMonth { 667 if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil { 668 return nil, err 669 } 670 if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil { 671 return nil, err 672 } 673 if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil { 674 return nil, err 675 } 676 } 677 678 slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int { 679 return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli()) 680 }) 681 682 if len(feed.Items) > 0 { 683 feed.Updated = feed.Items[0].Created 684 } 685 686 return &feed, nil 687} 688 689func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*models.Pull, author *feeds.Author) error { 690 for _, pull := range pulls { 691 owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 692 if err != nil { 693 return err 694 } 695 696 // Add pull request creation item 697 feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author)) 698 } 699 return nil 700} 701 702func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*models.Issue, author *feeds.Author) error { 703 for _, issue := range issues { 704 owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did) 705 if err != nil { 706 return err 707 } 708 709 feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author)) 710 } 711 return nil 712} 713 714func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []models.RepoEvent, author *feeds.Author) error { 715 for _, repo := range repos { 716 item, err := s.createRepoItem(ctx, repo, author) 717 if err != nil { 718 return err 719 } 720 feed.Items = append(feed.Items, item) 721 } 722 return nil 723} 724 725func (s *State) createPullRequestItem(pull *models.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 726 return &feeds.Item{ 727 Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 728 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.BaseUrl(), owner.Handle, pull.Repo.Rkey, pull.PullId), Type: "text/html", Rel: "alternate"}, 729 Created: pull.Created, 730 Author: author, 731 } 732} 733 734func (s *State) createIssueItem(issue *models.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 735 return &feeds.Item{ 736 Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name), 737 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.BaseUrl(), owner.Handle, issue.Repo.Rkey, issue.IssueId), Type: "text/html", Rel: "alternate"}, 738 Created: issue.Created, 739 Author: author, 740 } 741} 742 743func (s *State) createRepoItem(ctx context.Context, repo models.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 744 var title string 745 if repo.Source != nil { 746 sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) 747 if err != nil { 748 return nil, err 749 } 750 title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name) 751 } else { 752 title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) 753 } 754 755 return &feeds.Item{ 756 Title: title, 757 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.BaseUrl(), author.Name[1:], repo.Repo.Rkey), Type: "text/html", Rel: "alternate"}, // Remove @ prefix 758 Created: repo.Repo.Created, 759 Author: author, 760 }, nil 761} 762 763func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { 764 l := s.logger.With("handler", "UpdateProfileBio") 765 user := s.oauth.GetMultiAccountUser(r) 766 767 err := r.ParseForm() 768 if err != nil { 769 l.Error("invalid profile update form", "err", err) 770 s.pages.Notice(w, "update-profile", "Invalid form.") 771 return 772 } 773 774 profile, err := db.GetProfile(s.db, user.Did) 775 if err != nil { 776 l.Error("getting profile data", "did", user.Did, "err", err) 777 } 778 if profile == nil { 779 profile = &models.Profile{Did: user.Did} 780 } 781 782 profile.Description = r.FormValue("description") 783 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" 784 profile.Location = r.FormValue("location") 785 profile.Pronouns = r.FormValue("pronouns") 786 rawPreferredHandle := strings.TrimSpace(r.FormValue("preferredHandle")) 787 if rawPreferredHandle != "" { 788 h, err := syntax.ParseHandle(rawPreferredHandle) 789 if err != nil { 790 s.pages.Notice(w, "update-profile", "Invalid handle format.") 791 return 792 } 793 794 ident, err := s.idResolver.ResolveIdent(r.Context(), user.Did) 795 if err != nil || !slices.Contains(ident.AlsoKnownAs, "at://"+rawPreferredHandle) { 796 s.pages.Notice(w, "update-profile", "Handle not found in your DID document.") 797 return 798 } 799 profile.PreferredHandle = h 800 } else { 801 profile.PreferredHandle = "" 802 } 803 804 var links [5]string 805 for i := range 5 { 806 iLink := r.FormValue(fmt.Sprintf("link%d", i)) 807 links[i] = iLink 808 } 809 profile.Links = links 810 811 // Parse stats (exactly 2) 812 stat0 := r.FormValue("stat0") 813 stat1 := r.FormValue("stat1") 814 815 profile.Stats[0].Kind = models.ParseVanityStatKind(stat0) 816 profile.Stats[1].Kind = models.ParseVanityStatKind(stat1) 817 818 if err := db.ValidateProfile(s.db, profile); err != nil { 819 l.Error("invalid profile", "err", err) 820 s.pages.Notice(w, "update-profile", err.Error()) 821 return 822 } 823 824 s.updateProfile(profile, w, r) 825} 826 827func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { 828 l := s.logger.With("handler", "UpdateProfilePins") 829 user := s.oauth.GetMultiAccountUser(r) 830 831 err := r.ParseForm() 832 if err != nil { 833 l.Error("invalid profile update form", "err", err) 834 s.pages.Notice(w, "update-profile", "Invalid form.") 835 return 836 } 837 838 profile, err := db.GetProfile(s.db, user.Did) 839 if err != nil { 840 l.Error("getting profile data", "did", user.Did, "err", err) 841 } 842 if profile == nil { 843 profile = &models.Profile{Did: user.Did} 844 } 845 846 i := 0 847 var pinnedRepos [6]string 848 for key, values := range r.Form { 849 if i >= 6 { 850 l.Warn("too many pinned repos") 851 s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.") 852 return 853 } 854 if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 { 855 pinnedRepos[i] = values[0] 856 i++ 857 } 858 } 859 profile.PinnedRepos = pinnedRepos 860 861 s.updateProfile(profile, w, r) 862} 863 864func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) { 865 l := s.logger.With("handler", "updateProfile") 866 user := s.oauth.GetMultiAccountUser(r) 867 868 client, err := s.oauth.AuthorizedClient(r) 869 if err != nil { 870 l.Error("failed to get authorized client", "err", err) 871 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 872 return 873 } 874 875 var pinnedRepoStrings []string 876 for _, r := range profile.PinnedRepos { 877 if r != "" { 878 pinnedRepoStrings = append(pinnedRepoStrings, r) 879 } 880 } 881 882 var vanityStats []string 883 for _, v := range profile.Stats { 884 vanityStats = append(vanityStats, string(v.Kind)) 885 } 886 887 ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 888 var cid *string 889 var existingAvatar *lexutil.LexBlob 890 if ex != nil { 891 cid = ex.Cid 892 if rec, ok := ex.Value.Val.(*tangled.ActorProfile); ok { 893 existingAvatar = rec.Avatar 894 } 895 } 896 897 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 898 Collection: tangled.ActorProfileNSID, 899 Repo: user.Did, 900 Rkey: "self", 901 Record: &lexutil.LexiconTypeDecoder{ 902 Val: &tangled.ActorProfile{ 903 Avatar: existingAvatar, 904 Bluesky: profile.IncludeBluesky, 905 Description: &profile.Description, 906 Links: profile.Links[:], 907 Location: &profile.Location, 908 PinnedRepositories: pinnedRepoStrings, 909 Stats: vanityStats[:], 910 Pronouns: &profile.Pronouns, 911 PreferredHandle: (*string)(&profile.PreferredHandle), 912 }}, 913 SwapRecord: cid, 914 }) 915 if err != nil { 916 l.Error("failed to update profile on PDS", "err", err) 917 s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.") 918 return 919 } 920 921 tx, err := s.db.BeginTx(r.Context(), nil) 922 if err != nil { 923 l.Error("failed to start transaction", "err", err) 924 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 925 return 926 } 927 928 if err := db.UpsertProfile(tx, profile); err != nil { 929 l.Error("failed to update profile in DB", "err", err) 930 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 931 return 932 } 933 934 if s.rdb != nil { 935 ctx := r.Context() 936 pipe := s.rdb.Pipeline() 937 didKey := fmt.Sprintf(cache.PreferredHandleByDid, profile.Did) 938 if profile.PreferredHandle != "" { 939 pipe.Set(ctx, didKey, string(profile.PreferredHandle), cache.PreferredHandleTTL) 940 pipe.Set(ctx, fmt.Sprintf(cache.PreferredHandleByHandle, string(profile.PreferredHandle)), profile.Did, cache.PreferredHandleTTL) 941 } else { 942 pipe.Del(ctx, didKey) 943 } 944 if _, execErr := pipe.Exec(ctx); execErr != nil { 945 l.Warn("failed to update preferred handle cache", "err", execErr) 946 } 947 } 948 949 s.notifier.UpdateProfile(r.Context(), profile) 950 951 s.pages.HxRedirect(w, "/"+user.Did) 952} 953 954func (s *State) ProfilePopover(w http.ResponseWriter, r *http.Request) { 955 l := s.logger.With("handler", "ProfilePopover") 956 957 did := r.URL.Query().Get("did") 958 if did == "" { 959 l.Warn("missing did param") 960 w.WriteHeader(http.StatusBadRequest) 961 return 962 } 963 964 profile, err := db.GetProfile(s.db, did) 965 if err != nil { 966 l.Error("failed to get profile", "did", did, "err", err) 967 w.WriteHeader(http.StatusInternalServerError) 968 return 969 } 970 if profile == nil { 971 profile = &models.Profile{Did: did} 972 } 973 974 followStats, err := db.GetFollowerFollowingCount(s.db, did) 975 if err != nil { 976 l.Error("failed to get follower stats", "did", did, "err", err) 977 w.WriteHeader(http.StatusInternalServerError) 978 return 979 } 980 981 loggedInUser := s.oauth.GetMultiAccountUser(r) 982 followStatus := models.IsNotFollowing 983 var vouchRelationship *models.VouchRelationship 984 985 if loggedInUser != nil { 986 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 987 vouchRelationship, _ = db.GetVouchRelationship(s.db, syntax.DID(loggedInUser.Did), syntax.DID(did)) 988 } 989 990 s.pages.ProfilePopoverFragment(w, pages.ProfilePopoverParams{ 991 LoggedInUser: loggedInUser, 992 UserDid: did, 993 Profile: profile, 994 FollowStatus: followStatus, 995 VouchRelationship: vouchRelationship, 996 Stats: pages.ProfilePopoverStats{ 997 FollowersCount: followStats.Followers, 998 FollowingCount: followStats.Following, 999 }, 1000 }) 1001} 1002 1003func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) { 1004 l := s.logger.With("handler", "EditBioFragment") 1005 user := s.oauth.GetMultiAccountUser(r) 1006 1007 profile, err := db.GetProfile(s.db, user.Did) 1008 if err != nil { 1009 l.Error("getting profile data", "did", user.Did, "err", err) 1010 } 1011 if profile == nil { 1012 profile = &models.Profile{Did: user.Did} 1013 } 1014 1015 var alsoKnownAs []string 1016 ident, err := s.idResolver.ResolveIdent(r.Context(), user.Did) 1017 if err == nil { 1018 alsoKnownAs = ident.AlsoKnownAs 1019 } 1020 1021 s.pages.EditBioFragment(w, pages.EditBioParams{ 1022 LoggedInUser: user, 1023 Profile: profile, 1024 AlsoKnownAs: alsoKnownAs, 1025 }) 1026} 1027 1028func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) { 1029 l := s.logger.With("handler", "EditPinsFragment") 1030 user := s.oauth.GetMultiAccountUser(r) 1031 1032 profile, err := db.GetProfile(s.db, user.Did) 1033 if err != nil { 1034 l.Error("getting profile data", "did", user.Did, "err", err) 1035 } 1036 if profile == nil { 1037 profile = &models.Profile{Did: user.Did} 1038 } 1039 1040 repos, err := db.GetRepos(s.db, orm.FilterEq("did", user.Did)) 1041 if err != nil { 1042 l.Error("getting repos", "did", user.Did, "err", err) 1043 } 1044 1045 collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did) 1046 if err != nil { 1047 l.Error("getting collaborating repos", "did", user.Did, "err", err) 1048 } 1049 1050 allRepos := []pages.PinnedRepo{} 1051 1052 for _, r := range repos { 1053 allRepos = append(allRepos, pages.PinnedRepo{ 1054 IsPinned: profile.MatchesPinnedRepo(r), 1055 Repo: r, 1056 }) 1057 } 1058 for _, r := range collaboratingRepos { 1059 allRepos = append(allRepos, pages.PinnedRepo{ 1060 IsPinned: profile.MatchesPinnedRepo(r), 1061 Repo: r, 1062 }) 1063 } 1064 1065 s.pages.EditPinsFragment(w, pages.EditPinsParams{ 1066 LoggedInUser: user, 1067 Profile: profile, 1068 AllRepos: allRepos, 1069 }) 1070} 1071 1072func (s *State) UploadProfileAvatar(w http.ResponseWriter, r *http.Request) { 1073 l := s.logger.With("handler", "UploadProfileAvatar") 1074 user := s.oauth.GetMultiAccountUser(r) 1075 l = l.With("did", user.Did) 1076 1077 // Parse multipart form (10MB max) 1078 if err := r.ParseMultipartForm(10 << 20); err != nil { 1079 l.Error("failed to parse form", "err", err) 1080 s.pages.Notice(w, "avatar-error", "Failed to parse form") 1081 return 1082 } 1083 1084 file, header, err := r.FormFile("avatar") 1085 if err != nil { 1086 l.Error("failed to read avatar file", "err", err) 1087 s.pages.Notice(w, "avatar-error", "Failed to read avatar file") 1088 return 1089 } 1090 defer file.Close() 1091 1092 if header.Size > 5000000 { 1093 l.Warn("avatar file too large", "size", header.Size) 1094 s.pages.Notice(w, "avatar-error", "Avatar file too large (max 5MB)") 1095 return 1096 } 1097 1098 contentType := header.Header.Get("Content-Type") 1099 if contentType != "image/png" && contentType != "image/jpeg" { 1100 l.Warn("invalid image type", "contentType", contentType) 1101 s.pages.Notice(w, "avatar-error", "Invalid image type (only PNG and JPEG allowed)") 1102 return 1103 } 1104 1105 client, err := s.oauth.AuthorizedClient(r) 1106 if err != nil { 1107 l.Error("failed to get PDS client", "err", err) 1108 s.pages.Notice(w, "avatar-error", "Failed to connect to your PDS") 1109 return 1110 } 1111 1112 uploadBlobResp, err := xrpc.RepoUploadBlob(r.Context(), client, file, header.Header.Get("Content-Type")) 1113 if err != nil { 1114 l.Error("failed to upload avatar blob", "err", err) 1115 s.pages.Notice(w, "avatar-error", "Failed to upload avatar to your PDS") 1116 return 1117 } 1118 1119 l.Info("uploaded avatar blob", "cid", uploadBlobResp.Blob.Ref.String()) 1120 1121 // get current profile record from PDS to get its CID for swap 1122 getRecordResp, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 1123 if err != nil { 1124 l.Error("failed to get current profile record", "err", err) 1125 s.pages.Notice(w, "avatar-error", "Failed to get current profile from your PDS") 1126 return 1127 } 1128 1129 var profileRecord *tangled.ActorProfile 1130 if getRecordResp.Value != nil { 1131 if val, ok := getRecordResp.Value.Val.(*tangled.ActorProfile); ok { 1132 profileRecord = val 1133 } else { 1134 l.Warn("profile record type assertion failed, creating new record") 1135 profileRecord = &tangled.ActorProfile{} 1136 } 1137 } else { 1138 l.Warn("no existing profile record, creating new record") 1139 profileRecord = &tangled.ActorProfile{} 1140 } 1141 1142 profileRecord.Avatar = uploadBlobResp.Blob 1143 1144 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1145 Collection: tangled.ActorProfileNSID, 1146 Repo: user.Did, 1147 Rkey: "self", 1148 Record: &lexutil.LexiconTypeDecoder{Val: profileRecord}, 1149 SwapRecord: getRecordResp.Cid, 1150 }) 1151 1152 if err != nil { 1153 l.Error("failed to update profile record", "err", err) 1154 s.pages.Notice(w, "avatar-error", "Failed to update profile on your PDS") 1155 return 1156 } 1157 1158 l.Info("successfully updated profile with avatar") 1159 1160 profile, err := db.GetProfile(s.db, user.Did) 1161 if err != nil { 1162 l.Warn("getting profile data from DB", "err", err) 1163 } 1164 if profile == nil { 1165 profile = &models.Profile{Did: user.Did} 1166 } 1167 profile.Avatar = uploadBlobResp.Blob.Ref.String() 1168 1169 tx, err := s.db.BeginTx(r.Context(), nil) 1170 if err != nil { 1171 l.Error("failed to start transaction", "err", err) 1172 s.pages.HxRefresh(w) 1173 w.WriteHeader(http.StatusOK) 1174 return 1175 } 1176 1177 err = db.UpsertProfile(tx, profile) 1178 if err != nil { 1179 l.Error("failed to update profile in DB", "err", err) 1180 s.pages.HxRefresh(w) 1181 w.WriteHeader(http.StatusOK) 1182 return 1183 } 1184 1185 s.pages.HxRedirect(w, r.Header.Get("Referer")) 1186} 1187 1188func (s *State) RemoveProfileAvatar(w http.ResponseWriter, r *http.Request) { 1189 l := s.logger.With("handler", "RemoveProfileAvatar") 1190 user := s.oauth.GetMultiAccountUser(r) 1191 l = l.With("did", user.Did) 1192 1193 client, err := s.oauth.AuthorizedClient(r) 1194 if err != nil { 1195 l.Error("failed to get PDS client", "err", err) 1196 s.pages.Notice(w, "avatar-error", "Failed to connect to your PDS") 1197 return 1198 } 1199 1200 getRecordResp, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 1201 if err != nil { 1202 l.Error("failed to get current profile record", "err", err) 1203 s.pages.Notice(w, "avatar-error", "Failed to get current profile from your PDS") 1204 return 1205 } 1206 1207 var profileRecord *tangled.ActorProfile 1208 if getRecordResp.Value != nil { 1209 if val, ok := getRecordResp.Value.Val.(*tangled.ActorProfile); ok { 1210 profileRecord = val 1211 } else { 1212 l.Warn("profile record type assertion failed") 1213 profileRecord = &tangled.ActorProfile{} 1214 } 1215 } else { 1216 l.Warn("no existing profile record") 1217 profileRecord = &tangled.ActorProfile{} 1218 } 1219 1220 profileRecord.Avatar = nil 1221 1222 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1223 Collection: tangled.ActorProfileNSID, 1224 Repo: user.Did, 1225 Rkey: "self", 1226 Record: &lexutil.LexiconTypeDecoder{Val: profileRecord}, 1227 SwapRecord: getRecordResp.Cid, 1228 }) 1229 1230 if err != nil { 1231 l.Error("failed to update profile record", "err", err) 1232 s.pages.Notice(w, "avatar-error", "Failed to remove avatar from your PDS") 1233 return 1234 } 1235 1236 l.Info("successfully removed avatar from PDS") 1237 1238 profile, err := db.GetProfile(s.db, user.Did) 1239 if err != nil { 1240 l.Warn("getting profile data from DB", "err", err) 1241 } 1242 if profile == nil { 1243 profile = &models.Profile{Did: user.Did} 1244 } 1245 profile.Avatar = "" 1246 1247 tx, err := s.db.BeginTx(r.Context(), nil) 1248 if err != nil { 1249 l.Error("failed to start transaction", "err", err) 1250 s.pages.HxRefresh(w) 1251 w.WriteHeader(http.StatusOK) 1252 return 1253 } 1254 1255 err = db.UpsertProfile(tx, profile) 1256 if err != nil { 1257 l.Error("failed to update profile in DB", "err", err) 1258 s.pages.HxRefresh(w) 1259 w.WriteHeader(http.StatusOK) 1260 return 1261 } 1262 1263 s.pages.HxRedirect(w, r.Header.Get("Referer")) 1264} 1265 1266func (s *State) UpdateProfilePunchcardSetting(w http.ResponseWriter, r *http.Request) { 1267 l := s.logger.With("handler", "UpdateProfilePunchcardSetting") 1268 err := r.ParseForm() 1269 if err != nil { 1270 l.Error("invalid profile update form", "err", err) 1271 return 1272 } 1273 user := s.oauth.GetMultiAccountUser(r) 1274 1275 hideOthers := false 1276 hideMine := false 1277 1278 if r.Form.Get("hideMine") == "on" { 1279 hideMine = true 1280 } 1281 if r.Form.Get("hideOthers") == "on" { 1282 hideOthers = true 1283 } 1284 1285 err = db.UpsertPunchcardPreference(s.db, user.Did, hideMine, hideOthers) 1286 if err != nil { 1287 l.Error("failed to update punchcard preferences", "err", err) 1288 return 1289 } 1290 1291 s.pages.HxRefresh(w) 1292}