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