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