forked from tangled.org/core
this repo has no description
1package pages 2 3import ( 4 "bytes" 5 "crypto/sha256" 6 "embed" 7 "encoding/hex" 8 "fmt" 9 "html/template" 10 "io" 11 "io/fs" 12 "log" 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 RepoAt syntax.ATURI 235 IsStarred bool 236 Stats db.RepoStats 237 Roles RolesInRepo 238 Source *db.Repo 239 SourceHandle string 240 DisableFork bool 241} 242 243type RolesInRepo struct { 244 Roles []string 245} 246 247func (r RolesInRepo) SettingsAllowed() bool { 248 return slices.Contains(r.Roles, "repo:settings") 249} 250 251func (r RolesInRepo) CollaboratorInviteAllowed() bool { 252 return slices.Contains(r.Roles, "repo:invite") 253} 254 255func (r RolesInRepo) RepoDeleteAllowed() bool { 256 return slices.Contains(r.Roles, "repo:delete") 257} 258 259func (r RolesInRepo) IsOwner() bool { 260 return slices.Contains(r.Roles, "repo:owner") 261} 262 263func (r RolesInRepo) IsCollaborator() bool { 264 return slices.Contains(r.Roles, "repo:collaborator") 265} 266 267func (r RolesInRepo) IsPushAllowed() bool { 268 return slices.Contains(r.Roles, "repo:push") 269} 270 271func (r RepoInfo) OwnerWithAt() string { 272 if r.OwnerHandle != "" { 273 return fmt.Sprintf("@%s", r.OwnerHandle) 274 } else { 275 return r.OwnerDid 276 } 277} 278 279func (r RepoInfo) FullName() string { 280 return path.Join(r.OwnerWithAt(), r.Name) 281} 282 283func (r RepoInfo) OwnerWithoutAt() string { 284 if strings.HasPrefix(r.OwnerWithAt(), "@") { 285 return strings.TrimPrefix(r.OwnerWithAt(), "@") 286 } else { 287 return userutil.FlattenDid(r.OwnerDid) 288 } 289} 290 291func (r RepoInfo) FullNameWithoutAt() string { 292 return path.Join(r.OwnerWithoutAt(), r.Name) 293} 294 295func (r RepoInfo) GetTabs() [][]string { 296 tabs := [][]string{ 297 {"overview", "/", "square-chart-gantt"}, 298 {"issues", "/issues", "circle-dot"}, 299 {"pulls", "/pulls", "git-pull-request"}, 300 } 301 302 if r.Roles.SettingsAllowed() { 303 tabs = append(tabs, []string{"settings", "/settings", "cog"}) 304 } 305 306 return tabs 307} 308 309// each tab on a repo could have some metadata: 310// 311// issues -> number of open issues etc. 312// settings -> a warning icon to setup branch protection? idk 313// 314// we gather these bits of info here, because go templates 315// are difficult to program in 316func (r RepoInfo) TabMetadata() map[string]any { 317 meta := make(map[string]any) 318 319 if r.Stats.PullCount.Open > 0 { 320 meta["pulls"] = r.Stats.PullCount.Open 321 } 322 323 if r.Stats.IssueCount.Open > 0 { 324 meta["issues"] = r.Stats.IssueCount.Open 325 } 326 327 // more stuff? 328 329 return meta 330} 331 332type RepoIndexParams struct { 333 LoggedInUser *auth.User 334 RepoInfo RepoInfo 335 Active string 336 TagMap map[string][]string 337 types.RepoIndexResponse 338 HTMLReadme template.HTML 339 Raw bool 340 EmailToDidOrHandle map[string]string 341} 342 343func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { 344 params.Active = "overview" 345 if params.IsEmpty { 346 return p.executeRepo("repo/empty", w, params) 347 } 348 349 if params.ReadmeFileName != "" { 350 var htmlString string 351 ext := filepath.Ext(params.ReadmeFileName) 352 switch ext { 353 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 354 htmlString = markup.RenderMarkdown(params.Readme) 355 params.Raw = false 356 params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString)) 357 default: 358 htmlString = string(params.Readme) 359 params.Raw = true 360 params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString)) 361 } 362 } 363 364 return p.executeRepo("repo/index", w, params) 365} 366 367type RepoLogParams struct { 368 LoggedInUser *auth.User 369 RepoInfo RepoInfo 370 types.RepoLogResponse 371 Active string 372 EmailToDidOrHandle map[string]string 373} 374 375func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { 376 params.Active = "overview" 377 return p.execute("repo/log", w, params) 378} 379 380type RepoCommitParams struct { 381 LoggedInUser *auth.User 382 RepoInfo RepoInfo 383 Active string 384 types.RepoCommitResponse 385 EmailToDidOrHandle map[string]string 386} 387 388func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { 389 params.Active = "overview" 390 return p.executeRepo("repo/commit", w, params) 391} 392 393type RepoTreeParams struct { 394 LoggedInUser *auth.User 395 RepoInfo RepoInfo 396 Active string 397 BreadCrumbs [][]string 398 BaseTreeLink string 399 BaseBlobLink string 400 types.RepoTreeResponse 401} 402 403type RepoTreeStats struct { 404 NumFolders uint64 405 NumFiles uint64 406} 407 408func (r RepoTreeParams) TreeStats() RepoTreeStats { 409 numFolders, numFiles := 0, 0 410 for _, f := range r.Files { 411 if !f.IsFile { 412 numFolders += 1 413 } else if f.IsFile { 414 numFiles += 1 415 } 416 } 417 418 return RepoTreeStats{ 419 NumFolders: uint64(numFolders), 420 NumFiles: uint64(numFiles), 421 } 422} 423 424func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 425 params.Active = "overview" 426 return p.execute("repo/tree", w, params) 427} 428 429type RepoBranchesParams struct { 430 LoggedInUser *auth.User 431 RepoInfo RepoInfo 432 types.RepoBranchesResponse 433} 434 435func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error { 436 return p.executeRepo("repo/branches", w, params) 437} 438 439type RepoTagsParams struct { 440 LoggedInUser *auth.User 441 RepoInfo RepoInfo 442 types.RepoTagsResponse 443} 444 445func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { 446 return p.executeRepo("repo/tags", w, params) 447} 448 449type RepoBlobParams struct { 450 LoggedInUser *auth.User 451 RepoInfo RepoInfo 452 Active string 453 BreadCrumbs [][]string 454 ShowRendered bool 455 RenderedContents template.HTML 456 types.RepoBlobResponse 457} 458 459func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 460 style := styles.Get("bw") 461 b := style.Builder() 462 b.Add(chroma.LiteralString, "noitalic") 463 style, _ = b.Build() 464 465 if params.ShowRendered { 466 switch markup.GetFormat(params.Path) { 467 case markup.FormatMarkdown: 468 params.RenderedContents = template.HTML(markup.RenderMarkdown(params.Contents)) 469 } 470 } 471 472 if params.Lines < 5000 { 473 c := params.Contents 474 formatter := chromahtml.New( 475 chromahtml.InlineCode(false), 476 chromahtml.WithLineNumbers(true), 477 chromahtml.WithLinkableLineNumbers(true, "L"), 478 chromahtml.Standalone(false), 479 ) 480 481 lexer := lexers.Get(filepath.Base(params.Path)) 482 if lexer == nil { 483 lexer = lexers.Fallback 484 } 485 486 iterator, err := lexer.Tokenise(nil, c) 487 if err != nil { 488 return fmt.Errorf("chroma tokenize: %w", err) 489 } 490 491 var code bytes.Buffer 492 err = formatter.Format(&code, style, iterator) 493 if err != nil { 494 return fmt.Errorf("chroma format: %w", err) 495 } 496 497 params.Contents = code.String() 498 } 499 500 params.Active = "overview" 501 return p.executeRepo("repo/blob", w, params) 502} 503 504type Collaborator struct { 505 Did string 506 Handle string 507 Role string 508} 509 510type RepoSettingsParams struct { 511 LoggedInUser *auth.User 512 RepoInfo RepoInfo 513 Collaborators []Collaborator 514 Active string 515 Branches []string 516 DefaultBranch string 517 // TODO: use repoinfo.roles 518 IsCollaboratorInviteAllowed bool 519} 520 521func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { 522 params.Active = "settings" 523 return p.executeRepo("repo/settings", w, params) 524} 525 526type RepoIssuesParams struct { 527 LoggedInUser *auth.User 528 RepoInfo RepoInfo 529 Active string 530 Issues []db.Issue 531 DidHandleMap map[string]string 532 533 FilteringByOpen bool 534} 535 536func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { 537 params.Active = "issues" 538 return p.executeRepo("repo/issues/issues", w, params) 539} 540 541type RepoSingleIssueParams struct { 542 LoggedInUser *auth.User 543 RepoInfo RepoInfo 544 Active string 545 Issue db.Issue 546 Comments []db.Comment 547 IssueOwnerHandle string 548 DidHandleMap map[string]string 549 550 State string 551} 552 553func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 554 params.Active = "issues" 555 if params.Issue.Open { 556 params.State = "open" 557 } else { 558 params.State = "closed" 559 } 560 return p.execute("repo/issues/issue", w, params) 561} 562 563type RepoNewIssueParams struct { 564 LoggedInUser *auth.User 565 RepoInfo RepoInfo 566 Active string 567} 568 569func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 570 params.Active = "issues" 571 return p.executeRepo("repo/issues/new", w, params) 572} 573 574type EditIssueCommentParams struct { 575 LoggedInUser *auth.User 576 RepoInfo RepoInfo 577 Issue *db.Issue 578 Comment *db.Comment 579} 580 581func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 582 return p.executePlain("fragments/editIssueComment", w, params) 583} 584 585type SingleIssueCommentParams struct { 586 LoggedInUser *auth.User 587 DidHandleMap map[string]string 588 RepoInfo RepoInfo 589 Issue *db.Issue 590 Comment *db.Comment 591} 592 593func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 594 return p.executePlain("fragments/issueComment", w, params) 595} 596 597type RepoNewPullParams struct { 598 LoggedInUser *auth.User 599 RepoInfo RepoInfo 600 Branches []types.Branch 601 Active string 602} 603 604func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { 605 params.Active = "pulls" 606 return p.executeRepo("repo/pulls/new", w, params) 607} 608 609type RepoPullsParams struct { 610 LoggedInUser *auth.User 611 RepoInfo RepoInfo 612 Pulls []db.Pull 613 Active string 614 DidHandleMap map[string]string 615 FilteringBy db.PullState 616} 617 618func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { 619 params.Active = "pulls" 620 return p.executeRepo("repo/pulls/pulls", w, params) 621} 622 623type ResubmitResult uint64 624 625const ( 626 ShouldResubmit ResubmitResult = iota 627 ShouldNotResubmit 628 Unknown 629) 630 631func (r ResubmitResult) Yes() bool { 632 return r == ShouldResubmit 633} 634func (r ResubmitResult) No() bool { 635 return r == ShouldNotResubmit 636} 637func (r ResubmitResult) Unknown() bool { 638 return r == Unknown 639} 640 641type RepoSinglePullParams struct { 642 LoggedInUser *auth.User 643 RepoInfo RepoInfo 644 Active string 645 DidHandleMap map[string]string 646 Pull *db.Pull 647 PullSourceRepo *db.Repo 648 MergeCheck types.MergeCheckResponse 649 ResubmitCheck ResubmitResult 650} 651 652func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { 653 params.Active = "pulls" 654 return p.executeRepo("repo/pulls/pull", w, params) 655} 656 657type RepoPullPatchParams struct { 658 LoggedInUser *auth.User 659 DidHandleMap map[string]string 660 RepoInfo RepoInfo 661 Pull *db.Pull 662 Diff types.NiceDiff 663 Round int 664 Submission *db.PullSubmission 665} 666 667// this name is a mouthful 668func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error { 669 return p.execute("repo/pulls/patch", w, params) 670} 671 672type PullPatchUploadParams struct { 673 RepoInfo RepoInfo 674} 675 676func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error { 677 return p.executePlain("fragments/pullPatchUpload", w, params) 678} 679 680type PullCompareBranchesParams struct { 681 RepoInfo RepoInfo 682 Branches []types.Branch 683} 684 685func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error { 686 return p.executePlain("fragments/pullCompareBranches", w, params) 687} 688 689type PullCompareForkParams struct { 690 RepoInfo RepoInfo 691 Forks []db.Repo 692} 693 694func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error { 695 return p.executePlain("fragments/pullCompareForks", w, params) 696} 697 698type PullCompareForkBranchesParams struct { 699 RepoInfo RepoInfo 700 SourceBranches []types.Branch 701 TargetBranches []types.Branch 702} 703 704func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error { 705 return p.executePlain("fragments/pullCompareForksBranches", w, params) 706} 707 708type PullResubmitParams struct { 709 LoggedInUser *auth.User 710 RepoInfo RepoInfo 711 Pull *db.Pull 712 SubmissionId int 713} 714 715func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 716 return p.executePlain("fragments/pullResubmit", w, params) 717} 718 719type PullActionsParams struct { 720 LoggedInUser *auth.User 721 RepoInfo RepoInfo 722 Pull *db.Pull 723 RoundNumber int 724 MergeCheck types.MergeCheckResponse 725 ResubmitCheck ResubmitResult 726} 727 728func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 729 return p.executePlain("fragments/pullActions", w, params) 730} 731 732type PullNewCommentParams struct { 733 LoggedInUser *auth.User 734 RepoInfo RepoInfo 735 Pull *db.Pull 736 RoundNumber int 737} 738 739func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 740 return p.executePlain("fragments/pullNewComment", w, params) 741} 742 743func (p *Pages) Static() http.Handler { 744 sub, err := fs.Sub(Files, "static") 745 if err != nil { 746 log.Fatalf("no static dir found? that's crazy: %v", err) 747 } 748 // Custom handler to apply Cache-Control headers for font files 749 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 750} 751 752func Cache(h http.Handler) http.Handler { 753 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 754 path := strings.Split(r.URL.Path, "?")[0] 755 756 if strings.HasSuffix(path, ".css") { 757 // on day for css files 758 w.Header().Set("Cache-Control", "public, max-age=86400") 759 } else { 760 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 761 } 762 h.ServeHTTP(w, r) 763 }) 764} 765 766func CssContentHash() string { 767 cssFile, err := Files.Open("static/tw.css") 768 if err != nil { 769 log.Printf("Error opening CSS file: %v", err) 770 return "" 771 } 772 defer cssFile.Close() 773 774 hasher := sha256.New() 775 if _, err := io.Copy(hasher, cssFile); err != nil { 776 log.Printf("Error hashing CSS file: %v", err) 777 return "" 778 } 779 780 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash 781} 782 783func (p *Pages) Error500(w io.Writer) error { 784 return p.execute("errors/500", w, nil) 785} 786 787func (p *Pages) Error404(w io.Writer) error { 788 return p.execute("errors/404", w, nil) 789} 790 791func (p *Pages) Error503(w io.Writer) error { 792 return p.execute("errors/503", w, nil) 793}