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