[mirror] Command-line application for uploading a site to a git-pages server
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at v1.4.0 359 lines 9.8 kB view raw
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}