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