A vibe coded tangled fork which supports pijul.
at 10b0fbe912120e844758910613f7b10b5a1297c2 1008 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 defer tx.Rollback() 661 662 err = db.UpsertProfile(tx, profile) 663 if err != nil { 664 log.Println("failed to update profile", err) 665 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 666 return 667 } 668 669 client, err := s.oauth.AuthorizedClient(r) 670 if err != nil { 671 log.Println("failed to get authorized client", err) 672 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 673 return 674 } 675 676 // yeah... lexgen dose not support syntax.ATURI in the record for some reason, 677 // nor does it support exact size arrays 678 var pinnedRepoStrings []string 679 for _, r := range profile.PinnedRepos { 680 pinnedRepoStrings = append(pinnedRepoStrings, r.String()) 681 } 682 683 var vanityStats []string 684 for _, v := range profile.Stats { 685 vanityStats = append(vanityStats, string(v.Kind)) 686 } 687 688 ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Active.Did, "self") 689 var cid *string 690 if ex != nil { 691 cid = ex.Cid 692 } 693 694 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 695 Collection: tangled.ActorProfileNSID, 696 Repo: user.Active.Did, 697 Rkey: "self", 698 Record: &lexutil.LexiconTypeDecoder{ 699 Val: &tangled.ActorProfile{ 700 Bluesky: profile.IncludeBluesky, 701 Description: &profile.Description, 702 Links: profile.Links[:], 703 Location: &profile.Location, 704 PinnedRepositories: pinnedRepoStrings, 705 Stats: vanityStats[:], 706 Pronouns: &profile.Pronouns, 707 }}, 708 SwapRecord: cid, 709 }) 710 if err != nil { 711 log.Println("failed to update profile", err) 712 s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.") 713 return 714 } 715 716 if err := tx.Commit(); err != nil { 717 s.logger.Error("failed to commit transaction", "err", err) 718 // db failed, but PDS operation succeed. 719 // log error and continue 720 } 721 722 s.notifier.UpdateProfile(r.Context(), profile) 723 724 s.pages.HxRedirect(w, "/"+user.Active.Did) 725} 726 727func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) { 728 user := s.oauth.GetMultiAccountUser(r) 729 730 profile, err := db.GetProfile(s.db, user.Active.Did) 731 if err != nil { 732 log.Printf("getting profile data for %s: %s", user.Active.Did, err) 733 } 734 if profile == nil { 735 profile = &models.Profile{Did: user.Active.Did} 736 } 737 738 s.pages.EditBioFragment(w, pages.EditBioParams{ 739 LoggedInUser: user, 740 Profile: profile, 741 }) 742} 743 744func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) { 745 user := s.oauth.GetMultiAccountUser(r) 746 747 profile, err := db.GetProfile(s.db, user.Active.Did) 748 if err != nil { 749 log.Printf("getting profile data for %s: %s", user.Active.Did, err) 750 } 751 if profile == nil { 752 profile = &models.Profile{Did: user.Active.Did} 753 } 754 755 repos, err := db.GetRepos(s.db, 0, orm.FilterEq("did", user.Active.Did)) 756 if err != nil { 757 log.Printf("getting repos for %s: %s", user.Active.Did, err) 758 } 759 760 collaboratingRepos, err := db.CollaboratingIn(s.db, user.Active.Did) 761 if err != nil { 762 log.Printf("getting collaborating repos for %s: %s", user.Active.Did, err) 763 } 764 765 allRepos := []pages.PinnedRepo{} 766 767 for _, r := range repos { 768 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) 769 allRepos = append(allRepos, pages.PinnedRepo{ 770 IsPinned: isPinned, 771 Repo: r, 772 }) 773 } 774 for _, r := range collaboratingRepos { 775 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) 776 allRepos = append(allRepos, pages.PinnedRepo{ 777 IsPinned: isPinned, 778 Repo: r, 779 }) 780 } 781 782 s.pages.EditPinsFragment(w, pages.EditPinsParams{ 783 LoggedInUser: user, 784 Profile: profile, 785 AllRepos: allRepos, 786 }) 787} 788 789func (s *State) UploadProfileAvatar(w http.ResponseWriter, r *http.Request) { 790 l := s.logger.With("handler", "UploadProfileAvatar") 791 user := s.oauth.GetUser(r) 792 l = l.With("did", user.Did) 793 794 // Parse multipart form (10MB max) 795 if err := r.ParseMultipartForm(10 << 20); err != nil { 796 l.Error("failed to parse form", "err", err) 797 s.pages.Notice(w, "avatar-error", "Failed to parse form") 798 return 799 } 800 801 file, header, err := r.FormFile("avatar") 802 if err != nil { 803 l.Error("failed to read avatar file", "err", err) 804 s.pages.Notice(w, "avatar-error", "Failed to read avatar file") 805 return 806 } 807 defer file.Close() 808 809 if header.Size > 5000000 { 810 l.Warn("avatar file too large", "size", header.Size) 811 s.pages.Notice(w, "avatar-error", "Avatar file too large (max 5MB)") 812 return 813 } 814 815 contentType := header.Header.Get("Content-Type") 816 if contentType != "image/png" && contentType != "image/jpeg" { 817 l.Warn("invalid image type", "contentType", contentType) 818 s.pages.Notice(w, "avatar-error", "Invalid image type (only PNG and JPEG allowed)") 819 return 820 } 821 822 client, err := s.oauth.AuthorizedClient(r) 823 if err != nil { 824 l.Error("failed to get PDS client", "err", err) 825 s.pages.Notice(w, "avatar-error", "Failed to connect to your PDS") 826 return 827 } 828 829 uploadBlobResp, err := xrpc.RepoUploadBlob(r.Context(), client, file, header.Header.Get("Content-Type")) 830 if err != nil { 831 l.Error("failed to upload avatar blob", "err", err) 832 s.pages.Notice(w, "avatar-error", "Failed to upload avatar to your PDS") 833 return 834 } 835 836 l.Info("uploaded avatar blob", "cid", uploadBlobResp.Blob.Ref.String()) 837 838 // get current profile record from PDS to get its CID for swap 839 getRecordResp, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 840 if err != nil { 841 l.Error("failed to get current profile record", "err", err) 842 s.pages.Notice(w, "avatar-error", "Failed to get current profile from your PDS") 843 return 844 } 845 846 var profileRecord *tangled.ActorProfile 847 if getRecordResp.Value != nil { 848 if val, ok := getRecordResp.Value.Val.(*tangled.ActorProfile); ok { 849 profileRecord = val 850 } else { 851 l.Warn("profile record type assertion failed, creating new record") 852 profileRecord = &tangled.ActorProfile{} 853 } 854 } else { 855 l.Warn("no existing profile record, creating new record") 856 profileRecord = &tangled.ActorProfile{} 857 } 858 859 profileRecord.Avatar = uploadBlobResp.Blob 860 861 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 862 Collection: tangled.ActorProfileNSID, 863 Repo: user.Did, 864 Rkey: "self", 865 Record: &lexutil.LexiconTypeDecoder{Val: profileRecord}, 866 SwapRecord: getRecordResp.Cid, 867 }) 868 869 if err != nil { 870 l.Error("failed to update profile record", "err", err) 871 s.pages.Notice(w, "avatar-error", "Failed to update profile on your PDS") 872 return 873 } 874 875 l.Info("successfully updated profile with avatar") 876 877 profile, err := db.GetProfile(s.db, user.Did) 878 if err != nil { 879 l.Warn("getting profile data from DB", "err", err) 880 } 881 if profile == nil { 882 profile = &models.Profile{Did: user.Did} 883 } 884 profile.Avatar = uploadBlobResp.Blob.Ref.String() 885 886 tx, err := s.db.BeginTx(r.Context(), nil) 887 if err != nil { 888 l.Error("failed to start transaction", "err", err) 889 s.pages.HxRefresh(w) 890 w.WriteHeader(http.StatusOK) 891 return 892 } 893 894 err = db.UpsertProfile(tx, profile) 895 if err != nil { 896 l.Error("failed to update profile in DB", "err", err) 897 s.pages.HxRefresh(w) 898 w.WriteHeader(http.StatusOK) 899 return 900 } 901 902 s.pages.HxRedirect(w, r.Header.Get("Referer")) 903} 904 905func (s *State) RemoveProfileAvatar(w http.ResponseWriter, r *http.Request) { 906 l := s.logger.With("handler", "RemoveProfileAvatar") 907 user := s.oauth.GetUser(r) 908 l = l.With("did", user.Did) 909 910 client, err := s.oauth.AuthorizedClient(r) 911 if err != nil { 912 l.Error("failed to get PDS client", "err", err) 913 s.pages.Notice(w, "avatar-error", "Failed to connect to your PDS") 914 return 915 } 916 917 getRecordResp, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 918 if err != nil { 919 l.Error("failed to get current profile record", "err", err) 920 s.pages.Notice(w, "avatar-error", "Failed to get current profile from your PDS") 921 return 922 } 923 924 var profileRecord *tangled.ActorProfile 925 if getRecordResp.Value != nil { 926 if val, ok := getRecordResp.Value.Val.(*tangled.ActorProfile); ok { 927 profileRecord = val 928 } else { 929 l.Warn("profile record type assertion failed") 930 profileRecord = &tangled.ActorProfile{} 931 } 932 } else { 933 l.Warn("no existing profile record") 934 profileRecord = &tangled.ActorProfile{} 935 } 936 937 profileRecord.Avatar = nil 938 939 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 940 Collection: tangled.ActorProfileNSID, 941 Repo: user.Did, 942 Rkey: "self", 943 Record: &lexutil.LexiconTypeDecoder{Val: profileRecord}, 944 SwapRecord: getRecordResp.Cid, 945 }) 946 947 if err != nil { 948 l.Error("failed to update profile record", "err", err) 949 s.pages.Notice(w, "avatar-error", "Failed to remove avatar from your PDS") 950 return 951 } 952 953 l.Info("successfully removed avatar from PDS") 954 955 profile, err := db.GetProfile(s.db, user.Did) 956 if err != nil { 957 l.Warn("getting profile data from DB", "err", err) 958 } 959 if profile == nil { 960 profile = &models.Profile{Did: user.Did} 961 } 962 profile.Avatar = "" 963 964 tx, err := s.db.BeginTx(r.Context(), nil) 965 if err != nil { 966 l.Error("failed to start transaction", "err", err) 967 s.pages.HxRefresh(w) 968 w.WriteHeader(http.StatusOK) 969 return 970 } 971 972 err = db.UpsertProfile(tx, profile) 973 if err != nil { 974 l.Error("failed to update profile in DB", "err", err) 975 s.pages.HxRefresh(w) 976 w.WriteHeader(http.StatusOK) 977 return 978 } 979 980 s.pages.HxRedirect(w, r.Header.Get("Referer")) 981} 982 983func (s *State) UpdateProfilePunchcardSetting(w http.ResponseWriter, r *http.Request) { 984 err := r.ParseForm() 985 if err != nil { 986 log.Println("invalid profile update form", err) 987 return 988 } 989 user := s.oauth.GetUser(r) 990 991 hideOthers := false 992 hideMine := false 993 994 if r.Form.Get("hideMine") == "on" { 995 hideMine = true 996 } 997 if r.Form.Get("hideOthers") == "on" { 998 hideOthers = true 999 } 1000 1001 err = db.UpsertPunchcardPreference(s.db, user.Did, hideMine, hideOthers) 1002 if err != nil { 1003 log.Println("failed to update punchcard preferences", err) 1004 return 1005 } 1006 1007 s.pages.HxRefresh(w) 1008}