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