Agent skill
file-upload-handling
Implement secure file uploads with validation, size limits, type checking, virus scanning, and UUID naming. Use when handling file uploads like profile photos, documents, or resources.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/data/file-upload-handling
SKILL.md
You implement secure file upload handling for the QA Team Portal.
When to Use This Skill
- Implementing file upload endpoints
- Adding profile photo uploads
- Handling resource uploads (PDFs, videos, documents)
- Validating uploaded files
- Securing file storage
- Generating thumbnails
Security Requirements
From SECURITY_CHECKLIST.md:
- ✅ File type validation (whitelist only)
- ✅ Virus scanning (ClamAV or similar)
- ✅ Size limits enforced
- ✅ Secure file naming (UUIDs)
- ✅ Storage outside web root
- ✅ Access control on file retrieval
Implementation
1. File Upload Service
Location: backend/app/services/file_service.py
import os
import uuid
import magic
import aiofiles
from pathlib import Path
from fastapi import UploadFile, HTTPException
from app.core.config import settings
class FileUploadService:
"""Handle file uploads securely."""
ALLOWED_TYPES = {
'image': {
'extensions': ['.jpg', '.jpeg', '.png', '.gif', '.webp'],
'mime_types': ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
'max_size': 5 * 1024 * 1024 # 5MB
},
'document': {
'extensions': ['.pdf', '.docx', '.pptx', '.xlsx'],
'mime_types': [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
],
'max_size': 50 * 1024 * 1024 # 50MB
},
'video': {
'extensions': ['.mp4', '.webm', '.mov'],
'mime_types': ['video/mp4', 'video/webm', 'video/quicktime'],
'max_size': 100 * 1024 * 1024 # 100MB
}
}
def __init__(self, upload_dir: str = None):
"""Initialize file upload service."""
self.upload_dir = Path(upload_dir or settings.UPLOAD_DIR)
self.upload_dir.mkdir(parents=True, exist_ok=True)
async def validate_file(
self,
file: UploadFile,
file_type: str
) -> tuple[bool, str]:
"""
Validate uploaded file.
Args:
file: Uploaded file
file_type: Type category (image, document, video)
Returns:
(is_valid, error_message)
"""
if file_type not in self.ALLOWED_TYPES:
return False, f"Invalid file type: {file_type}"
allowed = self.ALLOWED_TYPES[file_type]
# Check file extension
file_ext = Path(file.filename).suffix.lower()
if file_ext not in allowed['extensions']:
return False, f"File extension {file_ext} not allowed for {file_type}"
# Read file content for MIME type checking
content = await file.read()
await file.seek(0) # Reset file pointer
# Check file size
file_size = len(content)
if file_size > allowed['max_size']:
max_mb = allowed['max_size'] / (1024 * 1024)
return False, f"File size exceeds {max_mb}MB limit"
if file_size == 0:
return False, "File is empty"
# Check MIME type using python-magic
mime_type = magic.from_buffer(content, mime=True)
if mime_type not in allowed['mime_types']:
return False, f"Invalid file type. Expected {file_type}, got {mime_type}"
# Check for malicious content (basic)
if self._contains_malicious_content(content, file_ext):
return False, "File contains potentially malicious content"
return True, "File is valid"
def _contains_malicious_content(self, content: bytes, ext: str) -> bool:
"""
Basic malicious content detection.
For production, integrate with ClamAV or similar.
"""
# Check for executable signatures
dangerous_signatures = [
b'MZ', # Windows executable
b'\x7fELF', # Linux executable
b'#!', # Script shebang
b'<?php', # PHP code
b'<script', # JavaScript in documents
]
content_lower = content[:1024].lower()
for signature in dangerous_signatures:
if signature.lower() in content_lower:
return True
# Check for null bytes (can bypass some filters)
if b'\x00' in content[:1024]:
if ext not in ['.jpg', '.jpeg', '.png', '.gif']: # Images can have null bytes
return True
return False
async def save_file(
self,
file: UploadFile,
file_type: str,
subfolder: str = None
) -> dict:
"""
Save uploaded file securely.
Args:
file: Uploaded file
file_type: Type category
subfolder: Optional subfolder (profiles, resources, etc.)
Returns:
Dict with file info (filename, path, url, size)
"""
# Validate file first
is_valid, error = await self.validate_file(file, file_type)
if not is_valid:
raise HTTPException(400, error)
# Generate secure filename
file_ext = Path(file.filename).suffix.lower()
secure_filename = f"{uuid.uuid4()}{file_ext}"
# Determine save path
save_dir = self.upload_dir
if subfolder:
save_dir = save_dir / subfolder
save_dir.mkdir(parents=True, exist_ok=True)
file_path = save_dir / secure_filename
# Save file
try:
async with aiofiles.open(file_path, 'wb') as f:
content = await file.read()
await f.write(content)
except Exception as e:
raise HTTPException(500, f"Failed to save file: {str(e)}")
# Get file info
file_size = file_path.stat().st_size
relative_path = file_path.relative_to(self.upload_dir)
return {
'filename': secure_filename,
'original_filename': file.filename,
'path': str(relative_path),
'url': f"/files/{relative_path}",
'size': file_size,
'mime_type': file.content_type
}
async def delete_file(self, file_path: str) -> bool:
"""
Delete uploaded file.
Args:
file_path: Relative path to file
Returns:
True if deleted successfully
"""
full_path = self.upload_dir / file_path
if not full_path.exists():
return False
# Security: Ensure path is within upload directory
if not str(full_path.resolve()).startswith(str(self.upload_dir.resolve())):
raise HTTPException(403, "Access denied")
try:
full_path.unlink()
return True
except Exception as e:
raise HTTPException(500, f"Failed to delete file: {str(e)}")
async def scan_with_clamav(self, file_path: Path) -> tuple[bool, str]:
"""
Scan file with ClamAV antivirus (optional).
Requires ClamAV to be installed and clamd daemon running.
"""
try:
import pyclamd
cd = pyclamd.ClamdUnixSocket()
if not cd.ping():
return True, "ClamAV not available, skipping scan"
result = cd.scan_file(str(file_path))
if result is None:
return True, "File is clean"
else:
return False, f"Virus detected: {result}"
except ImportError:
return True, "ClamAV not installed, skipping scan"
except Exception as e:
return True, f"Scan error: {str(e)}"
2. File Upload Endpoints
Location: backend/app/api/v1/endpoints/upload.py
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
from app.services.file_service import FileUploadService
from app.api.deps import get_current_active_admin
router = APIRouter()
file_service = FileUploadService()
@router.post("/upload/profile")
async def upload_profile_photo(
file: UploadFile = File(...),
current_user = Depends(get_current_active_admin)
):
"""
Upload profile photo (admin only).
Max size: 5MB
Allowed: JPG, PNG, GIF, WebP
"""
result = await file_service.save_file(
file,
file_type='image',
subfolder='profiles'
)
return result
@router.post("/upload/resource")
async def upload_resource(
file: UploadFile = File(...),
current_user = Depends(get_current_active_admin)
):
"""
Upload resource file (admin only).
Max size: 50MB for documents, 100MB for videos
Allowed: PDF, DOCX, PPTX, MP4
"""
# Determine file type based on extension
file_ext = Path(file.filename).suffix.lower()
if file_ext in ['.mp4', '.webm', '.mov']:
file_type = 'video'
elif file_ext in ['.pdf', '.docx', '.pptx']:
file_type = 'document'
else:
raise HTTPException(400, "Unsupported file type")
result = await file_service.save_file(
file,
file_type=file_type,
subfolder='resources'
)
return result
@router.post("/upload/tool-icon")
async def upload_tool_icon(
file: UploadFile = File(...),
current_user = Depends(get_current_active_admin)
):
"""Upload tool icon (admin only)."""
result = await file_service.save_file(
file,
file_type='image',
subfolder='tools'
)
return result
@router.delete("/upload/{file_path:path}")
async def delete_file(
file_path: str,
current_user = Depends(get_current_active_admin)
):
"""Delete uploaded file (admin only)."""
success = await file_service.delete_file(file_path)
if not success:
raise HTTPException(404, "File not found")
return {"message": "File deleted successfully"}
3. File Serving with Access Control
Location: backend/app/api/v1/endpoints/files.py
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from pathlib import Path
from app.core.config import settings
router = APIRouter()
@router.get("/files/{file_path:path}")
async def serve_file(file_path: str):
"""
Serve uploaded file.
Public access for now, add authentication if needed.
"""
full_path = Path(settings.UPLOAD_DIR) / file_path
# Security: Prevent path traversal
if not str(full_path.resolve()).startswith(str(Path(settings.UPLOAD_DIR).resolve())):
raise HTTPException(403, "Access denied")
if not full_path.exists():
raise HTTPException(404, "File not found")
return FileResponse(
full_path,
media_type='application/octet-stream',
filename=full_path.name
)
4. Frontend File Upload Component
Location: frontend/src/components/shared/FileUploader.tsx
import { useState } from 'react'
import { Upload, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
interface FileUploaderProps {
onUpload: (file: File) => Promise<any>
accept: string
maxSize: number // in MB
label: string
}
export const FileUploader = ({
onUpload,
accept,
maxSize,
label
}: FileUploaderProps) => {
const [file, setFile] = useState<File | null>(null)
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [error, setError] = useState<string | null>(null)
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]
if (!selectedFile) return
// Validate file size
if (selectedFile.size > maxSize * 1024 * 1024) {
setError(`File size must be less than ${maxSize}MB`)
return
}
setFile(selectedFile)
setError(null)
}
const handleUpload = async () => {
if (!file) return
setUploading(true)
setProgress(0)
setError(null)
try {
const formData = new FormData()
formData.append('file', file)
// Simulate progress (in real app, use axios onUploadProgress)
const interval = setInterval(() => {
setProgress(prev => Math.min(prev + 10, 90))
}, 200)
await onUpload(file)
clearInterval(interval)
setProgress(100)
// Reset after success
setTimeout(() => {
setFile(null)
setProgress(0)
setUploading(false)
}, 1000)
} catch (err: any) {
setError(err.response?.data?.detail || 'Upload failed')
setUploading(false)
setProgress(0)
}
}
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<input
type="file"
accept={accept}
onChange={handleFileChange}
disabled={uploading}
className="hidden"
id="file-upload"
/>
<label htmlFor="file-upload">
<Button
variant="outline"
disabled={uploading}
asChild
>
<span>
<Upload className="mr-2 h-4 w-4" />
{label}
</span>
</Button>
</label>
{file && (
<div className="flex items-center gap-2">
<span className="text-sm">{file.name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => setFile(null)}
disabled={uploading}
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
{file && !uploading && (
<Button onClick={handleUpload}>
Upload File
</Button>
)}
{uploading && (
<Progress value={progress} />
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
)
}
5. Usage Example
// In TeamForm component
import { FileUploader } from '@/components/shared/FileUploader'
import { uploadProfilePhoto } from '@/services/uploadService'
const TeamForm = () => {
const handlePhotoUpload = async (file: File) => {
const result = await uploadProfilePhoto(file)
// Update form with photo URL
form.setValue('profilePhotoUrl', result.url)
}
return (
<form>
{/* Other fields */}
<FileUploader
onUpload={handlePhotoUpload}
accept="image/jpeg,image/png,image/gif,image/webp"
maxSize={5}
label="Choose Profile Photo"
/>
</form>
)
}
6. Configuration
Location: backend/app/core/config.py
class Settings(BaseSettings):
# File Upload
UPLOAD_DIR: str = "./storage/uploads"
MAX_UPLOAD_SIZE: int = 52428800 # 50MB
ALLOWED_EXTENSIONS: list[str] = [
"pdf", "pptx", "docx", "mp4", "jpg", "png", "gif", "webp"
]
7. Dependencies
Add to backend/pyproject.toml:
dependencies = [
"aiofiles>=23.2.1",
"python-magic>=0.4.27",
"python-multipart>=0.0.9",
# Optional for virus scanning
# "py-clamd>=0.5.0",
]
Testing
# tests/integration/test_api_upload.py
import pytest
from pathlib import Path
def test_upload_valid_image(client, admin_token):
headers = {"Authorization": f"Bearer {admin_token}"}
# Create test image
test_file = ("test.jpg", b"fake image content", "image/jpeg")
response = client.post(
"/api/v1/upload/profile",
files={"file": test_file},
headers=headers
)
assert response.status_code == 200
assert "filename" in response.json()
assert "url" in response.json()
def test_upload_invalid_extension(client, admin_token):
headers = {"Authorization": f"Bearer {admin_token}"}
test_file = ("test.exe", b"fake content", "application/x-msdownload")
response = client.post(
"/api/v1/upload/profile",
files={"file": test_file},
headers=headers
)
assert response.status_code == 400
assert "not allowed" in response.json()["detail"].lower()
def test_upload_exceeds_size_limit(client, admin_token):
headers = {"Authorization": f"Bearer {admin_token}"}
# Create file larger than 5MB
large_content = b"x" * (6 * 1024 * 1024)
test_file = ("large.jpg", large_content, "image/jpeg")
response = client.post(
"/api/v1/upload/profile",
files={"file": test_file},
headers=headers
)
assert response.status_code == 400
assert "exceeds" in response.json()["detail"].lower()
def test_upload_requires_authentication(client):
test_file = ("test.jpg", b"content", "image/jpeg")
response = client.post(
"/api/v1/upload/profile",
files={"file": test_file}
)
assert response.status_code == 401
Security Checklist
- ✅ File type validated by extension AND MIME type
- ✅ File size limits enforced (5MB images, 50MB documents, 100MB videos)
- ✅ Filenames sanitized (UUID-based)
- ✅ Files stored outside web root (./storage/uploads)
- ✅ Basic malicious content detection
- ✅ Path traversal prevented
- ✅ Authentication required for uploads
- ✅ Optional ClamAV virus scanning support
- ⚠️ Add rate limiting for upload endpoints
- ⚠️ Consider adding watermarks to images
- ⚠️ Integrate ClamAV in production
Report Format
After implementation:
- ✅ File upload service created
- ✅ Upload endpoints added (profile, resource, tool-icon)
- ✅ Validation implemented (type, size, content)
- ✅ Secure storage configured
- ✅ Frontend upload component created
- ✅ Tests passing (X/Y)
- ⚠️ Recommendations: [Install ClamAV for production]
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
Didn't find tool you were looking for?