forked from
tangled.org/core
Monorepo for Tangled — https://tangled.org
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 b.Add(chroma.Background, "bg:")
435 style, _ = b.Build()
436
437 if params.Lines < 5000 {
438 c := params.Contents
439 formatter := chromahtml.New(
440 chromahtml.InlineCode(true),
441 chromahtml.WithLineNumbers(true),
442 chromahtml.WithLinkableLineNumbers(true, "L"),
443 chromahtml.Standalone(false),
444 )
445
446 lexer := lexers.Get(filepath.Base(params.Path))
447 if lexer == nil {
448 lexer = lexers.Fallback
449 }
450
451 iterator, err := lexer.Tokenise(nil, c)
452 if err != nil {
453 return fmt.Errorf("chroma tokenize: %w", err)
454 }
455
456 var code bytes.Buffer
457 err = formatter.Format(&code, style, iterator)
458 if err != nil {
459 return fmt.Errorf("chroma format: %w", err)
460 }
461
462 params.Contents = code.String()
463 }
464
465 params.Active = "overview"
466 return p.executeRepo("repo/blob", w, params)
467}
468
469type Collaborator struct {
470 Did string
471 Handle string
472 Role string
473}
474
475type RepoSettingsParams struct {
476 LoggedInUser *auth.User
477 RepoInfo RepoInfo
478 Collaborators []Collaborator
479 Active string
480 // TODO: use repoinfo.roles
481 IsCollaboratorInviteAllowed bool
482}
483
484func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error {
485 params.Active = "settings"
486 return p.executeRepo("repo/settings", w, params)
487}
488
489type RepoIssuesParams struct {
490 LoggedInUser *auth.User
491 RepoInfo RepoInfo
492 Active string
493 Issues []db.Issue
494 DidHandleMap map[string]string
495
496 FilteringByOpen bool
497}
498
499func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
500 params.Active = "issues"
501 return p.executeRepo("repo/issues/issues", w, params)
502}
503
504type RepoSingleIssueParams struct {
505 LoggedInUser *auth.User
506 RepoInfo RepoInfo
507 Active string
508 Issue db.Issue
509 Comments []db.Comment
510 IssueOwnerHandle string
511 DidHandleMap map[string]string
512
513 State string
514}
515
516func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
517 params.Active = "issues"
518 if params.Issue.Open {
519 params.State = "open"
520 } else {
521 params.State = "closed"
522 }
523 return p.execute("repo/issues/issue", w, params)
524}
525
526type RepoNewIssueParams struct {
527 LoggedInUser *auth.User
528 RepoInfo RepoInfo
529 Active string
530}
531
532func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
533 params.Active = "issues"
534 return p.executeRepo("repo/issues/new", w, params)
535}
536
537type RepoNewPullParams struct {
538 LoggedInUser *auth.User
539 RepoInfo RepoInfo
540 Branches []types.Branch
541 Active string
542}
543
544func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error {
545 params.Active = "pulls"
546 return p.executeRepo("repo/pulls/new", w, params)
547}
548
549type RepoPullsParams struct {
550 LoggedInUser *auth.User
551 RepoInfo RepoInfo
552 Pulls []db.Pull
553 Active string
554 DidHandleMap map[string]string
555 FilteringBy db.PullState
556}
557
558func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
559 params.Active = "pulls"
560 return p.executeRepo("repo/pulls/pulls", w, params)
561}
562
563type RepoSinglePullParams struct {
564 LoggedInUser *auth.User
565 RepoInfo RepoInfo
566 Active string
567 DidHandleMap map[string]string
568
569 Pull db.Pull
570 MergeCheck types.MergeCheckResponse
571}
572
573func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
574 params.Active = "pulls"
575 return p.executeRepo("repo/pulls/pull", w, params)
576}
577
578type RepoPullPatchParams struct {
579 LoggedInUser *auth.User
580 DidHandleMap map[string]string
581 RepoInfo RepoInfo
582 Pull *db.Pull
583 Diff types.NiceDiff
584 Round int
585 Submission *db.PullSubmission
586}
587
588// this name is a mouthful
589func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error {
590 return p.execute("repo/pulls/patch", w, params)
591}
592
593type PullResubmitParams struct {
594 LoggedInUser *auth.User
595 RepoInfo RepoInfo
596 Pull *db.Pull
597 SubmissionId int
598}
599
600func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error {
601 return p.executePlain("fragments/pullResubmit", w, params)
602}
603
604type PullActionsParams struct {
605 LoggedInUser *auth.User
606 RepoInfo RepoInfo
607 Pull *db.Pull
608 RoundNumber int
609 MergeCheck types.MergeCheckResponse
610}
611
612func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
613 return p.executePlain("fragments/pullActions", w, params)
614}
615
616type PullNewCommentParams struct {
617 LoggedInUser *auth.User
618 RepoInfo RepoInfo
619 Pull *db.Pull
620 RoundNumber int
621}
622
623func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error {
624 return p.executePlain("fragments/pullNewComment", w, params)
625}
626
627func (p *Pages) Static() http.Handler {
628 sub, err := fs.Sub(Files, "static")
629 if err != nil {
630 log.Fatalf("no static dir found? that's crazy: %v", err)
631 }
632 // Custom handler to apply Cache-Control headers for font files
633 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
634}
635
636func Cache(h http.Handler) http.Handler {
637 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
638 if strings.HasSuffix(r.URL.Path, ".css") {
639 // on day for css files
640 w.Header().Set("Cache-Control", "public, max-age=86400")
641 } else {
642 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
643 }
644 h.ServeHTTP(w, r)
645 })
646}
647
648func (p *Pages) Error500(w io.Writer) error {
649 return p.execute("errors/500", w, nil)
650}
651
652func (p *Pages) Error404(w io.Writer) error {
653 return p.execute("errors/404", w, nil)
654}
655
656func (p *Pages) Error503(w io.Writer) error {
657 return p.execute("errors/503", w, nil)
658}