[mirror] Command-line application for uploading a site to a git-pages server
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}