forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
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" 13 "net/http" 14 "path" 15 "path/filepath" 16 "slices" 17 "strings" 18 19 "github.com/alecthomas/chroma/v2" 20 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 21 "github.com/alecthomas/chroma/v2/lexers" 22 "github.com/alecthomas/chroma/v2/styles" 23 "github.com/bluesky-social/indigo/atproto/syntax" 24 "github.com/microcosm-cc/bluemonday" 25 "tangled.sh/tangled.sh/core/appview/auth" 26 "tangled.sh/tangled.sh/core/appview/db" 27 "tangled.sh/tangled.sh/core/appview/pages/markup" 28 "tangled.sh/tangled.sh/core/appview/state/userutil" 29 "tangled.sh/tangled.sh/core/types" 30) 31 32//go:embed templates/* static 33var Files embed.FS 34 35type Pages struct { 36 t map[string]*template.Template 37} 38 39func NewPages() *Pages { 40 templates := make(map[string]*template.Template) 41 42 // Walk through embedded templates directory and parse all .html files 43 err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error { 44 if err != nil { 45 return err 46 } 47 48 if !d.IsDir() && strings.HasSuffix(path, ".html") { 49 name := strings.TrimPrefix(path, "templates/") 50 name = strings.TrimSuffix(name, ".html") 51 52 // add fragments as templates 53 if strings.HasPrefix(path, "templates/fragments/") { 54 tmpl, err := template.New(name). 55 Funcs(funcMap()). 56 ParseFS(Files, path) 57 if err != nil { 58 return fmt.Errorf("setting up fragment: %w", err) 59 } 60 61 templates[name] = tmpl 62 log.Printf("loaded fragment: %s", name) 63 } 64 65 // layouts and fragments are applied first 66 if !strings.HasPrefix(path, "templates/layouts/") && 67 !strings.HasPrefix(path, "templates/fragments/") { 68 // Add the page template on top of the base 69 tmpl, err := template.New(name). 70 Funcs(funcMap()). 71 ParseFS(Files, "templates/layouts/*.html", "templates/fragments/*.html", path) 72 if err != nil { 73 return fmt.Errorf("setting up template: %w", err) 74 } 75 76 templates[name] = tmpl 77 log.Printf("loaded template: %s", name) 78 } 79 80 return nil 81 } 82 return nil 83 }) 84 if err != nil { 85 log.Fatalf("walking template dir: %v", err) 86 } 87 88 log.Printf("total templates loaded: %d", len(templates)) 89 90 return &Pages{ 91 t: templates, 92 } 93} 94 95type LoginParams struct { 96} 97 98func (p *Pages) execute(name string, w io.Writer, params any) error { 99 return p.t[name].ExecuteTemplate(w, "layouts/base", params) 100} 101 102func (p *Pages) executePlain(name string, w io.Writer, params any) error { 103 return p.t[name].Execute(w, params) 104} 105 106func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 107 return p.t[name].ExecuteTemplate(w, "layouts/repobase", params) 108} 109 110func (p *Pages) Login(w io.Writer, params LoginParams) error { 111 return p.executePlain("user/login", w, params) 112} 113 114type TimelineParams struct { 115 LoggedInUser *auth.User 116 Timeline []db.TimelineEvent 117 DidHandleMap map[string]string 118} 119 120func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 121 return p.execute("timeline", w, params) 122} 123 124type SettingsParams struct { 125 LoggedInUser *auth.User 126 PubKeys []db.PublicKey 127 Emails []db.Email 128} 129 130func (p *Pages) Settings(w io.Writer, params SettingsParams) error { 131 return p.execute("settings", w, params) 132} 133 134type KnotsParams struct { 135 LoggedInUser *auth.User 136 Registrations []db.Registration 137} 138 139func (p *Pages) Knots(w io.Writer, params KnotsParams) error { 140 return p.execute("knots", w, params) 141} 142 143type KnotParams struct { 144 LoggedInUser *auth.User 145 DidHandleMap map[string]string 146 Registration *db.Registration 147 Members []string 148 IsOwner bool 149} 150 151func (p *Pages) Knot(w io.Writer, params KnotParams) error { 152 return p.execute("knot", w, params) 153} 154 155type NewRepoParams struct { 156 LoggedInUser *auth.User 157 Knots []string 158} 159 160func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error { 161 return p.execute("repo/new", w, params) 162} 163 164type ForkRepoParams struct { 165 LoggedInUser *auth.User 166 Knots []string 167 RepoInfo RepoInfo 168} 169 170func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error { 171 return p.execute("repo/fork", w, params) 172} 173 174type ProfilePageParams struct { 175 LoggedInUser *auth.User 176 UserDid string 177 UserHandle string 178 Repos []db.Repo 179 CollaboratingRepos []db.Repo 180 ProfileStats ProfileStats 181 FollowStatus db.FollowStatus 182 AvatarUri string 183 ProfileTimeline *db.ProfileTimeline 184 185 DidHandleMap map[string]string 186} 187 188type ProfileStats struct { 189 Followers int 190 Following int 191} 192 193func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 194 return p.execute("user/profile", w, params) 195} 196 197type FollowFragmentParams struct { 198 UserDid string 199 FollowStatus db.FollowStatus 200} 201 202func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 203 return p.executePlain("fragments/follow", w, params) 204} 205 206type RepoActionsFragmentParams struct { 207 IsStarred bool 208 RepoAt syntax.ATURI 209 Stats db.RepoStats 210} 211 212func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error { 213 return p.executePlain("fragments/repoActions", w, params) 214} 215 216type RepoDescriptionParams struct { 217 RepoInfo RepoInfo 218} 219 220func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 221 return p.executePlain("fragments/editRepoDescription", w, params) 222} 223 224func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 225 return p.executePlain("fragments/repoDescription", w, params) 226} 227 228type RepoInfo struct { 229 Name string 230 OwnerDid string 231 OwnerHandle string 232 Description string 233 Knot string 234 Ref string 235 RepoAt syntax.ATURI 236 IsStarred bool 237 Stats db.RepoStats 238 Roles RolesInRepo 239 Source *db.Repo 240 SourceHandle string 241 DisableFork bool 242} 243 244type RolesInRepo struct { 245 Roles []string 246} 247 248func (r RolesInRepo) SettingsAllowed() bool { 249 return slices.Contains(r.Roles, "repo:settings") 250} 251 252func (r RolesInRepo) CollaboratorInviteAllowed() bool { 253 return slices.Contains(r.Roles, "repo:invite") 254} 255 256func (r RolesInRepo) RepoDeleteAllowed() bool { 257 return slices.Contains(r.Roles, "repo:delete") 258} 259 260func (r RolesInRepo) IsOwner() bool { 261 return slices.Contains(r.Roles, "repo:owner") 262} 263 264func (r RolesInRepo) IsCollaborator() bool { 265 return slices.Contains(r.Roles, "repo:collaborator") 266} 267 268func (r RolesInRepo) IsPushAllowed() bool { 269 return slices.Contains(r.Roles, "repo:push") 270} 271 272func (r RepoInfo) OwnerWithAt() string { 273 if r.OwnerHandle != "" { 274 return fmt.Sprintf("@%s", r.OwnerHandle) 275 } else { 276 return r.OwnerDid 277 } 278} 279 280func (r RepoInfo) FullName() string { 281 return path.Join(r.OwnerWithAt(), r.Name) 282} 283 284func (r RepoInfo) OwnerWithoutAt() string { 285 if strings.HasPrefix(r.OwnerWithAt(), "@") { 286 return strings.TrimPrefix(r.OwnerWithAt(), "@") 287 } else { 288 return userutil.FlattenDid(r.OwnerDid) 289 } 290} 291 292func (r RepoInfo) FullNameWithoutAt() string { 293 return path.Join(r.OwnerWithoutAt(), r.Name) 294} 295 296func (r RepoInfo) GetTabs() [][]string { 297 tabs := [][]string{ 298 {"overview", "/", "square-chart-gantt"}, 299 {"issues", "/issues", "circle-dot"}, 300 {"pulls", "/pulls", "git-pull-request"}, 301 } 302 303 if r.Roles.SettingsAllowed() { 304 tabs = append(tabs, []string{"settings", "/settings", "cog"}) 305 } 306 307 return tabs 308} 309 310// each tab on a repo could have some metadata: 311// 312// issues -> number of open issues etc. 313// settings -> a warning icon to setup branch protection? idk 314// 315// we gather these bits of info here, because go templates 316// are difficult to program in 317func (r RepoInfo) TabMetadata() map[string]any { 318 meta := make(map[string]any) 319 320 if r.Stats.PullCount.Open > 0 { 321 meta["pulls"] = r.Stats.PullCount.Open 322 } 323 324 if r.Stats.IssueCount.Open > 0 { 325 meta["issues"] = r.Stats.IssueCount.Open 326 } 327 328 // more stuff? 329 330 return meta 331} 332 333type RepoIndexParams struct { 334 LoggedInUser *auth.User 335 RepoInfo RepoInfo 336 Active string 337 TagMap map[string][]string 338 types.RepoIndexResponse 339 HTMLReadme template.HTML 340 Raw bool 341 EmailToDidOrHandle map[string]string 342} 343 344func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { 345 params.Active = "overview" 346 if params.IsEmpty { 347 return p.executeRepo("repo/empty", w, params) 348 } 349 350 if params.ReadmeFileName != "" { 351 var htmlString string 352 ext := filepath.Ext(params.ReadmeFileName) 353 switch ext { 354 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 355 htmlString = markup.RenderMarkdownExtended( 356 params.Readme, 357 params.RepoInfo.OwnerHandle, 358 params.RepoInfo.Name, 359 params.RepoInfo.Ref, 360 ) 361 params.Raw = false 362 params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString)) 363 default: 364 htmlString = string(params.Readme) 365 params.Raw = true 366 params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString)) 367 } 368 } 369 370 return p.executeRepo("repo/index", w, params) 371} 372 373type RepoLogParams struct { 374 LoggedInUser *auth.User 375 RepoInfo RepoInfo 376 types.RepoLogResponse 377 Active string 378 EmailToDidOrHandle map[string]string 379} 380 381func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { 382 params.Active = "overview" 383 return p.execute("repo/log", w, params) 384} 385 386type RepoCommitParams struct { 387 LoggedInUser *auth.User 388 RepoInfo RepoInfo 389 Active string 390 types.RepoCommitResponse 391 EmailToDidOrHandle map[string]string 392} 393 394func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { 395 params.Active = "overview" 396 return p.executeRepo("repo/commit", w, params) 397} 398 399type RepoTreeParams struct { 400 LoggedInUser *auth.User 401 RepoInfo RepoInfo 402 Active string 403 BreadCrumbs [][]string 404 BaseTreeLink string 405 BaseBlobLink string 406 types.RepoTreeResponse 407} 408 409type RepoTreeStats struct { 410 NumFolders uint64 411 NumFiles uint64 412} 413 414func (r RepoTreeParams) TreeStats() RepoTreeStats { 415 numFolders, numFiles := 0, 0 416 for _, f := range r.Files { 417 if !f.IsFile { 418 numFolders += 1 419 } else if f.IsFile { 420 numFiles += 1 421 } 422 } 423 424 return RepoTreeStats{ 425 NumFolders: uint64(numFolders), 426 NumFiles: uint64(numFiles), 427 } 428} 429 430func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 431 params.Active = "overview" 432 return p.execute("repo/tree", w, params) 433} 434 435type RepoBranchesParams struct { 436 LoggedInUser *auth.User 437 RepoInfo RepoInfo 438 types.RepoBranchesResponse 439} 440 441func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error { 442 return p.executeRepo("repo/branches", w, params) 443} 444 445type RepoTagsParams struct { 446 LoggedInUser *auth.User 447 RepoInfo RepoInfo 448 types.RepoTagsResponse 449} 450 451func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { 452 return p.executeRepo("repo/tags", w, params) 453} 454 455type RepoBlobParams struct { 456 LoggedInUser *auth.User 457 RepoInfo RepoInfo 458 Active string 459 BreadCrumbs [][]string 460 ShowRendered bool 461 RenderToggle bool 462 RenderedContents template.HTML 463 types.RepoBlobResponse 464} 465 466func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 467 style := styles.Get("bw") 468 b := style.Builder() 469 b.Add(chroma.LiteralString, "noitalic") 470 style, _ = b.Build() 471 472 if params.ShowRendered { 473 switch markup.GetFormat(params.Path) { 474 case markup.FormatMarkdown: 475 params.RenderedContents = template.HTML(markup.RenderMarkdownExtended( 476 params.Contents, 477 params.RepoInfo.OwnerHandle, 478 params.RepoInfo.Name, 479 params.RepoInfo.Ref, 480 )) 481 } 482 } 483 484 if params.Lines < 5000 { 485 c := params.Contents 486 formatter := chromahtml.New( 487 chromahtml.InlineCode(false), 488 chromahtml.WithLineNumbers(true), 489 chromahtml.WithLinkableLineNumbers(true, "L"), 490 chromahtml.Standalone(false), 491 ) 492 493 lexer := lexers.Get(filepath.Base(params.Path)) 494 if lexer == nil { 495 lexer = lexers.Fallback 496 } 497 498 iterator, err := lexer.Tokenise(nil, c) 499 if err != nil { 500 return fmt.Errorf("chroma tokenize: %w", err) 501 } 502 503 var code bytes.Buffer 504 err = formatter.Format(&code, style, iterator) 505 if err != nil { 506 return fmt.Errorf("chroma format: %w", err) 507 } 508 509 params.Contents = code.String() 510 } 511 512 params.Active = "overview" 513 return p.executeRepo("repo/blob", w, params) 514} 515 516type Collaborator struct { 517 Did string 518 Handle string 519 Role string 520} 521 522type RepoSettingsParams struct { 523 LoggedInUser *auth.User 524 RepoInfo RepoInfo 525 Collaborators []Collaborator 526 Active string 527 Branches []string 528 DefaultBranch string 529 // TODO: use repoinfo.roles 530 IsCollaboratorInviteAllowed bool 531} 532 533func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { 534 params.Active = "settings" 535 return p.executeRepo("repo/settings", w, params) 536} 537 538type RepoIssuesParams struct { 539 LoggedInUser *auth.User 540 RepoInfo RepoInfo 541 Active string 542 Issues []db.Issue 543 DidHandleMap map[string]string 544 545 FilteringByOpen bool 546} 547 548func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { 549 params.Active = "issues" 550 return p.executeRepo("repo/issues/issues", w, params) 551} 552 553type RepoSingleIssueParams struct { 554 LoggedInUser *auth.User 555 RepoInfo RepoInfo 556 Active string 557 Issue db.Issue 558 Comments []db.Comment 559 IssueOwnerHandle string 560 DidHandleMap map[string]string 561 562 State string 563} 564 565func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 566 params.Active = "issues" 567 if params.Issue.Open { 568 params.State = "open" 569 } else { 570 params.State = "closed" 571 } 572 return p.execute("repo/issues/issue", w, params) 573} 574 575type RepoNewIssueParams struct { 576 LoggedInUser *auth.User 577 RepoInfo RepoInfo 578 Active string 579} 580 581func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 582 params.Active = "issues" 583 return p.executeRepo("repo/issues/new", w, params) 584} 585 586type EditIssueCommentParams struct { 587 LoggedInUser *auth.User 588 RepoInfo RepoInfo 589 Issue *db.Issue 590 Comment *db.Comment 591} 592 593func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 594 return p.executePlain("fragments/editIssueComment", w, params) 595} 596 597type SingleIssueCommentParams struct { 598 LoggedInUser *auth.User 599 DidHandleMap map[string]string 600 RepoInfo RepoInfo 601 Issue *db.Issue 602 Comment *db.Comment 603} 604 605func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 606 return p.executePlain("fragments/issueComment", w, params) 607} 608 609type RepoNewPullParams struct { 610 LoggedInUser *auth.User 611 RepoInfo RepoInfo 612 Branches []types.Branch 613 Active string 614} 615 616func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { 617 params.Active = "pulls" 618 return p.executeRepo("repo/pulls/new", w, params) 619} 620 621type RepoPullsParams struct { 622 LoggedInUser *auth.User 623 RepoInfo RepoInfo 624 Pulls []db.Pull 625 Active string 626 DidHandleMap map[string]string 627 FilteringBy db.PullState 628} 629 630func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { 631 params.Active = "pulls" 632 return p.executeRepo("repo/pulls/pulls", w, params) 633} 634 635type ResubmitResult uint64 636 637const ( 638 ShouldResubmit ResubmitResult = iota 639 ShouldNotResubmit 640 Unknown 641) 642 643func (r ResubmitResult) Yes() bool { 644 return r == ShouldResubmit 645} 646func (r ResubmitResult) No() bool { 647 return r == ShouldNotResubmit 648} 649func (r ResubmitResult) Unknown() bool { 650 return r == Unknown 651} 652 653type RepoSinglePullParams struct { 654 LoggedInUser *auth.User 655 RepoInfo RepoInfo 656 Active string 657 DidHandleMap map[string]string 658 Pull *db.Pull 659 PullSourceRepo *db.Repo 660 MergeCheck types.MergeCheckResponse 661 ResubmitCheck ResubmitResult 662} 663 664func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { 665 params.Active = "pulls" 666 return p.executeRepo("repo/pulls/pull", w, params) 667} 668 669type RepoPullPatchParams struct { 670 LoggedInUser *auth.User 671 DidHandleMap map[string]string 672 RepoInfo RepoInfo 673 Pull *db.Pull 674 Diff types.NiceDiff 675 Round int 676 Submission *db.PullSubmission 677} 678 679// this name is a mouthful 680func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error { 681 return p.execute("repo/pulls/patch", w, params) 682} 683 684type PullPatchUploadParams struct { 685 RepoInfo RepoInfo 686} 687 688func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error { 689 return p.executePlain("fragments/pullPatchUpload", w, params) 690} 691 692type PullCompareBranchesParams struct { 693 RepoInfo RepoInfo 694 Branches []types.Branch 695} 696 697func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error { 698 return p.executePlain("fragments/pullCompareBranches", w, params) 699} 700 701type PullCompareForkParams struct { 702 RepoInfo RepoInfo 703 Forks []db.Repo 704} 705 706func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error { 707 return p.executePlain("fragments/pullCompareForks", w, params) 708} 709 710type PullCompareForkBranchesParams struct { 711 RepoInfo RepoInfo 712 SourceBranches []types.Branch 713 TargetBranches []types.Branch 714} 715 716func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error { 717 return p.executePlain("fragments/pullCompareForksBranches", w, params) 718} 719 720type PullResubmitParams struct { 721 LoggedInUser *auth.User 722 RepoInfo RepoInfo 723 Pull *db.Pull 724 SubmissionId int 725} 726 727func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 728 return p.executePlain("fragments/pullResubmit", w, params) 729} 730 731type PullActionsParams struct { 732 LoggedInUser *auth.User 733 RepoInfo RepoInfo 734 Pull *db.Pull 735 RoundNumber int 736 MergeCheck types.MergeCheckResponse 737 ResubmitCheck ResubmitResult 738} 739 740func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 741 return p.executePlain("fragments/pullActions", w, params) 742} 743 744type PullNewCommentParams struct { 745 LoggedInUser *auth.User 746 RepoInfo RepoInfo 747 Pull *db.Pull 748 RoundNumber int 749} 750 751func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 752 return p.executePlain("fragments/pullNewComment", w, params) 753} 754 755func (p *Pages) Static() http.Handler { 756 sub, err := fs.Sub(Files, "static") 757 if err != nil { 758 log.Fatalf("no static dir found? that's crazy: %v", err) 759 } 760 // Custom handler to apply Cache-Control headers for font files 761 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 762} 763 764func Cache(h http.Handler) http.Handler { 765 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 766 path := strings.Split(r.URL.Path, "?")[0] 767 768 if strings.HasSuffix(path, ".css") { 769 // on day for css files 770 w.Header().Set("Cache-Control", "public, max-age=86400") 771 } else { 772 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 773 } 774 h.ServeHTTP(w, r) 775 }) 776} 777 778func CssContentHash() string { 779 cssFile, err := Files.Open("static/tw.css") 780 if err != nil { 781 log.Printf("Error opening CSS file: %v", err) 782 return "" 783 } 784 defer cssFile.Close() 785 786 hasher := sha256.New() 787 if _, err := io.Copy(hasher, cssFile); err != nil { 788 log.Printf("Error hashing CSS file: %v", err) 789 return "" 790 } 791 792 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash 793} 794 795func (p *Pages) Error500(w io.Writer) error { 796 return p.execute("errors/500", w, nil) 797} 798 799func (p *Pages) Error404(w io.Writer) error { 800 return p.execute("errors/404", w, nil) 801} 802 803func (p *Pages) Error503(w io.Writer) error { 804 return p.execute("errors/503", w, nil) 805}