go / cloudflare

Cloudflare Images stores images and serves them through Cloudflare's CDN. I use it to store logos and avatars uploaded by users or fetched from third-party APIs.

The client wraps two endpoints: direct image uploads from the server and one-time upload URLs for client-side uploads.

Client

cloudflare.Client:

const defaultAPIBase = "https://api.cloudflare.com/client/v4"

var retryDelays = []time.Duration{time.Second, 2 * time.Second, 0}
var transientCodes = httputil.WithTransientCodes(429)

type Client struct {
	accountID   string
	apiToken    string
	apiBase     string
	accountHash string
	http        *http.Client
}

func NewClient(accountID, apiToken, accountHash string) *Client {
	return &Client{
		accountID:   accountID,
		apiToken:    apiToken,
		apiBase:     defaultAPIBase,
		accountHash: accountHash,
		http:        &http.Client{Timeout: 20 * time.Second},
	}
}

The retry and transient-code plumbing lives in a shared backoff helper. Each call builds a request, hands it to httputil.Do, and maps the response shape to a result.

// https://developers.cloudflare.com/api/operations/cloudflare-images-upload-an-image-via-url
func (c *Client) UploadImage(ctx context.Context, body []byte, contentType, filename string) (UploadResult, error) {
	url := fmt.Sprintf("%s/accounts/%s/images/v1", c.apiBase, c.accountID)
	res, err := httputil.Do(ctx, func() (*http.Request, error) {
		form := &bytes.Buffer{}
		writer := multipart.NewWriter(form)
		// Set the detected MIME type on the file part. CreateFormFile
		// would default to application/octet-stream and lose the
		// signal for providers that infer from the part headers.
		header := make(textproto.MIMEHeader)
		header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, escapeQuotes(filename)))
		header.Set("Content-Type", contentType)
		part, err := writer.CreatePart(header)
		if err != nil {
			return nil, err
		}
		if _, err := part.Write(body); err != nil {
			return nil, err
		}
		if err := writer.Close(); err != nil {
			return nil, err
		}
		req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, form)
		if err != nil {
			return nil, err
		}
		req.Header.Set("Authorization", "Bearer "+c.apiToken)
		req.Header.Set("Content-Type", writer.FormDataContentType())
		return req, nil
	}, httputil.Config{
		Client:         c.http,
		RetryDelays:    retryDelays,
		TransientCodes: transientCodes,
	})
	if err != nil {
		return UploadResult{}, err
	}
	if res.StatusCode/100 != 2 {
		return UploadResult{}, fmt.Errorf("HTTP %d", res.StatusCode)
	}

	var parsed struct {
		Success bool `json:"success"`
		Result  struct {
			ID       string `json:"id"`
			Filename string `json:"filename"`
		} `json:"result"`
	}
	if err := json.Unmarshal(res.Body, &parsed); err != nil {
		return UploadResult{}, fmt.Errorf("JSON parse error")
	}
	if !parsed.Success {
		return UploadResult{}, fmt.Errorf("API returned success=false")
	}
	return UploadResult{ImageID: parsed.Result.ID, Filename: parsed.Result.Filename}, nil
}

UploadImage retries with backoff because image uploads are idempotent and Cloudflare returns transient 502/503/504 and rate-limit 429 responses under load. The retry loop turns most of those into eventual successes. retryDelays = {1s, 2s, 0} keeps total backoff short. Background workers use longer delays.

multipart.CreateFormFile hardcodes Content-Type: application/octet-stream, so I build the part header by hand to carry the detected MIME type.

Server-side upload

The worker downloads an image from a third-party URL, validates it, uploads it to Cloudflare, and rewrites the stored URL:

func UploadImage(
	ctx context.Context,
	db *pgdb.DB,
	uploader Uploader,
	fetcher HTTPDoer,
	cfg ImageConfig,
	args UploadImageArgs,
) (string, error) {
	src := strings.TrimSpace(args.ImageURL)
	if !safeURL(src) {
		return "err: unsafe URL", nil
	}

	body, status, err := fetchImage(ctx, fetcher, src)
	if err != nil {
		return status, nil
	}

	contentType := detectContentType(body)
	if contentType == "" {
		return "err: unsupported file type", nil
	}
	if len(body) > maxBytes {
		return "err: image too large", nil
	}

	digest := sha256.Sum256(body)
	filename := fmt.Sprintf("%s-%d-%s.%s", args.Table, args.ID,
		hex.EncodeToString(digest[:])[:16], extensionForContentType(contentType))

	result, err := uploader.UploadImage(ctx, body, contentType, filename)
	if err != nil {
		return "err: Cloudflare upload failed " + err.Error(), nil
	}
	return "ok", nil
}

Uploader and HTTPDoer are one-method interfaces, so tests swap in fakes without touching the network.

type Uploader interface {
	UploadImage(ctx context.Context, body []byte, contentType, filename string) (UploadResult, error)
}

type HTTPDoer interface {
	Do(req *http.Request) (*http.Response, error)
}

SSRF guard

Fetches to attacker-supplied URLs need a guard. Without one, an image URL like http://localhost:5432/ or http://169.254.169.254/latest/meta-data/ would trick the server into reaching internal services or cloud metadata.

