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/slog"
13 "net/http"
14 "os"
15 "path/filepath"
16 "strings"
17 "sync"
18
19 "tangled.org/core/api/tangled"
20 "tangled.org/core/appview/commitverify"
21 "tangled.org/core/appview/config"
22 "tangled.org/core/appview/db"
23 "tangled.org/core/appview/models"
24 "tangled.org/core/appview/oauth"
25 "tangled.org/core/appview/pages/markup"
26 "tangled.org/core/appview/pages/repoinfo"
27 "tangled.org/core/appview/pagination"
28 "tangled.org/core/idresolver"
29 "tangled.org/core/patchutil"
30 "tangled.org/core/types"
31
32 "github.com/alecthomas/chroma/v2"
33 chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
34 "github.com/alecthomas/chroma/v2/lexers"
35 "github.com/alecthomas/chroma/v2/styles"
36 "github.com/bluesky-social/indigo/atproto/identity"
37 "github.com/bluesky-social/indigo/atproto/syntax"
38 "github.com/go-git/go-git/v5/plumbing"
39 "github.com/go-git/go-git/v5/plumbing/object"
40)
41
42//go:embed templates/* static
43var Files embed.FS
44
45type Pages struct {
46 mu sync.RWMutex
47 cache *TmplCache[string, *template.Template]
48
49 avatar config.AvatarConfig
50 resolver *idresolver.Resolver
51 dev bool
52 embedFS fs.FS
53 templateDir string // Path to templates on disk for dev mode
54 rctx *markup.RenderContext
55 logger *slog.Logger
56}
57
58func NewPages(config *config.Config, res *idresolver.Resolver) *Pages {
59 // initialized with safe defaults, can be overriden per use
60 rctx := &markup.RenderContext{
61 IsDev: config.Core.Dev,
62 CamoUrl: config.Camo.Host,
63 CamoSecret: config.Camo.SharedSecret,
64 Sanitizer: markup.NewSanitizer(),
65 }
66
67 p := &Pages{
68 mu: sync.RWMutex{},
69 cache: NewTmplCache[string, *template.Template](),
70 dev: config.Core.Dev,
71 avatar: config.Avatar,
72 rctx: rctx,
73 resolver: res,
74 templateDir: "appview/pages",
75 logger: slog.Default().With("component", "pages"),
76 }
77
78 if p.dev {
79 p.embedFS = os.DirFS(p.templateDir)
80 } else {
81 p.embedFS = Files
82 }
83
84 return p
85}
86
87func (p *Pages) pathToName(s string) string {
88 return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html")
89}
90
91// reverse of pathToName
92func (p *Pages) nameToPath(s string) string {
93 return "templates/" + s + ".html"
94}
95
96func (p *Pages) fragmentPaths() ([]string, error) {
97 var fragmentPaths []string
98 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
99 if err != nil {
100 return err
101 }
102 if d.IsDir() {
103 return nil
104 }
105 if !strings.HasSuffix(path, ".html") {
106 return nil
107 }
108 if !strings.Contains(path, "fragments/") {
109 return nil
110 }
111 fragmentPaths = append(fragmentPaths, path)
112 return nil
113 })
114 if err != nil {
115 return nil, err
116 }
117
118 return fragmentPaths, nil
119}
120
121// parse without memoization
122func (p *Pages) rawParse(stack ...string) (*template.Template, error) {
123 paths, err := p.fragmentPaths()
124 if err != nil {
125 return nil, err
126 }
127 for _, s := range stack {
128 paths = append(paths, p.nameToPath(s))
129 }
130
131 funcs := p.funcMap()
132 top := stack[len(stack)-1]
133 parsed, err := template.New(top).
134 Funcs(funcs).
135 ParseFS(p.embedFS, paths...)
136 if err != nil {
137 return nil, err
138 }
139
140 return parsed, nil
141}
142
143func (p *Pages) parse(stack ...string) (*template.Template, error) {
144 key := strings.Join(stack, "|")
145
146 // never cache in dev mode
147 if cached, exists := p.cache.Get(key); !p.dev && exists {
148 return cached, nil
149 }
150
151 result, err := p.rawParse(stack...)
152 if err != nil {
153 return nil, err
154 }
155
156 p.cache.Set(key, result)
157 return result, nil
158}
159
160func (p *Pages) parseBase(top string) (*template.Template, error) {
161 stack := []string{
162 "layouts/base",
163 top,
164 }
165 return p.parse(stack...)
166}
167
168func (p *Pages) parseRepoBase(top string) (*template.Template, error) {
169 stack := []string{
170 "layouts/base",
171 "layouts/repobase",
172 top,
173 }
174 return p.parse(stack...)
175}
176
177func (p *Pages) parseProfileBase(top string) (*template.Template, error) {
178 stack := []string{
179 "layouts/base",
180 "layouts/profilebase",
181 top,
182 }
183 return p.parse(stack...)
184}
185
186func (p *Pages) executePlain(name string, w io.Writer, params any) error {
187 tpl, err := p.parse(name)
188 if err != nil {
189 return err
190 }
191
192 return tpl.Execute(w, params)
193}
194
195func (p *Pages) execute(name string, w io.Writer, params any) error {
196 tpl, err := p.parseBase(name)
197 if err != nil {
198 return err
199 }
200
201 return tpl.ExecuteTemplate(w, "layouts/base", params)
202}
203
204func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
205 tpl, err := p.parseRepoBase(name)
206 if err != nil {
207 return err
208 }
209
210 return tpl.ExecuteTemplate(w, "layouts/base", params)
211}
212
213func (p *Pages) executeProfile(name string, w io.Writer, params any) error {
214 tpl, err := p.parseProfileBase(name)
215 if err != nil {
216 return err
217 }
218
219 return tpl.ExecuteTemplate(w, "layouts/base", params)
220}
221
222func (p *Pages) Favicon(w io.Writer) error {
223 return p.executePlain("fragments/dolly/silhouette", w, nil)
224}
225
226type LoginParams struct {
227 ReturnUrl string
228}
229
230func (p *Pages) Login(w io.Writer, params LoginParams) error {
231 return p.executePlain("user/login", w, params)
232}
233
234func (p *Pages) Signup(w io.Writer) error {
235 return p.executePlain("user/signup", w, nil)
236}
237
238func (p *Pages) CompleteSignup(w io.Writer) error {
239 return p.executePlain("user/completeSignup", w, nil)
240}
241
242type TermsOfServiceParams struct {
243 LoggedInUser *oauth.User
244 Content template.HTML
245}
246
247func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
248 filename := "terms.md"
249 filePath := filepath.Join("legal", filename)
250 markdownBytes, err := os.ReadFile(filePath)
251 if err != nil {
252 return fmt.Errorf("failed to read %s: %w", filename, err)
253 }
254
255 p.rctx.RendererType = markup.RendererTypeDefault
256 htmlString := p.rctx.RenderMarkdown(string(markdownBytes))
257 sanitized := p.rctx.SanitizeDefault(htmlString)
258 params.Content = template.HTML(sanitized)
259
260 return p.execute("legal/terms", w, params)
261}
262
263type PrivacyPolicyParams struct {
264 LoggedInUser *oauth.User
265 Content template.HTML
266}
267
268func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
269 filename := "privacy.md"
270 filePath := filepath.Join("legal", filename)
271 markdownBytes, err := os.ReadFile(filePath)
272 if err != nil {
273 return fmt.Errorf("failed to read %s: %w", filename, err)
274 }
275
276 p.rctx.RendererType = markup.RendererTypeDefault
277 htmlString := p.rctx.RenderMarkdown(string(markdownBytes))
278 sanitized := p.rctx.SanitizeDefault(htmlString)
279 params.Content = template.HTML(sanitized)
280
281 return p.execute("legal/privacy", w, params)
282}
283
284type TimelineParams struct {
285 LoggedInUser *oauth.User
286 Timeline []db.TimelineEvent
287 Repos []models.Repo
288}
289
290func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
291 return p.execute("timeline/timeline", w, params)
292}
293
294type UserProfileSettingsParams struct {
295 LoggedInUser *oauth.User
296 Tabs []map[string]any
297 Tab string
298}
299
300func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error {
301 return p.execute("user/settings/profile", w, params)
302}
303
304type UserKeysSettingsParams struct {
305 LoggedInUser *oauth.User
306 PubKeys []models.PublicKey
307 Tabs []map[string]any
308 Tab string
309}
310
311func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error {
312 return p.execute("user/settings/keys", w, params)
313}
314
315type UserEmailsSettingsParams struct {
316 LoggedInUser *oauth.User
317 Emails []models.Email
318 Tabs []map[string]any
319 Tab string
320}
321
322func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error {
323 return p.execute("user/settings/emails", w, params)
324}
325
326type UpgradeBannerParams struct {
327 Registrations []models.Registration
328 Spindles []db.Spindle
329}
330
331func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error {
332 return p.executePlain("banner", w, params)
333}
334
335type KnotsParams struct {
336 LoggedInUser *oauth.User
337 Registrations []models.Registration
338}
339
340func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
341 return p.execute("knots/index", w, params)
342}
343
344type KnotParams struct {
345 LoggedInUser *oauth.User
346 Registration *models.Registration
347 Members []string
348 Repos map[string][]models.Repo
349 IsOwner bool
350}
351
352func (p *Pages) Knot(w io.Writer, params KnotParams) error {
353 return p.execute("knots/dashboard", w, params)
354}
355
356type KnotListingParams struct {
357 *models.Registration
358}
359
360func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error {
361 return p.executePlain("knots/fragments/knotListing", w, params)
362}
363
364type SpindlesParams struct {
365 LoggedInUser *oauth.User
366 Spindles []db.Spindle
367}
368
369func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error {
370 return p.execute("spindles/index", w, params)
371}
372
373type SpindleListingParams struct {
374 db.Spindle
375}
376
377func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error {
378 return p.executePlain("spindles/fragments/spindleListing", w, params)
379}
380
381type SpindleDashboardParams struct {
382 LoggedInUser *oauth.User
383 Spindle db.Spindle
384 Members []string
385 Repos map[string][]models.Repo
386}
387
388func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
389 return p.execute("spindles/dashboard", w, params)
390}
391
392type NewRepoParams struct {
393 LoggedInUser *oauth.User
394 Knots []string
395}
396
397func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error {
398 return p.execute("repo/new", w, params)
399}
400
401type ForkRepoParams struct {
402 LoggedInUser *oauth.User
403 Knots []string
404 RepoInfo repoinfo.RepoInfo
405}
406
407func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error {
408 return p.execute("repo/fork", w, params)
409}
410
411type ProfileCard struct {
412 UserDid string
413 UserHandle string
414 FollowStatus models.FollowStatus
415 Punchcard *models.Punchcard
416 Profile *models.Profile
417 Stats ProfileStats
418 Active string
419}
420
421type ProfileStats struct {
422 RepoCount int64
423 StarredCount int64
424 StringCount int64
425 FollowersCount int64
426 FollowingCount int64
427}
428
429func (p *ProfileCard) GetTabs() [][]any {
430 tabs := [][]any{
431 {"overview", "overview", "square-chart-gantt", nil},
432 {"repos", "repos", "book-marked", p.Stats.RepoCount},
433 {"starred", "starred", "star", p.Stats.StarredCount},
434 {"strings", "strings", "line-squiggle", p.Stats.StringCount},
435 }
436
437 return tabs
438}
439
440type ProfileOverviewParams struct {
441 LoggedInUser *oauth.User
442 Repos []models.Repo
443 CollaboratingRepos []models.Repo
444 ProfileTimeline *models.ProfileTimeline
445 Card *ProfileCard
446 Active string
447}
448
449func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error {
450 params.Active = "overview"
451 return p.executeProfile("user/overview", w, params)
452}
453
454type ProfileReposParams struct {
455 LoggedInUser *oauth.User
456 Repos []models.Repo
457 Card *ProfileCard
458 Active string
459}
460
461func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error {
462 params.Active = "repos"
463 return p.executeProfile("user/repos", w, params)
464}
465
466type ProfileStarredParams struct {
467 LoggedInUser *oauth.User
468 Repos []models.Repo
469 Card *ProfileCard
470 Active string
471}
472
473func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error {
474 params.Active = "starred"
475 return p.executeProfile("user/starred", w, params)
476}
477
478type ProfileStringsParams struct {
479 LoggedInUser *oauth.User
480 Strings []db.String
481 Card *ProfileCard
482 Active string
483}
484
485func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error {
486 params.Active = "strings"
487 return p.executeProfile("user/strings", w, params)
488}
489
490type FollowCard struct {
491 UserDid string
492 FollowStatus models.FollowStatus
493 FollowersCount int64
494 FollowingCount int64
495 Profile *models.Profile
496}
497
498type ProfileFollowersParams struct {
499 LoggedInUser *oauth.User
500 Followers []FollowCard
501 Card *ProfileCard
502 Active string
503}
504
505func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error {
506 params.Active = "overview"
507 return p.executeProfile("user/followers", w, params)
508}
509
510type ProfileFollowingParams struct {
511 LoggedInUser *oauth.User
512 Following []FollowCard
513 Card *ProfileCard
514 Active string
515}
516
517func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error {
518 params.Active = "overview"
519 return p.executeProfile("user/following", w, params)
520}
521
522type FollowFragmentParams struct {
523 UserDid string
524 FollowStatus models.FollowStatus
525}
526
527func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
528 return p.executePlain("user/fragments/follow", w, params)
529}
530
531type EditBioParams struct {
532 LoggedInUser *oauth.User
533 Profile *models.Profile
534}
535
536func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error {
537 return p.executePlain("user/fragments/editBio", w, params)
538}
539
540type EditPinsParams struct {
541 LoggedInUser *oauth.User
542 Profile *models.Profile
543 AllRepos []PinnedRepo
544}
545
546type PinnedRepo struct {
547 IsPinned bool
548 models.Repo
549}
550
551func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error {
552 return p.executePlain("user/fragments/editPins", w, params)
553}
554
555type RepoStarFragmentParams struct {
556 IsStarred bool
557 RepoAt syntax.ATURI
558 Stats models.RepoStats
559}
560
561func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error {
562 return p.executePlain("repo/fragments/repoStar", w, params)
563}
564
565type RepoDescriptionParams struct {
566 RepoInfo repoinfo.RepoInfo
567}
568
569func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
570 return p.executePlain("repo/fragments/editRepoDescription", w, params)
571}
572
573func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
574 return p.executePlain("repo/fragments/repoDescription", w, params)
575}
576
577type RepoIndexParams struct {
578 LoggedInUser *oauth.User
579 RepoInfo repoinfo.RepoInfo
580 Active string
581 TagMap map[string][]string
582 CommitsTrunc []*object.Commit
583 TagsTrunc []*types.TagReference
584 BranchesTrunc []types.Branch
585 // ForkInfo *types.ForkInfo
586 HTMLReadme template.HTML
587 Raw bool
588 EmailToDidOrHandle map[string]string
589 VerifiedCommits commitverify.VerifiedCommits
590 Languages []types.RepoLanguageDetails
591 Pipelines map[string]models.Pipeline
592 NeedsKnotUpgrade bool
593 types.RepoIndexResponse
594}
595
596func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error {
597 params.Active = "overview"
598 if params.IsEmpty {
599 return p.executeRepo("repo/empty", w, params)
600 }
601
602 if params.NeedsKnotUpgrade {
603 return p.executeRepo("repo/needsUpgrade", w, params)
604 }
605
606 p.rctx.RepoInfo = params.RepoInfo
607 p.rctx.RepoInfo.Ref = params.Ref
608 p.rctx.RendererType = markup.RendererTypeRepoMarkdown
609
610 if params.ReadmeFileName != "" {
611 ext := filepath.Ext(params.ReadmeFileName)
612 switch ext {
613 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
614 params.Raw = false
615 htmlString := p.rctx.RenderMarkdown(params.Readme)
616 sanitized := p.rctx.SanitizeDefault(htmlString)
617 params.HTMLReadme = template.HTML(sanitized)
618 default:
619 params.Raw = true
620 }
621 }
622
623 return p.executeRepo("repo/index", w, params)
624}
625
626type RepoLogParams struct {
627 LoggedInUser *oauth.User
628 RepoInfo repoinfo.RepoInfo
629 TagMap map[string][]string
630 types.RepoLogResponse
631 Active string
632 EmailToDidOrHandle map[string]string
633 VerifiedCommits commitverify.VerifiedCommits
634 Pipelines map[string]models.Pipeline
635}
636
637func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
638 params.Active = "overview"
639 return p.executeRepo("repo/log", w, params)
640}
641
642type RepoCommitParams struct {
643 LoggedInUser *oauth.User
644 RepoInfo repoinfo.RepoInfo
645 Active string
646 EmailToDidOrHandle map[string]string
647 Pipeline *models.Pipeline
648 DiffOpts types.DiffOpts
649
650 // singular because it's always going to be just one
651 VerifiedCommit commitverify.VerifiedCommits
652
653 types.RepoCommitResponse
654}
655
656func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
657 params.Active = "overview"
658 return p.executeRepo("repo/commit", w, params)
659}
660
661type RepoTreeParams struct {
662 LoggedInUser *oauth.User
663 RepoInfo repoinfo.RepoInfo
664 Active string
665 BreadCrumbs [][]string
666 TreePath string
667 Readme string
668 ReadmeFileName string
669 HTMLReadme template.HTML
670 Raw bool
671 types.RepoTreeResponse
672}
673
674type RepoTreeStats struct {
675 NumFolders uint64
676 NumFiles uint64
677}
678
679func (r RepoTreeParams) TreeStats() RepoTreeStats {
680 numFolders, numFiles := 0, 0
681 for _, f := range r.Files {
682 if !f.IsFile {
683 numFolders += 1
684 } else if f.IsFile {
685 numFiles += 1
686 }
687 }
688
689 return RepoTreeStats{
690 NumFolders: uint64(numFolders),
691 NumFiles: uint64(numFiles),
692 }
693}
694
695func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
696 params.Active = "overview"
697
698 if params.ReadmeFileName != "" {
699 params.ReadmeFileName = filepath.Base(params.ReadmeFileName)
700
701 ext := filepath.Ext(params.ReadmeFileName)
702 switch ext {
703 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
704 params.Raw = false
705 htmlString := p.rctx.RenderMarkdown(params.Readme)
706 sanitized := p.rctx.SanitizeDefault(htmlString)
707 params.HTMLReadme = template.HTML(sanitized)
708 default:
709 params.Raw = true
710 }
711 }
712
713 return p.executeRepo("repo/tree", w, params)
714}
715
716type RepoBranchesParams struct {
717 LoggedInUser *oauth.User
718 RepoInfo repoinfo.RepoInfo
719 Active string
720 types.RepoBranchesResponse
721}
722
723func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error {
724 params.Active = "overview"
725 return p.executeRepo("repo/branches", w, params)
726}
727
728type RepoTagsParams struct {
729 LoggedInUser *oauth.User
730 RepoInfo repoinfo.RepoInfo
731 Active string
732 types.RepoTagsResponse
733 ArtifactMap map[plumbing.Hash][]models.Artifact
734 DanglingArtifacts []models.Artifact
735}
736
737func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
738 params.Active = "overview"
739 return p.executeRepo("repo/tags", w, params)
740}
741
742type RepoArtifactParams struct {
743 LoggedInUser *oauth.User
744 RepoInfo repoinfo.RepoInfo
745 Artifact models.Artifact
746}
747
748func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error {
749 return p.executePlain("repo/fragments/artifact", w, params)
750}
751
752type RepoBlobParams struct {
753 LoggedInUser *oauth.User
754 RepoInfo repoinfo.RepoInfo
755 Active string
756 Unsupported bool
757 IsImage bool
758 IsVideo bool
759 ContentSrc string
760 BreadCrumbs [][]string
761 ShowRendered bool
762 RenderToggle bool
763 RenderedContents template.HTML
764 *tangled.RepoBlob_Output
765 // Computed fields for template compatibility
766 Contents string
767 Lines int
768 SizeHint uint64
769 IsBinary bool
770}
771
772func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
773 var style *chroma.Style = styles.Get("catpuccin-latte")
774
775 if params.ShowRendered {
776 switch markup.GetFormat(params.Path) {
777 case markup.FormatMarkdown:
778 p.rctx.RepoInfo = params.RepoInfo
779 p.rctx.RendererType = markup.RendererTypeRepoMarkdown
780 htmlString := p.rctx.RenderMarkdown(params.Contents)
781 sanitized := p.rctx.SanitizeDefault(htmlString)
782 params.RenderedContents = template.HTML(sanitized)
783 }
784 }
785
786 c := params.Contents
787 formatter := chromahtml.New(
788 chromahtml.InlineCode(false),
789 chromahtml.WithLineNumbers(true),
790 chromahtml.WithLinkableLineNumbers(true, "L"),
791 chromahtml.Standalone(false),
792 chromahtml.WithClasses(true),
793 )
794
795 lexer := lexers.Get(filepath.Base(params.Path))
796 if lexer == nil {
797 lexer = lexers.Fallback
798 }
799
800 iterator, err := lexer.Tokenise(nil, c)
801 if err != nil {
802 return fmt.Errorf("chroma tokenize: %w", err)
803 }
804
805 var code bytes.Buffer
806 err = formatter.Format(&code, style, iterator)
807 if err != nil {
808 return fmt.Errorf("chroma format: %w", err)
809 }
810
811 params.Contents = code.String()
812 params.Active = "overview"
813 return p.executeRepo("repo/blob", w, params)
814}
815
816type Collaborator struct {
817 Did string
818 Handle string
819 Role string
820}
821
822type RepoSettingsParams struct {
823 LoggedInUser *oauth.User
824 RepoInfo repoinfo.RepoInfo
825 Collaborators []Collaborator
826 Active string
827 Branches []types.Branch
828 Spindles []string
829 CurrentSpindle string
830 Secrets []*tangled.RepoListSecrets_Secret
831
832 // TODO: use repoinfo.roles
833 IsCollaboratorInviteAllowed bool
834}
835
836func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error {
837 params.Active = "settings"
838 return p.executeRepo("repo/settings", w, params)
839}
840
841type RepoGeneralSettingsParams struct {
842 LoggedInUser *oauth.User
843 RepoInfo repoinfo.RepoInfo
844 Labels []models.LabelDefinition
845 DefaultLabels []models.LabelDefinition
846 SubscribedLabels map[string]struct{}
847 Active string
848 Tabs []map[string]any
849 Tab string
850 Branches []types.Branch
851}
852
853func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
854 params.Active = "settings"
855 return p.executeRepo("repo/settings/general", w, params)
856}
857
858type RepoAccessSettingsParams struct {
859 LoggedInUser *oauth.User
860 RepoInfo repoinfo.RepoInfo
861 Active string
862 Tabs []map[string]any
863 Tab string
864 Collaborators []Collaborator
865}
866
867func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error {
868 params.Active = "settings"
869 return p.executeRepo("repo/settings/access", w, params)
870}
871
872type RepoPipelineSettingsParams struct {
873 LoggedInUser *oauth.User
874 RepoInfo repoinfo.RepoInfo
875 Active string
876 Tabs []map[string]any
877 Tab string
878 Spindles []string
879 CurrentSpindle string
880 Secrets []map[string]any
881}
882
883func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error {
884 params.Active = "settings"
885 return p.executeRepo("repo/settings/pipelines", w, params)
886}
887
888type RepoIssuesParams struct {
889 LoggedInUser *oauth.User
890 RepoInfo repoinfo.RepoInfo
891 Active string
892 Issues []models.Issue
893 LabelDefs map[string]*models.LabelDefinition
894 Page pagination.Page
895 FilteringByOpen bool
896}
897
898func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
899 params.Active = "issues"
900 return p.executeRepo("repo/issues/issues", w, params)
901}
902
903type RepoSingleIssueParams struct {
904 LoggedInUser *oauth.User
905 RepoInfo repoinfo.RepoInfo
906 Active string
907 Issue *models.Issue
908 CommentList []models.CommentListItem
909 LabelDefs map[string]*models.LabelDefinition
910
911 OrderedReactionKinds []models.ReactionKind
912 Reactions map[models.ReactionKind]int
913 UserReacted map[models.ReactionKind]bool
914}
915
916func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
917 params.Active = "issues"
918 return p.executeRepo("repo/issues/issue", w, params)
919}
920
921type EditIssueParams struct {
922 LoggedInUser *oauth.User
923 RepoInfo repoinfo.RepoInfo
924 Issue *models.Issue
925 Action string
926}
927
928func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error {
929 params.Action = "edit"
930 return p.executePlain("repo/issues/fragments/putIssue", w, params)
931}
932
933type ThreadReactionFragmentParams struct {
934 ThreadAt syntax.ATURI
935 Kind models.ReactionKind
936 Count int
937 IsReacted bool
938}
939
940func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmentParams) error {
941 return p.executePlain("repo/fragments/reaction", w, params)
942}
943
944type RepoNewIssueParams struct {
945 LoggedInUser *oauth.User
946 RepoInfo repoinfo.RepoInfo
947 Issue *models.Issue // existing issue if any -- passed when editing
948 Active string
949 Action string
950}
951
952func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
953 params.Active = "issues"
954 params.Action = "create"
955 return p.executeRepo("repo/issues/new", w, params)
956}
957
958type EditIssueCommentParams struct {
959 LoggedInUser *oauth.User
960 RepoInfo repoinfo.RepoInfo
961 Issue *models.Issue
962 Comment *models.IssueComment
963}
964
965func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
966 return p.executePlain("repo/issues/fragments/editIssueComment", w, params)
967}
968
969type ReplyIssueCommentPlaceholderParams struct {
970 LoggedInUser *oauth.User
971 RepoInfo repoinfo.RepoInfo
972 Issue *models.Issue
973 Comment *models.IssueComment
974}
975
976func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error {
977 return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params)
978}
979
980type ReplyIssueCommentParams struct {
981 LoggedInUser *oauth.User
982 RepoInfo repoinfo.RepoInfo
983 Issue *models.Issue
984 Comment *models.IssueComment
985}
986
987func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
988 return p.executePlain("repo/issues/fragments/replyComment", w, params)
989}
990
991type IssueCommentBodyParams struct {
992 LoggedInUser *oauth.User
993 RepoInfo repoinfo.RepoInfo
994 Issue *models.Issue
995 Comment *models.IssueComment
996}
997
998func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
999 return p.executePlain("repo/issues/fragments/issueCommentBody", w, params)
1000}
1001
1002type RepoNewPullParams struct {
1003 LoggedInUser *oauth.User
1004 RepoInfo repoinfo.RepoInfo
1005 Branches []types.Branch
1006 Strategy string
1007 SourceBranch string
1008 TargetBranch string
1009 Title string
1010 Body string
1011 Active string
1012}
1013
1014func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error {
1015 params.Active = "pulls"
1016 return p.executeRepo("repo/pulls/new", w, params)
1017}
1018
1019type RepoPullsParams struct {
1020 LoggedInUser *oauth.User
1021 RepoInfo repoinfo.RepoInfo
1022 Pulls []*models.Pull
1023 Active string
1024 FilteringBy models.PullState
1025 Stacks map[string]models.Stack
1026 Pipelines map[string]models.Pipeline
1027}
1028
1029func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
1030 params.Active = "pulls"
1031 return p.executeRepo("repo/pulls/pulls", w, params)
1032}
1033
1034type ResubmitResult uint64
1035
1036const (
1037 ShouldResubmit ResubmitResult = iota
1038 ShouldNotResubmit
1039 Unknown
1040)
1041
1042func (r ResubmitResult) Yes() bool {
1043 return r == ShouldResubmit
1044}
1045func (r ResubmitResult) No() bool {
1046 return r == ShouldNotResubmit
1047}
1048func (r ResubmitResult) Unknown() bool {
1049 return r == Unknown
1050}
1051
1052type RepoSinglePullParams struct {
1053 LoggedInUser *oauth.User
1054 RepoInfo repoinfo.RepoInfo
1055 Active string
1056 Pull *models.Pull
1057 Stack models.Stack
1058 AbandonedPulls []*models.Pull
1059 MergeCheck types.MergeCheckResponse
1060 ResubmitCheck ResubmitResult
1061 Pipelines map[string]models.Pipeline
1062
1063 OrderedReactionKinds []models.ReactionKind
1064 Reactions map[models.ReactionKind]int
1065 UserReacted map[models.ReactionKind]bool
1066}
1067
1068func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
1069 params.Active = "pulls"
1070 return p.executeRepo("repo/pulls/pull", w, params)
1071}
1072
1073type RepoPullPatchParams struct {
1074 LoggedInUser *oauth.User
1075 RepoInfo repoinfo.RepoInfo
1076 Pull *models.Pull
1077 Stack models.Stack
1078 Diff *types.NiceDiff
1079 Round int
1080 Submission *models.PullSubmission
1081 OrderedReactionKinds []models.ReactionKind
1082 DiffOpts types.DiffOpts
1083}
1084
1085// this name is a mouthful
1086func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error {
1087 return p.execute("repo/pulls/patch", w, params)
1088}
1089
1090type RepoPullInterdiffParams struct {
1091 LoggedInUser *oauth.User
1092 RepoInfo repoinfo.RepoInfo
1093 Pull *models.Pull
1094 Round int
1095 Interdiff *patchutil.InterdiffResult
1096 OrderedReactionKinds []models.ReactionKind
1097 DiffOpts types.DiffOpts
1098}
1099
1100// this name is a mouthful
1101func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error {
1102 return p.execute("repo/pulls/interdiff", w, params)
1103}
1104
1105type PullPatchUploadParams struct {
1106 RepoInfo repoinfo.RepoInfo
1107}
1108
1109func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error {
1110 return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params)
1111}
1112
1113type PullCompareBranchesParams struct {
1114 RepoInfo repoinfo.RepoInfo
1115 Branches []types.Branch
1116 SourceBranch string
1117}
1118
1119func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error {
1120 return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params)
1121}
1122
1123type PullCompareForkParams struct {
1124 RepoInfo repoinfo.RepoInfo
1125 Forks []models.Repo
1126 Selected string
1127}
1128
1129func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error {
1130 return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params)
1131}
1132
1133type PullCompareForkBranchesParams struct {
1134 RepoInfo repoinfo.RepoInfo
1135 SourceBranches []types.Branch
1136 TargetBranches []types.Branch
1137}
1138
1139func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error {
1140 return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params)
1141}
1142
1143type PullResubmitParams struct {
1144 LoggedInUser *oauth.User
1145 RepoInfo repoinfo.RepoInfo
1146 Pull *models.Pull
1147 SubmissionId int
1148}
1149
1150func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error {
1151 return p.executePlain("repo/pulls/fragments/pullResubmit", w, params)
1152}
1153
1154type PullActionsParams struct {
1155 LoggedInUser *oauth.User
1156 RepoInfo repoinfo.RepoInfo
1157 Pull *models.Pull
1158 RoundNumber int
1159 MergeCheck types.MergeCheckResponse
1160 ResubmitCheck ResubmitResult
1161 Stack models.Stack
1162}
1163
1164func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
1165 return p.executePlain("repo/pulls/fragments/pullActions", w, params)
1166}
1167
1168type PullNewCommentParams struct {
1169 LoggedInUser *oauth.User
1170 RepoInfo repoinfo.RepoInfo
1171 Pull *models.Pull
1172 RoundNumber int
1173}
1174
1175func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error {
1176 return p.executePlain("repo/pulls/fragments/pullNewComment", w, params)
1177}
1178
1179type RepoCompareParams struct {
1180 LoggedInUser *oauth.User
1181 RepoInfo repoinfo.RepoInfo
1182 Forks []models.Repo
1183 Branches []types.Branch
1184 Tags []*types.TagReference
1185 Base string
1186 Head string
1187 Diff *types.NiceDiff
1188 DiffOpts types.DiffOpts
1189
1190 Active string
1191}
1192
1193func (p *Pages) RepoCompare(w io.Writer, params RepoCompareParams) error {
1194 params.Active = "overview"
1195 return p.executeRepo("repo/compare/compare", w, params)
1196}
1197
1198type RepoCompareNewParams struct {
1199 LoggedInUser *oauth.User
1200 RepoInfo repoinfo.RepoInfo
1201 Forks []models.Repo
1202 Branches []types.Branch
1203 Tags []*types.TagReference
1204 Base string
1205 Head string
1206
1207 Active string
1208}
1209
1210func (p *Pages) RepoCompareNew(w io.Writer, params RepoCompareNewParams) error {
1211 params.Active = "overview"
1212 return p.executeRepo("repo/compare/new", w, params)
1213}
1214
1215type RepoCompareAllowPullParams struct {
1216 LoggedInUser *oauth.User
1217 RepoInfo repoinfo.RepoInfo
1218 Base string
1219 Head string
1220}
1221
1222func (p *Pages) RepoCompareAllowPullFragment(w io.Writer, params RepoCompareAllowPullParams) error {
1223 return p.executePlain("repo/fragments/compareAllowPull", w, params)
1224}
1225
1226type RepoCompareDiffParams struct {
1227 LoggedInUser *oauth.User
1228 RepoInfo repoinfo.RepoInfo
1229 Diff types.NiceDiff
1230}
1231
1232func (p *Pages) RepoCompareDiff(w io.Writer, params RepoCompareDiffParams) error {
1233 return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, ¶ms.Diff})
1234}
1235
1236type LabelPanelParams struct {
1237 LoggedInUser *oauth.User
1238 RepoInfo repoinfo.RepoInfo
1239 Defs map[string]*models.LabelDefinition
1240 Subject string
1241 State models.LabelState
1242}
1243
1244func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error {
1245 return p.executePlain("repo/fragments/labelPanel", w, params)
1246}
1247
1248type EditLabelPanelParams struct {
1249 LoggedInUser *oauth.User
1250 RepoInfo repoinfo.RepoInfo
1251 Defs map[string]*models.LabelDefinition
1252 Subject string
1253 State models.LabelState
1254}
1255
1256func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error {
1257 return p.executePlain("repo/fragments/editLabelPanel", w, params)
1258}
1259
1260type PipelinesParams struct {
1261 LoggedInUser *oauth.User
1262 RepoInfo repoinfo.RepoInfo
1263 Pipelines []models.Pipeline
1264 Active string
1265}
1266
1267func (p *Pages) Pipelines(w io.Writer, params PipelinesParams) error {
1268 params.Active = "pipelines"
1269 return p.executeRepo("repo/pipelines/pipelines", w, params)
1270}
1271
1272type LogBlockParams struct {
1273 Id int
1274 Name string
1275 Command string
1276 Collapsed bool
1277}
1278
1279func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error {
1280 return p.executePlain("repo/pipelines/fragments/logBlock", w, params)
1281}
1282
1283type LogLineParams struct {
1284 Id int
1285 Content string
1286}
1287
1288func (p *Pages) LogLine(w io.Writer, params LogLineParams) error {
1289 return p.executePlain("repo/pipelines/fragments/logLine", w, params)
1290}
1291
1292type WorkflowParams struct {
1293 LoggedInUser *oauth.User
1294 RepoInfo repoinfo.RepoInfo
1295 Pipeline models.Pipeline
1296 Workflow string
1297 LogUrl string
1298 Active string
1299}
1300
1301func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error {
1302 params.Active = "pipelines"
1303 return p.executeRepo("repo/pipelines/workflow", w, params)
1304}
1305
1306type PutStringParams struct {
1307 LoggedInUser *oauth.User
1308 Action string
1309
1310 // this is supplied in the case of editing an existing string
1311 String db.String
1312}
1313
1314func (p *Pages) PutString(w io.Writer, params PutStringParams) error {
1315 return p.execute("strings/put", w, params)
1316}
1317
1318type StringsDashboardParams struct {
1319 LoggedInUser *oauth.User
1320 Card ProfileCard
1321 Strings []db.String
1322}
1323
1324func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error {
1325 return p.execute("strings/dashboard", w, params)
1326}
1327
1328type StringTimelineParams struct {
1329 LoggedInUser *oauth.User
1330 Strings []db.String
1331}
1332
1333func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error {
1334 return p.execute("strings/timeline", w, params)
1335}
1336
1337type SingleStringParams struct {
1338 LoggedInUser *oauth.User
1339 ShowRendered bool
1340 RenderToggle bool
1341 RenderedContents template.HTML
1342 String db.String
1343 Stats db.StringStats
1344 Owner identity.Identity
1345}
1346
1347func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
1348 var style *chroma.Style = styles.Get("catpuccin-latte")
1349
1350 if params.ShowRendered {
1351 switch markup.GetFormat(params.String.Filename) {
1352 case markup.FormatMarkdown:
1353 p.rctx.RendererType = markup.RendererTypeRepoMarkdown
1354 htmlString := p.rctx.RenderMarkdown(params.String.Contents)
1355 sanitized := p.rctx.SanitizeDefault(htmlString)
1356 params.RenderedContents = template.HTML(sanitized)
1357 }
1358 }
1359
1360 c := params.String.Contents
1361 formatter := chromahtml.New(
1362 chromahtml.InlineCode(false),
1363 chromahtml.WithLineNumbers(true),
1364 chromahtml.WithLinkableLineNumbers(true, "L"),
1365 chromahtml.Standalone(false),
1366 chromahtml.WithClasses(true),
1367 )
1368
1369 lexer := lexers.Get(filepath.Base(params.String.Filename))
1370 if lexer == nil {
1371 lexer = lexers.Fallback
1372 }
1373
1374 iterator, err := lexer.Tokenise(nil, c)
1375 if err != nil {
1376 return fmt.Errorf("chroma tokenize: %w", err)
1377 }
1378
1379 var code bytes.Buffer
1380 err = formatter.Format(&code, style, iterator)
1381 if err != nil {
1382 return fmt.Errorf("chroma format: %w", err)
1383 }
1384
1385 params.String.Contents = code.String()
1386 return p.execute("strings/string", w, params)
1387}
1388
1389func (p *Pages) Home(w io.Writer, params TimelineParams) error {
1390 return p.execute("timeline/home", w, params)
1391}
1392
1393func (p *Pages) Static() http.Handler {
1394 if p.dev {
1395 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
1396 }
1397
1398 sub, err := fs.Sub(Files, "static")
1399 if err != nil {
1400 p.logger.Error("no static dir found? that's crazy", "err", err)
1401 panic(err)
1402 }
1403 // Custom handler to apply Cache-Control headers for font files
1404 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
1405}
1406
1407func Cache(h http.Handler) http.Handler {
1408 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1409 path := strings.Split(r.URL.Path, "?")[0]
1410
1411 if strings.HasSuffix(path, ".css") {
1412 // on day for css files
1413 w.Header().Set("Cache-Control", "public, max-age=86400")
1414 } else {
1415 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
1416 }
1417 h.ServeHTTP(w, r)
1418 })
1419}
1420
1421func CssContentHash() string {
1422 cssFile, err := Files.Open("static/tw.css")
1423 if err != nil {
1424 slog.Debug("Error opening CSS file", "err", err)
1425 return ""
1426 }
1427 defer cssFile.Close()
1428
1429 hasher := sha256.New()
1430 if _, err := io.Copy(hasher, cssFile); err != nil {
1431 slog.Debug("Error hashing CSS file", "err", err)
1432 return ""
1433 }
1434
1435 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash
1436}
1437
1438func (p *Pages) Error500(w io.Writer) error {
1439 return p.execute("errors/500", w, nil)
1440}
1441
1442func (p *Pages) Error404(w io.Writer) error {
1443 return p.execute("errors/404", w, nil)
1444}
1445
1446func (p *Pages) ErrorKnot404(w io.Writer) error {
1447 return p.execute("errors/knot404", w, nil)
1448}
1449
1450func (p *Pages) Error503(w io.Writer) error {
1451 return p.execute("errors/503", w, nil)
1452}