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