File Storage

Keywords: file upload, upload directories, serving files, secure downloads, storage backend, multipart form

UploadedFile

Multipart file uploads are parsed into UploadedFile objects available on request.files.

from asok import Request

def render(request: Request):
    photo = request.files.get("photo")
    if photo:
        print(photo.filename)  # "avatar.jpg"
        print(photo.size)      # 102400
        saved = photo.save("avatar.jpg")
        # Returns actual path (may differ if conflict)

Properties

Property

Type

Description

filename

str

Original upload filename

content

bytes

Raw file content

size

int

File size in bytes

save(destination)

Saves the file to disk. By default, relative paths are resolved relative to src/partials/uploads/.

photo.save("avatar.jpg")        # saves to src/partials/uploads/avatar.jpg
photo.save("imgs/avatar.png")   # saves to src/partials/uploads/imgs/avatar.png

Features:

  • Auto-creates parent directories

  • Handles name conflicts: photo.jpgphoto_1.jpgphoto_2.jpg

  • Returns the actual saved absolute path

MIME Type Validation ⭐ NEW in v0.1.6

Asok provides automatic MIME type validation using magic bytes detection to prevent malicious file uploads. This security feature validates files based on their actual content, not just their extension.

Basic Usage

from asok import Request

def render(request: Request):
    photo = request.files.get("photo")
    if photo:
        try:
            # Validate and save - only allow images
            photo.save("uploads/", allowed_types=['image/jpeg', 'image/png'])
        except ValueError as e:
            return f"Error: {e}"

Validation Parameters

The save() method accepts these validation parameters:

photo.save(
    destination="uploads/",
    validate=True,  # Enable MIME validation (default: True)
    allowed_types=['image/jpeg', 'image/png'],  # Whitelist of MIME types
    secure_filename=True,  # Rename with UUID (default: True)
    private=True  # Restrict permissions to owner-only (default: False)
)

Parameters:

  • validate (bool): Enable/disable validation. Default: True

  • allowed_types (list): Whitelist of allowed MIME types. If None, accepts all validated types (⚠️ warning logged)

  • secure_filename (bool): Rename file with UUID for security. Default: True

  • private (bool): Restrict permissions to owner-only (local 0o600 instead of 0o644, or S3 "private" ACL). Default: False

Supported File Types (50+ formats)

Asok validates files using magic bytes (file signatures) for accuracy:

🖼️ Images (11 formats)

allowed_types = [
    'image/jpeg',      # .jpg, .jpeg
    'image/png',       # .png
    'image/gif',       # .gif
    'image/webp',      # .webp (modern format)
    'image/bmp',       # .bmp
    'image/tiff',      # .tif, .tiff
    'image/x-icon',    # .ico
    'image/svg+xml',   # .svg (vector)
]

🎵 Audio (8 formats)

allowed_types = [
    'audio/mpeg',      # .mp3
    'audio/wav',       # .wav
    'audio/flac',      # .flac (lossless)
    'audio/ogg',       # .ogg, .oga
    'audio/aac',       # .aac
    'audio/mp4',       # .m4a
]

🎬 Video (6 formats)

allowed_types = [
    'video/mp4',       # .mp4 (H.264)
    'video/webm',      # .webm (VP8/VP9)
    'video/x-matroska',# .mkv
    'video/avi',       # .avi
    'video/quicktime', # .mov
    'video/3gpp',      # .3gp (mobile)
]

📄 Documents & Archives

allowed_types = [
    'application/pdf',              # .pdf
    'application/zip',              # .zip, .docx, .xlsx, .pptx
    'application/msword',           # .doc, .xls, .ppt (legacy)
    'text/rtf',                     # .rtf
    'application/gzip',             # .gz
    'application/x-bzip2',          # .bz2
    'application/x-rar-compressed', # .rar
    'application/x-7z-compressed',  # .7z
]

Advanced Validation

Standalone Validation

You can validate files without saving:

photo = request.files.get("photo")
try:
    photo.validate_mime_type(allowed_types=['image/jpeg', 'image/png'])
    print("✅ File is valid")
except ValueError as e:
    print(f"❌ Invalid file: {e}")

Multiple File Types

# Accept images and PDFs
allowed = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf']
document.save("docs/", allowed_types=allowed)

