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 "tangled.sh/tangled.sh/core/appview/auth"
24 "tangled.sh/tangled.sh/core/appview/db"
25 "tangled.sh/tangled.sh/core/appview/state/userutil"
26 "tangled.sh/tangled.sh/core/types"
27)
28
29//go:embed templates/* static
30var Files embed.FS
31
32type Pages struct {
33 t map[string]*template.Template
34}
35
36func NewPages() *Pages {
37 templates := make(map[string]*template.Template)
38
39 // Walk through embedded templates directory and parse all .html files
40 err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error {
41 if err != nil {
42 return err
43 }
44
45 if !d.IsDir() && strings.HasSuffix(path, ".html") {
46 name := strings.TrimPrefix(path, "templates/")
47 name = strings.TrimSuffix(name, ".html")
48
49 // add fragments as templates
50 if strings.HasPrefix(path, "templates/fragments/") {
51 tmpl, err := template.New(name).
52 Funcs(funcMap()).
53 ParseFS(Files, path)
54 if err != nil {
55 return fmt.Errorf("setting up fragment: %w", err)
56 }
57
58 templates[name] = tmpl
59 log.Printf("loaded fragment: %s", name)
60 }
61
62 // layouts and fragments are applied first
63 if !strings.HasPrefix(path, "templates/layouts/") &&
64 !strings.HasPrefix(path, "templates/fragments/") {
65 // Add the page template on top of the base
66 tmpl, err := template.New(name).
67 Funcs(funcMap()).
68 ParseFS(Files, "templates/layouts/*.html", "templates/fragments/*.html", path)
69 if err != nil {
70 return fmt.Errorf("setting up template: %w", err)
71 }
72
73 templates[name] = tmpl
74 log.Printf("loaded template: %s", name)
75 }
76
77 return nil
78 }
79 return nil
80 })
81 if err != nil {
82 log.Fatalf("walking template dir: %v", err)
83 }
84
85 log.Printf("total templates loaded: %d", len(templates))
86
87 return &Pages{
88 t: templates,
89 }
90}
91
92type LoginParams struct {
93}
94
95func (p *Pages) execute(name string, w io.Writer, params any) error {
96 return p.t[name].ExecuteTemplate(w, "layouts/base", params)
97}
98
99func (p *Pages) executePlain(name string, w io.Writer, params any) error {
100 return p.t[name].Execute(w, params)
101}
102
103func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
104 return p.t[name].ExecuteTemplate(w, "layouts/repobase", params)
105}
106
107func (p *Pages) Login(w io.Writer, params LoginParams) error {
108 return p.executePlain("user/login", w, params)
109}
110
111type TimelineParams struct {
112 LoggedInUser *auth.User
113 Timeline []db.TimelineEvent
114 DidHandleMap map[string]string
115}
116
117func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
118 return p.execute("timeline", w, params)
119}
120
121type SettingsParams struct {
122 LoggedInUser *auth.User
123 PubKeys []db.PublicKey
124 Emails []db.Email
125}
126
127func (p *Pages) Settings(w io.Writer, params SettingsParams) error {
128 return p.execute("settings", w, params)
129}
130
131type KnotsParams struct {
132 LoggedInUser *auth.User
133 Registrations []db.Registration
134}
135
136func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
137 return p.execute("knots", w, params)
138}
139
140type KnotParams struct {
141 LoggedInUser *auth.User
142 DidHandleMap map[string]string
143 Registration *db.Registration
144 Members []string
145 IsOwner bool
146}
147
148func (p *Pages) Knot(w io.Writer, params KnotParams) error {
149 return p.execute("knot", w, params)
150}
151
152type NewRepoParams struct {
153 LoggedInUser *auth.User
154 Knots []string
155}
156
157func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error {
158 return p.execute("repo/new", w, params)
159}
160
161type ProfilePageParams struct {
162 LoggedInUser *auth.User
163 UserDid string
164 UserHandle string
165 Repos []db.Repo
166 CollaboratingRepos []db.Repo
167 ProfileStats ProfileStats
168 FollowStatus db.FollowStatus
169 DidHandleMap map[string]string
170 AvatarUri string
171}
172
173type ProfileStats struct {
174 Followers int
175 Following int
176}
177
178func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error {
179 return p.execute("user/profile", w, params)
180}
181
182type FollowFragmentParams struct {
183 UserDid string
184 FollowStatus db.FollowStatus
185}
186
187func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
188 return p.executePlain("fragments/follow", w, params)
189}
190
191type StarFragmentParams struct {
192 IsStarred bool
193 RepoAt syntax.ATURI
194 Stats db.RepoStats
195}
196
197func (p *Pages) StarFragment(w io.Writer, params StarFragmentParams) error {
198 return p.executePlain("fragments/star", w, params)
199}
200
201type RepoDescriptionParams struct {
202 RepoInfo RepoInfo
203}
204
205func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
206 return p.executePlain("fragments/editRepoDescription", w, params)
207}
208
209func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
210 return p.executePlain("fragments/repoDescription", w, params)
211}
212
213type RepoInfo struct {
214 Name string
215 OwnerDid string
216 OwnerHandle string
217 Description string
218 Knot string
219 RepoAt syntax.ATURI
220 IsStarred bool
221 Stats db.RepoStats
222 Roles RolesInRepo
223}
224
225type RolesInRepo struct {
226 Roles []string
227}
228
229func (r RolesInRepo) SettingsAllowed() bool {
230 return slices.Contains(r.Roles, "repo:settings")
231}
232
233func (r RolesInRepo) IsOwner() bool {
234 return slices.Contains(r.Roles, "repo:owner")
235}
236
237func (r RolesInRepo) IsCollaborator() bool {
238 return slices.Contains(r.Roles, "repo:collaborator")
239}
240
241func (r RolesInRepo) IsPushAllowed() bool {
242 return slices.Contains(r.Roles, "repo:push")
243}
244
245func (r RepoInfo) OwnerWithAt() string {
246 if r.OwnerHandle != "" {
247 return fmt.Sprintf("@%s", r.OwnerHandle)
248 } else {
249 return r.OwnerDid
250 }
251}
252
253func (r RepoInfo) FullName() string {
254 return path.Join(r.OwnerWithAt(), r.Name)
255}
256
257func (r RepoInfo) OwnerWithoutAt() string {
258 if strings.HasPrefix(r.OwnerWithAt(), "@") {
259 return strings.TrimPrefix(r.OwnerWithAt(), "@")
260 } else {
261 return userutil.FlattenDid(r.OwnerDid)
262 }
263}
264
265func (r RepoInfo) FullNameWithoutAt() string {
266 return path.Join(r.OwnerWithoutAt(), r.Name)
267}
268
269func (r RepoInfo) GetTabs() [][]string {
270 tabs := [][]string{
271 {"overview", "/"},
272 {"issues", "/issues"},
273 {"pulls", "/pulls"},
274 }
275
276 if r.Roles.SettingsAllowed() {
277 tabs = append(tabs, []string{"settings", "/settings"})
278 }
279
280 return tabs
281}
282
283// each tab on a repo could have some metadata:
284//
285// issues -> number of open issues etc.
286// settings -> a warning icon to setup branch protection? idk
287//
288// we gather these bits of info here, because go templates
289// are difficult to program in
290func (r RepoInfo) TabMetadata() map[string]any {
291 meta := make(map[string]any)
292
293 if r.Stats.PullCount.Open > 0 {
294 meta["pulls"] = r.Stats.PullCount.Open
295 }
296
297 if r.Stats.IssueCount.Open > 0 {
298 meta["issues"] = r.Stats.IssueCount.Open
299 }
300
301 // more stuff?
302
303 return meta
304}
305
306type RepoIndexParams struct {
307 LoggedInUser *auth.User
308 RepoInfo RepoInfo
309 Active string
310 TagMap map[string][]string
311 types.RepoIndexResponse
312 HTMLReadme template.HTML
313 Raw bool
314 EmailToDidOrHandle map[string]string
315}
316
317func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error {
318 params.Active = "overview"
319 if params.IsEmpty {
320 return p.executeRepo("repo/empty", w, params)
321 }
322
323 if params.ReadmeFileName != "" {
324 var htmlString string
325 ext := filepath.Ext(params.ReadmeFileName)
326 switch ext {
327 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
328 htmlString = renderMarkdown(params.Readme)
329 params.Raw = false
330 params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString))
331 default:
332 htmlString = string(params.Readme)
333 params.Raw = true
334 params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString))
335 }
336 }
337
338 return p.executeRepo("repo/index", w, params)
339}
340
341type RepoLogParams struct {
342 LoggedInUser *auth.User
343 RepoInfo RepoInfo
344 types.RepoLogResponse
345 Active string
346 EmailToDidOrHandle map[string]string
347}
348
349func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
350 params.Active = "overview"
351 return p.execute("repo/log", w, params)
352}
353
354type RepoCommitParams struct {
355 LoggedInUser *auth.User
356 RepoInfo RepoInfo
357 Active string
358 types.RepoCommitResponse
359 EmailToDidOrHandle map[string]string
360}
361
362func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
363 params.Active = "overview"
364 return p.executeRepo("repo/commit", w, params)
365}
366
367type RepoTreeParams struct {
368 LoggedInUser *auth.User
369 RepoInfo RepoInfo
370 Active string
371 BreadCrumbs [][]string
372 BaseTreeLink string
373 BaseBlobLink string
374 types.RepoTreeResponse
375}
376
377type RepoTreeStats struct {
378 NumFolders uint64
379 NumFiles uint64
380}
381
382func (r RepoTreeParams) TreeStats() RepoTreeStats {
383 numFolders, numFiles := 0, 0
384 for _, f := range r.Files {
385 if !f.IsFile {
386 numFolders += 1
387 } else if f.IsFile {
388 numFiles += 1
389 }
390 }
391
392 return RepoTreeStats{
393 NumFolders: uint64(numFolders),
394 NumFiles: uint64(numFiles),
395 }
396}
397
398func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
399 params.Active = "overview"
400 return p.execute("repo/tree", w, params)
401}
402
403type RepoBranchesParams struct {
404 LoggedInUser *auth.User
405 RepoInfo RepoInfo
406 types.RepoBranchesResponse
407}
408
409func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error {
410 return p.executeRepo("repo/branches", w, params)
411}
412
413type RepoTagsParams struct {
414 LoggedInUser *auth.User
415 RepoInfo RepoInfo
416 types.RepoTagsResponse
417}
418
419func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
420 return p.executeRepo("repo/tags", w, params)
421}
422
423type RepoBlobParams struct {
424 LoggedInUser *auth.User
425 RepoInfo RepoInfo
426 Active string
427 BreadCrumbs [][]string
428 types.RepoBlobResponse
429}
430
431func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
432 style := styles.Get("bw")
433 b := style.Builder()
434 b.Add(chroma.LiteralString, "noitalic")
435 style, _ = b.Build()
436
437 if params.Lines < 5000 {
438 c := params.Contents
439 formatter := chromahtml.New(
440 chromahtml.InlineCode(false),
441 chromahtml.WithLineNumbers(true),
442 chromahtml.WithLinkableLineNumbers(true, "L"),
443 chromahtml.Standalone(false),
444 )
445
446 lexer := lexers.Get(filepath.Base(params.Path))
447 if lexer == nil {
448 lexer = lexers.Fallback
449 }
450
451 iterator, err := lexer.Tokenise(nil, c)
452 if err != nil {
453 return fmt.Errorf("chroma tokenize: %w", err)
454 }
455
456 var code bytes.Buffer
457 err = formatter.Format(&code, style, iterator)
458 if err != nil {
459 return fmt.Errorf("chroma format: %w", err)
460 }
461
462 params.Contents = code.String()
463 }
464
465 params.Active = "overview"
466 return p.executeRepo("repo/blob", w, params)
467}
468
469type Collaborator struct {
470 Did string
471 Handle string
472 Role string
473}
474
475type RepoSettingsParams struct {
476 LoggedInUser *auth.User
477 RepoInfo RepoInfo
478 Collaborators []Collaborator
479 Active string
480 // TODO: use repoinfo.roles
481 IsCollaboratorInviteAllowed bool
482}
483
484func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error {
485 params.Active = "settings"
486 return p.executeRepo("repo/settings", w, params)
487}
488
489type RepoIssuesParams struct {
490 LoggedInUser *auth.User
491 RepoInfo RepoInfo
492 Active string
493 Issues []db.Issue
494 DidHandleMap map[string]string
495
496 FilteringByOpen bool
497}
498
499func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
500 params.Active = "issues"
501 return p.executeRepo("repo/issues/issues", w, params)
502}
503
504type RepoSingleIssueParams struct {
505 LoggedInUser *auth.User
506 RepoInfo RepoInfo
507 Active string
508 Issue db.Issue
509 Comments []db.Comment
510 IssueOwnerHandle string
511 DidHandleMap map[string]string
512
513 State string
514}
515
516func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
517 params.Active = "issues"
518 if params.Issue.Open {
519 params.State = "open"
520 } else {
521 params.State = "closed"
522 }
523 return p.execute("repo/issues/issue", w, params)
524}
525
526type RepoNewIssueParams struct {
527 LoggedInUser *auth.User
528 RepoInfo RepoInfo
529 Active string
530}
531
532func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
533 params.Active = "issues"
534 return p.executeRepo("repo/issues/new", w, params)
535}
536
537type EditIssueCommentParams struct {
538 LoggedInUser *auth.User
539 RepoInfo RepoInfo
540 Issue *db.Issue
541 Comment *db.Comment
542}
543
544func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
545 return p.executePlain("fragments/editIssueComment", w, params)
546}
547
548type SingleIssueCommentParams struct {
549 LoggedInUser *auth.User
550 DidHandleMap map[string]string
551 RepoInfo RepoInfo
552 Issue *db.Issue
553 Comment *db.Comment
554}
555
556func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error {
557 return p.executePlain("fragments/issueComment", w, params)
558}
559
560type RepoNewPullParams struct {
561 LoggedInUser *auth.User
562 RepoInfo RepoInfo
563 Branches []types.Branch
564 Active string
565}
566
567func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error {
568 params.Active = "pulls"
569 return p.executeRepo("repo/pulls/new", w, params)
570}
571
572type RepoPullsParams struct {
573 LoggedInUser *auth.User
574 RepoInfo RepoInfo
575 Pulls []db.Pull
576 Active string
577 DidHandleMap map[string]string
578 FilteringBy db.PullState
579}
580
581func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
582 params.Active = "pulls"
583 return p.executeRepo("repo/pulls/pulls", w, params)
584}
585
586type RepoSinglePullParams struct {
587 LoggedInUser *auth.User
588 RepoInfo RepoInfo
589 Active string
590 DidHandleMap map[string]string
591
592 Pull db.Pull
593 MergeCheck types.MergeCheckResponse
594}
595
596func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
597 params.Active = "pulls"
598 return p.executeRepo("repo/pulls/pull", w, params)
599}
600
601type RepoPullPatchParams struct {
602 LoggedInUser *auth.User
603 DidHandleMap map[string]string
604 RepoInfo RepoInfo
605 Pull *db.Pull
606 Diff types.NiceDiff
607 Round int
608 Submission *db.PullSubmission
609}
610
611// this name is a mouthful
612func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error {
613 return p.execute("repo/pulls/patch", w, params)
614}
615
616type PullResubmitParams struct {
617 LoggedInUser *auth.User
618 RepoInfo RepoInfo
619 Pull *db.Pull
620 SubmissionId int
621}
622
623func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error {
624 return p.executePlain("fragments/pullResubmit", w, params)
625}
626
627type PullActionsParams struct {
628 LoggedInUser *auth.User
629 RepoInfo RepoInfo
630 Pull *db.Pull
631 RoundNumber int
632 MergeCheck types.MergeCheckResponse
633}
634
635func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
636 return p.executePlain("fragments/pullActions", w, params)
637}
638
639type PullNewCommentParams struct {
640 LoggedInUser *auth.User
641 RepoInfo RepoInfo
642 Pull *db.Pull
643 RoundNumber int
644}
645
646func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error {
647 return p.executePlain("fragments/pullNewComment", w, params)
648}
649
650func (p *Pages) Static() http.Handler {
651 sub, err := fs.Sub(Files, "static")
652 if err != nil {
653 log.Fatalf("no static dir found? that's crazy: %v", err)
654 }
655 // Custom handler to apply Cache-Control headers for font files
656 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
657}
658
659func Cache(h http.Handler) http.Handler {
660 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
661 if strings.HasSuffix(r.URL.Path, ".css") {
662 // on day for css files
663 w.Header().Set("Cache-Control", "public, max-age=86400")
664 } else {
665 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
666 }
667 h.ServeHTTP(w, r)
668 })
669}
670
671func (p *Pages) Error500(w io.Writer) error {
672 return p.execute("errors/500", w, nil)
673}
674
675func (p *Pages) Error404(w io.Writer) error {
676 return p.execute("errors/404", w, nil)
677}
678
679func (p *Pages) Error503(w io.Writer) error {
680 return p.execute("errors/503", w, nil)
681}