[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
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}