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 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} 301 302func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { 303 params.Active = "overview" 304 if params.IsEmpty { 305 return p.executeRepo("repo/empty", w, params) 306 } 307 308 if params.ReadmeFileName != "" { 309 var htmlString string 310 ext := filepath.Ext(params.ReadmeFileName) 311 switch ext { 312 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 313 htmlString = renderMarkdown(params.Readme) 314 params.Raw = false 315 params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString)) 316 default: 317 htmlString = string(params.Readme) 318 params.Raw = true 319 params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString)) 320 } 321 } 322 323 return p.executeRepo("repo/index", w, params) 324} 325 326type RepoLogParams struct { 327 LoggedInUser *auth.User 328 RepoInfo RepoInfo 329 types.RepoLogResponse 330 Active string 331} 332 333func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { 334 params.Active = "overview" 335 return p.execute("repo/log", w, params) 336} 337 338type RepoCommitParams struct { 339 LoggedInUser *auth.User 340 RepoInfo RepoInfo 341 Active string 342 types.RepoCommitResponse 343} 344 345func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { 346 params.Active = "overview" 347 return p.executeRepo("repo/commit", w, params) 348} 349 350type RepoTreeParams struct { 351 LoggedInUser *auth.User 352 RepoInfo RepoInfo 353 Active string 354 BreadCrumbs [][]string 355 BaseTreeLink string 356 BaseBlobLink string 357 types.RepoTreeResponse 358} 359 360type RepoTreeStats struct { 361 NumFolders uint64 362 NumFiles uint64 363} 364 365func (r RepoTreeParams) TreeStats() RepoTreeStats { 366 numFolders, numFiles := 0, 0 367 for _, f := range r.Files { 368 if !f.IsFile { 369 numFolders += 1 370 } else if f.IsFile { 371 numFiles += 1 372 } 373 } 374 375 return RepoTreeStats{ 376 NumFolders: uint64(numFolders), 377 NumFiles: uint64(numFiles), 378 } 379} 380 381func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 382 params.Active = "overview" 383 return p.execute("repo/tree", w, params) 384} 385 386type RepoBranchesParams struct { 387 LoggedInUser *auth.User 388 RepoInfo RepoInfo 389 types.RepoBranchesResponse 390} 391 392func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error { 393 return p.executeRepo("repo/branches", w, params) 394} 395 396type RepoTagsParams struct { 397 LoggedInUser *auth.User 398 RepoInfo RepoInfo 399 types.RepoTagsResponse 400} 401 402func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { 403 return p.executeRepo("repo/tags", w, params) 404} 405 406type RepoBlobParams struct { 407 LoggedInUser *auth.User 408 RepoInfo RepoInfo 409 Active string 410 BreadCrumbs [][]string 411 types.RepoBlobResponse 412} 413 414func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 415 style := styles.Get("bw") 416 b := style.Builder() 417 b.Add(chroma.LiteralString, "noitalic") 418 style, _ = b.Build() 419 420 if params.Lines < 5000 { 421 c := params.Contents 422 formatter := chromahtml.New( 423 chromahtml.InlineCode(true), 424 chromahtml.WithLineNumbers(true), 425 chromahtml.WithLinkableLineNumbers(true, "L"), 426 chromahtml.Standalone(false), 427 ) 428 429 lexer := lexers.Get(filepath.Base(params.Path)) 430 if lexer == nil { 431 lexer = lexers.Fallback 432 } 433 434 iterator, err := lexer.Tokenise(nil, c) 435 if err != nil { 436 return fmt.Errorf("chroma tokenize: %w", err) 437 } 438 439 var code bytes.Buffer 440 err = formatter.Format(&code, style, iterator) 441 if err != nil { 442 return fmt.Errorf("chroma format: %w", err) 443 } 444 445 params.Contents = code.String() 446 } 447 448 params.Active = "overview" 449 return p.executeRepo("repo/blob", w, params) 450} 451 452type Collaborator struct { 453 Did string 454 Handle string 455 Role string 456} 457 458type RepoSettingsParams struct { 459 LoggedInUser *auth.User 460 RepoInfo RepoInfo 461 Collaborators []Collaborator 462 Active string 463 // TODO: use repoinfo.roles 464 IsCollaboratorInviteAllowed bool 465} 466 467func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { 468 params.Active = "settings" 469 return p.executeRepo("repo/settings", w, params) 470} 471 472type RepoIssuesParams struct { 473 LoggedInUser *auth.User 474 RepoInfo RepoInfo 475 Active string 476 Issues []db.Issue 477 DidHandleMap map[string]string 478 479 FilteringByOpen bool 480} 481 482func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { 483 params.Active = "issues" 484 return p.executeRepo("repo/issues/issues", w, params) 485} 486 487type RepoSingleIssueParams struct { 488 LoggedInUser *auth.User 489 RepoInfo RepoInfo 490 Active string 491 Issue db.Issue 492 Comments []db.Comment 493 IssueOwnerHandle string 494 DidHandleMap map[string]string 495 496 State string 497} 498 499func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 500 params.Active = "issues" 501 if params.Issue.Open { 502 params.State = "open" 503 } else { 504 params.State = "closed" 505 } 506 return p.execute("repo/issues/issue", w, params) 507} 508 509type RepoNewIssueParams struct { 510 LoggedInUser *auth.User 511 RepoInfo RepoInfo 512 Active string 513} 514 515func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 516 params.Active = "issues" 517 return p.executeRepo("repo/issues/new", w, params) 518} 519 520type RepoNewPullParams struct { 521 LoggedInUser *auth.User 522 RepoInfo RepoInfo 523 Branches []types.Branch 524 Active string 525} 526 527func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { 528 params.Active = "pulls" 529 return p.executeRepo("repo/pulls/new", w, params) 530} 531 532type RepoPullsParams struct { 533 LoggedInUser *auth.User 534 RepoInfo RepoInfo 535 Pulls []db.Pull 536 Active string 537 DidHandleMap map[string]string 538 FilteringBy db.PullState 539} 540 541func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { 542 params.Active = "pulls" 543 return p.executeRepo("repo/pulls/pulls", w, params) 544} 545 546type RepoSinglePullParams struct { 547 LoggedInUser *auth.User 548 RepoInfo RepoInfo 549 Active string 550 DidHandleMap map[string]string 551 552 Pull db.Pull 553 MergeCheck types.MergeCheckResponse 554} 555 556func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { 557 params.Active = "pulls" 558 return p.executeRepo("repo/pulls/pull", w, params) 559} 560 561type RepoPullPatchParams struct { 562 LoggedInUser *auth.User 563 DidHandleMap map[string]string 564 RepoInfo RepoInfo 565 Pull *db.Pull 566 Diff types.NiceDiff 567 Round int 568 Submission *db.PullSubmission 569} 570 571// this name is a mouthful 572func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error { 573 return p.execute("repo/pulls/patch", w, params) 574} 575 576func (p *Pages) Static() http.Handler { 577 sub, err := fs.Sub(files, "static") 578 if err != nil { 579 log.Fatalf("no static dir found? that's crazy: %v", err) 580 } 581 // Custom handler to apply Cache-Control headers for font files 582 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 583} 584 585func Cache(h http.Handler) http.Handler { 586 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 587 if strings.HasSuffix(r.URL.Path, ".css") { 588 // on day for css files 589 w.Header().Set("Cache-Control", "public, max-age=86400") 590 } else { 591 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 592 } 593 h.ServeHTTP(w, r) 594 }) 595} 596 597func (p *Pages) Error500(w io.Writer) error { 598 return p.execute("errors/500", w, nil) 599} 600 601func (p *Pages) Error404(w io.Writer) error { 602 return p.execute("errors/404", w, nil) 603} 604 605func (p *Pages) Error503(w io.Writer) error { 606 return p.execute("errors/503", w, nil) 607}