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 Ref string
235 RepoAt syntax.ATURI
236 IsStarred bool
237 Stats db.RepoStats
238 Roles RolesInRepo
239 Source *db.Repo
240 SourceHandle string
241 DisableFork bool
242}
243
244type RolesInRepo struct {
245 Roles []string
246}
247
248func (r RolesInRepo) SettingsAllowed() bool {
249 return slices.Contains(r.Roles, "repo:settings")
250}
251
252func (r RolesInRepo) CollaboratorInviteAllowed() bool {
253 return slices.Contains(r.Roles, "repo:invite")
254}
255
256func (r RolesInRepo) RepoDeleteAllowed() bool {
257 return slices.Contains(r.Roles, "repo:delete")
258}
259
260func (r RolesInRepo) IsOwner() bool {
261 return slices.Contains(r.Roles, "repo:owner")
262}
263
264func (r RolesInRepo) IsCollaborator() bool {
265 return slices.Contains(r.Roles, "repo:collaborator")
266}
267
268func (r RolesInRepo) IsPushAllowed() bool {
269 return slices.Contains(r.Roles, "repo:push")
270}
271
272func (r RepoInfo) OwnerWithAt() string {
273 if r.OwnerHandle != "" {
274 return fmt.Sprintf("@%s", r.OwnerHandle)
275 } else {
276 return r.OwnerDid
277 }
278}
279
280func (r RepoInfo) FullName() string {
281 return path.Join(r.OwnerWithAt(), r.Name)
282}
283
284func (r RepoInfo) OwnerWithoutAt() string {
285 if strings.HasPrefix(r.OwnerWithAt(), "@") {
286 return strings.TrimPrefix(r.OwnerWithAt(), "@")
287 } else {
288 return userutil.FlattenDid(r.OwnerDid)
289 }
290}
291
292func (r RepoInfo) FullNameWithoutAt() string {
293 return path.Join(r.OwnerWithoutAt(), r.Name)
294}
295
296func (r RepoInfo) GetTabs() [][]string {
297 tabs := [][]string{
298 {"overview", "/", "square-chart-gantt"},
299 {"issues", "/issues", "circle-dot"},
300 {"pulls", "/pulls", "git-pull-request"},
301 }
302
303 if r.Roles.SettingsAllowed() {
304 tabs = append(tabs, []string{"settings", "/settings", "cog"})
305 }
306
307 return tabs
308}
309
310// each tab on a repo could have some metadata:
311//
312// issues -> number of open issues etc.
313// settings -> a warning icon to setup branch protection? idk
314//
315// we gather these bits of info here, because go templates
316// are difficult to program in
317func (r RepoInfo) TabMetadata() map[string]any {
318 meta := make(map[string]any)
319
320 if r.Stats.PullCount.Open > 0 {
321 meta["pulls"] = r.Stats.PullCount.Open
322 }
323
324 if r.Stats.IssueCount.Open > 0 {
325 meta["issues"] = r.Stats.IssueCount.Open
326 }
327
328 // more stuff?
329
330 return meta
331}
332
333type RepoIndexParams struct {
334 LoggedInUser *auth.User
335 RepoInfo RepoInfo
336 Active string
337 TagMap map[string][]string
338 types.RepoIndexResponse
339 HTMLReadme template.HTML
340 Raw bool
341 EmailToDidOrHandle map[string]string
342}
343
344func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error {
345 params.Active = "overview"
346 if params.IsEmpty {
347 return p.executeRepo("repo/empty", w, params)
348 }
349
350 if params.ReadmeFileName != "" {
351 var htmlString string
352 ext := filepath.Ext(params.ReadmeFileName)
353 switch ext {
354 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
355 htmlString = markup.RenderMarkdownExtended(
356 params.Readme,
357 params.RepoInfo.OwnerHandle,
358 params.RepoInfo.Name,
359 params.RepoInfo.Ref,
360 )
361 params.Raw = false
362 params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString))
363 default:
364 htmlString = string(params.Readme)
365 params.Raw = true
366 params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString))
367 }
368 }
369
370 return p.executeRepo("repo/index", w, params)
371}
372
373type RepoLogParams struct {
374 LoggedInUser *auth.User
375 RepoInfo RepoInfo
376 types.RepoLogResponse
377 Active string
378 EmailToDidOrHandle map[string]string
379}
380
381func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
382 params.Active = "overview"
383 return p.execute("repo/log", w, params)
384}
385
386type RepoCommitParams struct {
387 LoggedInUser *auth.User
388 RepoInfo RepoInfo
389 Active string
390 types.RepoCommitResponse
391 EmailToDidOrHandle map[string]string
392}
393
394func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
395 params.Active = "overview"
396 return p.executeRepo("repo/commit", w, params)
397}
398
399type RepoTreeParams struct {
400 LoggedInUser *auth.User
401 RepoInfo RepoInfo
402 Active string
403 BreadCrumbs [][]string
404 BaseTreeLink string
405 BaseBlobLink string
406 types.RepoTreeResponse
407}
408
409type RepoTreeStats struct {
410 NumFolders uint64
411 NumFiles uint64
412}
413
414func (r RepoTreeParams) TreeStats() RepoTreeStats {
415 numFolders, numFiles := 0, 0
416 for _, f := range r.Files {
417 if !f.IsFile {
418 numFolders += 1
419 } else if f.IsFile {
420 numFiles += 1
421 }
422 }
423
424 return RepoTreeStats{
425 NumFolders: uint64(numFolders),
426 NumFiles: uint64(numFiles),
427 }
428}
429
430func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
431 params.Active = "overview"
432 return p.execute("repo/tree", w, params)
433}
434
435type RepoBranchesParams struct {
436 LoggedInUser *auth.User
437 RepoInfo RepoInfo
438 types.RepoBranchesResponse
439}
440
441func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error {
442 return p.executeRepo("repo/branches", w, params)
443}
444
445type RepoTagsParams struct {
446 LoggedInUser *auth.User
447 RepoInfo RepoInfo
448 types.RepoTagsResponse
449}
450
451func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
452 return p.executeRepo("repo/tags", w, params)
453}
454
455type RepoBlobParams struct {
456 LoggedInUser *auth.User
457 RepoInfo RepoInfo
458 Active string
459 BreadCrumbs [][]string
460 ShowRendered bool
461 RenderToggle bool
462 RenderedContents template.HTML
463 types.RepoBlobResponse
464}
465
466func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
467 style := styles.Get("bw")
468 b := style.Builder()
469 b.Add(chroma.LiteralString, "noitalic")
470 style, _ = b.Build()
471
472 if params.ShowRendered {
473 switch markup.GetFormat(params.Path) {
474 case markup.FormatMarkdown:
475 params.RenderedContents = template.HTML(markup.RenderMarkdownExtended(
476 params.Contents,
477 params.RepoInfo.OwnerHandle,
478 params.RepoInfo.Name,
479 params.RepoInfo.Ref,
480 ))
481 }
482 }
483
484 if params.Lines < 5000 {
485 c := params.Contents
486 formatter := chromahtml.New(
487 chromahtml.InlineCode(false),
488 chromahtml.WithLineNumbers(true),
489 chromahtml.WithLinkableLineNumbers(true, "L"),
490 chromahtml.Standalone(false),
491 )
492
493 lexer := lexers.Get(filepath.Base(params.Path))
494 if lexer == nil {
495 lexer = lexers.Fallback
496 }
497
498 iterator, err := lexer.Tokenise(nil, c)
499 if err != nil {
500 return fmt.Errorf("chroma tokenize: %w", err)
501 }
502
503 var code bytes.Buffer
504 err = formatter.Format(&code, style, iterator)
505 if err != nil {
506 return fmt.Errorf("chroma format: %w", err)
507 }
508
509 params.Contents = code.String()
510 }
511
512 params.Active = "overview"
513 return p.executeRepo("repo/blob", w, params)
514}
515
516type Collaborator struct {
517 Did string
518 Handle string
519 Role string
520}
521
522type RepoSettingsParams struct {
523 LoggedInUser *auth.User
524 RepoInfo RepoInfo
525 Collaborators []Collaborator
526 Active string
527 Branches []string
528 DefaultBranch string
529 // TODO: use repoinfo.roles
530 IsCollaboratorInviteAllowed bool
531}
532
533func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error {
534 params.Active = "settings"
535 return p.executeRepo("repo/settings", w, params)
536}
537
538type RepoIssuesParams struct {
539 LoggedInUser *auth.User
540 RepoInfo RepoInfo
541 Active string
542 Issues []db.Issue
543 DidHandleMap map[string]string
544
545 FilteringByOpen bool
546}
547
548func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
549 params.Active = "issues"
550 return p.executeRepo("repo/issues/issues", w, params)
551}
552
553type RepoSingleIssueParams struct {
554 LoggedInUser *auth.User
555 RepoInfo RepoInfo
556 Active string
557 Issue db.Issue
558 Comments []db.Comment
559 IssueOwnerHandle string
560 DidHandleMap map[string]string
561
562 State string
563}
564
565func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
566 params.Active = "issues"
567 if params.Issue.Open {
568 params.State = "open"
569 } else {
570 params.State = "closed"
571 }
572 return p.execute("repo/issues/issue", w, params)
573}
574
575type RepoNewIssueParams struct {
576 LoggedInUser *auth.User
577 RepoInfo RepoInfo
578 Active string
579}
580
581func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
582 params.Active = "issues"
583 return p.executeRepo("repo/issues/new", w, params)
584}
585
586type EditIssueCommentParams struct {
587 LoggedInUser *auth.User
588 RepoInfo RepoInfo
589 Issue *db.Issue
590 Comment *db.Comment
591}
592
593func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
594 return p.executePlain("fragments/editIssueComment", w, params)
595}
596
597type SingleIssueCommentParams struct {
598 LoggedInUser *auth.User
599 DidHandleMap map[string]string
600 RepoInfo RepoInfo
601 Issue *db.Issue
602 Comment *db.Comment
603}
604
605func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error {
606 return p.executePlain("fragments/issueComment", w, params)
607}
608
609type RepoNewPullParams struct {
610 LoggedInUser *auth.User
611 RepoInfo RepoInfo
612 Branches []types.Branch
613 Active string
614}
615
616func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error {
617 params.Active = "pulls"
618 return p.executeRepo("repo/pulls/new", w, params)
619}
620
621type RepoPullsParams struct {
622 LoggedInUser *auth.User
623 RepoInfo RepoInfo
624 Pulls []db.Pull
625 Active string
626 DidHandleMap map[string]string
627 FilteringBy db.PullState
628}
629
630func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
631 params.Active = "pulls"
632 return p.executeRepo("repo/pulls/pulls", w, params)
633}
634
635type ResubmitResult uint64
636
637const (
638 ShouldResubmit ResubmitResult = iota
639 ShouldNotResubmit
640 Unknown
641)
642
643func (r ResubmitResult) Yes() bool {
644 return r == ShouldResubmit
645}
646func (r ResubmitResult) No() bool {
647 return r == ShouldNotResubmit
648}
649func (r ResubmitResult) Unknown() bool {
650 return r == Unknown
651}
652
653type RepoSinglePullParams struct {
654 LoggedInUser *auth.User
655 RepoInfo RepoInfo
656 Active string
657 DidHandleMap map[string]string
658 Pull *db.Pull
659 PullSourceRepo *db.Repo
660 MergeCheck types.MergeCheckResponse
661 ResubmitCheck ResubmitResult
662}
663
664func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
665 params.Active = "pulls"
666 return p.executeRepo("repo/pulls/pull", w, params)
667}
668
669type RepoPullPatchParams struct {
670 LoggedInUser *auth.User
671 DidHandleMap map[string]string
672 RepoInfo RepoInfo
673 Pull *db.Pull
674 Diff types.NiceDiff
675 Round int
676 Submission *db.PullSubmission
677}
678
679// this name is a mouthful
680func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error {
681 return p.execute("repo/pulls/patch", w, params)
682}
683
684type PullPatchUploadParams struct {
685 RepoInfo RepoInfo
686}
687
688func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error {
689 return p.executePlain("fragments/pullPatchUpload", w, params)
690}
691
692type PullCompareBranchesParams struct {
693 RepoInfo RepoInfo
694 Branches []types.Branch
695}
696
697func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error {
698 return p.executePlain("fragments/pullCompareBranches", w, params)
699}
700
701type PullCompareForkParams struct {
702 RepoInfo RepoInfo
703 Forks []db.Repo
704}
705
706func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error {
707 return p.executePlain("fragments/pullCompareForks", w, params)
708}
709
710type PullCompareForkBranchesParams struct {
711 RepoInfo RepoInfo
712 SourceBranches []types.Branch
713 TargetBranches []types.Branch
714}
715
716func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error {
717 return p.executePlain("fragments/pullCompareForksBranches", w, params)
718}
719
720type PullResubmitParams struct {
721 LoggedInUser *auth.User
722 RepoInfo RepoInfo
723 Pull *db.Pull
724 SubmissionId int
725}
726
727func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error {
728 return p.executePlain("fragments/pullResubmit", w, params)
729}
730
731type PullActionsParams struct {
732 LoggedInUser *auth.User
733 RepoInfo RepoInfo
734 Pull *db.Pull
735 RoundNumber int
736 MergeCheck types.MergeCheckResponse
737 ResubmitCheck ResubmitResult
738}
739
740func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
741 return p.executePlain("fragments/pullActions", w, params)
742}
743
744type PullNewCommentParams struct {
745 LoggedInUser *auth.User
746 RepoInfo RepoInfo
747 Pull *db.Pull
748 RoundNumber int
749}
750
751func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error {
752 return p.executePlain("fragments/pullNewComment", w, params)
753}
754
755func (p *Pages) Static() http.Handler {
756 sub, err := fs.Sub(Files, "static")
757 if err != nil {
758 log.Fatalf("no static dir found? that's crazy: %v", err)
759 }
760 // Custom handler to apply Cache-Control headers for font files
761 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
762}
763
764func Cache(h http.Handler) http.Handler {
765 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
766 path := strings.Split(r.URL.Path, "?")[0]
767
768 if strings.HasSuffix(path, ".css") {
769 // on day for css files
770 w.Header().Set("Cache-Control", "public, max-age=86400")
771 } else {
772 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
773 }
774 h.ServeHTTP(w, r)
775 })
776}
777
778func CssContentHash() string {
779 cssFile, err := Files.Open("static/tw.css")
780 if err != nil {
781 log.Printf("Error opening CSS file: %v", err)
782 return ""
783 }
784 defer cssFile.Close()
785
786 hasher := sha256.New()
787 if _, err := io.Copy(hasher, cssFile); err != nil {
788 log.Printf("Error hashing CSS file: %v", err)
789 return ""
790 }
791
792 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash
793}
794
795func (p *Pages) Error500(w io.Writer) error {
796 return p.execute("errors/500", w, nil)
797}
798
799func (p *Pages) Error404(w io.Writer) error {
800 return p.execute("errors/404", w, nil)
801}
802
803func (p *Pages) Error503(w io.Writer) error {
804 return p.execute("errors/503", w, nil)
805}