This repository has no description
0

Configure Feed

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

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