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