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 RenderedContents template.HTML
456 types.RepoBlobResponse
457}
458
459func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
460 style := styles.Get("bw")
461 b := style.Builder()
462 b.Add(chroma.LiteralString, "noitalic")
463 style, _ = b.Build()
464
465 if params.ShowRendered {
466 switch markup.GetFormat(params.Path) {
467 case markup.FormatMarkdown:
468 params.RenderedContents = template.HTML(markup.RenderMarkdown(params.Contents))
469 }
470 }
471
472 if params.Lines < 5000 {
473 c := params.Contents
474 formatter := chromahtml.New(
475 chromahtml.InlineCode(false),
476 chromahtml.WithLineNumbers(true),
477 chromahtml.WithLinkableLineNumbers(true, "L"),
478 chromahtml.Standalone(false),
479 )
480
481 lexer := lexers.Get(filepath.Base(params.Path))
482 if lexer == nil {
483 lexer = lexers.Fallback
484 }
485
486 iterator, err := lexer.Tokenise(nil, c)
487 if err != nil {
488 return fmt.Errorf("chroma tokenize: %w", err)
489 }
490
491 var code bytes.Buffer
492 err = formatter.Format(&code, style, iterator)
493 if err != nil {
494 return fmt.Errorf("chroma format: %w", err)
495 }
496
497 params.Contents = code.String()
498 }
499
500 params.Active = "overview"
501 return p.executeRepo("repo/blob", w, params)
502}
503
504type Collaborator struct {
505 Did string
506 Handle string
507 Role string
508}
509
510type RepoSettingsParams struct {
511 LoggedInUser *auth.User
512 RepoInfo RepoInfo
513 Collaborators []Collaborator
514 Active string
515 Branches []string
516 DefaultBranch string
517 // TODO: use repoinfo.roles
518 IsCollaboratorInviteAllowed bool
519}
520
521func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error {
522 params.Active = "settings"
523 return p.executeRepo("repo/settings", w, params)
524}
525
526type RepoIssuesParams struct {
527 LoggedInUser *auth.User
528 RepoInfo RepoInfo
529 Active string
530 Issues []db.Issue
531 DidHandleMap map[string]string
532
533 FilteringByOpen bool
534}
535
536func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
537 params.Active = "issues"
538 return p.executeRepo("repo/issues/issues", w, params)
539}
540
541type RepoSingleIssueParams struct {
542 LoggedInUser *auth.User
543 RepoInfo RepoInfo
544 Active string
545 Issue db.Issue
546 Comments []db.Comment
547 IssueOwnerHandle string
548 DidHandleMap map[string]string
549
550 State string
551}
552
553func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
554 params.Active = "issues"
555 if params.Issue.Open {
556 params.State = "open"
557 } else {
558 params.State = "closed"
559 }
560 return p.execute("repo/issues/issue", w, params)
561}
562
563type RepoNewIssueParams struct {
564 LoggedInUser *auth.User
565 RepoInfo RepoInfo
566 Active string
567}
568
569func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
570 params.Active = "issues"
571 return p.executeRepo("repo/issues/new", w, params)
572}
573
574type EditIssueCommentParams struct {
575 LoggedInUser *auth.User
576 RepoInfo RepoInfo
577 Issue *db.Issue
578 Comment *db.Comment
579}
580
581func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
582 return p.executePlain("fragments/editIssueComment", w, params)
583}
584
585type SingleIssueCommentParams struct {
586 LoggedInUser *auth.User
587 DidHandleMap map[string]string
588 RepoInfo RepoInfo
589 Issue *db.Issue
590 Comment *db.Comment
591}
592
593func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error {
594 return p.executePlain("fragments/issueComment", w, params)
595}
596
597type RepoNewPullParams struct {
598 LoggedInUser *auth.User
599 RepoInfo RepoInfo
600 Branches []types.Branch
601 Active string
602}
603
604func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error {
605 params.Active = "pulls"
606 return p.executeRepo("repo/pulls/new", w, params)
607}
608
609type RepoPullsParams struct {
610 LoggedInUser *auth.User
611 RepoInfo RepoInfo
612 Pulls []db.Pull
613 Active string
614 DidHandleMap map[string]string
615 FilteringBy db.PullState
616}
617
618func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
619 params.Active = "pulls"
620 return p.executeRepo("repo/pulls/pulls", w, params)
621}
622
623type ResubmitResult uint64
624
625const (
626 ShouldResubmit ResubmitResult = iota
627 ShouldNotResubmit
628 Unknown
629)
630
631func (r ResubmitResult) Yes() bool {
632 return r == ShouldResubmit
633}
634func (r ResubmitResult) No() bool {
635 return r == ShouldNotResubmit
636}
637func (r ResubmitResult) Unknown() bool {
638 return r == Unknown
639}
640
641type RepoSinglePullParams struct {
642 LoggedInUser *auth.User
643 RepoInfo RepoInfo
644 Active string
645 DidHandleMap map[string]string
646 Pull *db.Pull
647 PullSourceRepo *db.Repo
648 MergeCheck types.MergeCheckResponse
649 ResubmitCheck ResubmitResult
650}
651
652func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
653 params.Active = "pulls"
654 return p.executeRepo("repo/pulls/pull", w, params)
655}
656
657type RepoPullPatchParams struct {
658 LoggedInUser *auth.User
659 DidHandleMap map[string]string
660 RepoInfo RepoInfo
661 Pull *db.Pull
662 Diff types.NiceDiff
663 Round int
664 Submission *db.PullSubmission
665}
666
667// this name is a mouthful
668func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error {
669 return p.execute("repo/pulls/patch", w, params)
670}
671
672type PullPatchUploadParams struct {
673 RepoInfo RepoInfo
674}
675
676func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error {
677 return p.executePlain("fragments/pullPatchUpload", w, params)
678}
679
680type PullCompareBranchesParams struct {
681 RepoInfo RepoInfo
682 Branches []types.Branch
683}
684
685func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error {
686 return p.executePlain("fragments/pullCompareBranches", w, params)
687}
688
689type PullCompareForkParams struct {
690 RepoInfo RepoInfo
691 Forks []db.Repo
692}
693
694func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error {
695 return p.executePlain("fragments/pullCompareForks", w, params)
696}
697
698type PullCompareForkBranchesParams struct {
699 RepoInfo RepoInfo
700 SourceBranches []types.Branch
701 TargetBranches []types.Branch
702}
703
704func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error {
705 return p.executePlain("fragments/pullCompareForksBranches", w, params)
706}
707
708type PullResubmitParams struct {
709 LoggedInUser *auth.User
710 RepoInfo RepoInfo
711 Pull *db.Pull
712 SubmissionId int
713}
714
715func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error {
716 return p.executePlain("fragments/pullResubmit", w, params)
717}
718
719type PullActionsParams struct {
720 LoggedInUser *auth.User
721 RepoInfo RepoInfo
722 Pull *db.Pull
723 RoundNumber int
724 MergeCheck types.MergeCheckResponse
725 ResubmitCheck ResubmitResult
726}
727
728func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
729 return p.executePlain("fragments/pullActions", w, params)
730}
731
732type PullNewCommentParams struct {
733 LoggedInUser *auth.User
734 RepoInfo RepoInfo
735 Pull *db.Pull
736 RoundNumber int
737}
738
739func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error {
740 return p.executePlain("fragments/pullNewComment", w, params)
741}
742
743func (p *Pages) Static() http.Handler {
744 sub, err := fs.Sub(Files, "static")
745 if err != nil {
746 log.Fatalf("no static dir found? that's crazy: %v", err)
747 }
748 // Custom handler to apply Cache-Control headers for font files
749 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
750}
751
752func Cache(h http.Handler) http.Handler {
753 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
754 path := strings.Split(r.URL.Path, "?")[0]
755
756 if strings.HasSuffix(path, ".css") {
757 // on day for css files
758 w.Header().Set("Cache-Control", "public, max-age=86400")
759 } else {
760 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
761 }
762 h.ServeHTTP(w, r)
763 })
764}
765
766func CssContentHash() string {
767 cssFile, err := Files.Open("static/tw.css")
768 if err != nil {
769 log.Printf("Error opening CSS file: %v", err)
770 return ""
771 }
772 defer cssFile.Close()
773
774 hasher := sha256.New()
775 if _, err := io.Copy(hasher, cssFile); err != nil {
776 log.Printf("Error hashing CSS file: %v", err)
777 return ""
778 }
779
780 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash
781}
782
783func (p *Pages) Error500(w io.Writer) error {
784 return p.execute("errors/500", w, nil)
785}
786
787func (p *Pages) Error404(w io.Writer) error {
788 return p.execute("errors/404", w, nil)
789}
790
791func (p *Pages) Error503(w io.Writer) error {
792 return p.execute("errors/503", w, nil)
793}