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