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