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