export/export.go
1// Package export renders the whole site to static files by crawling
2// the HTTP mux in-process. The output is suitable for any static host
3// (e.g. Cloudflare Pages with output directory pointing here).
4package export
5
6import (
7 "fmt"
8 "log"
9 "net/http/httptest"
10 "os"
11 "path"
12 "path/filepath"
13
14 "github.com/jxc2000-b/git-preview/config"
15 "github.com/jxc2000-b/git-preview/git"
16 "github.com/jxc2000-b/git-preview/routes"
17 "github.com/jxc2000-b/git-preview/showcase"
18 securejoin "github.com/cyphar/filepath-securejoin"
19)
20
21// Run exports the site for config c into outDir.
22func Run(c *config.Config, outDir string) error {
23 mux := routes.Handlers(c)
24
25 fetch := func(urlPath string) ([]byte, int, string) {
26 req := httptest.NewRequest("GET", urlPath, nil)
27 rec := httptest.NewRecorder()
28 mux.ServeHTTP(rec, req)
29 return rec.Body.Bytes(), rec.Code, rec.Header().Get("Content-Type")
30 }
31
32 // writePage stores an HTML page as <urlPath>/index.html so static
33 // hosts serve it at the same URL the live server used.
34 writePage := func(urlPath string) error {
35 body, code, _ := fetch(urlPath)
36 if code != 200 {
37 return fmt.Errorf("GET %s: status %d", urlPath, code)
38 }
39 rel := path.Clean("/" + urlPath)[1:]
40 fp := filepath.Join(outDir, filepath.FromSlash(rel), "index.html")
41 if rel == "" {
42 fp = filepath.Join(outDir, "index.html")
43 }
44 if err := os.MkdirAll(filepath.Dir(fp), 0755); err != nil {
45 return err
46 }
47 return os.WriteFile(fp, body, 0644)
48 }
49
50 // writeAsset stores a response at its literal path (real filename,
51 // real extension) — used for showcase media and static files.
52 writeAsset := func(urlPath string) error {
53 body, code, _ := fetch(urlPath)
54 if code != 200 {
55 return fmt.Errorf("GET %s: status %d", urlPath, code)
56 }
57 fp := filepath.Join(outDir, filepath.FromSlash(path.Clean("/"+urlPath)[1:]))
58 if err := os.MkdirAll(filepath.Dir(fp), 0755); err != nil {
59 return err
60 }
61 return os.WriteFile(fp, body, 0644)
62 }
63
64 if err := os.MkdirAll(outDir, 0755); err != nil {
65 return err
66 }
67
68 if err := writePage("/"); err != nil {
69 return err
70 }
71
72 // Static assets, straight from the static dir.
73 statics, err := os.ReadDir(c.Dirs.Static)
74 if err != nil {
75 return fmt.Errorf("static dir: %w", err)
76 }
77 for _, s := range statics {
78 if s.IsDir() {
79 continue
80 }
81 if err := writeAsset("/static/" + s.Name()); err != nil {
82 return err
83 }
84 }
85
86 // Repos.
87 dirs, err := os.ReadDir(c.Repo.ScanPath)
88 if err != nil {
89 return fmt.Errorf("scan path: %w", err)
90 }
91
92 var headers []string
93
94 for _, dir := range dirs {
95 name := dir.Name()
96 if !dir.IsDir() || ignored(c, name) {
97 continue
98 }
99
100 repoPath, err := securejoin.SecureJoin(c.Repo.ScanPath, name)
101 if err != nil {
102 return err
103 }
104
105 gr, err := git.Open(repoPath, "")
106 if err != nil {
107 log.Printf("skipping %s: %s", name, err)
108 continue
109 }
110
111 ref, err := gr.FindMainBranch(c.Repo.MainBranch)
112 if err != nil {
113 log.Printf("skipping %s: %s", name, err)
114 continue
115 }
116 // Re-open at the named ref so blob/tree URLs match live ones.
117 gr, err = git.Open(repoPath, ref)
118 if err != nil {
119 return err
120 }
121
122 base := "/" + name
123 if err := writePage(base); err != nil {
124 return err
125 }
126 if err := writePage(base + "/log/" + ref); err != nil {
127 return err
128 }
129
130 // Tree and blob pages.
131 var walk func(prefix string) error
132 walk = func(prefix string) error {
133 entries, err := gr.FileTree(prefix)
134 if err != nil {
135 return err
136 }
137 if err := writePage(base + "/tree/" + ref + "/" + prefix); err != nil {
138 return err
139 }
140 for _, e := range entries {
141 p := path.Join(prefix, e.Name)
142 if prefix == "" && e.Name == showcase.Dir {
143 continue
144 }
145 if e.IsFile {
146 if err := writePage(base + "/blob/" + ref + "/" + p); err != nil {
147 return err
148 }
149 } else {
150 if err := walk(p); err != nil {
151 return err
152 }
153 }
154 }
155 return nil
156 }
157 if err := walk(""); err != nil {
158 return err
159 }
160
161 // Commit pages.
162 commits, err := gr.Commits()
163 if err != nil {
164 return err
165 }
166 for _, cm := range commits {
167 if err := writePage(base + "/commit/" + cm.Hash.String()); err != nil {
168 return err
169 }
170 }
171
172 // Showcase assets referenced by the manifest.
173 sc, err := showcase.Load(gr)
174 if err != nil {
175 log.Printf("showcase manifest for %s: %s", name, err)
176 }
177 if sc != nil {
178 seen := map[string]bool{}
179 var assets []showcase.Asset
180 assets = append(assets, sc.Landing...)
181 for _, fe := range sc.Files {
182 assets = append(assets, fe.Assets...)
183 }
184 for _, as := range sc.Commits {
185 assets = append(assets, as...)
186 }
187 for _, a := range assets {
188 if seen[a.Asset] {
189 continue
190 }
191 seen[a.Asset] = true
192 if err := writeAsset(base + "/showcase/" + ref + "/" + a.Asset); err != nil {
193 return err
194 }
195 }
196 // Sandbox interactive widgets on the static host too.
197 headers = append(headers,
198 fmt.Sprintf("%s/showcase/%s/*", base, ref),
199 " Content-Security-Policy: sandbox allow-scripts",
200 " X-Content-Type-Options: nosniff",
201 "")
202 }
203 }
204
205 if len(headers) > 0 {
206 out := ""
207 for _, l := range headers {
208 out += l + "\n"
209 }
210 if err := os.WriteFile(filepath.Join(outDir, "_headers"), []byte(out), 0644); err != nil {
211 return err
212 }
213 }
214
215 return nil
216}
217
218func ignored(c *config.Config, name string) bool {
219 for _, i := range c.Repo.Ignore {
220 if name == i {
221 return true
222 }
223 }
224 return false
225}