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}
239
240type RolesInRepo struct {
241 Roles []string
242}
243
244func (r RolesInRepo) SettingsAllowed() bool {
245 return slices.Contains(r.Roles, "repo:settings")
246}
247
248func (r RolesInRepo) IsOwner() bool {
249 return slices.Contains(r.Roles, "repo:owner")
250}
251
252func (r RolesInRepo) IsCollaborator() bool {
253 return slices.Contains(r.Roles, "repo:collaborator")
254}
255
256func (r RolesInRepo) IsPushAllowed() bool {
257 return slices.Contains(r.Roles, "repo:push")
258}
259
260func (r RepoInfo) OwnerWithAt() string {
261 if r.OwnerHandle != "" {
262 return fmt.Sprintf("@%s", r.OwnerHandle)
263 } else {
264 return r.OwnerDid
265 }
266}
267
268func (r RepoInfo) FullName() string {
269 return path.Join(r.OwnerWithAt(), r.Name)
270}
271
272func (r RepoInfo) OwnerWithoutAt() string {
273 if strings.HasPrefix(r.OwnerWithAt(), "@") {
274 return strings.TrimPrefix(r.OwnerWithAt(), "@")
275 } else {
276 return userutil.FlattenDid(r.OwnerDid)
277 }
278}
279
280func (r RepoInfo) FullNameWithoutAt() string {
281 return path.Join(r.OwnerWithoutAt(), r.Name)
282}
283
284func (r RepoInfo) GetTabs() [][]string {
285 tabs := [][]string{
286 {"overview", "/"},
287 {"issues", "/issues"},
288 {"pulls", "/pulls"},
289 }
290
291 if r.Roles.SettingsAllowed() {
292 tabs = append(tabs, []string{"settings", "/settings"})
293 }
294
295 return tabs
296}
297
298// each tab on a repo could have some metadata:
299//
300// issues -> number of open issues etc.
301// settings -> a warning icon to setup branch protection? idk
302//
303// we gather these bits of info here, because go templates
304// are difficult to program in
305func (r RepoInfo) TabMetadata() map[string]any {
306 meta := make(map[string]any)
307
308 if r.Stats.PullCount.Open > 0 {
309 meta["pulls"] = r.Stats.PullCount.Open
310 }
311
312 if r.Stats.IssueCount.Open > 0 {
313 meta["issues"] = r.Stats.IssueCount.Open
314 }
315
316 // more stuff?
317
318 return meta
319}
320
321type RepoIndexParams struct {
322 LoggedInUser *auth.User
323 RepoInfo RepoInfo
324 Active string
325 TagMap map[string][]string
326 types.RepoIndexResponse
327 HTMLReadme template.HTML
328 Raw bool
329 EmailToDidOrHandle map[string]string
330}
331
332func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error {
333 params.Active = "overview"
334 if params.IsEmpty {
335 return p.executeRepo("repo/empty", w, params)
336 }
337
338 if params.ReadmeFileName != "" {
339 var htmlString string
340 ext := filepath.Ext(params.ReadmeFileName)
341 switch ext {
342 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
343 htmlString = renderMarkdown(params.Readme)
344 params.Raw = false
345 params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString))
346 default:
347 htmlString = string(params.Readme)
348 params.Raw = true
349 params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString))
350 }
351 }
352
353 return p.executeRepo("repo/index", w, params)
354}
355
356type RepoLogParams struct {
357 LoggedInUser *auth.User
358 RepoInfo RepoInfo
359 types.RepoLogResponse
360 Active string
361 EmailToDidOrHandle map[string]string
362}
363
364func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
365 params.Active = "overview"
366 return p.execute("repo/log", w, params)
367}
368
369type RepoCommitParams struct {
370 LoggedInUser *auth.User
371 RepoInfo RepoInfo
372 Active string
373 types.RepoCommitResponse
374 EmailToDidOrHandle map[string]string
375}
376
377func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
378 params.Active = "overview"
379 return p.executeRepo("repo/commit", w, params)
380}
381
382type RepoTreeParams struct {
383 LoggedInUser *auth.User
384 RepoInfo RepoInfo
385 Active string
386 BreadCrumbs [][]string
387 BaseTreeLink string
388 BaseBlobLink string
389 types.RepoTreeResponse
390}
391
392type RepoTreeStats struct {
393 NumFolders uint64
394 NumFiles uint64
395}
396
397func (r RepoTreeParams) TreeStats() RepoTreeStats {
398 numFolders, numFiles := 0, 0
399 for _, f := range r.Files {
400 if !f.IsFile {
401 numFolders += 1
402 } else if f.IsFile {
403 numFiles += 1
404 }
405 }
406
407 return RepoTreeStats{
408 NumFolders: uint64(numFolders),
409 NumFiles: uint64(numFiles),
410 }
411}
412
413func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
414 params.Active = "overview"
415 return p.execute("repo/tree", w, params)
416}
417
418type RepoBranchesParams struct {
419 LoggedInUser *auth.User
420 RepoInfo RepoInfo
421 types.RepoBranchesResponse
422}
423
424func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error {
425 return p.executeRepo("repo/branches", w, params)
426}
427
428type RepoTagsParams struct {
429 LoggedInUser *auth.User
430 RepoInfo RepoInfo
431 types.RepoTagsResponse
432}
433
434func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
435 return p.executeRepo("repo/tags", w, params)
436}
437
438type RepoBlobParams struct {
439 LoggedInUser *auth.User
440 RepoInfo RepoInfo
441 Active string
442 BreadCrumbs [][]string
443 types.RepoBlobResponse
444}
445
446func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
447 style := styles.Get("bw")
448 b := style.Builder()
449 b.Add(chroma.LiteralString, "noitalic")
450 style, _ = b.Build()
451
452 if params.Lines < 5000 {
453 c := params.Contents
454 formatter := chromahtml.New(
455 chromahtml.InlineCode(false),
456 chromahtml.WithLineNumbers(true),
457 chromahtml.WithLinkableLineNumbers(true, "L"),
458 chromahtml.Standalone(false),
459 )
460
461 lexer := lexers.Get(filepath.Base(params.Path))
462 if lexer == nil {
463 lexer = lexers.Fallback
464 }
465
466 iterator, err := lexer.Tokenise(nil, c)
467 if err != nil {
468 return fmt.Errorf("chroma tokenize: %w", err)
469 }
470
471 var code bytes.Buffer
472 err = formatter.Format(&code, style, iterator)
473 if err != nil {
474 return fmt.Errorf("chroma format: %w", err)
475 }
476
477 params.Contents = code.String()
478 }
479
480 params.Active = "overview"
481 return p.executeRepo("repo/blob", w, params)
482}
483
484type Collaborator struct {
485 Did string
486 Handle string
487 Role string
488}
489
490type RepoSettingsParams struct {
491 LoggedInUser *auth.User
492 RepoInfo RepoInfo
493 Collaborators []Collaborator
494 Active string
495 Branches []string
496 DefaultBranch string
497 // TODO: use repoinfo.roles
498 IsCollaboratorInviteAllowed bool
499}
500
501func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error {
502 params.Active = "settings"
503 return p.executeRepo("repo/settings", w, params)
504}
505
506type RepoIssuesParams struct {
507 LoggedInUser *auth.User
508 RepoInfo RepoInfo
509 Active string
510 Issues []db.Issue
511 DidHandleMap map[string]string
512
513 FilteringByOpen bool
514}
515
516func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
517 params.Active = "issues"
518 return p.executeRepo("repo/issues/issues", w, params)
519}
520
521type RepoSingleIssueParams struct {
522 LoggedInUser *auth.User
523 RepoInfo RepoInfo
524 Active string
525 Issue db.Issue
526 Comments []db.Comment
527 IssueOwnerHandle string
528 DidHandleMap map[string]string
529
530 State string
531}
532
533func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
534 params.Active = "issues"
535 if params.Issue.Open {
536 params.State = "open"
537 } else {
538 params.State = "closed"
539 }
540 return p.execute("repo/issues/issue", w, params)
541}
542
543type RepoNewIssueParams struct {
544 LoggedInUser *auth.User
545 RepoInfo RepoInfo
546 Active string
547}
548
549func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
550 params.Active = "issues"
551 return p.executeRepo("repo/issues/new", w, params)
552}
553
554type EditIssueCommentParams struct {
555 LoggedInUser *auth.User
556 RepoInfo RepoInfo
557 Issue *db.Issue
558 Comment *db.Comment
559}
560
561func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
562 return p.executePlain("fragments/editIssueComment", w, params)
563}
564
565type SingleIssueCommentParams struct {
566 LoggedInUser *auth.User
567 DidHandleMap map[string]string
568 RepoInfo RepoInfo
569 Issue *db.Issue
570 Comment *db.Comment
571}
572
573func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error {
574 return p.executePlain("fragments/issueComment", w, params)
575}
576
577type RepoNewPullParams struct {
578 LoggedInUser *auth.User
579 RepoInfo RepoInfo
580 Branches []types.Branch
581 Active string
582}
583
584func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error {
585 params.Active = "pulls"
586 return p.executeRepo("repo/pulls/new", w, params)
587}
588
589type RepoPullsParams struct {
590 LoggedInUser *auth.User
591 RepoInfo RepoInfo
592 Pulls []db.Pull
593 Active string
594 DidHandleMap map[string]string
595 FilteringBy db.PullState
596}
597
598func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
599 params.Active = "pulls"
600 return p.executeRepo("repo/pulls/pulls", w, params)
601}
602
603type ResubmitResult uint64
604
605const (
606 ShouldResubmit ResubmitResult = iota
607 ShouldNotResubmit
608 Unknown
609)
610
611func (r ResubmitResult) Yes() bool {
612 return r == ShouldResubmit
613}
614func (r ResubmitResult) No() bool {
615 return r == ShouldNotResubmit
616}
617func (r ResubmitResult) Unknown() bool {
618 return r == Unknown
619}
620
621type RepoSinglePullParams struct {
622 LoggedInUser *auth.User
623 RepoInfo RepoInfo
624 Active string
625 DidHandleMap map[string]string
626 Pull *db.Pull
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 PullResubmitParams struct {
669 LoggedInUser *auth.User
670 RepoInfo RepoInfo
671 Pull *db.Pull
672 SubmissionId int
673}
674
675func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error {
676 return p.executePlain("fragments/pullResubmit", w, params)
677}
678
679type PullActionsParams struct {
680 LoggedInUser *auth.User
681 RepoInfo RepoInfo
682 Pull *db.Pull
683 RoundNumber int
684 MergeCheck types.MergeCheckResponse
685 ResubmitCheck ResubmitResult
686}
687
688func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
689 return p.executePlain("fragments/pullActions", w, params)
690}
691
692type PullNewCommentParams struct {
693 LoggedInUser *auth.User
694 RepoInfo RepoInfo
695 Pull *db.Pull
696 RoundNumber int
697}
698
699func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error {
700 return p.executePlain("fragments/pullNewComment", w, params)
701}
702
703func (p *Pages) Static() http.Handler {
704 sub, err := fs.Sub(Files, "static")
705 if err != nil {
706 log.Fatalf("no static dir found? that's crazy: %v", err)
707 }
708 // Custom handler to apply Cache-Control headers for font files
709 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
710}
711
712func Cache(h http.Handler) http.Handler {
713 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
714 path := strings.Split(r.URL.Path, "?")[0]
715
716 if strings.HasSuffix(path, ".css") {
717 // on day for css files
718 w.Header().Set("Cache-Control", "public, max-age=86400")
719 } else {
720 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
721 }
722 h.ServeHTTP(w, r)
723 })
724}
725
726func CssContentHash() string {
727 cssFile, err := Files.Open("static/tw.css")
728 if err != nil {
729 log.Printf("Error opening CSS file: %v", err)
730 return ""
731 }
732 defer cssFile.Close()
733
734 hasher := sha256.New()
735 if _, err := io.Copy(hasher, cssFile); err != nil {
736 log.Printf("Error hashing CSS file: %v", err)
737 return ""
738 }
739
740 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash
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}