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