The codebase that powers boop.cat
boop.cat
1// Copyright 2025 boop.cat
2// Licensed under the Apache License, Version 2.0
3// See LICENSE file for details.
4
5package deploy
6
7import (
8 "bytes"
9 "crypto/sha1"
10 "encoding/base64"
11 "encoding/hex"
12 "encoding/json"
13 "fmt"
14 "io"
15 "net/http"
16 "net/url"
17 "sync"
18 "time"
19)
20
21type B2Client struct {
22 KeyID string
23 AppKey string
24 BucketID string
25 AuthToken string
26 APIURL string
27 DownloadURL string
28 Client *http.Client
29 mu sync.Mutex
30}
31
32func NewB2Client(keyID, appKey, bucketID string) *B2Client {
33 return &B2Client{
34 KeyID: keyID,
35 AppKey: appKey,
36 BucketID: bucketID,
37 Client: &http.Client{Timeout: 60 * time.Second},
38 }
39}
40
41func (c *B2Client) Authorize() error {
42 req, err := http.NewRequest("GET", "https://api.backblazeb2.com/b2api/v2/b2_authorize_account", nil)
43 if err != nil {
44 return err
45 }
46 auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", c.KeyID, c.AppKey)))
47 req.Header.Set("Authorization", "Basic "+auth)
48
49 resp, err := c.Client.Do(req)
50 if err != nil {
51 return err
52 }
53 defer resp.Body.Close()
54
55 if resp.StatusCode != 200 {
56 return fmt.Errorf("authorize failed: %d", resp.StatusCode)
57 }
58
59 var res struct {
60 AuthorizationToken string `json:"authorizationToken"`
61 APIURL string `json:"apiUrl"`
62 DownloadURL string `json:"downloadUrl"`
63 AbsoluteMinimumPartSize int `json:"absoluteMinimumPartSize"`
64 }
65 if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
66 return err
67 }
68
69 c.mu.Lock()
70 c.AuthToken = res.AuthorizationToken
71 c.APIURL = res.APIURL
72 c.DownloadURL = res.DownloadURL
73 c.mu.Unlock()
74
75 return nil
76}
77
78func (c *B2Client) GetUploadURL() (string, string, error) {
79 c.mu.Lock()
80 apiURL := c.APIURL
81 authToken := c.AuthToken
82 c.mu.Unlock()
83
84 if apiURL == "" {
85 return "", "", fmt.Errorf("not authorized")
86 }
87
88 body, _ := json.Marshal(map[string]string{
89 "bucketId": c.BucketID,
90 })
91
92 req, _ := http.NewRequest("POST", apiURL+"/b2api/v2/b2_get_upload_url", bytes.NewBuffer(body))
93 req.Header.Set("Authorization", authToken)
94 req.Header.Set("Content-Type", "application/json")
95
96 resp, err := c.Client.Do(req)
97 if err != nil {
98 return "", "", err
99 }
100 defer resp.Body.Close()
101
102 if resp.StatusCode != 200 {
103 return "", "", fmt.Errorf("get_upload_url failed: %d", resp.StatusCode)
104 }
105
106 var res struct {
107 UploadURL string `json:"uploadUrl"`
108 AuthorizationToken string `json:"authorizationToken"`
109 }
110 if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
111 return "", "", err
112 }
113
114 return res.UploadURL, res.AuthorizationToken, nil
115}
116
117func (c *B2Client) UploadFile(fileName string, content []byte, contentType string) error {
118
119 c.mu.Lock()
120 apiURL := c.APIURL
121 c.mu.Unlock()
122
123 if apiURL == "" {
124 if err := c.Authorize(); err != nil {
125 return err
126 }
127 }
128
129 uploadURL, uploadToken, err := c.GetUploadURL()
130 if err != nil {
131
132 if err := c.Authorize(); err != nil {
133 return err
134 }
135 uploadURL, uploadToken, err = c.GetUploadURL()
136 if err != nil {
137 return err
138 }
139 }
140
141 hash := sha1.Sum(content)
142 sha1Str := hex.EncodeToString(hash[:])
143
144 encodedName := url.PathEscape(fileName)
145
146 req, _ := http.NewRequest("POST", uploadURL, bytes.NewBuffer(content))
147 req.Header.Set("Authorization", uploadToken)
148 req.Header.Set("X-Bz-File-Name", encodedName)
149 req.Header.Set("Content-Type", contentType)
150 req.Header.Set("X-Bz-Content-Sha1", sha1Str)
151
152 resp, err := c.Client.Do(req)
153 if err != nil {
154 return err
155 }
156 defer resp.Body.Close()
157
158 if resp.StatusCode != 200 {
159 body, _ := io.ReadAll(resp.Body)
160 return fmt.Errorf("upload_file failed: %d %s", resp.StatusCode, string(body))
161 }
162
163 return nil
164}
165
166func (c *B2Client) ListFileNames(prefix string, startFileName string, maxFileCount int) ([]string, string, error) {
167 c.mu.Lock()
168 apiURL := c.APIURL
169 authToken := c.AuthToken
170 c.mu.Unlock()
171
172 if apiURL == "" {
173 if err := c.Authorize(); err != nil {
174 return nil, "", err
175 }
176 c.mu.Lock()
177 apiURL = c.APIURL
178 authToken = c.AuthToken
179 c.mu.Unlock()
180 }
181
182 bodyMap := map[string]interface{}{
183 "bucketId": c.BucketID,
184 "maxFileCount": maxFileCount,
185 }
186 if prefix != "" {
187 bodyMap["prefix"] = prefix
188 }
189 if startFileName != "" {
190 bodyMap["startFileName"] = startFileName
191 }
192
193 body, _ := json.Marshal(bodyMap)
194
195 req, _ := http.NewRequest("POST", apiURL+"/b2api/v2/b2_list_file_names", bytes.NewBuffer(body))
196 req.Header.Set("Authorization", authToken)
197 req.Header.Set("Content-Type", "application/json")
198
199 resp, err := c.Client.Do(req)
200 if err != nil {
201 return nil, "", err
202 }
203 defer resp.Body.Close()
204
205 if resp.StatusCode != 200 {
206 return nil, "", fmt.Errorf("list_file_names failed: %d", resp.StatusCode)
207 }
208
209 var res struct {
210 Files []struct {
211 FileName string `json:"fileName"`
212 FileID string `json:"fileId"`
213 } `json:"files"`
214 NextFileName *string `json:"nextFileName"`
215 }
216 if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
217 return nil, "", err
218 }
219
220 var names []string
221 for _, f := range res.Files {
222 names = append(names, f.FileName)
223 }
224
225 next := ""
226 if res.NextFileName != nil {
227 next = *res.NextFileName
228 }
229
230 return names, next, nil
231}
232
233func (c *B2Client) DeleteFileVersion(fileName, fileID string) error {
234 c.mu.Lock()
235 apiURL := c.APIURL
236 authToken := c.AuthToken
237 c.mu.Unlock()
238
239 if apiURL == "" {
240 if err := c.Authorize(); err != nil {
241 return err
242 }
243 c.mu.Lock()
244 apiURL = c.APIURL
245 authToken = c.AuthToken
246 c.mu.Unlock()
247 }
248
249 body, _ := json.Marshal(map[string]string{
250 "fileName": fileName,
251 "fileId": fileID,
252 })
253
254 req, _ := http.NewRequest("POST", apiURL+"/b2api/v2/b2_delete_file_version", bytes.NewBuffer(body))
255 req.Header.Set("Authorization", authToken)
256 req.Header.Set("Content-Type", "application/json")
257
258 resp, err := c.Client.Do(req)
259 if err != nil {
260 return err
261 }
262 defer resp.Body.Close()
263
264 if resp.StatusCode != 200 {
265 return fmt.Errorf("delete_file_version failed: %d", resp.StatusCode)
266 }
267
268 return nil
269}
270
271func (c *B2Client) DeleteFilesWithPrefix(prefix string) error {
272 for {
273
274 c.mu.Lock()
275 apiURL := c.APIURL
276 authToken := c.AuthToken
277 c.mu.Unlock()
278
279 if apiURL == "" {
280 if err := c.Authorize(); err != nil {
281 return err
282 }
283 c.mu.Lock()
284 apiURL = c.APIURL
285 authToken = c.AuthToken
286 c.mu.Unlock()
287 }
288
289 bodyMap := map[string]interface{}{
290 "bucketId": c.BucketID,
291 "maxFileCount": 100,
292 "prefix": prefix,
293 }
294 body, _ := json.Marshal(bodyMap)
295
296 req, _ := http.NewRequest("POST", apiURL+"/b2api/v2/b2_list_file_names", bytes.NewBuffer(body))
297 req.Header.Set("Authorization", authToken)
298 req.Header.Set("Content-Type", "application/json")
299
300 resp, err := c.Client.Do(req)
301 if err != nil {
302 return err
303 }
304 defer resp.Body.Close()
305
306 if resp.StatusCode != 200 {
307 return fmt.Errorf("list_file_names failed: %d", resp.StatusCode)
308 }
309
310 var res struct {
311 Files []struct {
312 FileName string `json:"fileName"`
313 FileID string `json:"fileId"`
314 } `json:"files"`
315 NextFileName *string `json:"nextFileName"`
316 }
317 if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
318 return err
319 }
320
321 if len(res.Files) == 0 {
322 break
323 }
324
325 for _, f := range res.Files {
326 if err := c.DeleteFileVersion(f.FileName, f.FileID); err != nil {
327
328 fmt.Printf("Failed to delete %s: %v\n", f.FileName, err)
329 }
330 }
331
332 if res.NextFileName == nil {
333 break
334 }
335 }
336 return nil
337}