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