Using Cloudflare Images to Store and Serve User-Uploaded Images

Using Cloudflare Images to Store and Serve User-Uploaded Images

A few weeks back, as part of an ongoing project, I was looking for an image hosting solution that would meet the following general requirements.

1. Authorized users can upload image resources.
2. Images need to be optimized for web/mobile clients.
3. Authorized users can retrieve specific images using pre-signed URLs.

I found Cloudflare images to suit my needs perfectly. Before diving into some implementation details, we need to look at some important stuff.

Price Estimates

According to their pricing page, the Cloudflare Images bill is broken down into three line items: images stored, images delivered, and images transformed. For my use case:

  1. 100 users are allowed to upload images, averaging 500 lifetime images per user. Storage cost for 50,000 images every month = $5
  2. 2,000 users, each retrieving an average of 50 images per day. Image delivery cost 2,000 users x 50 images x 30 days = 3 million. At a charge of $1 per 100,000 images delivered, this will run for about $30 per month. This can become quite expensive, so do proper estimates.

NOTE: Requesting the same resource multiple times will count against the images delivered. Utilize on-device image caching to avoid unnecessary requests.

NOTE 2: In its default configuration, an expired pre-signed URL can still retrieve the image from the CDN for a few hours. However, there are workarounds for this to customize edge cache settings.

Uploading Images

Only specific users are allowed to upload images. The client can send a request to our backend to generate a secure upload URL for the user uploads. The upload URL is a one-time use.

# Code snippet from a Django app to generate a one-time upload URL.

import requests
import json

def get_cloudflare_upload_details(metadata: dict):
    """
    Get a one-time upload URL from Cloudflare.
    """
    url = f"https://api.cloudflare.com/client/v4/accounts/{settings.MR_CLOUDFLARE_ACCOUNT_ID}/images/v2/direct_upload"
    headers = {
            'Authorization': f'Bearer {settings.MR_CLOUDFLARE_API_TOKEN}',
        }
    metadata_json = json.dumps(metadata)
    data = {
        "requireSignedURLs": 'true',
        "metadata": metadata_json,
    }

    files = {'file': ('', None, 'application/octet-stream')}
    response = requests.post(url, headers=headers, data=data, files=files)
    if response.status_code == 200:
        result = response.json().get("result")
        return result["id"], result["uploadURL"]
    else:
        raise MyException("Custom Exception Message")

The image can be tagged with metadata like a name or a primary key from our database.

Resizing

We can create variants for mobile or other screen sizes if necessary.

Generate Presigned URL

The Python equivalent for this example from the Cloudflare docs.

import hmac
import hashlib

def _generate_signed_url(url: str, key: str) -> str:
    """
    Generating presigned url using the cloudflare image key.

    Parameters:
        url (str): Url to be signed e.g https://imagedelivery.net/cheeW4vvvv5ljh8e8BoL2A/bc27a117-9509-446b-8c69-c81bfeac0a01/mobile
        key (str): Cloudflare image key from your account dashboard.
    """
    expiration_seconds = 60 * 5
    expiry = int((datetime.now() + timedelta(seconds=expiration_seconds)).timestamp())

    parsed_url = urllib.parse.urlparse(url)
    query_params = urllib.parse.parse_qs(parsed_url.query)
    query_params['exp'] = [str(expiry)]
    new_query = urllib.parse.urlencode(query_params, doseq=True)
    updated_url = urllib.parse.urlunparse((
        parsed_url.scheme, parsed_url.netloc, parsed_url.path, parsed_url.params,
        new_query, parsed_url.fragment
    ))

    string_to_sign = f"{parsed_url.path}?{new_query}".encode('utf-8')

    h = hmac.new(key.encode('utf-8'), string_to_sign, hashlib.sha256)
    signature = h.hexdigest()

    final_url = f"{updated_url}&sig={signature}"
    return final_url

That's it! An easy-to-integrate solution to host your images. Know any good alternatives? Leave them in the comments below.