[mirror] Command-line application for uploading a site to a git-pages server
at main 12 kB view raw
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}