1package pages
2
3import (
4 "bytes"
5 "crypto/sha256"
6 "embed"
7 "encoding/hex"
8 "fmt"
9 "html/template"
10 "io"
11 "io/fs"
12 "log"
13 "net/http"
14 "path"
15 "path/filepath"
16 "slices"
17 "strings"
18
19 "github.com/alecthomas/chroma/v2"
20 chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
21 "github.com/alecthomas/chroma/v2/lexers"
22 "github.com/alecthomas/chroma/v2/styles"
23 "github.com/bluesky-social/indigo/atproto/syntax"
24 "github.com/microcosm-cc/bluemonday"
25 "tangled.sh/tangled.sh/core/appview/auth"
26 "tangled.sh/tangled.sh/core/appview/db"
27 "tangled.sh/tangled.sh/core/appview/pages/markup"
28 "tangled.sh/tangled.sh/core/appview/state/userutil"
29 "tangled.sh/tangled.sh/core/types"
30)
31
32//go:embed templates/* static
33var Files embed.FS
34
35type Pages struct {
36 t map[string]*template.Template
37}
38
39func NewPages() *Pages {
40 templates := make(map[string]*template.Template)
41
42 var fragmentPaths []string
43 // First, collect all fragment paths
44 err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error {
45 if err != nil {
46 return err
47 }
48
49 if d.IsDir() {
50 return nil
51 }
52
53 if !strings.HasSuffix(path, ".html") {
54 return nil
55 }
56
57 if !strings.Contains(path, "fragments/") {
58 return nil
59 }
60
61 name := strings.TrimPrefix(path, "templates/")
62 name = strings.TrimSuffix(name, ".html")
63
64 tmpl, err := template.New(name).
65 Funcs(funcMap()).
66 ParseFS(Files, path)
67 if err != nil {
68 log.Fatalf("setting up fragment: %v", err)
69 }
70
71 templates[name] = tmpl
72 fragmentPaths = append(fragmentPaths, path)
73 log.Printf("loaded fragment: %s", name)
74 return nil
75 })
76 if err != nil {
77 log.Fatalf("walking template dir for fragments: %v", err)
78 }
79
80 // Then walk through and setup the rest of the templates
81 err = fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error {
82 if err != nil {
83 return err
84 }
85
86 if d.IsDir() {
87 return nil
88 }
89
90 if !strings.HasSuffix(path, "html") {
91 return nil
92 }
93
94 // Skip fragments as they've already been loaded
95 if strings.Contains(path, "fragments/") {
96 return nil
97 }
98
99 // Skip layouts
100 if strings.Contains(path, "layouts/") {
101 return nil
102 }
103
104 name := strings.TrimPrefix(path, "templates/")
105 name = strings.TrimSuffix(name, ".html")
106
107 // Add the page template on top of the base
108 allPaths := []string{}
109 allPaths = append(allPaths, "templates/layouts/*.html")
110 allPaths = append(allPaths, fragmentPaths...)
111 allPaths = append(allPaths, path)
112 tmpl, err := template.New(name).
113 Funcs(funcMap()).
114 ParseFS(Files, allPaths...)
115 if err != nil {
116 return fmt.Errorf("setting up template: %w", err)
117 }
118
119 templates[name] = tmpl
120 log.Printf("loaded template: %s", name)
121 return nil
122 })
123 if err != nil {
124 log.Fatalf("walking template dir: %v", err)
125 }
126
127 log.Printf("total templates loaded: %d", len(templates))
128
129 return &Pages{
130 t: templates,
131 }
132}
133
134type LoginParams struct {
135}
136
137func (p *Pages) execute(name string, w io.Writer, params any) error {
138 return p.t[name].ExecuteTemplate(w, "layouts/base", params)
139}
140
141func (p *Pages) executePlain(name string, w io.Writer, params any) error {
142 return p.t[name].Execute(w, params)
143}
144
145func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
146 return p.t[name].ExecuteTemplate(w, "layouts/repobase", params)
147}
148
149func (p *Pages) Login(w io.Writer, params LoginParams) error {
150 return p.executePlain("user/login", w, params)
151}
152
153type TimelineParams struct {
154 LoggedInUser *auth.User
155 Timeline []db.TimelineEvent
156 DidHandleMap map[string]string
157}
158
159func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
160 return p.execute("timeline", w, params)
161}
162
163type SettingsParams struct {
164 LoggedInUser *auth.User
165 PubKeys []db.PublicKey
166 Emails []db.Email
167}
168
169func (p *Pages) Settings(w io.Writer, params SettingsParams) error {
170 return p.execute("settings", w, params)
171}
172
173type KnotsParams struct {
174 LoggedInUser *auth.User
175 Registrations []db.Registration
176}
177
178func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
179 return p.execute("knots", w, params)
180}
181
182type KnotParams struct {
183 LoggedInUser *auth.User
184 DidHandleMap map[string]string
185 Registration *db.Registration
186 Members []string
187 IsOwner bool
188}
189
190func (p *Pages) Knot(w io.Writer, params KnotParams) error {
191 return p.execute("knot", w, params)
192}
193
194type NewRepoParams struct {
195 LoggedInUser *auth.User
196 Knots []string
197}
198
199func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error {
200 return p.execute("repo/new", w, params)
201}
202
203type ForkRepoParams struct {
204 LoggedInUser *auth.User
205 Knots []string
206 RepoInfo RepoInfo
207}
208
209func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error {
210 return p.execute("repo/fork", w, params)
211}
212
213type ProfilePageParams struct {
214 LoggedInUser *auth.User
215 UserDid string
216 UserHandle string
217 Repos []db.Repo
218 CollaboratingRepos []db.Repo
219 ProfileStats ProfileStats
220 FollowStatus db.FollowStatus
221 AvatarUri string
222 ProfileTimeline *db.ProfileTimeline
223
224 DidHandleMap map[string]string
225}
226
227type ProfileStats struct {
228 Followers int
229 Following int
230}
231
232func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error {
233 return p.execute("user/profile", w, params)
234}
235
236type FollowFragmentParams struct {
237 UserDid string
238 FollowStatus db.FollowStatus
239}
240
241func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
242 return p.executePlain("user/fragments/follow", w, params)
243}
244
245type RepoActionsFragmentParams struct {
246 IsStarred bool
247 RepoAt syntax.ATURI
248 Stats db.RepoStats
249}
250
251func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error {
252 return p.executePlain("repo/fragments/repoActions", w, params)
253}
254
255type RepoDescriptionParams struct {
256 RepoInfo RepoInfo
257}
258
259func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
260 return p.executePlain("repo/fragments/editRepoDescription", w, params)
261}
262
263func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
264 return p.executePlain("repo/fragments/repoDescription", w, params)
265}
266
267type RepoInfo struct {
268 Name string
269 OwnerDid string
270 OwnerHandle string
271 Description string
272 Knot string
273 RepoAt syntax.ATURI
274 IsStarred bool
275 Stats db.RepoStats
276 Roles RolesInRepo
277 Source *db.Repo
278 SourceHandle string
279 DisableFork bool
280}
281
282type RolesInRepo struct {
283 Roles []string
284}
285
286func (r RolesInRepo) SettingsAllowed() bool {
287 return slices.Contains(r.Roles, "repo:settings")
288}
289
290func (r RolesInRepo) CollaboratorInviteAllowed() bool {
291 return slices.Contains(r.Roles, "repo:invite")
292}
293
294func (r RolesInRepo) RepoDeleteAllowed() bool {
295 return slices.Contains(r.Roles, "repo:delete")
296}
297
298func (r RolesInRepo) IsOwner() bool {
299 return slices.Contains(r.Roles, "repo:owner")
300}
301
302func (r RolesInRepo) IsCollaborator() bool {
303 return slices.Contains(r.Roles, "repo:collaborator")
304}
305
306func (r RolesInRepo) IsPushAllowed() bool {
307 return slices.Contains(r.Roles, "repo:push")
308}
309
310func (r RepoInfo) OwnerWithAt() string {
311 if r.OwnerHandle != "" {
312 return fmt.Sprintf("@%s", r.OwnerHandle)
313 } else {
314 return r.OwnerDid
315 }
316}
317
318func (r RepoInfo) FullName() string {
319 return path.Join(r.OwnerWithAt(), r.Name)
320}
321
322func (r RepoInfo) OwnerWithoutAt() string {
323 if strings.HasPrefix(r.OwnerWithAt(), "@") {
324 return strings.TrimPrefix(r.OwnerWithAt(), "@")
325 } else {
326 return userutil.FlattenDid(r.OwnerDid)
327 }
328}
329
330func (r RepoInfo) FullNameWithoutAt() string {
331 return path.Join(r.OwnerWithoutAt(), r.Name)
332}
333
334func (r RepoInfo) GetTabs() [][]string {
335 tabs := [][]string{
336 {"overview", "/", "square-chart-gantt"},
337 {"issues", "/issues", "circle-dot"},
338 {"pulls", "/pulls", "git-pull-request"},
339 }
340
341 if r.Roles.SettingsAllowed() {
342 tabs = append(tabs, []string{"settings", "/settings", "cog"})
343 }
344
345 return tabs
346}
347
348// each tab on a repo could have some metadata:
349//
350// issues -> number of open issues etc.
351// settings -> a warning icon to setup branch protection? idk
352//
353// we gather these bits of info here, because go templates
354// are difficult to program in
355func (r RepoInfo) TabMetadata() map[string]any {
356 meta := make(map[string]any)
357
358 if r.Stats.PullCount.Open > 0 {
359 meta["pulls"] = r.Stats.PullCount.Open
360 }
361
362 if r.Stats.IssueCount.Open > 0 {
363 meta["issues"] = r.Stats.IssueCount.Open
364 }
365
366 // more stuff?
367
368 return meta
369}
370
371type RepoIndexParams struct {
372 LoggedInUser *auth.User
373 RepoInfo RepoInfo
374 Active string
375 TagMap map[string][]string
376 types.RepoIndexResponse
377 HTMLReadme template.HTML
378 Raw bool
379 EmailToDidOrHandle map[string]string
380}
381
382func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error {
383 params.Active = "overview"
384 if params.IsEmpty {
385 return p.executeRepo("repo/empty", w, params)
386 }
387
388 if params.ReadmeFileName != "" {
389 var htmlString string
390 ext := filepath.Ext(params.ReadmeFileName)
391 switch ext {
392 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
393 htmlString = markup.RenderMarkdown(params.Readme)
394 params.Raw = false
395 params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString))
396 default:
397 htmlString = string(params.Readme)
398 params.Raw = true
399 params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString))
400 }
401 }
402
403 return p.executeRepo("repo/index", w, params)
404}
405
406type RepoLogParams struct {
407 LoggedInUser *auth.User
408 RepoInfo RepoInfo
409 types.RepoLogResponse
410 Active string
411 EmailToDidOrHandle map[string]string
412}
413
414func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
415 params.Active = "overview"
416 return p.execute("repo/log", w, params)
417}
418
419type RepoCommitParams struct {
420 LoggedInUser *auth.User
421 RepoInfo RepoInfo
422 Active string
423 types.RepoCommitResponse
424 EmailToDidOrHandle map[string]string
425}
426
427func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
428 params.Active = "overview"
429 return p.executeRepo("repo/commit", w, params)
430}
431
432type RepoTreeParams struct {
433 LoggedInUser *auth.User
434 RepoInfo RepoInfo
435 Active string
436 BreadCrumbs [][]string
437 BaseTreeLink string
438 BaseBlobLink string
439 types.RepoTreeResponse
440}
441
442type RepoTreeStats struct {
443 NumFolders uint64
444 NumFiles uint64
445}
446
447func (r RepoTreeParams) TreeStats() RepoTreeStats {
448 numFolders, numFiles := 0, 0
449 for _, f := range r.Files {
450 if !f.IsFile {
451 numFolders += 1
452 } else if f.IsFile {
453 numFiles += 1
454 }
455 }
456
457 return RepoTreeStats{
458 NumFolders: uint64(numFolders),
459 NumFiles: uint64(numFiles),
460 }
461}
462
463func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
464 params.Active = "overview"
465 return p.execute("repo/tree", w, params)
466}
467
468type RepoBranchesParams struct {
469 LoggedInUser *auth.User
470 RepoInfo RepoInfo
471 types.RepoBranchesResponse
472}
473
474func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error {
475 return p.executeRepo("repo/branches", w, params)
476}
477
478type RepoTagsParams struct {
479 LoggedInUser *auth.User
480 RepoInfo RepoInfo
481 types.RepoTagsResponse
482}
483
484func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
485 return p.executeRepo("repo/tags", w, params)
486}
487
488type RepoBlobParams struct {
489 LoggedInUser *auth.User
490 RepoInfo RepoInfo
491 Active string
492 BreadCrumbs [][]string
493 ShowRendered bool
494 RenderToggle bool
495 RenderedContents template.HTML
496 types.RepoBlobResponse
497}
498
499func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
500 var style *chroma.Style = styles.Get("catpuccin-latte")
501
502 if params.ShowRendered {
503 switch markup.GetFormat(params.Path) {
504 case markup.FormatMarkdown:
505 params.RenderedContents = template.HTML(markup.RenderMarkdown(params.Contents))
506 }
507 }
508
509 if params.Lines < 5000 {
510 c := params.Contents
511 formatter := chromahtml.New(
512 chromahtml.InlineCode(false),
513 chromahtml.WithLineNumbers(true),
514 chromahtml.WithLinkableLineNumbers(true, "L"),
515 chromahtml.Standalone(false),
516 chromahtml.WithClasses(true),
517 )
518
519 lexer := lexers.Get(filepath.Base(params.Path))
520 if lexer == nil {
521 lexer = lexers.Fallback
522 }
523
524 iterator, err := lexer.Tokenise(nil, c)
525 if err != nil {
526 return fmt.Errorf("chroma tokenize: %w", err)
527 }
528
529 var code bytes.Buffer
530 err = formatter.Format(&code, style, iterator)
531 if err != nil {
532 return fmt.Errorf("chroma format: %w", err)
533 }
534
535 params.Contents = code.String()
536 }
537
538 params.Active = "overview"
539 return p.executeRepo("repo/blob", w, params)
540}
541
542type Collaborator struct {
543 Did string
544 Handle string
545 Role string
546}
547
548type RepoSettingsParams struct {
549 LoggedInUser *auth.User
550 RepoInfo RepoInfo
551 Collaborators []Collaborator
552 Active string
553 Branches []string
554 DefaultBranch string
555 // TODO: use repoinfo.roles
556 IsCollaboratorInviteAllowed bool
557}
558
559func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error {
560 params.Active = "settings"
561 return p.executeRepo("repo/settings", w, params)
562}
563
564type RepoIssuesParams struct {
565 LoggedInUser *auth.User
566 RepoInfo RepoInfo
567 Active string
568 Issues []db.Issue
569 DidHandleMap map[string]string
570
571 FilteringByOpen bool
572}
573
574func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
575 params.Active = "issues"
576 return p.executeRepo("repo/issues/issues", w, params)
577}
578
579type RepoSingleIssueParams struct {
580 LoggedInUser *auth.User
581 RepoInfo RepoInfo
582 Active string
583 Issue db.Issue
584 Comments []db.Comment
585 IssueOwnerHandle string
586 DidHandleMap map[string]string
587
588 State string
589}
590
591func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
592 params.Active = "issues"
593 if params.Issue.Open {
594 params.State = "open"
595 } else {
596 params.State = "closed"
597 }
598 return p.execute("repo/issues/issue", w, params)
599}
600
601type RepoNewIssueParams struct {
602 LoggedInUser *auth.User
603 RepoInfo RepoInfo
604 Active string
605}
606
607func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
608 params.Active = "issues"
609 return p.executeRepo("repo/issues/new", w, params)
610}
611
612type EditIssueCommentParams struct {
613 LoggedInUser *auth.User
614 RepoInfo RepoInfo
615 Issue *db.Issue
616 Comment *db.Comment
617}
618
619func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
620 return p.executePlain("repo/issues/fragments/editIssueComment", w, params)
621}
622
623type SingleIssueCommentParams struct {
624 LoggedInUser *auth.User
625 DidHandleMap map[string]string
626 RepoInfo RepoInfo
627 Issue *db.Issue
628 Comment *db.Comment
629}
630
631func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error {
632 return p.executePlain("repo/issues/fragments/issueComment", w, params)
633}
634
635type RepoNewPullParams struct {
636 LoggedInUser *auth.User
637 RepoInfo RepoInfo
638 Branches []types.Branch
639 Active string
640}
641
642func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error {
643 params.Active = "pulls"
644 return p.executeRepo("repo/pulls/new", w, params)
645}
646
647type RepoPullsParams struct {
648 LoggedInUser *auth.User
649 RepoInfo RepoInfo
650 Pulls []*db.Pull
651 Active string
652 DidHandleMap map[string]string
653 FilteringBy db.PullState
654}
655
656func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
657 params.Active = "pulls"
658 return p.executeRepo("repo/pulls/pulls", w, params)
659}
660
661type ResubmitResult uint64
662
663const (
664 ShouldResubmit ResubmitResult = iota
665 ShouldNotResubmit
666 Unknown
667)
668
669func (r ResubmitResult) Yes() bool {
670 return r == ShouldResubmit
671}
672func (r ResubmitResult) No() bool {
673 return r == ShouldNotResubmit
674}
675func (r ResubmitResult) Unknown() bool {
676 return r == Unknown
677}
678
679type RepoSinglePullParams struct {
680 LoggedInUser *auth.User
681 RepoInfo RepoInfo
682 Active string
683 DidHandleMap map[string]string
684 Pull *db.Pull
685 PullSourceRepo *db.Repo
686 MergeCheck types.MergeCheckResponse
687 ResubmitCheck ResubmitResult
688}
689
690func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
691 params.Active = "pulls"
692 return p.executeRepo("repo/pulls/pull", w, params)
693}
694
695type RepoPullPatchParams struct {
696 LoggedInUser *auth.User
697 DidHandleMap map[string]string
698 RepoInfo RepoInfo
699 Pull *db.Pull
700 Diff types.NiceDiff
701 Round int
702 Submission *db.PullSubmission
703}
704
705// this name is a mouthful
706func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error {
707 return p.execute("repo/pulls/patch", w, params)
708}
709
710type PullPatchUploadParams struct {
711 RepoInfo RepoInfo
712}
713
714func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error {
715 return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params)
716}
717
718type PullCompareBranchesParams struct {
719 RepoInfo RepoInfo
720 Branches []types.Branch
721}
722
723func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error {
724 return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params)
725}
726
727type PullCompareForkParams struct {
728 RepoInfo RepoInfo
729 Forks []db.Repo
730}
731
732func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error {
733 return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params)
734}
735
736type PullCompareForkBranchesParams struct {
737 RepoInfo RepoInfo
738 SourceBranches []types.Branch
739 TargetBranches []types.Branch
740}
741
742func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error {
743 return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params)
744}
745
746type PullResubmitParams struct {
747 LoggedInUser *auth.User
748 RepoInfo RepoInfo
749 Pull *db.Pull
750 SubmissionId int
751}
752
753func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error {
754 return p.executePlain("repo/pulls/fragments/pullResubmit", w, params)
755}
756
757type PullActionsParams struct {
758 LoggedInUser *auth.User
759 RepoInfo RepoInfo
760 Pull *db.Pull
761 RoundNumber int
762 MergeCheck types.MergeCheckResponse
763 ResubmitCheck ResubmitResult
764}
765
766func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
767 return p.executePlain("repo/pulls/fragments/pullActions", w, params)
768}
769
770type PullNewCommentParams struct {
771 LoggedInUser *auth.User
772 RepoInfo RepoInfo
773 Pull *db.Pull
774 RoundNumber int
775}
776
777func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error {
778 return p.executePlain("repo/pulls/fragments/pullNewComment", w, params)
779}
780
781func (p *Pages) Static() http.Handler {
782 sub, err := fs.Sub(Files, "static")
783 if err != nil {
784 log.Fatalf("no static dir found? that's crazy: %v", err)
785 }
786 // Custom handler to apply Cache-Control headers for font files
787 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
788}
789
790func Cache(h http.Handler) http.Handler {
791 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
792 path := strings.Split(r.URL.Path, "?")[0]
793
794 if strings.HasSuffix(path, ".css") {
795 // on day for css files
796 w.Header().Set("Cache-Control", "public, max-age=86400")
797 } else {
798 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
799 }
800 h.ServeHTTP(w, r)
801 })
802}
803
804func CssContentHash() string {
805 cssFile, err := Files.Open("static/tw.css")
806 if err != nil {
807 log.Printf("Error opening CSS file: %v", err)
808 return ""
809 }
810 defer cssFile.Close()
811
812 hasher := sha256.New()
813 if _, err := io.Copy(hasher, cssFile); err != nil {
814 log.Printf("Error hashing CSS file: %v", err)
815 return ""
816 }
817
818 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash
819}
820
821func (p *Pages) Error500(w io.Writer) error {
822 return p.execute("errors/500", w, nil)
823}
824
825func (p *Pages) Error404(w io.Writer) error {
826 return p.execute("errors/404", w, nil)
827}
828
829func (p *Pages) Error503(w io.Writer) error {
830 return p.execute("errors/503", w, nil)
831}