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