Get the FREE Ultimate OpenClaw Setup Guide →

file-storage

npx machina-cli add skill mrsknetwork/supernova/file-storage --openclaw
Files (1)
SKILL.md
6.7 KB

File Storage Engineering (S3 / Cloudflare R2)

Purpose

File storage is deceptively simple to get wrong. Storing uploads on the local filesystem breaks horizontally scaled deployments. Serving files through your API server adds latency and unnecessary load. Using the original user-supplied filename is a name collision and security vulnerability. This skill implements the production pattern: secure upload → cloud object storage → CDN delivery.

Storage Provider Decision

ScenarioChoose
Already on AWSS3 (native, IAM integration)
Want S3-compatible but cheaper egressCloudflare R2 (S3-compatible API, zero egress fees)
Video files / large mediaS3 or R2 with multipart upload

Both use the same boto3 client in Python.

SOP: File Storage

Step 1 - Setup

uv pip install boto3 Pillow python-magic
# config.py additions
class Settings(BaseSettings):
    AWS_ACCESS_KEY_ID: str
    AWS_SECRET_ACCESS_KEY: str
    AWS_REGION: str = "us-east-1"
    S3_BUCKET: str
    CDN_BASE_URL: str  # your CloudFront or R2 custom domain
    # For Cloudflare R2, also set:
    R2_ENDPOINT_URL: str | None = None  # https://<account>.r2.cloudflarestorage.com
# integrations/storage.py
import boto3
from src.config import settings

def get_s3_client():
    kwargs = dict(
        aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
        aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
        region_name=settings.AWS_REGION,
    )
    if settings.R2_ENDPOINT_URL:
        kwargs["endpoint_url"] = settings.R2_ENDPOINT_URL  # Cloudflare R2 override
    return boto3.client("s3", **kwargs)

s3 = get_s3_client()

Step 2 - Secure Upload Endpoint (FastAPI)

# api/v1/uploads.py
import uuid, magic
from PIL import Image
from io import BytesIO
from fastapi import UploadFile, HTTPException

ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
MAX_FILE_SIZE = 10 * 1024 * 1024  # 10MB

@router.post("/uploads/image", response_model=SuccessResponse[UploadOut])
async def upload_image(
    file: UploadFile,
    current_user: User = Depends(get_current_user),
):
    # 1. Read file into memory
    content = await file.read()
    if len(content) > MAX_FILE_SIZE:
        raise HTTPException(422, "File exceeds 10MB limit")

    # 2. Detect MIME type from file content (NEVER trust Content-Type header)
    detected_mime = magic.from_buffer(content[:2048], mime=True)
    if detected_mime not in ALLOWED_IMAGE_TYPES:
        raise HTTPException(422, f"File type '{detected_mime}' not allowed")

    # 3. Resize large images to max 2048px wide (saves storage + bandwidth)
    content = _resize_if_needed(content, max_width=2048)

    # 4. Generate a safe, unique filename - never use the user's filename
    extension = detected_mime.split("/")[1].replace("jpeg", "jpg")
    s3_key = f"uploads/{current_user.id}/{uuid.uuid4()}.{extension}"

    # 5. Upload to S3/R2
    s3.put_object(
        Bucket=settings.S3_BUCKET,
        Key=s3_key,
        Body=content,
        ContentType=detected_mime,
        CacheControl="public, max-age=31536000",  # 1 year (files are immutable by key)
    )

    public_url = f"{settings.CDN_BASE_URL}/{s3_key}"
    return SuccessResponse(data=UploadOut(url=public_url, key=s3_key))


def _resize_if_needed(content: bytes, max_width: int) -> bytes:
    img = Image.open(BytesIO(content))
    if img.width > max_width:
        ratio = max_width / img.width
        new_size = (max_width, int(img.height * ratio))
        img = img.resize(new_size, Image.LANCZOS)
        output = BytesIO()
        img.save(output, format=img.format or "JPEG", optimize=True, quality=85)
        return output.getvalue()
    return content

Step 3 - Pre-signed Upload URLs (Frontend Direct Upload)

For large files or when you want to bypass your API server for upload bandwidth, use pre-signed URLs. The browser uploads directly to S3 — your server never touches the file bytes.

