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