[mirror] Command-line application for uploading a site to a git-pages server
1package main
2
3import (
4 "archive/tar"
5 "bytes"
6 "crypto/sha256"
7 "errors"
8 "fmt"
9 "io"
10 "io/fs"
11 "net/http"
12 "net/url"
13 "os"
14 "runtime/debug"
15 "strings"
16
17 "github.com/google/uuid"
18 "github.com/klauspost/compress/zstd"
19 "github.com/spf13/pflag"
20)
21
22// By default the version information is retrieved from VCS. If not available during build,
23// override this variable using linker flags to change the displayed version.
24// Example: `-ldflags "-X main.versionOverride=v1.2.3"`
25var versionOverride = ""
26
27func versionInfo() string {
28 version := "(unknown)"
29 if versionOverride != "" {
30 version = versionOverride
31 } else if buildInfo, ok := debug.ReadBuildInfo(); ok {
32 version = buildInfo.Main.Version
33 }
34 return fmt.Sprintf("git-pages-cli %s", version)
35}
36
37var passwordFlag = pflag.String("password", "", "password for DNS challenge authorization")
38var tokenFlag = pflag.String("token", "", "token for forge authorization")
39var challengeFlag = pflag.Bool("challenge", false, "compute DNS challenge entry from password (output zone file record)")
40var challengeBareFlag = pflag.Bool("challenge-bare", false, "compute DNS challenge entry from password (output bare TXT value)")
41var uploadGitFlag = pflag.String("upload-git", "", "replace site with contents of specified git repository")
42var uploadDirFlag = pflag.String("upload-dir", "", "replace site with contents of specified directory")
43var deleteFlag = pflag.Bool("delete", false, "delete site")
44var debugManifestFlag = pflag.Bool("debug-manifest", false, "retrieve site manifest as ProtoJSON, for debugging")
45var serverFlag = pflag.String("server", "", "hostname of server to connect to")
46var pathFlag = pflag.String("path", "", "partially update site at specified path")
47var raceFreeFlag = pflag.Bool("race-free", false, "require partial updates to be atomic")
48var verboseFlag = pflag.Bool("verbose", false, "display more information for debugging")
49var versionFlag = pflag.Bool("version", false, "display version information")
50
51func singleOperation() bool {
52 operations := 0
53 if *challengeFlag {
54 operations++
55 }
56 if *challengeBareFlag {
57 operations++
58 }
59 if *uploadDirFlag != "" {
60 operations++
61 }
62 if *uploadGitFlag != "" {
63 operations++
64 }
65 if *deleteFlag {
66 operations++
67 }
68 if *debugManifestFlag {
69 operations++
70 }
71 if *versionFlag {
72 operations++
73 }
74 return operations == 1
75}
76
77func displayFS(root fs.FS, prefix string) error {
78 return fs.WalkDir(root, ".", func(name string, entry fs.DirEntry, err error) error {
79 if err != nil {
80 return err
81 }
82 switch {
83 case entry.Type().IsDir():
84 fmt.Fprintf(os.Stderr, "dir %s%s\n", prefix, name)
85 case entry.Type().IsRegular():
86 fmt.Fprintf(os.Stderr, "file %s%s\n", prefix, name)
87 case entry.Type() == fs.ModeSymlink:
88 fmt.Fprintf(os.Stderr, "symlink %s%s\n", prefix, name)
89 default:
90 fmt.Fprintf(os.Stderr, "other %s%s\n", prefix, name)
91 }
92 return nil
93 })
94}
95
96func archiveFS(writer io.Writer, root fs.FS, prefix string) (err error) {
97 zstdWriter, _ := zstd.NewWriter(writer)
98 tarWriter := tar.NewWriter(zstdWriter)
99 if err = fs.WalkDir(root, ".", func(name string, entry fs.DirEntry, err error) error {
100 if err != nil {
101 return err
102 }
103 fileInfo, err := entry.Info()
104 if err != nil {
105 return err
106 }
107 var tarName string
108 if prefix == "" && name == "." {
109 return nil
110 } else if name == "." {
111 tarName = prefix
112 } else {
113 tarName = prefix + name
114 }
115 var file io.ReadCloser
116 var linkTarget string
117 switch {
118 case entry.Type().IsDir():
119 name += "/"
120 case entry.Type().IsRegular():
121 if file, err = root.Open(name); err != nil {
122 return err
123 }
124 defer file.Close()
125 case entry.Type() == fs.ModeSymlink:
126 if linkTarget, err = fs.ReadLink(root, name); err != nil {
127 return err
128 }
129 default:
130 return errors.New("tar: cannot add non-regular file")
131 }
132 header, err := tar.FileInfoHeader(fileInfo, linkTarget)
133 if err != nil {
134 return err
135 }
136 header.Name = tarName
137 if err = tarWriter.WriteHeader(header); err != nil {
138 return err
139 }
140 if file != nil {
141 _, err = io.Copy(tarWriter, file)
142 }
143 return err
144 }); err != nil {
145 return
146 }
147 if err = tarWriter.Close(); err != nil {
148 return
149 }
150 if err = zstdWriter.Close(); err != nil {
151 return
152 }
153 return
154}
155
156func makeWhiteout(path string) (reader io.Reader) {
157 buffer := &bytes.Buffer{}
158 tarWriter := tar.NewWriter(buffer)
159 tarWriter.WriteHeader(&tar.Header{
160 Typeflag: tar.TypeChar,
161 Name: path,
162 })
163 tarWriter.Flush()
164 return buffer
165}
166
167const usageExitCode = 125
168
169func usage() {
170 fmt.Fprintf(os.Stderr,
171 "Usage: %s <site-url> {--challenge|--upload-git url|--upload-dir path|--delete} [options...]\n",
172 os.Args[0],
173 )
174 pflag.PrintDefaults()
175}
176
177func main() {
178 pflag.Usage = usage
179 pflag.Parse()
180 if !singleOperation() || (!*versionFlag && len(pflag.Args()) != 1) {
181 pflag.Usage()
182 os.Exit(usageExitCode)
183 }
184
185 if *versionFlag {
186 fmt.Fprintln(os.Stdout, versionInfo())
187 os.Exit(0)
188 }
189
190 if *passwordFlag != "" && *tokenFlag != "" {
191 fmt.Fprintf(os.Stderr, "--password and --token are mutually exclusive")
192 os.Exit(usageExitCode)
193 }
194
195 var pathPrefix string
196 if *pathFlag != "" {
197 if *uploadDirFlag == "" && !*deleteFlag {
198 fmt.Fprintf(os.Stderr, "--path requires --upload-dir or --delete")
199 os.Exit(usageExitCode)
200 } else {
201 pathPrefix = strings.Trim(*pathFlag, "/") + "/"
202 }
203 }
204
205 var err error
206 siteURL, err := url.Parse(pflag.Args()[0])
207 if err != nil {
208 fmt.Fprintf(os.Stderr, "error: invalid site URL: %s\n", err)
209 os.Exit(1)
210 }
211
212 var request *http.Request
213 switch {
214 case *challengeFlag || *challengeBareFlag:
215 if *passwordFlag == "" {
216 *passwordFlag = uuid.NewString()
217 fmt.Fprintf(os.Stderr, "password: %s\n", *passwordFlag)
218 }
219
220 challenge := sha256.Sum256(fmt.Appendf(nil, "%s %s", siteURL.Hostname(), *passwordFlag))
221 if *challengeBareFlag {
222 fmt.Fprintf(os.Stdout, "%x\n", challenge)
223 } else {
224 fmt.Fprintf(os.Stdout, "_git-pages-challenge.%s. 3600 IN TXT \"%x\"\n", siteURL.Hostname(), challenge)
225 }
226 os.Exit(0)
227
228 case *uploadGitFlag != "":
229 uploadGitUrl, err := url.Parse(*uploadGitFlag)
230 if err != nil {
231 fmt.Fprintf(os.Stderr, "error: invalid repository URL: %s\n", err)
232 os.Exit(1)
233 }
234
235 requestBody := []byte(uploadGitUrl.String())
236 request, err = http.NewRequest("PUT", siteURL.String(), bytes.NewReader(requestBody))
237 if err != nil {
238 fmt.Fprintf(os.Stderr, "error: %s\n", err)
239 os.Exit(1)
240 }
241 request.Header.Add("Content-Type", "application/x-www-form-urlencoded")
242
243 case *uploadDirFlag != "":
244 uploadDirFS, err := os.OpenRoot(*uploadDirFlag)
245 if err != nil {
246 fmt.Fprintf(os.Stderr, "error: invalid directory: %s\n", err)
247 os.Exit(1)
248 }
249
250 if *verboseFlag {
251 err := displayFS(uploadDirFS.FS(), pathPrefix)
252 if err != nil {
253 fmt.Fprintf(os.Stderr, "error: %s\n", err)
254 os.Exit(1)
255 }
256 }
257
258 // Stream archive data without ever loading the entire working set into RAM.
259 reader, writer := io.Pipe()
260 go func() {
261 err = archiveFS(writer, uploadDirFS.FS(), pathPrefix)
262 if err != nil {
263 fmt.Fprintf(os.Stderr, "error: %s\n", err)
264 os.Exit(1)
265 }
266 writer.Close()
267 }()
268
269 if *pathFlag == "" {
270 request, err = http.NewRequest("PUT", siteURL.String(), reader)
271 } else {
272 request, err = http.NewRequest("PATCH", siteURL.String(), reader)
273 }
274 if err != nil {
275 fmt.Fprintf(os.Stderr, "error: %s\n", err)
276 os.Exit(1)
277 }
278 request.ContentLength = -1
279 request.Header.Add("Content-Type", "application/x-tar+zstd")
280
281 case *deleteFlag:
282 if *pathFlag == "" {
283 request, err = http.NewRequest("DELETE", siteURL.String(), nil)
284 if err != nil {
285 fmt.Fprintf(os.Stderr, "error: %s\n", err)
286 os.Exit(1)
287 }
288 } else {
289 request, err = http.NewRequest("PATCH", siteURL.String(), makeWhiteout(pathPrefix))
290 if err != nil {
291 fmt.Fprintf(os.Stderr, "error: %s\n", err)
292 os.Exit(1)
293 }
294 request.Header.Add("Content-Type", "application/x-tar")
295 }
296
297 case *debugManifestFlag:
298 manifestURL := siteURL.ResolveReference(&url.URL{Path: ".git-pages/manifest.json"})
299 request, err = http.NewRequest("GET", manifestURL.String(), nil)
300 if err != nil {
301 fmt.Fprintf(os.Stderr, "error: %s\n", err)
302 os.Exit(1)
303 }
304
305 default:
306 panic("no operation chosen")
307 }
308 request.Header.Add("User-Agent", versionInfo())
309 if request.Method == "PATCH" {
310 if *raceFreeFlag {
311 request.Header.Add("Race-Free", "yes")
312 } else {
313 request.Header.Add("Race-Free", "no")
314 }
315 }
316 switch {
317 case *passwordFlag != "":
318 request.Header.Add("Authorization", fmt.Sprintf("Pages %s", *passwordFlag))
319 case *tokenFlag != "":
320 request.Header.Add("Forge-Authorization", fmt.Sprintf("token %s", *tokenFlag))
321 }
322 if *serverFlag != "" {
323 // Send the request to `--server` host, but set the `Host:` header to the site host.
324 // This allows first-time publishing to proceed without the git-pages server yet having
325 // a TLS certificate for the site host (which has a circular dependency on completion of
326 // first-time publishing).
327 newURL := *request.URL
328 newURL.Host = *serverFlag
329 request.URL = &newURL
330 request.Header.Set("Host", siteURL.Host)
331 }
332
333 response, err := http.DefaultClient.Do(request)
334 if err != nil {
335 fmt.Fprintf(os.Stderr, "error: %s\n", err)
336 os.Exit(1)
337 }
338 if *verboseFlag {
339 fmt.Fprintf(os.Stderr, "server: %s\n", response.Header.Get("Server"))
340 }
341 if *debugManifestFlag {
342 if response.StatusCode == 200 {
343 io.Copy(os.Stdout, response.Body)
344 fmt.Fprintf(os.Stdout, "\n")
345 } else {
346 io.Copy(os.Stderr, response.Body)
347 os.Exit(1)
348 }
349 } else { // an update operation
350 if response.StatusCode == 200 {
351 fmt.Fprintf(os.Stdout, "result: %s\n", response.Header.Get("Update-Result"))
352 io.Copy(os.Stdout, response.Body)
353 } else {
354 fmt.Fprintf(os.Stderr, "result: error\n")
355 io.Copy(os.Stderr, response.Body)
356 os.Exit(1)
357 }
358 }
359}