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