forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
at pr-actions 15 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 "github.com/sotangled/tangled/appview/auth" 24 "github.com/sotangled/tangled/appview/db" 25 "github.com/sotangled/tangled/types" 26) 27 28//go:embed templates/* static 29var Files embed.FS 30 31type Pages struct { 32 t map[string]*template.Template 33} 34 35func NewPages() *Pages { 36 templates := make(map[string]*template.Template) 37 38 // Walk through embedded templates directory and parse all .html files 39 err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error { 40 if err != nil { 41 return err 42 } 43 44 if !d.IsDir() && strings.HasSuffix(path, ".html") { 45 name := strings.TrimPrefix(path, "templates/") 46 name = strings.TrimSuffix(name, ".html") 47 48 // add fragments as templates 49 if strings.HasPrefix(path, "templates/fragments/") { 50 tmpl, err := template.New(name). 51 Funcs(funcMap()). 52 ParseFS(Files, path) 53 if err != nil { 54 return fmt.Errorf("setting up fragment: %w", err) 55 } 56 57 templates[name] = tmpl 58 log.Printf("loaded fragment: %s", name) 59 } 60 61 // layouts and fragments are applied first 62 if !strings.HasPrefix(path, "templates/layouts/") && 63 !strings.HasPrefix(path, "templates/fragments/") { 64 // Add the page template on top of the base 65 tmpl, err := template.New(name). 66 Funcs(funcMap()). 67 ParseFS(Files, "templates/layouts/*.html", "templates/fragments/*.html", path) 68 if err != nil { 69 return fmt.Errorf("setting up template: %w", err) 70 } 71 72 templates[name] = tmpl 73 log.Printf("loaded template: %s", name) 74 } 75 76 return nil 77 } 78 return nil 79 }) 80 if err != nil { 81 log.Fatalf("walking template dir: %v", err) 82 } 83 84 log.Printf("total templates loaded: %d", len(templates)) 85 86 return &Pages{ 87 t: templates, 88 } 89} 90 91type LoginParams struct { 92} 93 94func (p *Pages) execute(name string, w io.Writer, params any) error { 95 return p.t[name].ExecuteTemplate(w, "layouts/base", params) 96} 97 98func (p *Pages) executePlain(name string, w io.Writer, params any) error { 99 return p.t[name].Execute(w, params) 100} 101 102func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 103 return p.t[name].ExecuteTemplate(w, "layouts/repobase", params) 104} 105 106func (p *Pages) Login(w io.Writer, params LoginParams) error { 107 return p.executePlain("user/login", w, params) 108} 109 110type TimelineParams struct { 111 LoggedInUser *auth.User 112 Timeline []db.TimelineEvent 113 DidHandleMap map[string]string 114} 115 116func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 117 return p.execute("timeline", w, params) 118} 119 120type SettingsParams struct { 121 LoggedInUser *auth.User 122 PubKeys []db.PublicKey 123 Emails []db.Email 124} 125 126func (p *Pages) Settings(w io.Writer, params SettingsParams) error { 127 return p.execute("settings", w, params) 128} 129 130type KnotsParams struct { 131 LoggedInUser *auth.User 132 Registrations []db.Registration 133} 134 135func (p *Pages) Knots(w io.Writer, params KnotsParams) error { 136 return p.execute("knots", w, params) 137} 138 139type KnotParams struct { 140 LoggedInUser *auth.User 141 Registration *db.Registration 142 Members []string 143 IsOwner bool 144} 145 146func (p *Pages) Knot(w io.Writer, params KnotParams) error { 147 return p.execute("knot", w, params) 148} 149 150type NewRepoParams struct { 151 LoggedInUser *auth.User 152 Knots []string 153} 154 155func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error { 156 return p.execute("repo/new", w, params) 157} 158 159type ProfilePageParams struct { 160 LoggedInUser *auth.User 161 UserDid string 162 UserHandle string 163 Repos []db.Repo 164 CollaboratingRepos []db.Repo 165 ProfileStats ProfileStats 166 FollowStatus db.FollowStatus 167 DidHandleMap map[string]string 168 AvatarUri string 169} 170 171type ProfileStats struct { 172 Followers int 173 Following int 174} 175 176func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 177 return p.execute("user/profile", w, params) 178} 179 180type FollowFragmentParams struct { 181 UserDid string 182 FollowStatus db.FollowStatus 183} 184 185func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 186 return p.executePlain("fragments/follow", w, params) 187} 188 189type StarFragmentParams struct { 190 IsStarred bool 191 RepoAt syntax.ATURI 192 Stats db.RepoStats 193} 194 195func (p *Pages) StarFragment(w io.Writer, params StarFragmentParams) error { 196 return p.executePlain("fragments/star", w, params) 197} 198 199type RepoDescriptionParams struct { 200 RepoInfo RepoInfo 201} 202 203func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 204 return p.executePlain("fragments/editRepoDescription", w, params) 205} 206 207func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 208 return p.executePlain("fragments/repoDescription", w, params) 209} 210 211type RepoInfo struct { 212 Name string 213 OwnerDid string 214 OwnerHandle string 215 Description string 216 Knot string 217 RepoAt syntax.ATURI 218 IsStarred bool 219 Stats db.RepoStats 220 Roles RolesInRepo 221} 222 223type RolesInRepo struct { 224 Roles []string 225} 226 227func (r RolesInRepo) SettingsAllowed() bool { 228 return slices.Contains(r.Roles, "repo:settings") 229} 230 231func (r RolesInRepo) IsOwner() bool { 232 return slices.Contains(r.Roles, "repo:owner") 233} 234 235func (r RolesInRepo) IsCollaborator() bool { 236 return slices.Contains(r.Roles, "repo:collaborator") 237} 238 239func (r RolesInRepo) IsPushAllowed() bool { 240 return slices.Contains(r.Roles, "repo:push") 241} 242 243func (r RepoInfo) OwnerWithAt() string { 244 if r.OwnerHandle != "" { 245 return fmt.Sprintf("@%s", r.OwnerHandle) 246 } else { 247 return r.OwnerDid 248 } 249} 250 251func (r RepoInfo) FullName() string { 252 return path.Join(r.OwnerWithAt(), r.Name) 253} 254 255func (r RepoInfo) GetTabs() [][]string { 256 tabs := [][]string{ 257 {"overview", "/"}, 258 {"issues", "/issues"}, 259 {"pulls", "/pulls"}, 260 } 261 262 if r.Roles.SettingsAllowed() { 263 tabs = append(tabs, []string{"settings", "/settings"}) 264 } 265 266 return tabs 267} 268 269// each tab on a repo could have some metadata: 270// 271// issues -> number of open issues etc. 272// settings -> a warning icon to setup branch protection? idk 273// 274// we gather these bits of info here, because go templates 275// are difficult to program in 276func (r RepoInfo) TabMetadata() map[string]any { 277 meta := make(map[string]any) 278 279 if r.Stats.PullCount.Open > 0 { 280 meta["pulls"] = r.Stats.PullCount.Open 281 } 282 283 if r.Stats.IssueCount.Open > 0 { 284 meta["issues"] = r.Stats.IssueCount.Open 285 } 286 287 // more stuff? 288 289 return meta 290} 291 292type RepoIndexParams struct { 293 LoggedInUser *auth.User 294 RepoInfo RepoInfo 295 Active string 296 TagMap map[string][]string 297 types.RepoIndexResponse 298 HTMLReadme template.HTML 299 Raw bool 300 EmailToDidOrHandle map[string]string 301} 302 303func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { 304 params.Active = "overview" 305 if params.IsEmpty { 306 return p.executeRepo("repo/empty", w, params) 307 } 308 309 if params.ReadmeFileName != "" { 310 var htmlString string 311 ext := filepath.Ext(params.ReadmeFileName) 312 switch ext { 313 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 314 htmlString = renderMarkdown(params.Readme) 315 params.Raw = false 316 params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString)) 317 default: 318 htmlString = string(params.Readme) 319 params.Raw = true 320 params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString)) 321 } 322 } 323 324 return p.executeRepo("repo/index", w, params) 325} 326 327type RepoLogParams struct { 328 LoggedInUser *auth.User 329 RepoInfo RepoInfo 330 types.RepoLogResponse 331 Active string 332 EmailToDidOrHandle map[string]string 333} 334 335func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { 336 params.Active = "overview" 337 return p.execute("repo/log", w, params) 338} 339 340type RepoCommitParams struct { 341 LoggedInUser *auth.User 342 RepoInfo RepoInfo 343 Active string 344 types.RepoCommitResponse 345 EmailToDidOrHandle map[string]string 346} 347 348func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { 349 params.Active = "overview" 350 return p.executeRepo("repo/commit", w, params) 351} 352 353type RepoTreeParams struct { 354 LoggedInUser *auth.User 355 RepoInfo RepoInfo 356 Active string 357 BreadCrumbs [][]string 358 BaseTreeLink string 359 BaseBlobLink string 360 types.RepoTreeResponse 361} 362 363type RepoTreeStats struct { 364 NumFolders uint64 365 NumFiles uint64 366} 367 368func (r RepoTreeParams) TreeStats() RepoTreeStats { 369 numFolders, numFiles := 0, 0 370 for _, f := range r.Files { 371 if !f.IsFile { 372 numFolders += 1 373 } else if f.IsFile { 374 numFiles += 1 375 } 376 } 377 378 return RepoTreeStats{ 379 NumFolders: uint64(numFolders), 380 NumFiles: uint64(numFiles), 381 } 382} 383 384func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 385 params.Active = "overview" 386 return p.execute("repo/tree", w, params) 387} 388 389type RepoBranchesParams struct { 390 LoggedInUser *auth.User 391 RepoInfo RepoInfo 392 types.RepoBranchesResponse 393} 394 395func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error { 396 return p.executeRepo("repo/branches", w, params) 397} 398 399type RepoTagsParams struct { 400 LoggedInUser *auth.User 401 RepoInfo RepoInfo 402 types.RepoTagsResponse 403} 404 405func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { 406 return p.executeRepo("repo/tags", w, params) 407} 408 409type RepoBlobParams struct { 410 LoggedInUser *auth.User 411 RepoInfo RepoInfo 412 Active string 413 BreadCrumbs [][]string 414 types.RepoBlobResponse 415} 416 417func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 418 style := styles.Get("bw") 419 b := style.Builder() 420 b.Add(chroma.LiteralString, "noitalic") 421 style, _ = b.Build() 422 423 if params.Lines < 5000 { 424 c := params.Contents 425 formatter := chromahtml.New( 426 chromahtml.InlineCode(true), 427 chromahtml.WithLineNumbers(true), 428 chromahtml.WithLinkableLineNumbers(true, "L"), 429 chromahtml.Standalone(false), 430 ) 431 432 lexer := lexers.Get(filepath.Base(params.Path)) 433 if lexer == nil { 434 lexer = lexers.Fallback 435 } 436 437 iterator, err := lexer.Tokenise(nil, c) 438 if err != nil { 439 return fmt.Errorf("chroma tokenize: %w", err) 440 } 441 442 var code bytes.Buffer 443 err = formatter.Format(&code, style, iterator) 444 if err != nil { 445 return fmt.Errorf("chroma format: %w", err) 446 } 447 448 params.Contents = code.String() 449 } 450 451 params.Active = "overview" 452 return p.executeRepo("repo/blob", w, params) 453} 454 455type Collaborator struct { 456 Did string 457 Handle string 458 Role string 459} 460 461type RepoSettingsParams struct { 462 LoggedInUser *auth.User 463 RepoInfo RepoInfo 464 Collaborators []Collaborator 465 Active string 466 // TODO: use repoinfo.roles 467 IsCollaboratorInviteAllowed bool 468} 469 470func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { 471 params.Active = "settings" 472 return p.executeRepo("repo/settings", w, params) 473} 474 475type RepoIssuesParams struct { 476 LoggedInUser *auth.User 477 RepoInfo RepoInfo 478 Active string 479 Issues []db.Issue 480 DidHandleMap map[string]string 481 482 FilteringByOpen bool 483} 484 485func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { 486 params.Active = "issues" 487 return p.executeRepo("repo/issues/issues", w, params) 488} 489 490type RepoSingleIssueParams struct { 491 LoggedInUser *auth.User 492 RepoInfo RepoInfo 493 Active string 494 Issue db.Issue 495 Comments []db.Comment 496 IssueOwnerHandle string 497 DidHandleMap map[string]string 498 499 State string 500} 501 502func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 503 params.Active = "issues" 504 if params.Issue.Open { 505 params.State = "open" 506 } else { 507 params.State = "closed" 508 } 509 return p.execute("repo/issues/issue", w, params) 510} 511 512type RepoNewIssueParams struct { 513 LoggedInUser *auth.User 514 RepoInfo RepoInfo 515 Active string 516} 517 518func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 519 params.Active = "issues" 520 return p.executeRepo("repo/issues/new", w, params) 521} 522 523type RepoNewPullParams struct { 524 LoggedInUser *auth.User 525 RepoInfo RepoInfo 526 Branches []types.Branch 527 Active string 528} 529 530func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { 531 params.Active = "pulls" 532 return p.executeRepo("repo/pulls/new", w, params) 533} 534 535type RepoPullsParams struct { 536 LoggedInUser *auth.User 537 RepoInfo RepoInfo 538 Pulls []db.Pull 539 Active string 540 DidHandleMap map[string]string 541 FilteringBy db.PullState 542} 543 544func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { 545 params.Active = "pulls" 546 return p.executeRepo("repo/pulls/pulls", w, params) 547} 548 549type RepoSinglePullParams struct { 550 LoggedInUser *auth.User 551 RepoInfo RepoInfo 552 Active string 553 DidHandleMap map[string]string 554 555 Pull db.Pull 556 MergeCheck types.MergeCheckResponse 557} 558 559func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { 560 params.Active = "pulls" 561 return p.executeRepo("repo/pulls/pull", w, params) 562} 563 564type RepoPullPatchParams struct { 565 LoggedInUser *auth.User 566 DidHandleMap map[string]string 567 RepoInfo RepoInfo 568 Pull *db.Pull 569 Diff types.NiceDiff 570 Round int 571 Submission *db.PullSubmission 572} 573 574// this name is a mouthful 575func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error { 576 return p.execute("repo/pulls/patch", w, params) 577} 578 579type PullResubmitParams struct { 580 LoggedInUser *auth.User 581 RepoInfo RepoInfo 582 Pull *db.Pull 583 SubmissionId int 584} 585 586func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 587 return p.executePlain("fragments/pullResubmit", w, params) 588} 589 590type PullActionsParams struct { 591 LoggedInUser *auth.User 592 RepoInfo RepoInfo 593 Pull *db.Pull 594 RoundNumber int 595 MergeCheck types.MergeCheckResponse 596} 597 598func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 599 return p.executePlain("fragments/pullActions", w, params) 600} 601 602type PullNewCommentParams struct { 603 LoggedInUser *auth.User 604 RepoInfo RepoInfo 605 Pull *db.Pull 606 RoundNumber int 607} 608 609func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 610 return p.executePlain("fragments/pullNewComment", w, params) 611} 612 613func (p *Pages) Static() http.Handler { 614 sub, err := fs.Sub(Files, "static") 615 if err != nil { 616 log.Fatalf("no static dir found? that's crazy: %v", err) 617 } 618 // Custom handler to apply Cache-Control headers for font files 619 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 620} 621 622func Cache(h http.Handler) http.Handler { 623 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 624 if strings.HasSuffix(r.URL.Path, ".css") { 625 // on day for css files 626 w.Header().Set("Cache-Control", "public, max-age=86400") 627 } else { 628 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 629 } 630 h.ServeHTTP(w, r) 631 }) 632} 633 634func (p *Pages) Error500(w io.Writer) error { 635 return p.execute("errors/500", w, nil) 636} 637 638func (p *Pages) Error404(w io.Writer) error { 639 return p.execute("errors/404", w, nil) 640} 641 642func (p *Pages) Error503(w io.Writer) error { 643 return p.execute("errors/503", w, nil) 644}