File Uploads¶
Handle multipart uploads with consistent patterns per binding.
Upload handler¶
id: python_upload language: python title: Upload tags: - python
from spikard import Spikard, UploadFile
import os
import uuid
from pathlib import Path
app = Spikard()
# Basic upload handler
@app.post("/upload")
async def upload(file: UploadFile) -> dict:
content = file.read()
return {"filename": file.filename, "size": len(content)}
# Complete upload handler with validation and storage
@app.post("/upload/complete")
async def upload_with_validation(file: UploadFile) -> dict:
# Validate file size (10MB limit)
MAX_SIZE = 10 * 1024 * 1024
content = file.read()
if len(content) > MAX_SIZE:
raise ValueError(f"File size {len(content)} exceeds {MAX_SIZE} bytes")
# Validate MIME type
ALLOWED_TYPES = ["image/jpeg", "image/png", "image/gif", "application/pdf"]
if file.content_type not in ALLOWED_TYPES:
raise ValueError(f"File type {file.content_type} not allowed")
# Prevent path traversal - sanitize filename
safe_filename = os.path.basename(file.filename)
unique_filename = f"{uuid.uuid4()}_{safe_filename}"
# Save to local filesystem
upload_dir = Path("/var/uploads")
upload_dir.mkdir(parents=True, exist_ok=True)
file_path = upload_dir / unique_filename
with open(file_path, "wb") as f:
f.write(content)
return {
"filename": safe_filename,
"stored_as": unique_filename,
"size": len(content),
"content_type": file.content_type,
"url": f"/files/{unique_filename}"
}
# Upload to S3/cloud storage
@app.post("/upload/s3")
async def upload_to_s3(file: UploadFile) -> dict:
import boto3
s3_client = boto3.client('s3')
bucket_name = "my-uploads-bucket"
# Validate and sanitize
safe_filename = os.path.basename(file.filename)
s3_key = f"uploads/{uuid.uuid4()}/{safe_filename}"
# Upload to S3
s3_client.put_object(
Bucket=bucket_name,
Key=s3_key,
Body=file.read(),
ContentType=file.content_type
)
return {
"filename": safe_filename,
"s3_key": s3_key,
"url": f"https://{bucket_name}.s3.amazonaws.com/{s3_key}"
}
id: typescript_upload language: typescript title: Upload tags: - typescript
import { Spikard, UploadFile } from "spikard";
import { promises as fs } from "fs";
import path from "path";
import { v4 as uuidv4 } from "uuid";
const app = new Spikard();
// Basic upload handler
app.addRoute(
{ method: "POST", path: "/upload", handler_name: "upload", is_async: true },
async (req) => {
const body = req.json<{ file: UploadFile; description?: string }>();
const size = body.file.size;
return { filename: body.file.filename, size };
},
);
// Complete upload handler with validation and storage
app.addRoute(
{ method: "POST", path: "/upload/complete", handler_name: "uploadComplete", is_async: true },
async (req) => {
const body = req.json<{ file: UploadFile }>();
const file = body.file;
// Validate file size (10MB limit)
const MAX_SIZE = 10 * 1024 * 1024;
if (file.size > MAX_SIZE) {
throw new Error(`File size ${file.size} exceeds ${MAX_SIZE} bytes`);
}
// Validate MIME type
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/gif", "application/pdf"];
if (!ALLOWED_TYPES.includes(file.content_type)) {
throw new Error(`File type ${file.content_type} not allowed`);
}
// Prevent path traversal - sanitize filename
const safeFilename = path.basename(file.filename);
const uniqueFilename = `${uuidv4()}_${safeFilename}`;
// Save to local filesystem
const uploadDir = "/var/uploads";
await fs.mkdir(uploadDir, { recursive: true });
const filePath = path.join(uploadDir, uniqueFilename);
await fs.writeFile(filePath, Buffer.from(file.content));
return {
filename: safeFilename,
stored_as: uniqueFilename,
size: file.size,
content_type: file.content_type,
url: `/files/${uniqueFilename}`,
};
},
);
// Upload to S3/cloud storage
app.addRoute(
{ method: "POST", path: "/upload/s3", handler_name: "uploadToS3", is_async: true },
async (req) => {
const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
const body = req.json<{ file: UploadFile }>();
const file = body.file;
const s3Client = new S3Client({ region: "us-east-1" });
const bucketName = "my-uploads-bucket";
// Validate and sanitize
const safeFilename = path.basename(file.filename);
const s3Key = `uploads/${uuidv4()}/${safeFilename}`;
// Upload to S3
await s3Client.send(
new PutObjectCommand({
Bucket: bucketName,
Key: s3Key,
Body: Buffer.from(file.content),
ContentType: file.content_type,
}),
);
return {
filename: safeFilename,
s3_key: s3Key,
url: `https://${bucketName}.s3.amazonaws.com/${s3Key}`,
};
},
);
id: ruby_upload language: ruby title: Upload tags: - ruby
require "spikard"
require "securerandom"
require "fileutils"
app = Spikard::App.new
# Basic upload handler
app.post "/upload" do |_params, _query, body|
file = body["file"]
{ filename: file[:filename], size: file[:tempfile].size }
end
# Complete upload handler with validation and storage
app.post "/upload/complete" do |_params, _query, body|
file = body["file"]
tempfile = file[:tempfile]
filename = file[:filename]
content_type = file[:type]
# Validate file size (10MB limit)
max_size = 10 * 1024 * 1024
file_size = tempfile.size
raise "File size #{file_size} exceeds #{max_size} bytes" if file_size > max_size
# Validate MIME type
allowed_types = ["image/jpeg", "image/png", "image/gif", "application/pdf"]
raise "File type #{content_type} not allowed" unless allowed_types.include?(content_type)
# Prevent path traversal - sanitize filename
safe_filename = File.basename(filename)
unique_filename = "#{SecureRandom.uuid}_#{safe_filename}"
# Save to local filesystem
upload_dir = "/var/uploads"
FileUtils.mkdir_p(upload_dir)
file_path = File.join(upload_dir, unique_filename)
File.open(file_path, "wb") do |f|
f.write(tempfile.read)
end
{
filename: safe_filename,
stored_as: unique_filename,
size: file_size,
content_type: content_type,
url: "/files/#{unique_filename}"
}
end
# Upload to S3/cloud storage
app.post "/upload/s3" do |_params, _query, body|
require "aws-sdk-s3"
file = body["file"]
tempfile = file[:tempfile]
filename = file[:filename]
content_type = file[:type]
s3_client = Aws::S3::Client.new(region: "us-east-1")
bucket_name = "my-uploads-bucket"
# Validate and sanitize
safe_filename = File.basename(filename)
s3_key = "uploads/#{SecureRandom.uuid}/#{safe_filename}"
# Upload to S3
s3_client.put_object(
bucket: bucket_name,
key: s3_key,
body: tempfile.read,
content_type: content_type
)
{
filename: safe_filename,
s3_key: s3_key,
url: "https://#{bucket_name}.s3.amazonaws.com/#{s3_key}"
}
end
id: php_upload language: php title: Upload tags: - php
<?php
declare(strict_types=1);
use Spikard\App;
use Spikard\Attributes\Post;
use Spikard\Config\ServerConfig;
use Spikard\Http\Request;
use Spikard\Http\Response;
final class UploadController
{
#[Post('/upload')]
public function upload(Request $request): Response
{
$file = $request->files['file'] ?? null;
if ($file === null) {
return Response::json(['error' => 'No file uploaded'], 400);
}
$filename = $file['filename'] ?? 'unknown';
$size = $file['size'] ?? 0;
$contentType = $file['content_type'] ?? 'application/octet-stream';
return Response::json([
'filename' => $filename,
'size' => $size,
'content_type' => $contentType,
'received' => true
]);
}
}
$app = (new App(new ServerConfig(port: 8000)))
->registerController(new UploadController());
id: rust_upload language: rust title: Upload tags: - rust
use spikard::prelude::*;
use spikard::UploadFile;
use std::path::Path;
use tokio::fs;
use uuid::Uuid;
// Basic upload handler
app.route(post("/upload"), |ctx: Context| async move {
let upload: UploadFile = ctx.json()?;
Ok(Json(json!({ "filename": upload.filename, "size": upload.size })))
})?;
// Complete upload handler with validation and storage
app.route(post("/upload/complete"), |ctx: Context| async move {
let upload: UploadFile = ctx.json()?;
// Validate file size (10MB limit)
const MAX_SIZE: usize = 10 * 1024 * 1024;
if upload.content.len() > MAX_SIZE {
return Err(format!("File size {} exceeds {} bytes", upload.content.len(), MAX_SIZE).into());
}
// Validate MIME type
let allowed_types = vec!["image/jpeg", "image/png", "image/gif", "application/pdf"];
if !allowed_types.contains(&upload.content_type.as_str()) {
return Err(format!("File type {} not allowed", upload.content_type).into());
}
// Prevent path traversal - sanitize filename
let safe_filename = Path::new(&upload.filename)
.file_name()
.and_then(|n| n.to_str())
.ok_or("Invalid filename")?;
let unique_filename = format!("{}_{}", Uuid::new_v4(), safe_filename);
// Save to local filesystem
let upload_dir = Path::new("/var/uploads");
fs::create_dir_all(upload_dir).await?;
let file_path = upload_dir.join(&unique_filename);
fs::write(&file_path, &upload.content).await?;
Ok(Json(json!({
"filename": safe_filename,
"stored_as": unique_filename,
"size": upload.content.len(),
"content_type": upload.content_type,
"url": format!("/files/{}", unique_filename)
})))
})?;
// Upload to S3/cloud storage
app.route(post("/upload/s3"), |ctx: Context| async move {
use aws_sdk_s3::Client;
let upload: UploadFile = ctx.json()?;
let config = aws_config::load_from_env().await;
let s3_client = Client::new(&config);
let bucket_name = "my-uploads-bucket";
// Validate and sanitize
let safe_filename = Path::new(&upload.filename)
.file_name()
.and_then(|n| n.to_str())
.ok_or("Invalid filename")?;
let s3_key = format!("uploads/{}/{}", Uuid::new_v4(), safe_filename);
// Upload to S3
s3_client
.put_object()
.bucket(bucket_name)
.key(&s3_key)
.body(upload.content.into())
.content_type(&upload.content_type)
.send()
.await?;
Ok(Json(json!({
"filename": safe_filename,
"s3_key": s3_key,
"url": format!("https://{}.s3.amazonaws.com/{}", bucket_name, s3_key)
})))
})?;
Validation¶
Always validate uploaded files to prevent security issues and resource exhaustion:
File size limits: Prevent denial-of-service attacks by enforcing maximum file sizes before processing.
MIME type validation: Check the content_type field to ensure only expected file types are accepted. Never trust file extensions alone.
Filename sanitization: Use path.basename() or equivalent to prevent directory traversal attacks. Malicious filenames like ../../etc/passwd could overwrite system files.
Content validation: For images, consider using libraries to validate that the file content matches the declared MIME type.
Storage strategies¶
Local filesystem¶
Store files on the server's filesystem for simple deployments:
- Generate unique filenames using UUIDs to prevent collisions
- Create organized directory structures (e.g., by date or user ID)
- Set appropriate file permissions
- Consider disk space monitoring
Cloud storage (S3, GCS, Azure Blob)¶
For production deployments, use object storage:
- Direct uploads reduce server load
- Built-in redundancy and scalability
- CDN integration for faster downloads
- Lifecycle policies for automatic cleanup
Database storage¶
Store small files (< 1MB) directly in databases:
- Simplifies backup and replication
- Keeps data and metadata together
- Not recommended for large files due to performance impact
Testing file uploads¶
Test your upload handlers with different scenarios:
import pytest
from spikard.testing import TestClient
def test_upload_success(client: TestClient):
response = client.post(
"/upload/complete",
files={"file": ("test.jpg", b"fake image data", "image/jpeg")}
)
assert response.status_code == 200
assert response.json()["filename"] == "test.jpg"
def test_upload_exceeds_size_limit(client: TestClient):
large_file = b"x" * (11 * 1024 * 1024) # 11MB
response = client.post(
"/upload/complete",
files={"file": ("large.jpg", large_file, "image/jpeg")}
)
assert response.status_code == 400
def test_upload_invalid_type(client: TestClient):
response = client.post(
"/upload/complete",
files={"file": ("malware.exe", b"fake data", "application/x-msdownload")}
)
assert response.status_code == 400
def test_upload_path_traversal(client: TestClient):
response = client.post(
"/upload/complete",
files={"file": ("../../etc/passwd", b"fake data", "image/jpeg")}
)
# Should sanitize to just "passwd"
assert "../../" not in response.json()["stored_as"]
import { TestClient } from "spikard/testing";
test("upload success", async () => {
const client = new TestClient(app);
const response = await client.post("/upload/complete", {
file: {
filename: "test.jpg",
content: Buffer.from("fake image data"),
content_type: "image/jpeg",
},
});
expect(response.status).toBe(200);
expect(response.json().filename).toBe("test.jpg");
});
test("upload exceeds size limit", async () => {
const client = new TestClient(app);
const largeFile = Buffer.alloc(11 * 1024 * 1024); // 11MB
const response = await client.post("/upload/complete", {
file: {
filename: "large.jpg",
content: largeFile,
content_type: "image/jpeg",
},
});
expect(response.status).toBe(400);
});
require "rack/test"
RSpec.describe "File uploads" do
include Rack::Test::Methods
def app
@app
end
it "uploads file successfully" do
post "/upload/complete", {
file: Rack::Test::UploadedFile.new(
StringIO.new("fake image data"),
"image/jpeg",
original_filename: "test.jpg"
)
}
expect(last_response.status).to eq(200)
expect(JSON.parse(last_response.body)["filename"]).to eq("test.jpg")
end
it "rejects files exceeding size limit" do
large_file = StringIO.new("x" * (11 * 1024 * 1024))
post "/upload/complete", {
file: Rack::Test::UploadedFile.new(
large_file,
"image/jpeg",
original_filename: "large.jpg"
)
}
expect(last_response.status).to eq(400)
end
end
Security considerations¶
- Virus scanning: For user-generated content, integrate antivirus scanning before storage
- Content-Type spoofing: Validate file signatures (magic bytes) in addition to MIME types
- Resource limits: Set timeouts for upload processing to prevent slowloris attacks
- Access control: Verify user permissions before allowing uploads
- Temporary file cleanup: Ensure temporary files are deleted after processing or on errors
Tips¶
- Enforce size/type limits via middleware or schema where supported.
- For large uploads, stream chunks instead of reading all bytes into memory.
- Return metadata (filename, size, type) and store bytes in durable storage.
- Use presigned URLs for direct client-to-S3 uploads to bypass your server entirely.
- Implement rate limiting to prevent abuse of upload endpoints.