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 RenderToggle bool 456 RenderedContents template.HTML 457 types.RepoBlobResponse 458} 459 460func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 461 style := styles.Get("bw") 462 b := style.Builder() 463 b.Add(chroma.LiteralString, "noitalic") 464 style, _ = b.Build() 465 466 if params.ShowRendered { 467 switch markup.GetFormat(params.Path) { 468 case markup.FormatMarkdown: 469 params.RenderedContents = template.HTML(markup.RenderMarkdown(params.Contents)) 470 } 471 } 472 473 if params.Lines < 5000 { 474 c := params.Contents 475 formatter := chromahtml.New( 476 chromahtml.InlineCode(false), 477 chromahtml.WithLineNumbers(true), 478 chromahtml.WithLinkableLineNumbers(true, "L"), 479 chromahtml.Standalone(false), 480 ) 481 482 lexer := lexers.Get(filepath.Base(params.Path)) 483 if lexer == nil { 484 lexer = lexers.Fallback 485 } 486 487 iterator, err := lexer.Tokenise(nil, c) 488 if err != nil { 489 return fmt.Errorf("chroma tokenize: %w", err) 490 } 491 492 var code bytes.Buffer 493 err = formatter.Format(&code, style, iterator) 494 if err != nil { 495 return fmt.Errorf("chroma format: %w", err) 496 } 497 498 params.Contents = code.String() 499 } 500 501 params.Active = "overview" 502 return p.executeRepo("repo/blob", w, params) 503} 504 505type Collaborator struct { 506 Did string 507 Handle string 508 Role string 509} 510 511type RepoSettingsParams struct { 512 LoggedInUser *auth.User 513 RepoInfo RepoInfo 514 Collaborators []Collaborator 515 Active string 516 Branches []string 517 DefaultBranch string 518 // TODO: use repoinfo.roles 519 IsCollaboratorInviteAllowed bool 520} 521 522func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { 523 params.Active = "settings" 524 return p.executeRepo("repo/settings", w, params) 525} 526 527type RepoIssuesParams struct { 528 LoggedInUser *auth.User 529 RepoInfo RepoInfo 530 Active string 531 Issues []db.Issue 532 DidHandleMap map[string]string 533 534 FilteringByOpen bool 535} 536 537func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { 538 params.Active = "issues" 539 return p.executeRepo("repo/issues/issues", w, params) 540} 541 542type RepoSingleIssueParams struct { 543 LoggedInUser *auth.User 544 RepoInfo RepoInfo 545 Active string 546 Issue db.Issue 547 Comments []db.Comment 548 IssueOwnerHandle string 549 DidHandleMap map[string]string 550 551 State string 552} 553 554func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 555 params.Active = "issues" 556 if params.Issue.Open { 557 params.State = "open" 558 } else { 559 params.State = "closed" 560 } 561 return p.execute("repo/issues/issue", w, params) 562} 563 564type RepoNewIssueParams struct { 565 LoggedInUser *auth.User 566 RepoInfo RepoInfo 567 Active string 568} 569 570func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 571 params.Active = "issues" 572 return p.executeRepo("repo/issues/new", w, params) 573} 574 575type EditIssueCommentParams struct { 576 LoggedInUser *auth.User 577 RepoInfo RepoInfo 578 Issue *db.Issue 579 Comment *db.Comment 580} 581 582func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 583 return p.executePlain("fragments/editIssueComment", w, params) 584} 585 586type SingleIssueCommentParams struct { 587 LoggedInUser *auth.User 588 DidHandleMap map[string]string 589 RepoInfo RepoInfo 590 Issue *db.Issue 591 Comment *db.Comment 592} 593 594func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 595 return p.executePlain("fragments/issueComment", w, params) 596} 597 598type RepoNewPullParams struct { 599 LoggedInUser *auth.User 600 RepoInfo RepoInfo 601 Branches []types.Branch 602 Active string 603} 604 605func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { 606 params.Active = "pulls" 607 return p.executeRepo("repo/pulls/new", w, params) 608} 609 610type RepoPullsParams struct { 611 LoggedInUser *auth.User 612 RepoInfo RepoInfo 613 Pulls []db.Pull 614 Active string 615 DidHandleMap map[string]string 616 FilteringBy db.PullState 617} 618 619func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { 620 params.Active = "pulls" 621 return p.executeRepo("repo/pulls/pulls", w, params) 622} 623 624type ResubmitResult uint64 625 626const ( 627 ShouldResubmit ResubmitResult = iota 628 ShouldNotResubmit 629 Unknown 630) 631 632func (r ResubmitResult) Yes() bool { 633 return r == ShouldResubmit 634} 635func (r ResubmitResult) No() bool { 636 return r == ShouldNotResubmit 637} 638func (r ResubmitResult) Unknown() bool { 639 return r == Unknown 640} 641 642type RepoSinglePullParams struct { 643 LoggedInUser *auth.User 644 RepoInfo RepoInfo 645 Active string 646 DidHandleMap map[string]string 647 Pull *db.Pull 648 PullSourceRepo *db.Repo 649 MergeCheck types.MergeCheckResponse 650 ResubmitCheck ResubmitResult 651} 652 653func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { 654 params.Active = "pulls" 655 return p.executeRepo("repo/pulls/pull", w, params) 656} 657 658type RepoPullPatchParams struct { 659 LoggedInUser *auth.User 660 DidHandleMap map[string]string 661 RepoInfo RepoInfo 662 Pull *db.Pull 663 Diff types.NiceDiff 664 Round int 665 Submission *db.PullSubmission 666} 667 668// this name is a mouthful 669func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error { 670 return p.execute("repo/pulls/patch", w, params) 671} 672 673type PullPatchUploadParams struct { 674 RepoInfo RepoInfo 675} 676 677func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error { 678 return p.executePlain("fragments/pullPatchUpload", w, params) 679} 680 681type PullCompareBranchesParams struct { 682 RepoInfo RepoInfo 683 Branches []types.Branch 684} 685 686func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error { 687 return p.executePlain("fragments/pullCompareBranches", w, params) 688} 689 690type PullCompareForkParams struct { 691 RepoInfo RepoInfo 692 Forks []db.Repo 693} 694 695func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error { 696 return p.executePlain("fragments/pullCompareForks", w, params) 697} 698 699type PullCompareForkBranchesParams struct { 700 RepoInfo RepoInfo 701 SourceBranches []types.Branch 702 TargetBranches []types.Branch 703} 704 705func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error { 706 return p.executePlain("fragments/pullCompareForksBranches", w, params) 707} 708 709type PullResubmitParams struct { 710 LoggedInUser *auth.User 711 RepoInfo RepoInfo 712 Pull *db.Pull 713 SubmissionId int 714} 715 716func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 717 return p.executePlain("fragments/pullResubmit", w, params) 718} 719 720type PullActionsParams struct { 721 LoggedInUser *auth.User 722 RepoInfo RepoInfo 723 Pull *db.Pull 724 RoundNumber int 725 MergeCheck types.MergeCheckResponse 726 ResubmitCheck ResubmitResult 727} 728 729func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 730 return p.executePlain("fragments/pullActions", w, params) 731} 732 733type PullNewCommentParams struct { 734 LoggedInUser *auth.User 735 RepoInfo RepoInfo 736 Pull *db.Pull 737 RoundNumber int 738} 739 740func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 741 return p.executePlain("fragments/pullNewComment", w, params) 742} 743 744func (p *Pages) Static() http.Handler { 745 sub, err := fs.Sub(Files, "static") 746 if err != nil { 747 log.Fatalf("no static dir found? that's crazy: %v", err) 748 } 749 // Custom handler to apply Cache-Control headers for font files 750 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 751} 752 753func Cache(h http.Handler) http.Handler { 754 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 755 path := strings.Split(r.URL.Path, "?")[0] 756 757 if strings.HasSuffix(path, ".css") { 758 // on day for css files 759 w.Header().Set("Cache-Control", "public, max-age=86400") 760 } else { 761 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 762 } 763 h.ServeHTTP(w, r) 764 }) 765} 766 767func CssContentHash() string { 768 cssFile, err := Files.Open("static/tw.css") 769 if err != nil { 770 log.Printf("Error opening CSS file: %v", err) 771 return "" 772 } 773 defer cssFile.Close() 774 775 hasher := sha256.New() 776 if _, err := io.Copy(hasher, cssFile); err != nil { 777 log.Printf("Error hashing CSS file: %v", err) 778 return "" 779 } 780 781 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash 782} 783 784func (p *Pages) Error500(w io.Writer) error { 785 return p.execute("errors/500", w, nil) 786} 787 788func (p *Pages) Error404(w io.Writer) error { 789 return p.execute("errors/404", w, nil) 790} 791 792func (p *Pages) Error503(w io.Writer) error { 793 return p.execute("errors/503", w, nil) 794}