ruby / 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.

I use a thin HTTP client for two endpoints: direct image uploads from the server and one-time upload URLs for client-side uploads.

Client

Cloudflare::Client:

require "http"
require "json"
require "stringio"

module Cloudflare
  class Client
    API_BASE = "https://api.cloudflare.com/client/v4"
    ACCOUNT_ID = ENV.fetch("CLOUDFLARE_ACCOUNT_ID")
    ACCOUNT_HASH = ENV.fetch("CLOUDFLARE_ACCOUNT_HASH")
    API_TOKEN = ENV.fetch("CLOUDFLARE_API_TOKEN")
    IMAGE_DOMAIN = ENV.fetch("CLOUDFLARE_IMAGE_DOMAIN")

    DELAYS = [1, 2, nil].freeze
    TRANSIENT_CODES = [429, 502, 503, 504].freeze

    # https://developers.cloudflare.com/api/operations/cloudflare-images-upload-an-image-via-url
    def upload_image(body:, content_type:, filename:)
      uri = "#{API_BASE}/accounts/#{ACCOUNT_ID}/images/v1"

      DELAYS.each do |delay|
        file = HTTP::FormData::File.new(
          StringIO.new(body),
          filename: filename,
          content_type: content_type
        )
        resp = http.post(uri, form: {file: file})
        if TRANSIENT_CODES.include?(resp.code)
          sleep(delay) if delay
          next
        end

        if resp.code / 100 != 2
          return [nil, "HTTP #{resp.code}"]
        end

        data = JSON.parse(resp.body.to_s)
        if !data["success"] || !data["result"]
          return [nil, "API returned success=false"]
        end

        return [{
          image_id: data["result"]["id"],
          filename: data["result"]["filename"]
        }, nil]
      end

      [nil, "timeout after retries"]
    end

    # https://developers.cloudflare.com/images/upload-images/direct-creator-upload/
    def get_direct_upload_url
      uri = "#{API_BASE}/accounts/#{ACCOUNT_ID}/images/v2/direct_upload"
      resp = http.post(uri)
      if resp.code / 100 != 2
        return [nil, "HTTP #{resp.status}"]
      end

      data = JSON.parse(resp.body.to_s)
      if !data["success"] || !data["result"]
        return [nil, "API returned success=false"]
      end

      image_id = data["result"]["id"]
      [{
        upload_url: data["result"]["uploadURL"],
        image_id: image_id,
        public_url: "https://#{IMAGE_DOMAIN}/cdn-cgi/imagedelivery/#{ACCOUNT_HASH}/#{image_id}/public"
      }, nil]
    end

    private def http
      HTTP
        .auth("Bearer #{API_TOKEN}")
        .timeout(connect: 5, write: 60, read: 60)
    end
  end
end

Production code adds Sentry logging on non-2xx, rescues for transient network exceptions, and JSON parse error handling; I've trimmed those here for clarity.

upload_image 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. DELAYS = [1, 2, nil] keeps total backoff under Rack's 15s request timeout. Background ingestion workers use longer delays.

Server-side upload

The full pipeline downloads an image from a third-party URL, validates it, and uploads it to Cloudflare:

class UploadImage
  MAX_BYTES = 10 * 1024 * 1024
  CONTENT_TYPES = {
    "image/jpeg" => "jpg",
    "image/png" => "png",
    "image/webp" => "webp",
    "image/gif" => "gif",
    "image/svg+xml" => "svg"
  }.freeze

  def call(image_url:, client: Cloudflare::Client.new)
    uri = safe_uri(image_url)
    if !uri
      return "err: unsafe URL"
    end

    resp = HTTP.timeout(connect: 3, read: 10).get(uri.to_s)
    if !resp.status.success?
      return "err: failed to fetch image"
    end

    body = resp.body.to_s
    content_type = detect_content_type(body)
    if !CONTENT_TYPES.key?(content_type)
      return "err: unsupported file type"
    end
    if body.bytesize > MAX_BYTES
      return "err: image too large"
    end

    digest = OpenSSL::Digest::SHA256.hexdigest(body)[0, 16]
    filename = "#{digest}.#{CONTENT_TYPES[content_type]}"

    cf_result, err = client.upload_image(
      body: body,
      content_type: content_type,
      filename: filename
    )
    if err
      return "err: Cloudflare upload failed #{err}"
    end

    cf_result[:image_id]
  end
end

SSRF guard

safe_uri blocks fetches to localhost and other unsafe schemes:

private def safe_uri(url)
  uri = URI.parse(url.to_s.strip)
  if uri.scheme != "http" && uri.scheme != "https"
    return nil
  end
  if uri.host.nil?
    return nil
  end

  host = uri.host.downcase
  if host == "localhost" || host == "::1" || host.start_with?("127.")
    return nil
  end

  uri
rescue URI::InvalidURIError
  nil
end

Without this guard, an attacker who controls an image URL could trick the server into fetching http://localhost:5432/ or other internal services.

Magic bytes content-type detection

Trust the bytes, not the headers:

private def detect_content_type(body)
  bytes = body.bytes.take(4)

  if bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF
    "image/jpeg"
  elsif bytes == [0x89, 0x50, 0x4E, 0x47]
    "image/png"
  elsif bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46
    "image/gif"
  elsif bytes[0..3] == [0x52, 0x49, 0x46, 0x46] &&
      body.bytes.take(12)[8..11] == [0x57, 0x45, 0x42, 0x50]
    "image/webp"
  elsif body.start_with?("<?xml") || body.start_with?("<svg")
    "image/svg+xml"
  end
end

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:

class UploadUrl < Handler
  def handle
    cf_upload, err = Cloudflare::Client.new.get_direct_upload_url
    if err
      return [502, {}, [JSON.generate({error: err})]]
    end

    render_json(cf_upload)
  end
end

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 background worker that runs UploadImage adapts its rate based on the most recent job's status:

private def max_jobs_per_second_for_status(status)
  if status.include?("429 Too Many Requests")
    1 / 300.0 # 5-minute pause to clear the block
  else
    4
  end
end

Tests

Tests run on a custom framework. Stub the HTTP boundary with webmock. The retry loop makes the chained .then form especially handy for scripting transient failures:

stub_request(:post, /api\.cloudflare\.com/)
  .to_return(status: 502)
  .then
  .to_return(
    status: 200,
    body: {success: true, result: {id: "img-123"}}.to_json
  )

← All articles