# services/storage_service.py
def generate_presigned_upload_url(s3_key: str, content_type: str, expires_in: int = 300) -> dict:
    """Returns a URL the browser POSTs to directly. Expires in 5 minutes."""
    url = s3.generate_presigned_url(
        "put_object",
        Params={"Bucket": settings.S3_BUCKET, "Key": s3_key, "ContentType": content_type},
        ExpiresIn=expires_in,
    )
    return {"upload_url": url, "key": s3_key, "public_url": f"{settings.CDN_BASE_URL}/{s3_key}"}

# Route: client calls this first, gets a URL, uploads directly, then sends us the key
@router.post("/uploads/presign")
async def get_upload_url(content_type: str, current_user: User = Depends(get_current_user)):
    if content_type not in ALLOWED_IMAGE_TYPES:
        raise HTTPException(422, "Content type not allowed")
    s3_key = f"uploads/{current_user.id}/{uuid.uuid4()}"
    return SuccessResponse(data=generate_presigned_upload_url(s3_key, content_type))

Frontend flow:

async function uploadAvatar(file: File) {
  // 1. Get pre-signed URL from our API
  const { data } = await fetch("/api/v1/uploads/presign?content_type=" + file.type).then(r => r.json());

  // 2. Upload directly to S3 - no proxy through our server
  await fetch(data.upload_url, { method: "PUT", body: file, headers: { "Content-Type": file.type } });

  // 3. Save the public URL to our DB via our API
  await fetch("/api/v1/users/me", { method: "PATCH", body: JSON.stringify({ avatar_url: data.public_url }) });
}

Step 4 - Delete Files

async def delete_file(s3_key: str) -> None:
    s3.delete_object(Bucket=settings.S3_BUCKET, Key=s3_key)
    # Also update DB to clear the URL reference

When a user deletes their account or replaces their avatar, always delete the old S3 object to avoid orphaned storage costs.

Step 5 - S3 Bucket Security Configuration

// Bucket policy (public read for CDN, no public write)
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "cloudfront.amazonaws.com" },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::your-bucket/*"
    }
  ]
}

Block all public access on the bucket itself. Serve through CloudFront or R2's CDN domain only.

Source

git clone https://github.com/mrsknetwork/supernova/blob/main/skills/file-storage/SKILL.mdView on GitHub

Overview

File storage streams user uploads to cloud object storage (AWS S3 or Cloudflare R2) and serves them through a CDN. It validates MIME on the server, resizes images with Pillow, and avoids local filesystem storage in production. Ideal for profile pics, attachments, product images, and other user-generated media.

How This Skill Works

Configure credentials and a bucket, then instantiate a boto3 S3 client (with optional R2 endpoint). The upload flow reads the file, validates MIME using content-based detection, resizes images to a max width, generates a safe, unique key under uploads/{user_id}/{uuid}.{ext}, uploads to S3/R2 with proper Content-Type and long Cache-Control, and returns a CDN-backed public URL for delivery.

When to Use It

  • When users upload profile pictures or avatars
  • When attaching documents or other files to records
  • When hosting product images or media that benefit from CDN delivery
  • When migrating from local filesystem storage to cloud storage in production
  • When you need secure, MIME-validated uploads with image resizing

Quick Start

  1. Step 1: Install dependencies (boto3, Pillow, python-magic) and configure AWS/R2 credentials and CDN_BASE_URL
  2. Step 2: Implement get_s3_client() and initialize s3 = get_s3_client() in your storage module
  3. Step 3: Create a secure upload endpoint that validates MIME, resizes images, stores to S3/R2, and returns a CDN-backed URL

Best Practices

  • Validate MIME from file content, not the client-provided header
  • Enforce a sensible size limit (e.g., 10 MB) to prevent abuse
  • Generate safe, unique filenames and avoid using user-supplied names
  • Store files under a per-user path (uploads/{user_id}/...) and use a CDN URL with Cache-Control
  • Consider using pre-signed URLs for client-side uploads when appropriate

Example Use Cases

  • User uploads an avatar; file stored at uploads/{user_id}/{uuid}.jpg and served via CDN_BASE_URL
  • Document attachment added to a record; stored under a per-user path and delivered through CDN
  • Product image uploaded for a catalog page; resized, stored in S3/R2, served by CDN
  • User-generated media in a post or gallery with immutable key-based URLs
  • Migration from local storage to cloud storage using per-user keys and CDN delivery

Frequently Asked Questions

Add this skill to your agents
Sponsor this space

Reach thousands of developers