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