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