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