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