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 Registration *db.Registration 143 Members []string 144 IsOwner bool 145} 146 147func (p *Pages) Knot(w io.Writer, params KnotParams) error { 148 return p.execute("knot", w, params) 149} 150 151type NewRepoParams struct { 152 LoggedInUser *auth.User 153 Knots []string 154} 155 156func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error { 157 return p.execute("repo/new", w, params) 158} 159 160type ProfilePageParams struct { 161 LoggedInUser *auth.User 162 UserDid string 163 UserHandle string 164 Repos []db.Repo 165 CollaboratingRepos []db.Repo 166 ProfileStats ProfileStats 167 FollowStatus db.FollowStatus 168 DidHandleMap map[string]string 169 AvatarUri string 170} 171 172type ProfileStats struct { 173 Followers int 174 Following int 175} 176 177func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 178 return p.execute("user/profile", w, params) 179} 180 181type FollowFragmentParams struct { 182 UserDid string 183 FollowStatus db.FollowStatus 184} 185 186func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 187 return p.executePlain("fragments/follow", w, params) 188} 189 190type StarFragmentParams struct { 191 IsStarred bool 192 RepoAt syntax.ATURI 193 Stats db.RepoStats 194} 195 196func (p *Pages) StarFragment(w io.Writer, params StarFragmentParams) error { 197 return p.executePlain("fragments/star", w, params) 198} 199 200type RepoDescriptionParams struct { 201 RepoInfo RepoInfo 202} 203 204func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 205 return p.executePlain("fragments/editRepoDescription", w, params) 206} 207 208func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 209 return p.executePlain("fragments/repoDescription", w, params) 210} 211 212type RepoInfo struct { 213 Name string 214 OwnerDid string 215 OwnerHandle string 216 Description string 217 Knot string 218 RepoAt syntax.ATURI 219 IsStarred bool 220 Stats db.RepoStats 221 Roles RolesInRepo 222} 223 224type RolesInRepo struct { 225 Roles []string 226} 227 228func (r RolesInRepo) SettingsAllowed() bool { 229 return slices.Contains(r.Roles, "repo:settings") 230} 231 232func (r RolesInRepo) IsOwner() bool { 233 return slices.Contains(r.Roles, "repo:owner") 234} 235 236func (r RolesInRepo) IsCollaborator() bool { 237 return slices.Contains(r.Roles, "repo:collaborator") 238} 239 240func (r RolesInRepo) IsPushAllowed() bool { 241 return slices.Contains(r.Roles, "repo:push") 242} 243 244func (r RepoInfo) OwnerWithAt() string { 245 if r.OwnerHandle != "" { 246 return fmt.Sprintf("@%s", r.OwnerHandle) 247 } else { 248 return r.OwnerDid 249 } 250} 251 252func (r RepoInfo) FullName() string { 253 return path.Join(r.OwnerWithAt(), r.Name) 254} 255 256func (r RepoInfo) OwnerWithoutAt() string { 257 if strings.HasPrefix(r.OwnerWithAt(), "@") { 258 return strings.TrimPrefix(r.OwnerWithAt(), "@") 259 } else { 260 return userutil.FlattenDid(r.OwnerDid) 261 } 262} 263 264func (r RepoInfo) FullNameWithoutAt() string { 265 return path.Join(r.OwnerWithoutAt(), r.Name) 266} 267 268func (r RepoInfo) GetTabs() [][]string { 269 tabs := [][]string{ 270 {"overview", "/"}, 271 {"issues", "/issues"}, 272 {"pulls", "/pulls"}, 273 } 274 275 if r.Roles.SettingsAllowed() { 276 tabs = append(tabs, []string{"settings", "/settings"}) 277 } 278 279 return tabs 280} 281 282// each tab on a repo could have some metadata: 283// 284// issues -> number of open issues etc. 285// settings -> a warning icon to setup branch protection? idk 286// 287// we gather these bits of info here, because go templates 288// are difficult to program in 289func (r RepoInfo) TabMetadata() map[string]any { 290 meta := make(map[string]any) 291 292 if r.Stats.PullCount.Open > 0 { 293 meta["pulls"] = r.Stats.PullCount.Open 294 } 295 296 if r.Stats.IssueCount.Open > 0 { 297 meta["issues"] = r.Stats.IssueCount.Open 298 } 299 300 // more stuff? 301 302 return meta 303} 304 305type RepoIndexParams struct { 306 LoggedInUser *auth.User 307 RepoInfo RepoInfo 308 Active string 309 TagMap map[string][]string 310 types.RepoIndexResponse 311 HTMLReadme template.HTML 312 Raw bool 313 EmailToDidOrHandle map[string]string 314} 315 316func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { 317 params.Active = "overview" 318 if params.IsEmpty { 319 return p.executeRepo("repo/empty", w, params) 320 } 321 322 if params.ReadmeFileName != "" { 323 var htmlString string 324 ext := filepath.Ext(params.ReadmeFileName) 325 switch ext { 326 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 327 htmlString = renderMarkdown(params.Readme) 328 params.Raw = false 329 params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString)) 330 default: 331 htmlString = string(params.Readme) 332 params.Raw = true 333 params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString)) 334 } 335 } 336 337 return p.executeRepo("repo/index", w, params) 338} 339 340type RepoLogParams struct { 341 LoggedInUser *auth.User 342 RepoInfo RepoInfo 343 types.RepoLogResponse 344 Active string 345 EmailToDidOrHandle map[string]string 346} 347 348func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { 349 params.Active = "overview" 350 return p.execute("repo/log", w, params) 351} 352 353type RepoCommitParams struct { 354 LoggedInUser *auth.User 355 RepoInfo RepoInfo 356 Active string 357 types.RepoCommitResponse 358 EmailToDidOrHandle map[string]string 359} 360 361func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { 362 params.Active = "overview" 363 return p.executeRepo("repo/commit", w, params) 364} 365 366type RepoTreeParams struct { 367 LoggedInUser *auth.User 368 RepoInfo RepoInfo 369 Active string 370 BreadCrumbs [][]string 371 BaseTreeLink string 372 BaseBlobLink string 373 types.RepoTreeResponse 374} 375 376type RepoTreeStats struct { 377 NumFolders uint64 378 NumFiles uint64 379} 380 381func (r RepoTreeParams) TreeStats() RepoTreeStats { 382 numFolders, numFiles := 0, 0 383 for _, f := range r.Files { 384 if !f.IsFile { 385 numFolders += 1 386 } else if f.IsFile { 387 numFiles += 1 388 } 389 } 390 391 return RepoTreeStats{ 392 NumFolders: uint64(numFolders), 393 NumFiles: uint64(numFiles), 394 } 395} 396 397func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 398 params.Active = "overview" 399 return p.execute("repo/tree", w, params) 400} 401 402type RepoBranchesParams struct { 403 LoggedInUser *auth.User 404 RepoInfo RepoInfo 405 types.RepoBranchesResponse 406} 407 408func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error { 409 return p.executeRepo("repo/branches", w, params) 410} 411 412type RepoTagsParams struct { 413 LoggedInUser *auth.User 414 RepoInfo RepoInfo 415 types.RepoTagsResponse 416} 417 418func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { 419 return p.executeRepo("repo/tags", w, params) 420} 421 422type RepoBlobParams struct { 423 LoggedInUser *auth.User 424 RepoInfo RepoInfo 425 Active string 426 BreadCrumbs [][]string 427 types.RepoBlobResponse 428} 429 430func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 431 style := styles.Get("bw") 432 b := style.Builder() 433 b.Add(chroma.LiteralString, "noitalic") 434 b.Add(chroma.Background, "bg:") 435 style, _ = b.Build() 436 437 if params.Lines < 5000 { 438 c := params.Contents 439 formatter := chromahtml.New( 440 chromahtml.InlineCode(true), 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 RepoNewPullParams struct { 538 LoggedInUser *auth.User 539 RepoInfo RepoInfo 540 Branches []types.Branch 541 Active string 542} 543 544func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { 545 params.Active = "pulls" 546 return p.executeRepo("repo/pulls/new", w, params) 547} 548 549type RepoPullsParams struct { 550 LoggedInUser *auth.User 551 RepoInfo RepoInfo 552 Pulls []db.Pull 553 Active string 554 DidHandleMap map[string]string 555 FilteringBy db.PullState 556} 557 558func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { 559 params.Active = "pulls" 560 return p.executeRepo("repo/pulls/pulls", w, params) 561} 562 563type RepoSinglePullParams struct { 564 LoggedInUser *auth.User 565 RepoInfo RepoInfo 566 Active string 567 DidHandleMap map[string]string 568 569 Pull db.Pull 570 MergeCheck types.MergeCheckResponse 571} 572 573func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { 574 params.Active = "pulls" 575 return p.executeRepo("repo/pulls/pull", w, params) 576} 577 578type RepoPullPatchParams struct { 579 LoggedInUser *auth.User 580 DidHandleMap map[string]string 581 RepoInfo RepoInfo 582 Pull *db.Pull 583 Diff types.NiceDiff 584 Round int 585 Submission *db.PullSubmission 586} 587 588// this name is a mouthful 589func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error { 590 return p.execute("repo/pulls/patch", w, params) 591} 592 593type PullResubmitParams struct { 594 LoggedInUser *auth.User 595 RepoInfo RepoInfo 596 Pull *db.Pull 597 SubmissionId int 598} 599 600func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 601 return p.executePlain("fragments/pullResubmit", w, params) 602} 603 604type PullActionsParams struct { 605 LoggedInUser *auth.User 606 RepoInfo RepoInfo 607 Pull *db.Pull 608 RoundNumber int 609 MergeCheck types.MergeCheckResponse 610} 611 612func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 613 return p.executePlain("fragments/pullActions", w, params) 614} 615 616type PullNewCommentParams struct { 617 LoggedInUser *auth.User 618 RepoInfo RepoInfo 619 Pull *db.Pull 620 RoundNumber int 621} 622 623func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 624 return p.executePlain("fragments/pullNewComment", w, params) 625} 626 627func (p *Pages) Static() http.Handler { 628 sub, err := fs.Sub(Files, "static") 629 if err != nil { 630 log.Fatalf("no static dir found? that's crazy: %v", err) 631 } 632 // Custom handler to apply Cache-Control headers for font files 633 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 634} 635 636func Cache(h http.Handler) http.Handler { 637 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 638 if strings.HasSuffix(r.URL.Path, ".css") { 639 // on day for css files 640 w.Header().Set("Cache-Control", "public, max-age=86400") 641 } else { 642 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 643 } 644 h.ServeHTTP(w, r) 645 }) 646} 647 648func (p *Pages) Error500(w io.Writer) error { 649 return p.execute("errors/500", w, nil) 650} 651 652func (p *Pages) Error404(w io.Writer) error { 653 return p.execute("errors/404", w, nil) 654} 655 656func (p *Pages) Error503(w io.Writer) error { 657 return p.execute("errors/503", w, nil) 658}