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