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