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