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