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