Security Features

  1. Magic Bytes Detection: Files are validated by their actual content, not extension

    # A .jpg renamed to .txt will still be detected as image/jpeg
    
  2. Extension Matching: File extension must match the detected MIME type

    # A PNG file with .jpg extension will be rejected
    
  3. Secure Filenames: Files are renamed with UUID by default

    photo.save("uploads/")  # → uploads/a3f2b9c1-4567-89ab-cdef.jpg
    
  4. Restrictive Permissions: Saved files get 0o644 (rw-r–r–) permissions by default, but can be locked down to 0o600 (rw——-, owner only) using private=True (e.g. for sensitive/non-public uploads).

  5. Security Warnings: Logs warning if allowed_types not specified

    # ⚠️ Generates security warning
    photo.save("uploads/")  # No allowed_types!
    
    # ✅ Recommended
    photo.save("uploads/", allowed_types=['image/jpeg'])
    

Error Handling

from asok import Request

def render(request: Request):
    photo = request.files.get("photo")
    if not photo:
        return "No file uploaded"

    try:
        path = photo.save(
            "avatars/",
            allowed_types=['image/jpeg', 'image/png', 'image/webp']
        )
        return f"✅ File saved: {path}"

    except ValueError as e:
        # Handle validation errors
        if "not allowed" in str(e):
            return "Only JPEG, PNG, and WebP images are allowed"
        elif "does not match" in str(e):
            return "File extension doesn't match file type"
        else:
            return f"Validation error: {e}"

Best Practices

DO:

  • Always specify allowed_types for security

  • Use the most restrictive whitelist possible

  • Keep validate=True (default)

  • Use secure_filename=True for public uploads

# ✅ Secure upload
avatar.save("avatars/", allowed_types=['image/jpeg', 'image/png'])

DON’T:

  • Don’t disable validation (validate=False)

  • Don’t allow all types without restriction

  • Don’t trust file extensions alone

# ❌ Insecure - accepts any file!
file.save("uploads/", validate=False)

# ⚠️ Less secure - accepts any valid type
file.save("uploads/")  # allowed_types=None

Performance Notes

  • Validation reads only the first few bytes of each file (magic bytes)

  • Minimal performance impact even for large files

  • Files are validated before writing to disk

Accessing files

request.files is a dictionary of UploadedFile objects. You can access them using bracket notation or .get() (safest):

# Safest: returns None if missing
photo = request.files.get("photo")

# Alternative: raises KeyError if missing
photo = request.files["photo"]

# Accessing properties (preferred)
print(photo.filename)

# Dict-style access (backward compatibility)
print(request.files["photo"]["filename"])

Serving Files

Use request.send_file() to return a file to the browser. Relative paths are automatically resolved relative to src/partials/uploads/.

from asok import Request

def render(request: Request):
    # Resolves to src/partials/uploads/report.pdf
    return request.send_file("report.pdf")

    # Resolves to src/partials/uploads/pdf/cv.pdf
    return request.send_file("pdf/cv.pdf")

    # Force download with custom name
    return request.send_file("data.csv", filename="export.csv")

    # Display image in browser (inline)
    return request.send_file("header.png", as_attachment=False)

Path Resolution

All paths passed to request.send_file() are resolved relative to src/partials/uploads/ (leading and root slash prefixes are stripped for security).

For security, request.send_file() only allows serving files from within the src/partials/uploads directory. Attempts to escape or access files outside this directory will return a 403 Forbidden error.

Configuration

1. Upload Size Limits

You can limit the maximum upload size globally in your Asok app configuration:

# wsgi.py
app = Asok()
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024  # 16 MB limit

Default is 10 MB. If a request exceeds this limit, Asok returns a 413 Payload Too Large error.

2. Storage Backends (S3 Cloud Storage)

Asok supports abstract storage backends. By default, it uses local storage. You can switch to s3 for storing uploads in Amazon S3 or S3-compatible endpoints (like MinIO or DigitalOcean Spaces).

To use S3, install the optional extra:

pip install "asok[s3]"

Configure your .env file:

# Enable S3 backend (default: local)
ASOK_STORAGE_BACKEND=s3

# S3 Credentials
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key

# S3 Bucket configuration
ASOK_S3_BUCKET=your-bucket-name
ASOK_S3_REGION=us-east-1

# Optional: Endpoint for S3-compatible storage (e.g. MinIO)
# ASOK_S3_ENDPOINT=http://localhost:9000

# Optional: Custom CDN Domain for URLs
# ASOK_S3_CUSTOM_DOMAIN=cdn.myapp.com

When s3 is enabled:

  • photo.save() automatically uploads to S3 and returns the public S3 URL of the file.

  • Your database (Field.File fields) continues to store the raw filename, maintaining storage independence.

  • When retrieving the field, the FileRef string automatically resolves to the S3 cloud URL.