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 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 style, _ = b.Build() 435 436 if params.Lines < 5000 { 437 c := params.Contents 438 formatter := chromahtml.New( 439 chromahtml.InlineCode(true), 440 chromahtml.WithLineNumbers(true), 441 chromahtml.WithLinkableLineNumbers(true, "L"), 442 chromahtml.Standalone(false), 443 ) 444 445 lexer := lexers.Get(filepath.Base(params.Path)) 446 if lexer == nil { 447 lexer = lexers.Fallback 448 } 449 450 iterator, err := lexer.Tokenise(nil, c) 451 if err != nil { 452 return fmt.Errorf("chroma tokenize: %w", err) 453 } 454 455 var code bytes.Buffer 456 err = formatter.Format(&code, style, iterator) 457 if err != nil { 458 return fmt.Errorf("chroma format: %w", err) 459 } 460 461 params.Contents = code.String() 462 } 463 464 params.Active = "overview" 465 return p.executeRepo("repo/blob", w, params) 466} 467 468type Collaborator struct { 469 Did string 470 Handle string 471 Role string 472} 473 474type RepoSettingsParams struct { 475 LoggedInUser *auth.User 476 RepoInfo RepoInfo 477 Collaborators []Collaborator 478 Active string 479 // TODO: use repoinfo.roles 480 IsCollaboratorInviteAllowed bool 481} 482 483func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { 484 params.Active = "settings" 485 return p.executeRepo("repo/settings", w, params) 486} 487 488type RepoIssuesParams struct { 489 LoggedInUser *auth.User 490 RepoInfo RepoInfo 491 Active string 492 Issues []db.Issue 493 DidHandleMap map[string]string 494 495 FilteringByOpen bool 496} 497 498func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { 499 params.Active = "issues" 500 return p.executeRepo("repo/issues/issues", w, params) 501} 502 503type RepoSingleIssueParams struct { 504 LoggedInUser *auth.User 505 RepoInfo RepoInfo 506 Active string 507 Issue db.Issue 508 Comments []db.Comment 509 IssueOwnerHandle string 510 DidHandleMap map[string]string 511 512 State string 513} 514 515func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 516 params.Active = "issues" 517 if params.Issue.Open { 518 params.State = "open" 519 } else { 520 params.State = "closed" 521 } 522 return p.execute("repo/issues/issue", w, params) 523} 524 525type RepoNewIssueParams struct { 526 LoggedInUser *auth.User 527 RepoInfo RepoInfo 528 Active string 529} 530 531func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 532 params.Active = "issues" 533 return p.executeRepo("repo/issues/new", w, params) 534} 535 536type RepoNewPullParams struct { 537 LoggedInUser *auth.User 538 RepoInfo RepoInfo 539 Branches []types.Branch 540 Active string 541} 542 543func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { 544 params.Active = "pulls" 545 return p.executeRepo("repo/pulls/new", w, params) 546} 547 548type RepoPullsParams struct { 549 LoggedInUser *auth.User 550 RepoInfo RepoInfo 551 Pulls []db.Pull 552 Active string 553 DidHandleMap map[string]string 554 FilteringBy db.PullState 555} 556 557func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { 558 params.Active = "pulls" 559 return p.executeRepo("repo/pulls/pulls", w, params) 560} 561 562type RepoSinglePullParams struct { 563 LoggedInUser *auth.User 564 RepoInfo RepoInfo 565 Active string 566 DidHandleMap map[string]string 567 568 Pull db.Pull 569 MergeCheck types.MergeCheckResponse 570} 571 572func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { 573 params.Active = "pulls" 574 return p.executeRepo("repo/pulls/pull", w, params) 575} 576 577type RepoPullPatchParams struct { 578 LoggedInUser *auth.User 579 DidHandleMap map[string]string 580 RepoInfo RepoInfo 581 Pull *db.Pull 582 Diff types.NiceDiff 583 Round int 584 Submission *db.PullSubmission 585} 586 587// this name is a mouthful 588func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error { 589 return p.execute("repo/pulls/patch", w, params) 590} 591 592type PullResubmitParams struct { 593 LoggedInUser *auth.User 594 RepoInfo RepoInfo 595 Pull *db.Pull 596 SubmissionId int 597} 598 599func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 600 return p.executePlain("fragments/pullResubmit", w, params) 601} 602 603type PullActionsParams struct { 604 LoggedInUser *auth.User 605 RepoInfo RepoInfo 606 Pull *db.Pull 607 RoundNumber int 608 MergeCheck types.MergeCheckResponse 609} 610 611func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 612 return p.executePlain("fragments/pullActions", w, params) 613} 614 615type PullNewCommentParams struct { 616 LoggedInUser *auth.User 617 RepoInfo RepoInfo 618 Pull *db.Pull 619 RoundNumber int 620} 621 622func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 623 return p.executePlain("fragments/pullNewComment", w, params) 624} 625 626func (p *Pages) Static() http.Handler { 627 sub, err := fs.Sub(Files, "static") 628 if err != nil { 629 log.Fatalf("no static dir found? that's crazy: %v", err) 630 } 631 // Custom handler to apply Cache-Control headers for font files 632 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 633} 634 635func Cache(h http.Handler) http.Handler { 636 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 637 if strings.HasSuffix(r.URL.Path, ".css") { 638 // on day for css files 639 w.Header().Set("Cache-Control", "public, max-age=86400") 640 } else { 641 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 642 } 643 h.ServeHTTP(w, r) 644 }) 645} 646 647func (p *Pages) Error500(w io.Writer) error { 648 return p.execute("errors/500", w, nil) 649} 650 651func (p *Pages) Error404(w io.Writer) error { 652 return p.execute("errors/404", w, nil) 653} 654 655func (p *Pages) Error503(w io.Writer) error { 656 return p.execute("errors/503", w, nil) 657}