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