The codebase that powers boop.cat boop.cat
at main 314 lines 7.7 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 "encoding/json" 10 "fmt" 11 "io" 12 "net/http" 13 "net/url" 14 "time" 15) 16 17type CloudflareClient struct { 18 AccountID string 19 NamespaceID string 20 APIToken string 21 Client *http.Client 22} 23 24func NewCloudflareClient(accountID, namespaceID, token string) *CloudflareClient { 25 return &CloudflareClient{ 26 AccountID: accountID, 27 NamespaceID: namespaceID, 28 APIToken: token, 29 Client: &http.Client{Timeout: 30 * time.Second}, 30 } 31} 32 33func (c *CloudflareClient) Do(method, path string, body interface{}) (*http.Response, error) { 34 var bodyReader io.Reader 35 if body != nil { 36 jsonBody, _ := json.Marshal(body) 37 bodyReader = bytes.NewBuffer(jsonBody) 38 } 39 40 url := "https://api.cloudflare.com/client/v4" + path 41 req, err := http.NewRequest(method, url, bodyReader) 42 if err != nil { 43 return nil, err 44 } 45 req.Header.Set("Authorization", "Bearer "+c.APIToken) 46 req.Header.Set("Content-Type", "application/json") 47 48 return c.Client.Do(req) 49} 50 51func (c *CloudflareClient) KVPut(key, value string) error { 52 encodedKey := url.PathEscape(key) 53 url := fmt.Sprintf("/accounts/%s/storage/kv/namespaces/%s/values/%s", 54 c.AccountID, c.NamespaceID, encodedKey) 55 56 fullURL := "https://api.cloudflare.com/client/v4" + url 57 req, _ := http.NewRequest("PUT", fullURL, bytes.NewBuffer([]byte(value))) 58 req.Header.Set("Authorization", "Bearer "+c.APIToken) 59 req.Header.Set("Content-Type", "text/plain") 60 61 resp, err := c.Client.Do(req) 62 if err != nil { 63 return err 64 } 65 defer resp.Body.Close() 66 67 if resp.StatusCode != 200 { 68 return fmt.Errorf("kv_put failed: %s", resp.Status) 69 } 70 return nil 71} 72 73func (c *CloudflareClient) KVGet(key string) (string, error) { 74 encodedKey := url.PathEscape(key) 75 url := fmt.Sprintf("/accounts/%s/storage/kv/namespaces/%s/values/%s", 76 c.AccountID, c.NamespaceID, encodedKey) 77 78 fullURL := "https://api.cloudflare.com/client/v4" + url 79 req, _ := http.NewRequest("GET", fullURL, nil) 80 req.Header.Set("Authorization", "Bearer "+c.APIToken) 81 82 resp, err := c.Client.Do(req) 83 if err != nil { 84 return "", err 85 } 86 defer resp.Body.Close() 87 88 if resp.StatusCode == 404 { 89 return "", nil 90 } 91 if resp.StatusCode != 200 { 92 return "", fmt.Errorf("kv_get failed: %s", resp.Status) 93 } 94 95 b, _ := io.ReadAll(resp.Body) 96 return string(b), nil 97} 98 99func (c *CloudflareClient) KVDelete(key string) error { 100 encodedKey := url.PathEscape(key) 101 url := fmt.Sprintf("/accounts/%s/storage/kv/namespaces/%s/values/%s", 102 c.AccountID, c.NamespaceID, encodedKey) 103 104 fullURL := "https://api.cloudflare.com/client/v4" + url 105 req, _ := http.NewRequest("DELETE", fullURL, nil) 106 req.Header.Set("Authorization", "Bearer "+c.APIToken) 107 108 resp, err := c.Client.Do(req) 109 if err != nil { 110 return err 111 } 112 defer resp.Body.Close() 113 return nil 114} 115 116type Response struct { 117 Result json.RawMessage `json:"result"` 118 Success bool `json:"success"` 119 Errors []struct { 120 Message string `json:"message"` 121 } `json:"errors"` 122} 123 124func (c *CloudflareClient) GetZoneID(domain string) (string, error) { 125 resp, err := c.Do("GET", "/zones?name="+domain, nil) 126 if err != nil { 127 return "", err 128 } 129 defer resp.Body.Close() 130 131 var res struct { 132 Result []struct { 133 ID string `json:"id"` 134 Name string `json:"name"` 135 } `json:"result"` 136 } 137 json.NewDecoder(resp.Body).Decode(&res) 138 139 if len(res.Result) == 0 { 140 return "", fmt.Errorf("zone not found") 141 } 142 return res.Result[0].ID, nil 143} 144 145func (c *CloudflareClient) CreateCustomHostname(zoneID, hostname string) (json.RawMessage, error) { 146 body := map[string]interface{}{ 147 "hostname": hostname, 148 "ssl": map[string]interface{}{ 149 "method": "http", 150 "type": "dv", 151 "settings": map[string]interface{}{ 152 "min_tls_version": "1.2", 153 "http2": "on", 154 }, 155 }, 156 } 157 158 resp, err := c.Do("POST", fmt.Sprintf("/zones/%s/custom_hostnames", zoneID), body) 159 if err != nil { 160 return nil, err 161 } 162 defer resp.Body.Close() 163 164 var res Response 165 json.NewDecoder(resp.Body).Decode(&res) 166 167 if !res.Success { 168 msg := "unknown" 169 if len(res.Errors) > 0 { 170 msg = res.Errors[0].Message 171 } 172 return nil, fmt.Errorf("create_custom_hostname failed: %s", msg) 173 } 174 return res.Result, nil 175} 176 177func (c *CloudflareClient) GetCustomHostname(zoneID, id string) (json.RawMessage, error) { 178 resp, err := c.Do("GET", fmt.Sprintf("/zones/%s/custom_hostnames/%s", zoneID, id), nil) 179 if err != nil { 180 return nil, err 181 } 182 defer resp.Body.Close() 183 184 var res Response 185 json.NewDecoder(resp.Body).Decode(&res) 186 return res.Result, nil 187} 188 189func (c *CloudflareClient) GetCustomHostnameIDByName(zoneID, hostname string) (string, error) { 190 resp, err := c.Do("GET", fmt.Sprintf("/zones/%s/custom_hostnames?hostname=%s", zoneID, url.QueryEscape(hostname)), nil) 191 if err != nil { 192 return "", err 193 } 194 defer resp.Body.Close() 195 196 var res struct { 197 Result []struct { 198 ID string `json:"id"` 199 } `json:"result"` 200 } 201 if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { 202 return "", err 203 } 204 205 if len(res.Result) == 0 { 206 return "", fmt.Errorf("hostname not found") 207 } 208 return res.Result[0].ID, nil 209} 210 211func (c *CloudflareClient) DeleteCustomHostname(zoneID, id string) error { 212 resp, err := c.Do("DELETE", fmt.Sprintf("/zones/%s/custom_hostnames/%s", zoneID, id), nil) 213 if err != nil { 214 return err 215 } 216 defer resp.Body.Close() 217 return nil 218} 219 220func (c *CloudflareClient) UpdateFallbackOrigin(zoneID, origin string) error { 221 body := map[string]string{"origin": origin} 222 resp, err := c.Do("PUT", fmt.Sprintf("/zones/%s/custom_hostnames/fallback_origin", zoneID), body) 223 if err != nil { 224 return err 225 } 226 defer resp.Body.Close() 227 return nil 228} 229 230type DNSRecord struct { 231 ID string `json:"id"` 232 Type string `json:"type"` 233 Name string `json:"name"` 234 Content string `json:"content"` 235 Proxied bool `json:"proxied"` 236} 237 238func (c *CloudflareClient) GetDNSRecords(zoneID, name string) ([]DNSRecord, error) { 239 path := fmt.Sprintf("/zones/%s/dns_records", zoneID) 240 if name != "" { 241 path += "?name=" + url.QueryEscape(name) 242 } 243 resp, err := c.Do("GET", path, nil) 244 if err != nil { 245 return nil, err 246 } 247 defer resp.Body.Close() 248 249 var res struct { 250 Result []DNSRecord `json:"result"` 251 } 252 json.NewDecoder(resp.Body).Decode(&res) 253 return res.Result, nil 254} 255 256func (c *CloudflareClient) CreateDNSRecord(zoneID string, record DNSRecord) error { 257 path := fmt.Sprintf("/zones/%s/dns_records", zoneID) 258 resp, err := c.Do("POST", path, record) 259 if err != nil { 260 return err 261 } 262 defer resp.Body.Close() 263 if resp.StatusCode >= 400 { 264 return fmt.Errorf("create_dns failed: %s", resp.Status) 265 } 266 return nil 267} 268 269func (c *CloudflareClient) UpdateDNSRecord(zoneID, recordID string, record DNSRecord) error { 270 path := fmt.Sprintf("/zones/%s/dns_records/%s", zoneID, recordID) 271 resp, err := c.Do("PATCH", path, record) 272 if err != nil { 273 return err 274 } 275 defer resp.Body.Close() 276 if resp.StatusCode >= 400 { 277 return fmt.Errorf("update_dns failed: %s", resp.Status) 278 } 279 return nil 280} 281 282func (c *CloudflareClient) EnsureRouting(subdomain, siteID, deployID string) error { 283 if subdomain != "" { 284 if err := c.KVPut("host:"+subdomain, siteID); err != nil { 285 return err 286 } 287 } 288 if deployID != "" { 289 if err := c.KVPut("current:"+siteID, deployID); err != nil { 290 return err 291 } 292 } 293 return nil 294} 295 296func (c *CloudflareClient) RemoveRouting(subdomain, siteID, domain string) error { 297 if domain != "" { 298 299 if err := c.KVDelete("host:" + domain); err != nil { 300 return err 301 } 302 } 303 if subdomain != "" { 304 if err := c.KVDelete("host:" + subdomain); err != nil { 305 return err 306 } 307 } 308 if siteID != "" { 309 if err := c.KVDelete("current:" + siteID); err != nil { 310 return err 311 } 312 } 313 return nil 314}