Monorepo for Tangled
at 332c39c64d01212d199fa312cf9d1a23bf78c22c 1477 lines 38 kB view raw
1package pages 2 3import ( 4 "crypto/sha256" 5 "embed" 6 "encoding/hex" 7 "fmt" 8 "html/template" 9 "io" 10 "io/fs" 11 "log/slog" 12 "net/http" 13 "os" 14 "path/filepath" 15 "strings" 16 "sync" 17 "time" 18 19 "tangled.org/core/api/tangled" 20 "tangled.org/core/appview/commitverify" 21 "tangled.org/core/appview/config" 22 "tangled.org/core/appview/models" 23 "tangled.org/core/appview/oauth" 24 "tangled.org/core/appview/pages/markup" 25 "tangled.org/core/appview/pages/repoinfo" 26 "tangled.org/core/appview/pagination" 27 "tangled.org/core/idresolver" 28 "tangled.org/core/patchutil" 29 "tangled.org/core/types" 30 31 "github.com/bluesky-social/indigo/atproto/identity" 32 "github.com/bluesky-social/indigo/atproto/syntax" 33 "github.com/go-git/go-git/v5/plumbing" 34) 35 36//go:embed templates/* static legal 37var Files embed.FS 38 39type Pages struct { 40 mu sync.RWMutex 41 cache *TmplCache[string, *template.Template] 42 43 avatar config.AvatarConfig 44 resolver *idresolver.Resolver 45 dev bool 46 embedFS fs.FS 47 templateDir string // Path to templates on disk for dev mode 48 rctx *markup.RenderContext 49 logger *slog.Logger 50} 51 52func NewPages(config *config.Config, res *idresolver.Resolver, logger *slog.Logger) *Pages { 53 // initialized with safe defaults, can be overriden per use 54 rctx := &markup.RenderContext{ 55 IsDev: config.Core.Dev, 56 CamoUrl: config.Camo.Host, 57 CamoSecret: config.Camo.SharedSecret, 58 Sanitizer: markup.NewSanitizer(), 59 Files: Files, 60 } 61 62 p := &Pages{ 63 mu: sync.RWMutex{}, 64 cache: NewTmplCache[string, *template.Template](), 65 dev: config.Core.Dev, 66 avatar: config.Avatar, 67 rctx: rctx, 68 resolver: res, 69 templateDir: "appview/pages", 70 logger: logger, 71 } 72 73 if p.dev { 74 p.embedFS = os.DirFS(p.templateDir) 75 } else { 76 p.embedFS = Files 77 } 78 79 return p 80} 81 82// reverse of pathToName 83func (p *Pages) nameToPath(s string) string { 84 return "templates/" + s + ".html" 85} 86 87func (p *Pages) fragmentPaths() ([]string, error) { 88 var fragmentPaths []string 89 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 90 if err != nil { 91 return err 92 } 93 if d.IsDir() { 94 return nil 95 } 96 if !strings.HasSuffix(path, ".html") { 97 return nil 98 } 99 if !strings.Contains(path, "fragments/") { 100 return nil 101 } 102 fragmentPaths = append(fragmentPaths, path) 103 return nil 104 }) 105 if err != nil { 106 return nil, err 107 } 108 109 return fragmentPaths, nil 110} 111 112// parse without memoization 113func (p *Pages) rawParse(stack ...string) (*template.Template, error) { 114 paths, err := p.fragmentPaths() 115 if err != nil { 116 return nil, err 117 } 118 for _, s := range stack { 119 paths = append(paths, p.nameToPath(s)) 120 } 121 122 funcs := p.funcMap() 123 top := stack[len(stack)-1] 124 parsed, err := template.New(top). 125 Funcs(funcs). 126 ParseFS(p.embedFS, paths...) 127 if err != nil { 128 return nil, err 129 } 130 131 return parsed, nil 132} 133 134func (p *Pages) parse(stack ...string) (*template.Template, error) { 135 key := strings.Join(stack, "|") 136 137 // never cache in dev mode 138 if cached, exists := p.cache.Get(key); !p.dev && exists { 139 return cached, nil 140 } 141 142 result, err := p.rawParse(stack...) 143 if err != nil { 144 return nil, err 145 } 146 147 p.cache.Set(key, result) 148 return result, nil 149} 150 151func (p *Pages) parseBase(top string) (*template.Template, error) { 152 stack := []string{ 153 "layouts/base", 154 top, 155 } 156 return p.parse(stack...) 157} 158 159func (p *Pages) parseRepoBase(top string) (*template.Template, error) { 160 stack := []string{ 161 "layouts/base", 162 "layouts/repobase", 163 top, 164 } 165 return p.parse(stack...) 166} 167 168func (p *Pages) parseProfileBase(top string) (*template.Template, error) { 169 stack := []string{ 170 "layouts/base", 171 "layouts/profilebase", 172 top, 173 } 174 return p.parse(stack...) 175} 176 177func (p *Pages) executePlain(name string, w io.Writer, params any) error { 178 tpl, err := p.parse(name) 179 if err != nil { 180 return err 181 } 182 183 return tpl.Execute(w, params) 184} 185 186func (p *Pages) execute(name string, w io.Writer, params any) error { 187 tpl, err := p.parseBase(name) 188 if err != nil { 189 return err 190 } 191 192 return tpl.ExecuteTemplate(w, "layouts/base", params) 193} 194 195func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 196 tpl, err := p.parseRepoBase(name) 197 if err != nil { 198 return err 199 } 200 201 return tpl.ExecuteTemplate(w, "layouts/base", params) 202} 203 204func (p *Pages) executeProfile(name string, w io.Writer, params any) error { 205 tpl, err := p.parseProfileBase(name) 206 if err != nil { 207 return err 208 } 209 210 return tpl.ExecuteTemplate(w, "layouts/base", params) 211} 212 213type DollyParams struct { 214 Classes string 215 FillColor string 216} 217 218func (p *Pages) Dolly(w io.Writer, params DollyParams) error { 219 return p.executePlain("fragments/dolly/logo", w, params) 220} 221 222func (p *Pages) Favicon(w io.Writer) error { 223 return p.Dolly(w, DollyParams{ 224 Classes: "text-black dark:text-white", 225 }) 226} 227 228type LoginParams struct { 229 ReturnUrl string 230 ErrorCode string 231 AddAccount bool 232 LoggedInUser *oauth.MultiAccountUser 233} 234 235func (p *Pages) Login(w io.Writer, params LoginParams) error { 236 return p.executePlain("user/login", w, params) 237} 238 239type SignupParams struct { 240 CloudflareSiteKey string 241} 242 243func (p *Pages) Signup(w io.Writer, params SignupParams) error { 244 return p.executePlain("user/signup", w, params) 245} 246 247func (p *Pages) CompleteSignup(w io.Writer) error { 248 return p.executePlain("user/completeSignup", w, nil) 249} 250 251type TermsOfServiceParams struct { 252 LoggedInUser *oauth.MultiAccountUser 253 Content template.HTML 254} 255 256func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 257 filename := "terms.md" 258 filePath := filepath.Join("legal", filename) 259 260 file, err := p.embedFS.Open(filePath) 261 if err != nil { 262 return fmt.Errorf("failed to read %s: %w", filename, err) 263 } 264 defer file.Close() 265 266 markdownBytes, err := io.ReadAll(file) 267 if err != nil { 268 return fmt.Errorf("failed to read %s: %w", filename, err) 269 } 270 271 p.rctx.RendererType = markup.RendererTypeDefault 272 htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 273 sanitized := p.rctx.SanitizeDefault(htmlString) 274 params.Content = template.HTML(sanitized) 275 276 return p.execute("legal/terms", w, params) 277} 278 279type PrivacyPolicyParams struct { 280 LoggedInUser *oauth.MultiAccountUser 281 Content template.HTML 282} 283 284func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 285 filename := "privacy.md" 286 filePath := filepath.Join("legal", filename) 287 288 file, err := p.embedFS.Open(filePath) 289 if err != nil { 290 return fmt.Errorf("failed to read %s: %w", filename, err) 291 } 292 defer file.Close() 293 294 markdownBytes, err := io.ReadAll(file) 295 if err != nil { 296 return fmt.Errorf("failed to read %s: %w", filename, err) 297 } 298 299 p.rctx.RendererType = markup.RendererTypeDefault 300 htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 301 sanitized := p.rctx.SanitizeDefault(htmlString) 302 params.Content = template.HTML(sanitized) 303 304 return p.execute("legal/privacy", w, params) 305} 306 307type BrandParams struct { 308 LoggedInUser *oauth.MultiAccountUser 309} 310 311func (p *Pages) Brand(w io.Writer, params BrandParams) error { 312 return p.execute("brand/brand", w, params) 313} 314 315type TimelineParams struct { 316 LoggedInUser *oauth.MultiAccountUser 317 Timeline []models.TimelineEvent 318 Repos []models.Repo 319 GfiLabel *models.LabelDefinition 320} 321 322func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 323 return p.execute("timeline/timeline", w, params) 324} 325 326type GoodFirstIssuesParams struct { 327 LoggedInUser *oauth.MultiAccountUser 328 Issues []models.Issue 329 RepoGroups []*models.RepoGroup 330 LabelDefs map[string]*models.LabelDefinition 331 GfiLabel *models.LabelDefinition 332 Page pagination.Page 333} 334 335func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error { 336 return p.execute("goodfirstissues/index", w, params) 337} 338 339type UserProfileSettingsParams struct { 340 LoggedInUser *oauth.MultiAccountUser 341 Tab string 342} 343 344func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { 345 params.Tab = "profile" 346 return p.execute("user/settings/profile", w, params) 347} 348 349type NotificationsParams struct { 350 LoggedInUser *oauth.MultiAccountUser 351 Notifications []*models.NotificationWithEntity 352 UnreadCount int 353 Page pagination.Page 354 Total int64 355} 356 357func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error { 358 return p.execute("notifications/list", w, params) 359} 360 361type NotificationItemParams struct { 362 Notification *models.Notification 363} 364 365func (p *Pages) NotificationItem(w io.Writer, params NotificationItemParams) error { 366 return p.executePlain("notifications/fragments/item", w, params) 367} 368 369type NotificationCountParams struct { 370 Count int64 371} 372 373func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error { 374 return p.executePlain("notifications/fragments/count", w, params) 375} 376 377type UserKeysSettingsParams struct { 378 LoggedInUser *oauth.MultiAccountUser 379 PubKeys []models.PublicKey 380 Tab string 381} 382 383func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error { 384 params.Tab = "keys" 385 return p.execute("user/settings/keys", w, params) 386} 387 388type UserEmailsSettingsParams struct { 389 LoggedInUser *oauth.MultiAccountUser 390 Emails []models.Email 391 Tab string 392} 393 394func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error { 395 params.Tab = "emails" 396 return p.execute("user/settings/emails", w, params) 397} 398 399type UserNotificationSettingsParams struct { 400 LoggedInUser *oauth.MultiAccountUser 401 Preferences *models.NotificationPreferences 402 Tab string 403} 404 405func (p *Pages) UserNotificationSettings(w io.Writer, params UserNotificationSettingsParams) error { 406 params.Tab = "notifications" 407 return p.execute("user/settings/notifications", w, params) 408} 409 410type UpgradeBannerParams struct { 411 Registrations []models.Registration 412 Spindles []models.Spindle 413} 414 415func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error { 416 return p.executePlain("banner", w, params) 417} 418 419type KnotsParams struct { 420 LoggedInUser *oauth.MultiAccountUser 421 Registrations []models.Registration 422 Tab string 423} 424 425func (p *Pages) Knots(w io.Writer, params KnotsParams) error { 426 params.Tab = "knots" 427 return p.execute("knots/index", w, params) 428} 429 430type KnotParams struct { 431 LoggedInUser *oauth.MultiAccountUser 432 Registration *models.Registration 433 Members []string 434 Repos map[string][]models.Repo 435 IsOwner bool 436 Tab string 437} 438 439func (p *Pages) Knot(w io.Writer, params KnotParams) error { 440 return p.execute("knots/dashboard", w, params) 441} 442 443type KnotListingParams struct { 444 *models.Registration 445} 446 447func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 448 return p.executePlain("knots/fragments/knotListing", w, params) 449} 450 451type SpindlesParams struct { 452 LoggedInUser *oauth.MultiAccountUser 453 Spindles []models.Spindle 454 Tab string 455} 456 457func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error { 458 params.Tab = "spindles" 459 return p.execute("spindles/index", w, params) 460} 461 462type SpindleListingParams struct { 463 models.Spindle 464 Tab string 465} 466 467func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { 468 return p.executePlain("spindles/fragments/spindleListing", w, params) 469} 470 471type SpindleDashboardParams struct { 472 LoggedInUser *oauth.MultiAccountUser 473 Spindle models.Spindle 474 Members []string 475 Repos map[string][]models.Repo 476 Tab string 477} 478 479func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { 480 return p.execute("spindles/dashboard", w, params) 481} 482 483type NewRepoParams struct { 484 LoggedInUser *oauth.MultiAccountUser 485 Knots []string 486} 487 488func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error { 489 return p.execute("repo/new", w, params) 490} 491 492type ForkRepoParams struct { 493 LoggedInUser *oauth.MultiAccountUser 494 Knots []string 495 RepoInfo repoinfo.RepoInfo 496} 497 498func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error { 499 return p.execute("repo/fork", w, params) 500} 501 502type ProfileCard struct { 503 UserDid string 504 FollowStatus models.FollowStatus 505 Punchcard *models.Punchcard 506 Profile *models.Profile 507 Stats ProfileStats 508 Active string 509} 510 511type ProfileStats struct { 512 RepoCount int64 513 StarredCount int64 514 StringCount int64 515 FollowersCount int64 516 FollowingCount int64 517} 518 519func (p *ProfileCard) GetTabs() [][]any { 520 tabs := [][]any{ 521 {"overview", "overview", "square-chart-gantt", nil}, 522 {"repos", "repos", "book-marked", p.Stats.RepoCount}, 523 {"starred", "starred", "star", p.Stats.StarredCount}, 524 {"strings", "strings", "line-squiggle", p.Stats.StringCount}, 525 } 526 527 return tabs 528} 529 530type ProfileOverviewParams struct { 531 LoggedInUser *oauth.MultiAccountUser 532 Repos []models.Repo 533 CollaboratingRepos []models.Repo 534 ProfileTimeline *models.ProfileTimeline 535 Card *ProfileCard 536 Active string 537} 538 539func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error { 540 params.Active = "overview" 541 return p.executeProfile("user/overview", w, params) 542} 543 544type ProfileReposParams struct { 545 LoggedInUser *oauth.MultiAccountUser 546 Repos []models.Repo 547 Card *ProfileCard 548 Active string 549} 550 551func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error { 552 params.Active = "repos" 553 return p.executeProfile("user/repos", w, params) 554} 555 556type ProfileStarredParams struct { 557 LoggedInUser *oauth.MultiAccountUser 558 Repos []models.Repo 559 Card *ProfileCard 560 Active string 561} 562 563func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error { 564 params.Active = "starred" 565 return p.executeProfile("user/starred", w, params) 566} 567 568type ProfileStringsParams struct { 569 LoggedInUser *oauth.MultiAccountUser 570 Strings []models.String 571 Card *ProfileCard 572 Active string 573} 574 575func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error { 576 params.Active = "strings" 577 return p.executeProfile("user/strings", w, params) 578} 579 580type FollowCard struct { 581 UserDid string 582 LoggedInUser *oauth.MultiAccountUser 583 FollowStatus models.FollowStatus 584 FollowersCount int64 585 FollowingCount int64 586 Profile *models.Profile 587} 588 589type ProfileFollowersParams struct { 590 LoggedInUser *oauth.MultiAccountUser 591 Followers []FollowCard 592 Card *ProfileCard 593 Active string 594} 595 596func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error { 597 params.Active = "overview" 598 return p.executeProfile("user/followers", w, params) 599} 600 601type ProfileFollowingParams struct { 602 LoggedInUser *oauth.MultiAccountUser 603 Following []FollowCard 604 Card *ProfileCard 605 Active string 606} 607 608func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error { 609 params.Active = "overview" 610 return p.executeProfile("user/following", w, params) 611} 612 613type FollowFragmentParams struct { 614 UserDid string 615 FollowStatus models.FollowStatus 616 FollowersCount int64 617} 618 619func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 620 return p.executePlain("user/fragments/follow-oob", w, params) 621} 622 623type EditBioParams struct { 624 LoggedInUser *oauth.MultiAccountUser 625 Profile *models.Profile 626} 627 628func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error { 629 return p.executePlain("user/fragments/editBio", w, params) 630} 631 632type EditPinsParams struct { 633 LoggedInUser *oauth.MultiAccountUser 634 Profile *models.Profile 635 AllRepos []PinnedRepo 636} 637 638type PinnedRepo struct { 639 IsPinned bool 640 models.Repo 641} 642 643func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error { 644 return p.executePlain("user/fragments/editPins", w, params) 645} 646 647type StarBtnFragmentParams struct { 648 IsStarred bool 649 SubjectAt syntax.ATURI 650 StarCount int 651 HxSwapOob bool 652} 653 654func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error { 655 params.HxSwapOob = true 656 return p.executePlain("fragments/starBtn", w, params) 657} 658 659type RepoIndexParams struct { 660 LoggedInUser *oauth.MultiAccountUser 661 RepoInfo repoinfo.RepoInfo 662 Active string 663 TagMap map[string][]string 664 CommitsTrunc []types.Commit 665 TagsTrunc []*types.TagReference 666 BranchesTrunc []types.Branch 667 // ForkInfo *types.ForkInfo 668 HTMLReadme template.HTML 669 Raw bool 670 EmailToDid map[string]string 671 VerifiedCommits commitverify.VerifiedCommits 672 Languages []types.RepoLanguageDetails 673 Pipelines map[string]models.Pipeline 674 NeedsKnotUpgrade bool 675 types.RepoIndexResponse 676} 677 678func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { 679 params.Active = "overview" 680 if params.IsEmpty { 681 return p.executeRepo("repo/empty", w, params) 682 } 683 684 if params.NeedsKnotUpgrade { 685 return p.executeRepo("repo/needsUpgrade", w, params) 686 } 687 688 p.rctx.RepoInfo = params.RepoInfo 689 p.rctx.RepoInfo.Ref = params.Ref 690 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 691 692 if params.ReadmeFileName != "" { 693 ext := filepath.Ext(params.ReadmeFileName) 694 switch ext { 695 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 696 params.Raw = false 697 htmlString := p.rctx.RenderMarkdown(params.Readme) 698 sanitized := p.rctx.SanitizeDefault(htmlString) 699 params.HTMLReadme = template.HTML(sanitized) 700 default: 701 params.Raw = true 702 } 703 } 704 705 return p.executeRepo("repo/index", w, params) 706} 707 708type RepoLogParams struct { 709 LoggedInUser *oauth.MultiAccountUser 710 RepoInfo repoinfo.RepoInfo 711 TagMap map[string][]string 712 Active string 713 EmailToDid map[string]string 714 VerifiedCommits commitverify.VerifiedCommits 715 Pipelines map[string]models.Pipeline 716 717 types.RepoLogResponse 718} 719 720func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { 721 params.Active = "overview" 722 return p.executeRepo("repo/log", w, params) 723} 724 725type RepoCommitParams struct { 726 LoggedInUser *oauth.MultiAccountUser 727 RepoInfo repoinfo.RepoInfo 728 Active string 729 EmailToDid map[string]string 730 Pipeline *models.Pipeline 731 DiffOpts types.DiffOpts 732 733 // singular because it's always going to be just one 734 VerifiedCommit commitverify.VerifiedCommits 735 736 types.RepoCommitResponse 737} 738 739func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { 740 params.Active = "overview" 741 return p.executeRepo("repo/commit", w, params) 742} 743 744type RepoTreeParams struct { 745 LoggedInUser *oauth.MultiAccountUser 746 RepoInfo repoinfo.RepoInfo 747 Active string 748 BreadCrumbs [][]string 749 TreePath string 750 Raw bool 751 HTMLReadme template.HTML 752 types.RepoTreeResponse 753} 754 755type RepoTreeStats struct { 756 NumFolders uint64 757 NumFiles uint64 758} 759 760func (r RepoTreeParams) TreeStats() RepoTreeStats { 761 numFolders, numFiles := 0, 0 762 for _, f := range r.Files { 763 if !f.IsFile() { 764 numFolders += 1 765 } else if f.IsFile() { 766 numFiles += 1 767 } 768 } 769 770 return RepoTreeStats{ 771 NumFolders: uint64(numFolders), 772 NumFiles: uint64(numFiles), 773 } 774} 775 776func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 777 params.Active = "overview" 778 779 p.rctx.RepoInfo = params.RepoInfo 780 p.rctx.RepoInfo.Ref = params.Ref 781 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 782 783 if params.ReadmeFileName != "" { 784 ext := filepath.Ext(params.ReadmeFileName) 785 switch ext { 786 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 787 params.Raw = false 788 htmlString := p.rctx.RenderMarkdown(params.Readme) 789 sanitized := p.rctx.SanitizeDefault(htmlString) 790 params.HTMLReadme = template.HTML(sanitized) 791 default: 792 params.Raw = true 793 } 794 } 795 796 return p.executeRepo("repo/tree", w, params) 797} 798 799type RepoBranchesParams struct { 800 LoggedInUser *oauth.MultiAccountUser 801 RepoInfo repoinfo.RepoInfo 802 Active string 803 types.RepoBranchesResponse 804} 805 806func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error { 807 params.Active = "overview" 808 return p.executeRepo("repo/branches", w, params) 809} 810 811type RepoTagsParams struct { 812 LoggedInUser *oauth.MultiAccountUser 813 RepoInfo repoinfo.RepoInfo 814 Active string 815 types.RepoTagsResponse 816 ArtifactMap map[plumbing.Hash][]models.Artifact 817 DanglingArtifacts []models.Artifact 818} 819 820func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { 821 params.Active = "overview" 822 return p.executeRepo("repo/tags", w, params) 823} 824 825type RepoArtifactParams struct { 826 LoggedInUser *oauth.MultiAccountUser 827 RepoInfo repoinfo.RepoInfo 828 Artifact models.Artifact 829} 830 831func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error { 832 return p.executePlain("repo/fragments/artifact", w, params) 833} 834 835type RepoBlobParams struct { 836 LoggedInUser *oauth.MultiAccountUser 837 RepoInfo repoinfo.RepoInfo 838 Active string 839 BreadCrumbs [][]string 840 BlobView models.BlobView 841 *tangled.RepoBlob_Output 842} 843 844func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 845 switch params.BlobView.ContentType { 846 case models.BlobContentTypeMarkup: 847 p.rctx.RepoInfo = params.RepoInfo 848 } 849 850 params.Active = "overview" 851 return p.executeRepo("repo/blob", w, params) 852} 853 854type Collaborator struct { 855 Did string 856 Role string 857} 858 859type RepoSettingsParams struct { 860 LoggedInUser *oauth.MultiAccountUser 861 RepoInfo repoinfo.RepoInfo 862 Collaborators []Collaborator 863 Active string 864 Branches []types.Branch 865 Spindles []string 866 CurrentSpindle string 867 Secrets []*tangled.RepoListSecrets_Secret 868 869 // TODO: use repoinfo.roles 870 IsCollaboratorInviteAllowed bool 871} 872 873func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { 874 params.Active = "settings" 875 return p.executeRepo("repo/settings", w, params) 876} 877 878type RepoGeneralSettingsParams struct { 879 LoggedInUser *oauth.MultiAccountUser 880 RepoInfo repoinfo.RepoInfo 881 Labels []models.LabelDefinition 882 DefaultLabels []models.LabelDefinition 883 SubscribedLabels map[string]struct{} 884 ShouldSubscribeAll bool 885 Active string 886 Tab string 887 Branches []types.Branch 888} 889 890func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { 891 params.Active = "settings" 892 params.Tab = "general" 893 return p.executeRepo("repo/settings/general", w, params) 894} 895 896type RepoAccessSettingsParams struct { 897 LoggedInUser *oauth.MultiAccountUser 898 RepoInfo repoinfo.RepoInfo 899 Active string 900 Tab string 901 Collaborators []Collaborator 902} 903 904func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error { 905 params.Active = "settings" 906 params.Tab = "access" 907 return p.executeRepo("repo/settings/access", w, params) 908} 909 910type RepoPipelineSettingsParams struct { 911 LoggedInUser *oauth.MultiAccountUser 912 RepoInfo repoinfo.RepoInfo 913 Active string 914 Tab string 915 Spindles []string 916 CurrentSpindle string 917 Secrets []map[string]any 918} 919 920func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error { 921 params.Active = "settings" 922 params.Tab = "pipelines" 923 return p.executeRepo("repo/settings/pipelines", w, params) 924} 925 926type RepoIssuesParams struct { 927 LoggedInUser *oauth.MultiAccountUser 928 RepoInfo repoinfo.RepoInfo 929 Active string 930 Issues []models.Issue 931 IssueCount int 932 LabelDefs map[string]*models.LabelDefinition 933 Page pagination.Page 934 FilteringByOpen bool 935 FilterQuery string 936} 937 938func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { 939 params.Active = "issues" 940 return p.executeRepo("repo/issues/issues", w, params) 941} 942 943type RepoSingleIssueParams struct { 944 LoggedInUser *oauth.MultiAccountUser 945 RepoInfo repoinfo.RepoInfo 946 Active string 947 Issue *models.Issue 948 CommentList []models.CommentListItem 949 Backlinks []models.RichReferenceLink 950 LabelDefs map[string]*models.LabelDefinition 951 952 Reactions map[models.ReactionKind]models.ReactionDisplayData 953 UserReacted map[models.ReactionKind]bool 954} 955 956func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 957 params.Active = "issues" 958 return p.executeRepo("repo/issues/issue", w, params) 959} 960 961type EditIssueParams struct { 962 LoggedInUser *oauth.MultiAccountUser 963 RepoInfo repoinfo.RepoInfo 964 Issue *models.Issue 965 Action string 966} 967 968func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error { 969 params.Action = "edit" 970 return p.executePlain("repo/issues/fragments/putIssue", w, params) 971} 972 973type ThreadReactionFragmentParams struct { 974 ThreadAt syntax.ATURI 975 Kind models.ReactionKind 976 Count int 977 Users []string 978 IsReacted bool 979} 980 981func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmentParams) error { 982 return p.executePlain("repo/fragments/reaction", w, params) 983} 984 985type RepoNewIssueParams struct { 986 LoggedInUser *oauth.MultiAccountUser 987 RepoInfo repoinfo.RepoInfo 988 Issue *models.Issue // existing issue if any -- passed when editing 989 Active string 990 Action string 991} 992 993func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 994 params.Active = "issues" 995 params.Action = "create" 996 return p.executeRepo("repo/issues/new", w, params) 997} 998 999type EditIssueCommentParams struct { 1000 LoggedInUser *oauth.MultiAccountUser 1001 RepoInfo repoinfo.RepoInfo 1002 Issue *models.Issue 1003 Comment *models.IssueComment 1004} 1005 1006func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 1007 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 1008} 1009 1010type ReplyIssueCommentPlaceholderParams struct { 1011 LoggedInUser *oauth.MultiAccountUser 1012 RepoInfo repoinfo.RepoInfo 1013 Issue *models.Issue 1014 Comment *models.IssueComment 1015} 1016 1017func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { 1018 return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params) 1019} 1020 1021type ReplyIssueCommentParams struct { 1022 LoggedInUser *oauth.MultiAccountUser 1023 RepoInfo repoinfo.RepoInfo 1024 Issue *models.Issue 1025 Comment *models.IssueComment 1026} 1027 1028func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { 1029 return p.executePlain("repo/issues/fragments/replyComment", w, params) 1030} 1031 1032type IssueCommentBodyParams struct { 1033 LoggedInUser *oauth.MultiAccountUser 1034 RepoInfo repoinfo.RepoInfo 1035 Issue *models.Issue 1036 Comment *models.IssueComment 1037} 1038 1039func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { 1040 return p.executePlain("repo/issues/fragments/issueCommentBody", w, params) 1041} 1042 1043type RepoNewPullParams struct { 1044 LoggedInUser *oauth.MultiAccountUser 1045 RepoInfo repoinfo.RepoInfo 1046 Branches []types.Branch 1047 Strategy string 1048 SourceBranch string 1049 TargetBranch string 1050 Title string 1051 Body string 1052 Active string 1053} 1054 1055func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { 1056 params.Active = "pulls" 1057 return p.executeRepo("repo/pulls/new", w, params) 1058} 1059 1060type RepoPullsParams struct { 1061 LoggedInUser *oauth.MultiAccountUser 1062 RepoInfo repoinfo.RepoInfo 1063 Pulls []*models.Pull 1064 Active string 1065 FilteringBy models.PullState 1066 FilterQuery string 1067 Stacks map[string]models.Stack 1068 Pipelines map[string]models.Pipeline 1069 LabelDefs map[string]*models.LabelDefinition 1070 Page pagination.Page 1071 PullCount int 1072} 1073 1074func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { 1075 params.Active = "pulls" 1076 return p.executeRepo("repo/pulls/pulls", w, params) 1077} 1078 1079type ResubmitResult uint64 1080 1081const ( 1082 ShouldResubmit ResubmitResult = iota 1083 ShouldNotResubmit 1084 Unknown 1085) 1086 1087func (r ResubmitResult) Yes() bool { 1088 return r == ShouldResubmit 1089} 1090func (r ResubmitResult) No() bool { 1091 return r == ShouldNotResubmit 1092} 1093func (r ResubmitResult) Unknown() bool { 1094 return r == Unknown 1095} 1096 1097type RepoSinglePullParams struct { 1098 LoggedInUser *oauth.MultiAccountUser 1099 RepoInfo repoinfo.RepoInfo 1100 Active string 1101 Pull *models.Pull 1102 Stack models.Stack 1103 AbandonedPulls []*models.Pull 1104 Backlinks []models.RichReferenceLink 1105 BranchDeleteStatus *models.BranchDeleteStatus 1106 MergeCheck types.MergeCheckResponse 1107 ResubmitCheck ResubmitResult 1108 Pipelines map[string]models.Pipeline 1109 Diff types.DiffRenderer 1110 DiffOpts types.DiffOpts 1111 ActiveRound int 1112 IsInterdiff bool 1113 1114 Reactions map[models.ReactionKind]models.ReactionDisplayData 1115 UserReacted map[models.ReactionKind]bool 1116 1117 LabelDefs map[string]*models.LabelDefinition 1118} 1119 1120func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { 1121 params.Active = "pulls" 1122 return p.executeRepo("repo/pulls/pull", w, params) 1123} 1124 1125type RepoPullPatchParams struct { 1126 LoggedInUser *oauth.MultiAccountUser 1127 RepoInfo repoinfo.RepoInfo 1128 Pull *models.Pull 1129 Stack models.Stack 1130 Diff *types.NiceDiff 1131 Round int 1132 Submission *models.PullSubmission 1133 DiffOpts types.DiffOpts 1134} 1135 1136// this name is a mouthful 1137func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error { 1138 return p.execute("repo/pulls/patch", w, params) 1139} 1140 1141type RepoPullInterdiffParams struct { 1142 LoggedInUser *oauth.MultiAccountUser 1143 RepoInfo repoinfo.RepoInfo 1144 Pull *models.Pull 1145 Round int 1146 Interdiff *patchutil.InterdiffResult 1147 DiffOpts types.DiffOpts 1148} 1149 1150// this name is a mouthful 1151func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error { 1152 return p.execute("repo/pulls/interdiff", w, params) 1153} 1154 1155type PullPatchUploadParams struct { 1156 RepoInfo repoinfo.RepoInfo 1157} 1158 1159func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error { 1160 return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params) 1161} 1162 1163type PullCompareBranchesParams struct { 1164 RepoInfo repoinfo.RepoInfo 1165 Branches []types.Branch 1166 SourceBranch string 1167} 1168 1169func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error { 1170 return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params) 1171} 1172 1173type PullCompareForkParams struct { 1174 RepoInfo repoinfo.RepoInfo 1175 Forks []models.Repo 1176 Selected string 1177} 1178 1179func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error { 1180 return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params) 1181} 1182 1183type PullCompareForkBranchesParams struct { 1184 RepoInfo repoinfo.RepoInfo 1185 SourceBranches []types.Branch 1186 TargetBranches []types.Branch 1187} 1188 1189func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error { 1190 return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params) 1191} 1192 1193type PullResubmitParams struct { 1194 LoggedInUser *oauth.MultiAccountUser 1195 RepoInfo repoinfo.RepoInfo 1196 Pull *models.Pull 1197 SubmissionId int 1198} 1199 1200func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 1201 return p.executePlain("repo/pulls/fragments/pullResubmit", w, params) 1202} 1203 1204type PullActionsParams struct { 1205 LoggedInUser *oauth.MultiAccountUser 1206 RepoInfo repoinfo.RepoInfo 1207 Pull *models.Pull 1208 RoundNumber int 1209 MergeCheck types.MergeCheckResponse 1210 ResubmitCheck ResubmitResult 1211 BranchDeleteStatus *models.BranchDeleteStatus 1212 Stack models.Stack 1213} 1214 1215func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 1216 return p.executePlain("repo/pulls/fragments/pullActions", w, params) 1217} 1218 1219type PullNewCommentParams struct { 1220 LoggedInUser *oauth.MultiAccountUser 1221 RepoInfo repoinfo.RepoInfo 1222 Pull *models.Pull 1223 RoundNumber int 1224} 1225 1226func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 1227 return p.executePlain("repo/pulls/fragments/pullNewComment", w, params) 1228} 1229 1230type RepoCompareParams struct { 1231 LoggedInUser *oauth.MultiAccountUser 1232 RepoInfo repoinfo.RepoInfo 1233 Forks []models.Repo 1234 Branches []types.Branch 1235 Tags []*types.TagReference 1236 Base string 1237 Head string 1238 Diff *types.NiceDiff 1239 DiffOpts types.DiffOpts 1240 1241 Active string 1242} 1243 1244func (p *Pages) RepoCompare(w io.Writer, params RepoCompareParams) error { 1245 params.Active = "overview" 1246 return p.executeRepo("repo/compare/compare", w, params) 1247} 1248 1249type RepoCompareNewParams struct { 1250 LoggedInUser *oauth.MultiAccountUser 1251 RepoInfo repoinfo.RepoInfo 1252 Forks []models.Repo 1253 Branches []types.Branch 1254 Tags []*types.TagReference 1255 Base string 1256 Head string 1257 1258 Active string 1259} 1260 1261func (p *Pages) RepoCompareNew(w io.Writer, params RepoCompareNewParams) error { 1262 params.Active = "overview" 1263 return p.executeRepo("repo/compare/new", w, params) 1264} 1265 1266type RepoCompareAllowPullParams struct { 1267 LoggedInUser *oauth.MultiAccountUser 1268 RepoInfo repoinfo.RepoInfo 1269 Base string 1270 Head string 1271} 1272 1273func (p *Pages) RepoCompareAllowPullFragment(w io.Writer, params RepoCompareAllowPullParams) error { 1274 return p.executePlain("repo/fragments/compareAllowPull", w, params) 1275} 1276 1277type RepoCompareDiffFragmentParams struct { 1278 Diff types.NiceDiff 1279 DiffOpts types.DiffOpts 1280} 1281 1282func (p *Pages) RepoCompareDiffFragment(w io.Writer, params RepoCompareDiffFragmentParams) error { 1283 return p.executePlain("repo/fragments/diff", w, []any{&params.Diff, &params.DiffOpts}) 1284} 1285 1286type LabelPanelParams struct { 1287 LoggedInUser *oauth.MultiAccountUser 1288 RepoInfo repoinfo.RepoInfo 1289 Defs map[string]*models.LabelDefinition 1290 Subject string 1291 State models.LabelState 1292} 1293 1294func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error { 1295 return p.executePlain("repo/fragments/labelPanel", w, params) 1296} 1297 1298type EditLabelPanelParams struct { 1299 LoggedInUser *oauth.MultiAccountUser 1300 RepoInfo repoinfo.RepoInfo 1301 Defs map[string]*models.LabelDefinition 1302 Subject string 1303 State models.LabelState 1304} 1305 1306func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error { 1307 return p.executePlain("repo/fragments/editLabelPanel", w, params) 1308} 1309 1310type PipelinesParams struct { 1311 LoggedInUser *oauth.MultiAccountUser 1312 RepoInfo repoinfo.RepoInfo 1313 Pipelines []models.Pipeline 1314 Active string 1315} 1316 1317func (p *Pages) Pipelines(w io.Writer, params PipelinesParams) error { 1318 params.Active = "pipelines" 1319 return p.executeRepo("repo/pipelines/pipelines", w, params) 1320} 1321 1322type LogBlockParams struct { 1323 Id int 1324 Name string 1325 Command string 1326 Collapsed bool 1327 StartTime time.Time 1328} 1329 1330func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error { 1331 return p.executePlain("repo/pipelines/fragments/logBlock", w, params) 1332} 1333 1334type LogBlockEndParams struct { 1335 Id int 1336 StartTime time.Time 1337 EndTime time.Time 1338} 1339 1340func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error { 1341 return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params) 1342} 1343 1344type LogLineParams struct { 1345 Id int 1346 Content string 1347} 1348 1349func (p *Pages) LogLine(w io.Writer, params LogLineParams) error { 1350 return p.executePlain("repo/pipelines/fragments/logLine", w, params) 1351} 1352 1353type WorkflowParams struct { 1354 LoggedInUser *oauth.MultiAccountUser 1355 RepoInfo repoinfo.RepoInfo 1356 Pipeline models.Pipeline 1357 Workflow string 1358 LogUrl string 1359 Active string 1360} 1361 1362func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error { 1363 params.Active = "pipelines" 1364 return p.executeRepo("repo/pipelines/workflow", w, params) 1365} 1366 1367type PutStringParams struct { 1368 LoggedInUser *oauth.MultiAccountUser 1369 Action string 1370 1371 // this is supplied in the case of editing an existing string 1372 String models.String 1373} 1374 1375func (p *Pages) PutString(w io.Writer, params PutStringParams) error { 1376 return p.execute("strings/put", w, params) 1377} 1378 1379type StringsDashboardParams struct { 1380 LoggedInUser *oauth.MultiAccountUser 1381 Card ProfileCard 1382 Strings []models.String 1383} 1384 1385func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { 1386 return p.execute("strings/dashboard", w, params) 1387} 1388 1389type StringTimelineParams struct { 1390 LoggedInUser *oauth.MultiAccountUser 1391 Strings []models.String 1392} 1393 1394func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { 1395 return p.execute("strings/timeline", w, params) 1396} 1397 1398type SingleStringParams struct { 1399 LoggedInUser *oauth.MultiAccountUser 1400 ShowRendered bool 1401 RenderToggle bool 1402 RenderedContents template.HTML 1403 String *models.String 1404 Stats models.StringStats 1405 IsStarred bool 1406 StarCount int 1407 Owner identity.Identity 1408} 1409 1410func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1411 return p.execute("strings/string", w, params) 1412} 1413 1414func (p *Pages) Home(w io.Writer, params TimelineParams) error { 1415 return p.execute("timeline/home", w, params) 1416} 1417 1418func (p *Pages) Static() http.Handler { 1419 if p.dev { 1420 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 1421 } 1422 1423 sub, err := fs.Sub(p.embedFS, "static") 1424 if err != nil { 1425 p.logger.Error("no static dir found? that's crazy", "err", err) 1426 panic(err) 1427 } 1428 // Custom handler to apply Cache-Control headers for font files 1429 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 1430} 1431 1432func Cache(h http.Handler) http.Handler { 1433 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1434 path := strings.Split(r.URL.Path, "?")[0] 1435 1436 if strings.HasSuffix(path, ".css") { 1437 // on day for css files 1438 w.Header().Set("Cache-Control", "public, max-age=86400") 1439 } else { 1440 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 1441 } 1442 h.ServeHTTP(w, r) 1443 }) 1444} 1445 1446func (p *Pages) CssContentHash() string { 1447 cssFile, err := p.embedFS.Open("static/tw.css") 1448 if err != nil { 1449 slog.Debug("Error opening CSS file", "err", err) 1450 return "" 1451 } 1452 defer cssFile.Close() 1453 1454 hasher := sha256.New() 1455 if _, err := io.Copy(hasher, cssFile); err != nil { 1456 slog.Debug("Error hashing CSS file", "err", err) 1457 return "" 1458 } 1459 1460 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash 1461} 1462 1463func (p *Pages) Error500(w io.Writer) error { 1464 return p.execute("errors/500", w, nil) 1465} 1466 1467func (p *Pages) Error404(w io.Writer) error { 1468 return p.execute("errors/404", w, nil) 1469} 1470 1471func (p *Pages) ErrorKnot404(w io.Writer) error { 1472 return p.execute("errors/knot404", w, nil) 1473} 1474 1475func (p *Pages) Error503(w io.Writer) error { 1476 return p.execute("errors/503", w, nil) 1477}