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