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 RepoInfo) OwnerWithAt() string { 239 if r.OwnerHandle != "" { 240 return fmt.Sprintf("@%s", r.OwnerHandle) 241 } else { 242 return r.OwnerDid 243 } 244} 245 246func (r RepoInfo) FullName() string { 247 return path.Join(r.OwnerWithAt(), r.Name) 248} 249 250func (r RepoInfo) GetTabs() [][]string { 251 tabs := [][]string{ 252 {"overview", "/"}, 253 {"issues", "/issues"}, 254 {"pulls", "/pulls"}, 255 } 256 257 if r.Roles.SettingsAllowed() { 258 tabs = append(tabs, []string{"settings", "/settings"}) 259 } 260 261 return tabs 262} 263 264// each tab on a repo could have some metadata: 265// 266// issues -> number of open issues etc. 267// settings -> a warning icon to setup branch protection? idk 268// 269// we gather these bits of info here, because go templates 270// are difficult to program in 271func (r RepoInfo) TabMetadata() map[string]any { 272 meta := make(map[string]any) 273 274 if r.Stats.PullCount.Open > 0 { 275 meta["pulls"] = r.Stats.PullCount.Open 276 } 277 278 if r.Stats.IssueCount.Open > 0 { 279 meta["issues"] = r.Stats.IssueCount.Open 280 } 281 282 // more stuff? 283 284 return meta 285} 286 287type RepoIndexParams struct { 288 LoggedInUser *auth.User 289 RepoInfo RepoInfo 290 Active string 291 TagMap map[string][]string 292 types.RepoIndexResponse 293 HTMLReadme template.HTML 294 Raw bool 295} 296 297func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { 298 params.Active = "overview" 299 if params.IsEmpty { 300 return p.executeRepo("repo/empty", w, params) 301 } 302 303 if params.ReadmeFileName != "" { 304 var htmlString string 305 ext := filepath.Ext(params.ReadmeFileName) 306 switch ext { 307 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 308 htmlString = renderMarkdown(params.Readme) 309 params.Raw = false 310 params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString)) 311 default: 312 htmlString = string(params.Readme) 313 params.Raw = true 314 params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString)) 315 } 316 } 317 318 return p.executeRepo("repo/index", w, params) 319} 320 321type RepoLogParams struct { 322 LoggedInUser *auth.User 323 RepoInfo RepoInfo 324 types.RepoLogResponse 325 Active string 326} 327 328func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { 329 params.Active = "overview" 330 return p.execute("repo/log", w, params) 331} 332 333type RepoCommitParams struct { 334 LoggedInUser *auth.User 335 RepoInfo RepoInfo 336 Active string 337 types.RepoCommitResponse 338} 339 340func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { 341 params.Active = "overview" 342 return p.executeRepo("repo/commit", w, params) 343} 344 345type RepoTreeParams struct { 346 LoggedInUser *auth.User 347 RepoInfo RepoInfo 348 Active string 349 BreadCrumbs [][]string 350 BaseTreeLink string 351 BaseBlobLink string 352 types.RepoTreeResponse 353} 354 355type RepoTreeStats struct { 356 NumFolders uint64 357 NumFiles uint64 358} 359 360func (r RepoTreeParams) TreeStats() RepoTreeStats { 361 numFolders, numFiles := 0, 0 362 for _, f := range r.Files { 363 if !f.IsFile { 364 numFolders += 1 365 } else if f.IsFile { 366 numFiles += 1 367 } 368 } 369 370 return RepoTreeStats{ 371 NumFolders: uint64(numFolders), 372 NumFiles: uint64(numFiles), 373 } 374} 375 376func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 377 params.Active = "overview" 378 return p.execute("repo/tree", w, params) 379} 380 381type RepoBranchesParams struct { 382 LoggedInUser *auth.User 383 RepoInfo RepoInfo 384 types.RepoBranchesResponse 385} 386 387func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error { 388 return p.executeRepo("repo/branches", w, params) 389} 390 391type RepoTagsParams struct { 392 LoggedInUser *auth.User 393 RepoInfo RepoInfo 394 types.RepoTagsResponse 395} 396 397func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { 398 return p.executeRepo("repo/tags", w, params) 399} 400 401type RepoBlobParams struct { 402 LoggedInUser *auth.User 403 RepoInfo RepoInfo 404 Active string 405 BreadCrumbs [][]string 406 types.RepoBlobResponse 407} 408 409func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 410 style := styles.Get("bw") 411 b := style.Builder() 412 b.Add(chroma.LiteralString, "noitalic") 413 style, _ = b.Build() 414 415 if params.Lines < 5000 { 416 c := params.Contents 417 formatter := chromahtml.New( 418 chromahtml.InlineCode(true), 419 chromahtml.WithLineNumbers(true), 420 chromahtml.WithLinkableLineNumbers(true, "L"), 421 chromahtml.Standalone(false), 422 ) 423 424 lexer := lexers.Get(filepath.Base(params.Path)) 425 if lexer == nil { 426 lexer = lexers.Fallback 427 } 428 429 iterator, err := lexer.Tokenise(nil, c) 430 if err != nil { 431 return fmt.Errorf("chroma tokenize: %w", err) 432 } 433 434 var code bytes.Buffer 435 err = formatter.Format(&code, style, iterator) 436 if err != nil { 437 return fmt.Errorf("chroma format: %w", err) 438 } 439 440 params.Contents = code.String() 441 } 442 443 params.Active = "overview" 444 return p.executeRepo("repo/blob", w, params) 445} 446 447type Collaborator struct { 448 Did string 449 Handle string 450 Role string 451} 452 453type RepoSettingsParams struct { 454 LoggedInUser *auth.User 455 RepoInfo RepoInfo 456 Collaborators []Collaborator 457 Active string 458 // TODO: use repoinfo.roles 459 IsCollaboratorInviteAllowed bool 460} 461 462func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { 463 params.Active = "settings" 464 return p.executeRepo("repo/settings", w, params) 465} 466 467type RepoIssuesParams struct { 468 LoggedInUser *auth.User 469 RepoInfo RepoInfo 470 Active string 471 Issues []db.Issue 472 DidHandleMap map[string]string 473 474 FilteringByOpen bool 475} 476 477func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { 478 params.Active = "issues" 479 return p.executeRepo("repo/issues/issues", w, params) 480} 481 482type RepoSingleIssueParams struct { 483 LoggedInUser *auth.User 484 RepoInfo RepoInfo 485 Active string 486 Issue db.Issue 487 Comments []db.Comment 488 IssueOwnerHandle string 489 DidHandleMap map[string]string 490 491 State string 492} 493 494func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 495 params.Active = "issues" 496 if params.Issue.Open { 497 params.State = "open" 498 } else { 499 params.State = "closed" 500 } 501 return p.execute("repo/issues/issue", w, params) 502} 503 504type RepoNewIssueParams struct { 505 LoggedInUser *auth.User 506 RepoInfo RepoInfo 507 Active string 508} 509 510func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 511 params.Active = "issues" 512 return p.executeRepo("repo/issues/new", w, params) 513} 514 515type RepoNewPullParams struct { 516 LoggedInUser *auth.User 517 RepoInfo RepoInfo 518 Branches []types.Branch 519 Active string 520} 521 522func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { 523 params.Active = "pulls" 524 return p.executeRepo("repo/pulls/new", w, params) 525} 526 527type RepoPullsParams struct { 528 LoggedInUser *auth.User 529 RepoInfo RepoInfo 530 Pulls []db.Pull 531 Active string 532 DidHandleMap map[string]string 533 FilteringBy db.PullState 534} 535 536func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { 537 params.Active = "pulls" 538 return p.executeRepo("repo/pulls/pulls", w, params) 539} 540 541type RepoSinglePullParams struct { 542 LoggedInUser *auth.User 543 RepoInfo RepoInfo 544 DidHandleMap map[string]string 545 Pull db.Pull 546 PullOwnerHandle string 547 Comments []db.PullComment 548 Active string 549 MergeCheck types.MergeCheckResponse 550} 551 552func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { 553 params.Active = "pulls" 554 return p.executeRepo("repo/pulls/pull", w, params) 555} 556 557func (p *Pages) Static() http.Handler { 558 sub, err := fs.Sub(files, "static") 559 if err != nil { 560 log.Fatalf("no static dir found? that's crazy: %v", err) 561 } 562 // Custom handler to apply Cache-Control headers for font files 563 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 564} 565 566func Cache(h http.Handler) http.Handler { 567 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 568 if strings.HasSuffix(r.URL.Path, ".css") { 569 // on day for css files 570 w.Header().Set("Cache-Control", "public, max-age=86400") 571 } else { 572 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 573 } 574 h.ServeHTTP(w, r) 575 }) 576} 577 578func (p *Pages) Error500(w io.Writer) error { 579 return p.execute("errors/500", w, nil) 580} 581 582func (p *Pages) Error404(w io.Writer) error { 583 return p.execute("errors/404", w, nil) 584} 585 586func (p *Pages) Error503(w io.Writer) error { 587 return p.execute("errors/503", w, nil) 588}