[mirror] Command-line application for uploading a site to a git-pages server
at v1.3.0 7.3 kB view raw
1package main 2 3import ( 4 "archive/tar" 5 "bytes" 6 "crypto/sha256" 7 "fmt" 8 "io" 9 "io/fs" 10 "net/http" 11 "net/url" 12 "os" 13 "runtime/debug" 14 15 "github.com/google/uuid" 16 "github.com/klauspost/compress/zstd" 17 "github.com/spf13/pflag" 18) 19 20// By default the version information is retrieved from VCS. If not available during build, 21// override this variable using linker flags to change the displayed version. 22// Example: `-ldflags "-X main.versionOverride=v1.2.3"` 23var versionOverride = "" 24 25func versionInfo() string { 26 version := "(unknown)" 27 if versionOverride != "" { 28 version = versionOverride 29 } else if buildInfo, ok := debug.ReadBuildInfo(); ok { 30 version = buildInfo.Main.Version 31 } 32 return fmt.Sprintf("git-pages-cli %s", version) 33} 34 35var passwordFlag = pflag.String("password", "", "password for DNS challenge authorization") 36var tokenFlag = pflag.String("token", "", "token for forge authorization") 37var challengeFlag = pflag.Bool("challenge", false, "compute DNS challenge entry from password (output zone file record)") 38var challengeBareFlag = pflag.Bool("challenge-bare", false, "compute DNS challenge entry from password (output bare TXT value)") 39var uploadGitFlag = pflag.String("upload-git", "", "replace site with contents of specified git repository") 40var uploadDirFlag = pflag.String("upload-dir", "", "replace site with contents of specified directory") 41var deleteFlag = pflag.Bool("delete", false, "delete site") 42var debugManifestFlag = pflag.Bool("debug-manifest", false, "retrieve site manifest as ProtoJSON, for debugging") 43var serverFlag = pflag.String("server", "", "hostname of server to connect to") 44var verboseFlag = pflag.Bool("verbose", false, "display more information for debugging") 45var versionFlag = pflag.Bool("version", false, "display version information") 46 47func singleOperation() bool { 48 operations := 0 49 if *challengeFlag { 50 operations++ 51 } 52 if *challengeBareFlag { 53 operations++ 54 } 55 if *uploadDirFlag != "" { 56 operations++ 57 } 58 if *uploadGitFlag != "" { 59 operations++ 60 } 61 if *deleteFlag { 62 operations++ 63 } 64 if *debugManifestFlag { 65 operations++ 66 } 67 if *versionFlag { 68 operations++ 69 } 70 return operations == 1 71} 72 73func displayFS(root fs.FS) error { 74 return fs.WalkDir(root, ".", func(name string, entry fs.DirEntry, err error) error { 75 if err != nil { 76 return err 77 } 78 switch { 79 case entry.Type() == 0: 80 fmt.Fprintln(os.Stderr, "file", name) 81 case entry.Type() == fs.ModeDir: 82 fmt.Fprintln(os.Stderr, "dir", name) 83 case entry.Type() == fs.ModeSymlink: 84 fmt.Fprintln(os.Stderr, "symlink", name) 85 default: 86 fmt.Fprintln(os.Stderr, "other", name) 87 } 88 return nil 89 }) 90} 91 92func archiveFS(writer io.Writer, root fs.FS) (err error) { 93 zstdWriter, _ := zstd.NewWriter(writer) 94 tarWriter := tar.NewWriter(zstdWriter) 95 err = tarWriter.AddFS(root) 96 if err != nil { 97 return 98 } 99 err = tarWriter.Close() 100 if err != nil { 101 return 102 } 103 err = zstdWriter.Close() 104 if err != nil { 105 return 106 } 107 return 108} 109 110const usageExitCode = 125 111 112func main() { 113 pflag.Parse() 114 if !singleOperation() || (!*versionFlag && len(pflag.Args()) != 1) { 115 fmt.Fprintf(os.Stderr, 116 "Usage: %s <site-url> [--challenge|--upload-git url|--upload-dir path|--delete]\n", 117 os.Args[0], 118 ) 119 os.Exit(usageExitCode) 120 } 121 122 if *versionFlag { 123 fmt.Fprintln(os.Stdout, versionInfo()) 124 os.Exit(0) 125 } 126 127 if *passwordFlag != "" && *tokenFlag != "" { 128 fmt.Fprintf(os.Stderr, "--password and --token are mutually exclusive") 129 os.Exit(usageExitCode) 130 } 131 132 var err error 133 siteURL, err := url.Parse(pflag.Args()[0]) 134 if err != nil { 135 fmt.Fprintf(os.Stderr, "error: invalid site URL: %s\n", err) 136 os.Exit(1) 137 } 138 139 var request *http.Request 140 switch { 141 case *challengeFlag || *challengeBareFlag: 142 if *passwordFlag == "" { 143 *passwordFlag = uuid.NewString() 144 fmt.Fprintf(os.Stderr, "password: %s\n", *passwordFlag) 145 } 146 147 challenge := sha256.Sum256(fmt.Appendf(nil, "%s %s", siteURL.Hostname(), *passwordFlag)) 148 if *challengeBareFlag { 149 fmt.Fprintf(os.Stdout, "%x\n", challenge) 150 } else { 151 fmt.Fprintf(os.Stdout, "_git-pages-challenge.%s. 3600 IN TXT \"%x\"\n", siteURL.Hostname(), challenge) 152 } 153 os.Exit(0) 154 155 case *uploadGitFlag != "": 156 uploadGitUrl, err := url.Parse(*uploadGitFlag) 157 if err != nil { 158 fmt.Fprintf(os.Stderr, "error: invalid repository URL: %s\n", err) 159 os.Exit(1) 160 } 161 162 requestBody := []byte(uploadGitUrl.String()) 163 request, err = http.NewRequest("PUT", siteURL.String(), bytes.NewReader(requestBody)) 164 if err != nil { 165 fmt.Fprintf(os.Stderr, "error: %s\n", err) 166 os.Exit(1) 167 } 168 request.Header.Add("Content-Type", "application/x-www-form-urlencoded") 169 170 case *uploadDirFlag != "": 171 uploadDirFS, err := os.OpenRoot(*uploadDirFlag) 172 if err != nil { 173 fmt.Fprintf(os.Stderr, "error: invalid directory: %s\n", err) 174 os.Exit(1) 175 } 176 177 if *verboseFlag { 178 err := displayFS(uploadDirFS.FS()) 179 if err != nil { 180 fmt.Fprintf(os.Stderr, "error: %s\n", err) 181 os.Exit(1) 182 } 183 } 184 185 // Stream archive data without ever loading the entire working set into RAM. 186 reader, writer := io.Pipe() 187 go func() { 188 err = archiveFS(writer, uploadDirFS.FS()) 189 if err != nil { 190 fmt.Fprintf(os.Stderr, "error: %s\n", err) 191 os.Exit(1) 192 } 193 writer.Close() 194 }() 195 196 request, err = http.NewRequest("PUT", siteURL.String(), reader) 197 if err != nil { 198 fmt.Fprintf(os.Stderr, "error: %s\n", err) 199 os.Exit(1) 200 } 201 request.ContentLength = -1 202 request.Header.Add("Content-Type", "application/x-tar+zstd") 203 204 case *deleteFlag: 205 request, err = http.NewRequest("DELETE", siteURL.String(), nil) 206 if err != nil { 207 fmt.Fprintf(os.Stderr, "error: %s\n", err) 208 os.Exit(1) 209 } 210 211 case *debugManifestFlag: 212 manifestURL := siteURL.ResolveReference(&url.URL{Path: ".git-pages/manifest.json"}) 213 request, err = http.NewRequest("GET", manifestURL.String(), nil) 214 if err != nil { 215 fmt.Fprintf(os.Stderr, "error: %s\n", err) 216 os.Exit(1) 217 } 218 219 default: 220 panic("no operation chosen") 221 } 222 request.Header.Add("User-Agent", versionInfo()) 223 switch { 224 case *passwordFlag != "": 225 request.Header.Add("Authorization", fmt.Sprintf("Pages %s", *passwordFlag)) 226 case *tokenFlag != "": 227 request.Header.Add("Forge-Authorization", fmt.Sprintf("token %s", *tokenFlag)) 228 } 229 if *serverFlag != "" { 230 // Send the request to `--server` host, but set the `Host:` header to the site host. 231 // This allows first-time publishing to proceed without the git-pages server yet having 232 // a TLS certificate for the site host (which has a circular dependency on completion of 233 // first-time publishing). 234 newURL := *request.URL 235 newURL.Host = *serverFlag 236 request.URL = &newURL 237 request.Header.Set("Host", siteURL.Host) 238 } 239 240 response, err := http.DefaultClient.Do(request) 241 if err != nil { 242 fmt.Fprintf(os.Stderr, "error: %s\n", err) 243 os.Exit(1) 244 } 245 if *verboseFlag { 246 fmt.Fprintf(os.Stderr, "server: %s\n", response.Header.Get("Server")) 247 } 248 if *debugManifestFlag { 249 if response.StatusCode == 200 { 250 io.Copy(os.Stdout, response.Body) 251 fmt.Fprintf(os.Stdout, "\n") 252 } else { 253 io.Copy(os.Stderr, response.Body) 254 os.Exit(1) 255 } 256 } else { // an update operation 257 if response.StatusCode == 200 { 258 fmt.Fprintf(os.Stdout, "result: %s\n", response.Header.Get("Update-Result")) 259 io.Copy(os.Stdout, response.Body) 260 } else { 261 fmt.Fprintf(os.Stderr, "result: error\n") 262 io.Copy(os.Stderr, response.Body) 263 os.Exit(1) 264 } 265 } 266}