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