Skip to content
file-upload-handling logo

tests/integration/test_api_upload.py

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.

SKILL.md

Full skill instructions

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:

  1. ✅ File upload service created
  2. ✅ Upload endpoints added (profile, resource, tool-icon)
  3. ✅ Validation implemented (type, size, content)
  4. ✅ Secure storage configured
  5. ✅ Frontend upload component created
  6. ✅ Tests passing (X/​Y)
  7. ⚠️ Recommendations: [Install ClamAV for production]

More skills from majiayu000

xiaohongshu logo
majiayu000/claude-arsenal

xiaohongshu

xiaohongshu

286 148
View
agent-task-conductor logo
majiayu000/claude-skill-registry

agent-task-conductor

Conduct multi-agent task orchestration and workflow coordination.

663 1
View
conductor-setup logo
majiayu000/claude-skill-registry

conductor-setup

Initialize project with Conductor artifacts (product definition,

663 1
View
animation-designer logo
majiayu000/claude-skill-registry

animation-designer

Expert in web animations, transitions, and motion design using Framer Motion and CSS

663 1
View
diagramming logo
majiayu000/claude-skill-registry

diagramming

Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...

663 1
View
h3-pg logo
majiayu000/claude-skill-registry-data

h3-pg

PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.

23 1
View
conductor-development logo
majiayu000/claude-skill-registry

conductor-development

Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...

663 1
View
conductor-status logo
majiayu000/claude-skill-registry

conductor-status

Display project status, active tracks, and next actions

663 1
View
dockerization logo
majiayu000/claude-skill-registry

dockerization

Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.

663 1
View

Popular AI tools

Kaiber logo
Video

Kaiber

Generate, edit, and beat-sync AI video with leading models in one workspace.

Paid
View
Vimcal logo
Productivity

Vimcal

The world's fastest calendar for remote work

Free
View

Transform Your Design with AI Designer by ImgCreator.ai

Freemium
View
Akool AI logo
Content & writing

Akool AI

Revolutionizing Video Production with AI-Powered Creativity

Paid
View

Extend an image past the frame and let AI fill the new aspect ratio.

Freemium
View
StarByFace logo
Security

StarByFace

Discover your celebrity doppelgänger with StarByFace!

Free
View
C

ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.

Freemium
View
Opus Clip logo
Coding & apps

Opus Clip

Opus.ai: Revolutionize Your Web Experience

Free
View