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