safeURL parses the URL, requires http/https, and rejects loopback and localhost hosts before the HTTP client connects. Any handler that fetches an attacker-supplied URL goes through it.

func safeURL(raw string) bool {
	parsed, err := url.Parse(raw)
	if err != nil {
		return false
	}
	if parsed.Scheme != "http" && parsed.Scheme != "https" {
		return false
	}
	host := strings.ToLower(parsed.Hostname())
	if host == "" || host == "localhost" || host == "::1" {
		return false
	}
	if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
		return false
	}
	return true
}

Magic bytes content-type detection

Trust the bytes, not the headers:

func detectContentType(body []byte) string {
	if len(body) >= 3 && body[0] == 0xFF && body[1] == 0xD8 && body[2] == 0xFF {
		return "image/jpeg"
	}
	if len(body) >= 4 && bytes.Equal(body[:4], []byte{0x89, 0x50, 0x4E, 0x47}) {
		return "image/png"
	}
	if len(body) >= 3 && bytes.Equal(body[:3], []byte{0x47, 0x49, 0x46}) {
		return "image/gif"
	}
	if len(body) >= 12 && bytes.Equal(body[:4], []byte{0x52, 0x49, 0x46, 0x46}) &&
		bytes.Equal(body[8:12], []byte{0x57, 0x45, 0x42, 0x50}) {
		return "image/webp"
	}
	if bytes.HasPrefix(body, []byte("<?xml")) || bytes.HasPrefix(body, []byte("<svg")) {
		return "image/svg+xml"
	}
	return ""
}

A Content-Type: image/png header proves nothing about the payload. The first few bytes do.

Direct creator upload

For browser uploads, generate a one-time URL the client POSTs to directly. The server never sees the image bytes:

func (c *Client) GetDirectUploadURL(ctx context.Context) (DirectUploadResult, error) {
	url := fmt.Sprintf("%s/accounts/%s/images/v2/direct_upload", c.apiBase, c.accountID)
	res, err := httputil.Do(ctx, func() (*http.Request, error) {
		req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
		if err != nil {
			return nil, err
		}
		req.Header.Set("Authorization", "Bearer "+c.apiToken)
		return req, nil
	}, httputil.Config{
		Client:         c.http,
		RetryDelays:    retryDelays,
		TransientCodes: transientCodes,
	})
	if err != nil {
		return DirectUploadResult{}, err
	}
	if res.StatusCode/100 != 2 {
		return DirectUploadResult{}, fmt.Errorf("HTTP %d", res.StatusCode)
	}

	var parsed struct {
		Success bool `json:"success"`
		Result  struct {
			UploadURL string `json:"uploadURL"`
			ID        string `json:"id"`
		} `json:"result"`
	}
	if err := json.Unmarshal(res.Body, &parsed); err != nil {
		return DirectUploadResult{}, fmt.Errorf("JSON parse error")
	}
	if !parsed.Success {
		return DirectUploadResult{}, fmt.Errorf("API returned success=false")
	}
	return DirectUploadResult{
		UploadURL: parsed.Result.UploadURL,
		ImageID:   parsed.Result.ID,
		PublicURL: fmt.Sprintf("https://%s/cdn-cgi/imagedelivery/%s/%s/public", imageDomain, c.accountHash, parsed.Result.ID),
	}, nil
}

The browser receives {upload_url, image_id, public_url}, POSTs the file to upload_url, and uses public_url once the upload completes.

Rate limits

Cloudflare's API allows 1200 requests per 5 minutes and blocks for 5 minutes if exceeded. The worker's throttle sets the delay before the next job based on the last job's status:

const (
	maxJobsPerSecond = 4
	rateLimitBackoff = 5 * time.Minute
)

func throttle(status string, _ error, elapsed time.Duration) time.Duration {
	if strings.Contains(status, "429 Too Many Requests") {
		if elapsed >= rateLimitBackoff {
			return 0
		}
		return rateLimitBackoff - elapsed // pause to clear the block
	}
	minJobTime := time.Second / maxJobsPerSecond
	if elapsed >= minJobTime {
		return 0
	}
	return minJobTime - elapsed
}

Tests

Stub the HTTP boundary with httptest.NewServer. The retry loop is easy to script with an atomic counter that fails the first attempts and then succeeds:

func TestClientUploadImageRetries429(t *testing.T) {
	var calls atomic.Int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		if calls.Add(1) <= 2 {
			w.WriteHeader(429)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(map[string]any{
			"success": true,
			"result":  map[string]string{"id": "img-1", "filename": "x.png"},
		})
	}))
	defer srv.Close()

	c := NewClient("acct", "tok", "hash")
	c.apiBase = srv.URL
	c.http = srv.Client()

	_, err := c.UploadImage(context.Background(), []byte("png-bytes"), "image/png", "logo.png")
	tu.OK(err == nil)
	tu.OK(calls.Load() == 3)
}

For the upload job, the Uploader and HTTPDoer interfaces take fakes, so the whole pipeline runs without a network or a real Cloudflare account.

← All articles