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