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