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