The codebase that powers boop.cat boop.cat
at main 337 lines 7.3 kB view raw
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}