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