Upload Files to Cloudflare R2 Storage

class CloudflareR2Uploader
{
    private string $accountId;
    private string $accessKey;
    private string $secretKey;
    private string $bucketName;
    private array $mimeOverrides = [
        'pdf' => 'application/pdf',
        'jpg' => 'image/jpeg',
        'jpeg' => 'image/jpeg',
        'png' => 'image/png',
        'gif' => 'image/gif',
        'svg' => 'image/svg+xml',
        'html' => 'text/html',
        'txt' => 'text/plain'
    ];

    public function __construct(string $accountId, string $accessKey, string $secretKey, string $bucketName)
    {
        $this->accountId = $accountId;
        $this->accessKey = $accessKey;
        $this->secretKey = $secretKey;
        $this->bucketName = $bucketName;
    }

    public function uploadFromFile(string $filePath, string $objectKey): string
    {
        return $this->upload($filePath, $objectKey);
    }

    public function uploadFromUrl(string $imageUrl, string $objectKey = null): string
    {
        $imageData = $this->downloadImage($imageUrl);
        if ($objectKey === null) {
            $objectKey = $this->deriveObjectKeyFromUrl($imageUrl);
        }
        return $this->uploadData($imageData, $objectKey);
    }

    private function downloadImage(string $url): string
    {
        $ch = curl_init($url);
        if ($ch === false) {
            throw new Exception("Failed to initialize cURL.");
        }
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_MAXREDIRS => 5,
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_SSL_VERIFYPEER => true,
        ]);
        $data = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $error = curl_error($ch);
        curl_close($ch);
        if ($error) {
            throw new Exception("cURL Error while downloading image: $error");
        }
        if ($httpCode !== 200) {
            throw new Exception("Failed to download image. HTTP Status Code: $httpCode");
        }
        return $data;
    }

    private function deriveObjectKeyFromUrl(string $url): string
    {
        $path = parse_url($url, PHP_URL_PATH);
        $fileName = basename($path);
        if (empty($fileName)) {
            $fileName = uniqid('image_', true) . '.jpg';
        }
        return $fileName;
    }

    private function uploadData(string $data, string $objectKey): string
    {
        $url = "https://{$this->accountId}.r2.cloudflarestorage.com/{$this->bucketName}/$objectKey";
        $payloadHash = hash('sha256', $data);
        $mimeType = $this->getMimeTypeFromData($data, $objectKey);
        $timestamp = gmdate('Ymd\THis\Z');
        $date = gmdate('Ymd');
        $canonicalRequest = $this->createCanonicalRequest('PUT', $objectKey, '', $payloadHash, $timestamp);
        $signature = $this->generateSignature($canonicalRequest, $timestamp, $date);
        $response = $this->sendRequest($url, $data, $signature, $timestamp, $payloadHash, $mimeType);
        if ($response['httpCode'] >= 200 && $response['httpCode'] < 300) {
            return $url;
        } else {
            throw new Exception("Failed to upload file. HTTP Code: {$response['httpCode']}. Response: {$response['response']}");
        }
    }

    public function upload(string $filePath, string $objectKey): string
    {
        if (!filter_var($filePath, FILTER_VALIDATE_URL)) {
            if (!file_exists($filePath)) {
                throw new Exception("File does not exist: $filePath");
            }
            $fileContent = file_get_contents($filePath);
            if ($fileContent === false) {
                throw new Exception("Unable to read the file: $filePath");
            }
            return $this->uploadData($fileContent, $objectKey);
        } else {
            return $this->uploadFromUrl($filePath, $objectKey);
        }
    }

    private function getMimeTypeFromData(string $data, string $filePath): string
    {
        $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
        if (array_key_exists($extension, $this->mimeOverrides)) {
            return $this->mimeOverrides[$extension];
        }
        $finfo = new finfo(FILEINFO_MIME_TYPE);
        $mimeType = $finfo->buffer($data);
        return $mimeType ?: 'application/octet-stream';
    }

    private function createCanonicalRequest(string $method, string $objectKey, string $queryString, string $payloadHash, string $timestamp): string
    {
        $path = "/{$this->bucketName}/$objectKey";
        $canonicalHeaders = [
            'host' => "{$this->accountId}.r2.cloudflarestorage.com",
            'x-amz-content-sha256' => $payloadHash,
            'x-amz-date' => $timestamp
        ];
        if ($method === 'PUT') {
            $canonicalHeaders['x-amz-acl'] = 'public-read';
        }
        ksort($canonicalHeaders);
        $canonicalHeadersStr = '';
        foreach ($canonicalHeaders as $key => $value) {
            $canonicalHeadersStr .= strtolower($key) . ':' . trim($value) . "\n";
        }
        $signedHeaders = implode(';', array_keys($canonicalHeaders));
        $signedHeaders = strtolower($signedHeaders);
        return "$method\n$path\n$queryString\n$canonicalHeadersStr\n$signedHeaders\n$payloadHash";
    }

    private function generateSignature(string $canonicalRequest, string $timestamp, string $date): string
    {
        $region = "auto";
        $service = "s3";
        $credentialScope = "$date/$region/$service/aws4_request";
        $hashCanonicalRequest = hash('sha256', $canonicalRequest);
        $stringToSign = "AWS4-HMAC-SHA256\n$timestamp\n$credentialScope\n$hashCanonicalRequest";
        $kDate = hash_hmac('sha256', $date, "AWS4{$this->secretKey}", true);
        $kRegion = hash_hmac('sha256', $region, $kDate, true);
        $kService = hash_hmac('sha256', $service, $kRegion, true);
        $kSigning = hash_hmac('sha256', 'aws4_request', $kService, true);
        $signature = hash_hmac('sha256', $stringToSign, $kSigning);
        return "AWS4-HMAC-SHA256 Credential={$this->accessKey}/$credentialScope, SignedHeaders=host;x-amz-acl;x-amz-content-sha256;x-amz-date, Signature=$signature";
    }

    private function sendRequest(string $url, string $fileContent, string $authHeader, string $timestamp, string $payloadHash, string $mimeType): array
    {
        $curl = curl_init();
        if ($curl === false) {
            throw new Exception("Failed to initialize cURL for upload.");
        }
        curl_setopt_array($curl, [
            CURLOPT_URL => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CUSTOMREQUEST => 'PUT',
            CURLOPT_POSTFIELDS => $fileContent,
            CURLOPT_HTTPHEADER => [
                "Authorization: $authHeader",
                "x-amz-acl: public-read",
                "x-amz-content-sha256: $payloadHash",
                "x-amz-date: $timestamp",
                "Content-Type: $mimeType",
                "Content-Disposition: inline",
                "Content-Length: " . strlen($fileContent)
            ],
            CURLOPT_SSL_VERIFYPEER => true,
        ]);
        $response = curl_exec($curl);
        $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
        $error = curl_error($curl);
        curl_close($curl);
        if ($error) {
            throw new Exception("cURL Error during upload: $error");
        }
        return ['response' => $response, 'httpCode' => $httpCode];
    }
}

Usage Example

function cloudflare_upload($url, $key) {
    $uploader = new CloudflareR2Uploader(
        $accountId,
        $accessKey,
        $accessSecret,
        $bucket
    );
    try {
        return $uploader->uploadFromUrl($url, $key);
    } catch (Exception $e) {
        echo "Error: " . $e->getMessage() . "\n";
    }
}