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