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