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