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