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