A vibe coded tangled fork which supports pijul.
at 5b6b7aea6d3cfcfd102636849b4e2b690a7160ce 684 lines 19 kB view raw
1package state 2 3import ( 4 "context" 5 "errors" 6 "fmt" 7 "log" 8 "net/http" 9 "slices" 10 "strings" 11 "time" 12 13 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 "github.com/bluesky-social/indigo/atproto/identity" 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 "github.com/go-chi/chi/v5" 18 "github.com/gorilla/feeds" 19 "tangled.sh/tangled.sh/core/api/tangled" 20 "tangled.sh/tangled.sh/core/appview/db" 21 "tangled.sh/tangled.sh/core/appview/oauth" 22 "tangled.sh/tangled.sh/core/appview/pages" 23) 24 25func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 26 tabVal := r.URL.Query().Get("tab") 27 switch tabVal { 28 case "": 29 s.profilePage(w, r) 30 case "repos": 31 s.reposPage(w, r) 32 case "followers": 33 s.followersPage(w, r) 34 case "following": 35 s.followingPage(w, r) 36 } 37} 38 39func (s *State) profilePage(w http.ResponseWriter, r *http.Request) { 40 didOrHandle := chi.URLParam(r, "user") 41 if didOrHandle == "" { 42 http.Error(w, "Bad request", http.StatusBadRequest) 43 return 44 } 45 46 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 47 if !ok { 48 s.pages.Error404(w) 49 return 50 } 51 52 profile, err := db.GetProfile(s.db, ident.DID.String()) 53 if err != nil { 54 log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 55 } 56 57 repos, err := db.GetRepos( 58 s.db, 59 0, 60 db.FilterEq("did", ident.DID.String()), 61 ) 62 if err != nil { 63 log.Printf("getting repos for %s: %s", ident.DID.String(), err) 64 } 65 66 // filter out ones that are pinned 67 pinnedRepos := []db.Repo{} 68 for i, r := range repos { 69 // if this is a pinned repo, add it 70 if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 71 pinnedRepos = append(pinnedRepos, r) 72 } 73 74 // if there are no saved pins, add the first 4 repos 75 if profile.IsPinnedReposEmpty() && i < 4 { 76 pinnedRepos = append(pinnedRepos, r) 77 } 78 } 79 80 collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) 81 if err != nil { 82 log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) 83 } 84 85 pinnedCollaboratingRepos := []db.Repo{} 86 for _, r := range collaboratingRepos { 87 // if this is a pinned repo, add it 88 if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 89 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) 90 } 91 } 92 93 timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String()) 94 if err != nil { 95 log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) 96 } 97 98 followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 99 if err != nil { 100 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 101 } 102 103 loggedInUser := s.oauth.GetUser(r) 104 followStatus := db.IsNotFollowing 105 if loggedInUser != nil { 106 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 107 } 108 109 now := time.Now() 110 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 111 punchcard, err := db.MakePunchcard( 112 s.db, 113 db.FilterEq("did", ident.DID.String()), 114 db.FilterGte("date", startOfYear.Format(time.DateOnly)), 115 db.FilterLte("date", now.Format(time.DateOnly)), 116 ) 117 if err != nil { 118 log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 119 } 120 121 s.pages.ProfilePage(w, pages.ProfilePageParams{ 122 LoggedInUser: loggedInUser, 123 Repos: pinnedRepos, 124 CollaboratingRepos: pinnedCollaboratingRepos, 125 Card: pages.ProfileCard{ 126 UserDid: ident.DID.String(), 127 UserHandle: ident.Handle.String(), 128 Profile: profile, 129 FollowStatus: followStatus, 130 FollowersCount: followers, 131 FollowingCount: following, 132 }, 133 Punchcard: punchcard, 134 ProfileTimeline: timeline, 135 }) 136} 137 138func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 139 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 140 if !ok { 141 s.pages.Error404(w) 142 return 143 } 144 145 profile, err := db.GetProfile(s.db, ident.DID.String()) 146 if err != nil { 147 log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 148 } 149 150 repos, err := db.GetRepos( 151 s.db, 152 0, 153 db.FilterEq("did", ident.DID.String()), 154 ) 155 if err != nil { 156 log.Printf("getting repos for %s: %s", ident.DID.String(), err) 157 } 158 159 loggedInUser := s.oauth.GetUser(r) 160 followStatus := db.IsNotFollowing 161 if loggedInUser != nil { 162 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 163 } 164 165 followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 166 if err != nil { 167 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 168 } 169 170 s.pages.ReposPage(w, pages.ReposPageParams{ 171 LoggedInUser: loggedInUser, 172 Repos: repos, 173 Card: pages.ProfileCard{ 174 UserDid: ident.DID.String(), 175 UserHandle: ident.Handle.String(), 176 Profile: profile, 177 FollowStatus: followStatus, 178 FollowersCount: followers, 179 FollowingCount: following, 180 }, 181 }) 182} 183 184type FollowsPageParams struct { 185 LoggedInUser *oauth.User 186 Follows []pages.FollowCard 187 Card pages.ProfileCard 188} 189 190func (s *State) followPage(w http.ResponseWriter, r *http.Request, fetchFollows func(db.Execer, string) ([]db.Follow, error), extractDid func(db.Follow) string) (FollowsPageParams, error) { 191 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 192 if !ok { 193 s.pages.Error404(w) 194 return FollowsPageParams{}, errors.New("identity not found") 195 } 196 did := ident.DID.String() 197 198 profile, err := db.GetProfile(s.db, did) 199 if err != nil { 200 log.Printf("getting profile data for %s: %s", did, err) 201 return FollowsPageParams{}, err 202 } 203 204 loggedInUser := s.oauth.GetUser(r) 205 206 follows, err := fetchFollows(s.db, did) 207 if err != nil { 208 log.Printf("getting followers for %s: %s", did, err) 209 return FollowsPageParams{}, err 210 } 211 212 var loggedInUserFollowing map[string]struct{} 213 if loggedInUser != nil { 214 following, err := db.GetFollowing(s.db, loggedInUser.Did) 215 if err != nil { 216 return FollowsPageParams{}, err 217 } 218 if len(following) > 0 { 219 loggedInUserFollowing = make(map[string]struct{}, len(following)) 220 for _, follow := range following { 221 loggedInUserFollowing[follow.SubjectDid] = struct{}{} 222 } 223 } 224 } 225 226 followStatus := db.IsNotFollowing 227 if loggedInUser != nil { 228 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 229 } 230 231 followersCount, followingCount, err := db.GetFollowerFollowingCount(s.db, did) 232 if err != nil { 233 log.Printf("getting follow stats followers for %s: %s", did, err) 234 return FollowsPageParams{}, err 235 } 236 237 if len(follows) == 0 { 238 return FollowsPageParams{ 239 LoggedInUser: loggedInUser, 240 Follows: []pages.FollowCard{}, 241 Card: pages.ProfileCard{ 242 UserDid: did, 243 UserHandle: ident.Handle.String(), 244 Profile: profile, 245 FollowStatus: followStatus, 246 FollowersCount: followersCount, 247 FollowingCount: followingCount, 248 }, 249 }, nil 250 } 251 252 followDids := make([]string, 0, len(follows)) 253 for _, follow := range follows { 254 followDids = append(followDids, extractDid(follow)) 255 } 256 257 profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 258 if err != nil { 259 log.Printf("getting profile for %s: %s", followDids, err) 260 return FollowsPageParams{}, err 261 } 262 263 followCards := make([]pages.FollowCard, 0, len(follows)) 264 for _, did := range followDids { 265 followersCount, followingCount, err := db.GetFollowerFollowingCount(s.db, did) 266 if err != nil { 267 log.Printf("getting follow stats for %s: %s", did, err) 268 } 269 followStatus := db.IsNotFollowing 270 if loggedInUserFollowing != nil { 271 if _, exists := loggedInUserFollowing[did]; exists { 272 followStatus = db.IsFollowing 273 } else if loggedInUser.Did == did { 274 followStatus = db.IsSelf 275 } 276 } 277 var profile *db.Profile 278 if p, exists := profiles[did]; exists { 279 profile = p 280 } else { 281 profile = &db.Profile{} 282 profile.Did = did 283 } 284 followCards = append(followCards, pages.FollowCard{ 285 UserDid: did, 286 FollowStatus: followStatus, 287 FollowersCount: followersCount, 288 FollowingCount: followingCount, 289 Profile: profile, 290 }) 291 } 292 293 return FollowsPageParams{ 294 LoggedInUser: loggedInUser, 295 Follows: followCards, 296 Card: pages.ProfileCard{ 297 UserDid: did, 298 UserHandle: ident.Handle.String(), 299 Profile: profile, 300 FollowStatus: followStatus, 301 FollowersCount: followersCount, 302 FollowingCount: followingCount, 303 }, 304 }, nil 305} 306 307func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 308 followPage, err := s.followPage(w, r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 309 if err != nil { 310 s.pages.Notice(w, "all-followers", "Failed to load followers") 311 return 312 } 313 314 s.pages.FollowersPage(w, pages.FollowersPageParams{ 315 LoggedInUser: followPage.LoggedInUser, 316 Followers: followPage.Follows, 317 Card: followPage.Card, 318 }) 319} 320 321func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 322 followPage, err := s.followPage(w, r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 323 if err != nil { 324 s.pages.Notice(w, "all-following", "Failed to load following") 325 return 326 } 327 328 s.pages.FollowingPage(w, pages.FollowingPageParams{ 329 LoggedInUser: followPage.LoggedInUser, 330 Following: followPage.Follows, 331 Card: followPage.Card, 332 }) 333} 334 335func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { 336 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 337 if !ok { 338 s.pages.Error404(w) 339 return 340 } 341 342 feed, err := s.getProfileFeed(r.Context(), &ident) 343 if err != nil { 344 s.pages.Error500(w) 345 return 346 } 347 348 if feed == nil { 349 return 350 } 351 352 atom, err := feed.ToAtom() 353 if err != nil { 354 s.pages.Error500(w) 355 return 356 } 357 358 w.Header().Set("content-type", "application/atom+xml") 359 w.Write([]byte(atom)) 360} 361 362func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) { 363 timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 364 if err != nil { 365 return nil, err 366 } 367 368 author := &feeds.Author{ 369 Name: fmt.Sprintf("@%s", id.Handle), 370 } 371 372 feed := feeds.Feed{ 373 Title: fmt.Sprintf("%s's timeline", author.Name), 374 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"}, 375 Items: make([]*feeds.Item, 0), 376 Updated: time.UnixMilli(0), 377 Author: author, 378 } 379 380 for _, byMonth := range timeline.ByMonth { 381 if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil { 382 return nil, err 383 } 384 if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil { 385 return nil, err 386 } 387 if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil { 388 return nil, err 389 } 390 } 391 392 slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int { 393 return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli()) 394 }) 395 396 if len(feed.Items) > 0 { 397 feed.Updated = feed.Items[0].Created 398 } 399 400 return &feed, nil 401} 402 403func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error { 404 for _, pull := range pulls { 405 owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 406 if err != nil { 407 return err 408 } 409 410 // Add pull request creation item 411 feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author)) 412 } 413 return nil 414} 415 416func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 417 for _, issue := range issues { 418 owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 419 if err != nil { 420 return err 421 } 422 423 feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author)) 424 } 425 return nil 426} 427 428func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error { 429 for _, repo := range repos { 430 item, err := s.createRepoItem(ctx, repo, author) 431 if err != nil { 432 return err 433 } 434 feed.Items = append(feed.Items, item) 435 } 436 return nil 437} 438 439func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 440 return &feeds.Item{ 441 Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 442 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"}, 443 Created: pull.Created, 444 Author: author, 445 } 446} 447 448func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 449 return &feeds.Item{ 450 Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), 451 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 452 Created: issue.Created, 453 Author: author, 454 } 455} 456 457func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 458 var title string 459 if repo.Source != nil { 460 sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) 461 if err != nil { 462 return nil, err 463 } 464 title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name) 465 } else { 466 title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) 467 } 468 469 return &feeds.Item{ 470 Title: title, 471 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, author.Name[1:], repo.Repo.Name), Type: "text/html", Rel: "alternate"}, // Remove @ prefix 472 Created: repo.Repo.Created, 473 Author: author, 474 }, nil 475} 476 477func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { 478 user := s.oauth.GetUser(r) 479 480 err := r.ParseForm() 481 if err != nil { 482 log.Println("invalid profile update form", err) 483 s.pages.Notice(w, "update-profile", "Invalid form.") 484 return 485 } 486 487 profile, err := db.GetProfile(s.db, user.Did) 488 if err != nil { 489 log.Printf("getting profile data for %s: %s", user.Did, err) 490 } 491 492 profile.Description = r.FormValue("description") 493 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" 494 profile.Location = r.FormValue("location") 495 496 var links [5]string 497 for i := range 5 { 498 iLink := r.FormValue(fmt.Sprintf("link%d", i)) 499 links[i] = iLink 500 } 501 profile.Links = links 502 503 // Parse stats (exactly 2) 504 stat0 := r.FormValue("stat0") 505 stat1 := r.FormValue("stat1") 506 507 if stat0 != "" { 508 profile.Stats[0].Kind = db.VanityStatKind(stat0) 509 } 510 511 if stat1 != "" { 512 profile.Stats[1].Kind = db.VanityStatKind(stat1) 513 } 514 515 if err := db.ValidateProfile(s.db, profile); err != nil { 516 log.Println("invalid profile", err) 517 s.pages.Notice(w, "update-profile", err.Error()) 518 return 519 } 520 521 s.updateProfile(profile, w, r) 522} 523 524func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { 525 user := s.oauth.GetUser(r) 526 527 err := r.ParseForm() 528 if err != nil { 529 log.Println("invalid profile update form", err) 530 s.pages.Notice(w, "update-profile", "Invalid form.") 531 return 532 } 533 534 profile, err := db.GetProfile(s.db, user.Did) 535 if err != nil { 536 log.Printf("getting profile data for %s: %s", user.Did, err) 537 } 538 539 i := 0 540 var pinnedRepos [6]syntax.ATURI 541 for key, values := range r.Form { 542 if i >= 6 { 543 log.Println("invalid pin update form", err) 544 s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.") 545 return 546 } 547 if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 { 548 aturi, err := syntax.ParseATURI(values[0]) 549 if err != nil { 550 log.Println("invalid profile update form", err) 551 s.pages.Notice(w, "update-profile", "Invalid form.") 552 return 553 } 554 pinnedRepos[i] = aturi 555 i++ 556 } 557 } 558 profile.PinnedRepos = pinnedRepos 559 560 s.updateProfile(profile, w, r) 561} 562 563func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { 564 user := s.oauth.GetUser(r) 565 tx, err := s.db.BeginTx(r.Context(), nil) 566 if err != nil { 567 log.Println("failed to start transaction", err) 568 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 569 return 570 } 571 572 client, err := s.oauth.AuthorizedClient(r) 573 if err != nil { 574 log.Println("failed to get authorized client", err) 575 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 576 return 577 } 578 579 // yeah... lexgen dose not support syntax.ATURI in the record for some reason, 580 // nor does it support exact size arrays 581 var pinnedRepoStrings []string 582 for _, r := range profile.PinnedRepos { 583 pinnedRepoStrings = append(pinnedRepoStrings, r.String()) 584 } 585 586 var vanityStats []string 587 for _, v := range profile.Stats { 588 vanityStats = append(vanityStats, string(v.Kind)) 589 } 590 591 ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 592 var cid *string 593 if ex != nil { 594 cid = ex.Cid 595 } 596 597 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 598 Collection: tangled.ActorProfileNSID, 599 Repo: user.Did, 600 Rkey: "self", 601 Record: &lexutil.LexiconTypeDecoder{ 602 Val: &tangled.ActorProfile{ 603 Bluesky: profile.IncludeBluesky, 604 Description: &profile.Description, 605 Links: profile.Links[:], 606 Location: &profile.Location, 607 PinnedRepositories: pinnedRepoStrings, 608 Stats: vanityStats[:], 609 }}, 610 SwapRecord: cid, 611 }) 612 if err != nil { 613 log.Println("failed to update profile", err) 614 s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.") 615 return 616 } 617 618 err = db.UpsertProfile(tx, profile) 619 if err != nil { 620 log.Println("failed to update profile", err) 621 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 622 return 623 } 624 625 s.notifier.UpdateProfile(r.Context(), profile) 626 627 s.pages.HxRedirect(w, "/"+user.Did) 628} 629 630func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) { 631 user := s.oauth.GetUser(r) 632 633 profile, err := db.GetProfile(s.db, user.Did) 634 if err != nil { 635 log.Printf("getting profile data for %s: %s", user.Did, err) 636 } 637 638 s.pages.EditBioFragment(w, pages.EditBioParams{ 639 LoggedInUser: user, 640 Profile: profile, 641 }) 642} 643 644func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) { 645 user := s.oauth.GetUser(r) 646 647 profile, err := db.GetProfile(s.db, user.Did) 648 if err != nil { 649 log.Printf("getting profile data for %s: %s", user.Did, err) 650 } 651 652 repos, err := db.GetAllReposByDid(s.db, user.Did) 653 if err != nil { 654 log.Printf("getting repos for %s: %s", user.Did, err) 655 } 656 657 collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did) 658 if err != nil { 659 log.Printf("getting collaborating repos for %s: %s", user.Did, err) 660 } 661 662 allRepos := []pages.PinnedRepo{} 663 664 for _, r := range repos { 665 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) 666 allRepos = append(allRepos, pages.PinnedRepo{ 667 IsPinned: isPinned, 668 Repo: r, 669 }) 670 } 671 for _, r := range collaboratingRepos { 672 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) 673 allRepos = append(allRepos, pages.PinnedRepo{ 674 IsPinned: isPinned, 675 Repo: r, 676 }) 677 } 678 679 s.pages.EditPinsFragment(w, pages.EditPinsParams{ 680 LoggedInUser: user, 681 Profile: profile, 682 AllRepos: allRepos, 683 }) 684}