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