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
)