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