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}