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