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