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