Skip to content
Build a File Upload Service (Step by Step)

Build a File Upload Service (Step by Step)

DodaTech Updated Jun 19, 2026 11 min read

Build a file upload service with Python Flask featuring drag-and-drop UI, real-time progress bar, file type and size validation, thumbnail preview for images, and secure filename sanitization.

What You’ll Build

You’ll build a web application where users drag files onto a drop zone, see a live progress bar as the file uploads, get thumbnail previews for images, and receive validation feedback for unsupported types or oversized files. Uploaded files are stored with secure, sanitized filenames. This pattern is used at DodaTech in DodaZIP’s file conversion service and Doda Browser’s bookmark import feature.

Why Build a File Upload Service?

File upload is a requirement in nearly every web application — profile pictures, document attachments, CSV imports, media galleries. Building your own teaches you multipart form handling, file validation (a critical security skill), server-side storage patterns, and frontend upload UX. Security is especially important: unsanitized file uploads are a common attack vector.

Prerequisites

Step 1: Setup

mkdir upload-service
cd upload-service
python -m venv venv
source venv/bin/activate
pip install flask Pillow python-magic

Pillow handles image processing (thumbnails). python-magic detects file types by content (not extension).

Project structure:

upload-service/
├── app.py
├── uploads/         # Uploaded files stored here
├── thumbnails/      # Image thumbnails stored here
└── templates/
    └── index.html

Step 2: Secure File Handler

# upload_handler.py
import os
import secrets
import string
import mimetypes
from pathlib import Path
from werkzeug.utils import secure_filename

# Allowed file extensions and their MIME types
ALLOWED_EXTENSIONS = {
    # Images
    'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
    'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml',
    # Documents
    'pdf': 'application/pdf', 'doc': 'application/msword',
    'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    'txt': 'text/plain', 'csv': 'text/csv',
    # Archives
    'zip': 'application/zip', 'rar': 'application/vnd.rar',
    '7z': 'application/x-7z-compressed', 'tar': 'application/x-tar',
    'gz': 'application/gzip',
}

MAX_FILE_SIZE = 50 * 1024 * 1024  # 50 MB
MAX_IMAGE_DIMENSION = 5000  # px

def allowed_file(filename: str) -> bool:
    """Check if file extension is allowed."""
    ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
    return ext in ALLOWED_EXTENSIONS

def validate_file_size(content_length: int) -> bool:
    """Check if file size is within limits."""
    return content_length <= MAX_FILE_SIZE

def get_secure_filename(original_name: str) -> str:
    """Generate a secure, unique filename while preserving extension."""
    ext = original_name.rsplit('.', 1)[-1].lower() if '.' in original_name else ''
    random_name = secrets.token_hex(16)
    if ext:
        return f"{random_name}.{ext}"
    return random_name

def sanitize_filename(name: str) -> str:
    """Remove potentially dangerous characters from filename."""
    return secure_filename(name) or "unnamed"

def create_thumbnail(image_path: str, thumbnail_dir: str, size: tuple = (200, 200)):
    """Create a thumbnail for an image file."""
    from PIL import Image
    thumb_dir = Path(thumbnail_dir)
    thumb_dir.mkdir(exist_ok=True)

    img = Image.open(image_path)
    img.thumbnail(size, Image.LANCZOS)

    original = Path(image_path)
    thumb_name = f"thumb_{original.name}"
    thumb_path = thumb_dir / thumb_name
    img.save(thumb_path, optimize=True)
    return str(thumb_path)

def get_file_type(filepath: str) -> str:
    """Detect file MIME type using content inspection."""
    try:
        import magic
        mime = magic.from_file(filepath, mime=True)
        return mime
    except ImportError:
        # Fallback to extension-based detection
        mime_type, _ = mimetypes.guess_type(filepath)
        return mime_type or 'application/octet-stream'

Step 3: Flask App

# app.py
from flask import Flask, request, render_template, jsonify, send_from_directory
from pathlib import Path
from upload_handler import (
    allowed_file, validate_file_size, get_secure_filename,
    create_thumbnail, get_file_type, MAX_FILE_SIZE
)
import os

app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE

UPLOAD_DIR = Path('uploads')
THUMBNAIL_DIR = Path('thumbnails')
UPLOAD_DIR.mkdir(exist_ok=True)
THUMBNAIL_DIR.mkdir(exist_ok=True)

@app.route('/')
def index():
    return render_template('index.html', max_size_mb=MAX_FILE_SIZE // (1024*1024))

@app.route('/upload', methods=['POST'])
def upload_file():
    """Handle file upload via multipart form."""
    if 'file' not in request.files:
        return jsonify({'error': 'No file provided'}), 400

    file = request.files['file']
    if not file.filename:
        return jsonify({'error': 'No file selected'}), 400

    original_name = file.filename

    # Validate extension
    if not allowed_file(original_name):
        return jsonify({'error': f'File type not allowed. Supported: {", ".join(["." + k for k in ALLOWED_EXTENSIONS.keys()])}'}), 400

    # Secure and save the file
    safe_name = sanitize_filename(original_name)
    secure_name = get_secure_filename(safe_name)
    filepath = UPLOAD_DIR / secure_name

    try:
        file.save(str(filepath))
    except Exception as e:
        return jsonify({'error': f'Failed to save file: {str(e)}'}), 500

    # Verify file type matches extension
    detected_type = get_file_type(str(filepath))
    ext = original_name.rsplit('.', 1)[-1].lower()
    expected_type = ALLOWED_EXTENSIONS.get(ext, '')
    if expected_type and detected_type != expected_type:
        filepath.unlink()  # Delete the file
        return jsonify({
            'error': f'File content mismatch. Expected {expected_type}, detected {detected_type}'
        }), 400

    # Generate thumbnail for images
    thumbnail_url = None
    is_image = ext in {'jpg', 'jpeg', 'png', 'gif', 'webp'}
    if is_image:
        try:
            thumb_path = create_thumbnail(str(filepath), str(THUMBNAIL_DIR))
            thumbnail_url = f'/thumbnails/{Path(thumb_path).name}'
        except Exception as e:
            print(f"Thumbnail failed: {e}")

    file_size = filepath.stat().st_size

    return jsonify({
        'message': 'Upload successful',
        'filename': secure_name,
        'original_name': original_name,
        'size': file_size,
        'size_formatted': format_size(file_size),
        'type': detected_type,
        'is_image': is_image,
        'thumbnail_url': thumbnail_url,
        'download_url': f'/uploads/{secure_name}',
    })

@app.route('/uploads/<filename>')
def uploaded_file(filename):
    """Serve uploaded files."""
    return send_from_directory(str(UPLOAD_DIR), filename)

@app.route('/thumbnails/<filename>')
def thumbnail_file(filename):
    """Serve thumbnail images."""
    return send_from_directory(str(THUMBNAIL_DIR), filename)

@app.route('/files', methods=['GET'])
def list_files():
    """List all uploaded files."""
    files = []
    for f in sorted(UPLOAD_DIR.iterdir(), key=os.path.getmtime, reverse=True):
        if f.is_file():
            stat = f.stat()
            files.append({
                'name': f.name,
                'size': stat.st_size,
                'size_formatted': format_size(stat.st_size),
                'uploaded_at': stat.st_mtime,
            })
    return jsonify({'files': files})

def format_size(size: int) -> str:
    """Format file size in human-readable format."""
    for unit in ['B', 'KB', 'MB', 'GB']:
        if size < 1024:
            return f"{size:.1f} {unit}"
        size /= 1024
    return f"{size:.1f} TB"

if __name__ == '__main__':
    app.run(debug=True, port=5000)

Step 4: Frontend with Drag-and-Drop

<!-- templates/index.html -->
<!DOCTYPE html>
<html>
<head>
    <title>File Upload Service</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: system-ui, sans-serif; background: #f5f5f5; padding: 40px 20px; }
        .container { max-width: 700px; margin: 0 auto; }
        h1 { text-align: center; margin-bottom: 8px; color: #333; }
        .subtitle { text-align: center; color: #888; margin-bottom: 30px; }

        /* Drop zone */
        .drop-zone {
            border: 2px dashed #ccc; border-radius: 16px; padding: 60px 40px;
            text-align: center; background: white; cursor: pointer;
            transition: all 0.3s; margin-bottom: 24px;
        }
        .drop-zone:hover, .drop-zone.dragover {
            border-color: #007bff; background: #f0f7ff;
        }
        .drop-zone .icon { font-size: 48px; margin-bottom: 12px; }
        .drop-zone .text { color: #666; font-size: 16px; }
        .drop-zone .text strong { color: #007bff; }
        .drop-zone .hint { color: #aaa; font-size: 13px; margin-top: 8px; }

        /* Progress bar */
        .progress-container { display: none; margin-bottom: 24px; }
        .progress-bar { height: 6px; background: #e9ecef; border-radius: 3px; overflow: hidden; }
        .progress-fill { height: 100%; width: 0%; background: linear-gradient(90deg, #007bff, #00d4ff); border-radius: 3px; transition: width 0.3s; }
        .progress-text { font-size: 13px; color: #666; margin-top: 6px; display: flex; justify-content: space-between; }

        /* Result card */
        .result { display: none; background: white; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); margin-bottom: 24px; }
        .result .file-info { display: flex; gap: 20px; align-items: center; }
        .result .thumbnail { width: 80px; height: 80px; object-fit: cover; border-radius: 8px; background: #f5f5f5; }
        .result .details { flex: 1; }
        .result .details .name { font-weight: 600; color: #333; word-break: break-all; }
        .result .details .meta { color: #888; font-size: 14px; margin-top: 4px; }
        .result .details .type-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; background: #e8f5e9; color: #2e7d32; font-size: 12px; margin-top: 6px; }

        /* Error */
        .error { display: none; background: #fff0f0; color: #d32f2f; padding: 14px 18px; border-radius: 10px; margin-bottom: 24px; font-size: 14px; }

        /* File list */
        .file-list { background: white; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
        .file-list h3 { margin-bottom: 16px; color: #555; font-size: 16px; }
        .file-list .file-item { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #f0f0f0; font-size: 14px; }
        .file-list .file-item:last-child { border-bottom: none; }
        .file-list .file-item .fname { color: #333; }
        .file-list .file-item .fsize { color: #999; }

        .uploading * { pointer-events: none; }
    </style>
</head>
<body>
    <div class="container">
        <h1>File Upload Service</h1>
        <p class="subtitle">Drag and drop files, or click to browse. Max {{ max_size_mb }} MB per file.</p>

        <div class="drop-zone" id="dropZone">
            <div class="icon">📂</div>
            <div class="text"><strong>Click to browse</strong> or drag files here</div>
            <div class="hint">Supports images, PDFs, documents, and archives</div>
        </div>
        <input type="file" id="fileInput" style="display:none">

        <div class="progress-container" id="progressContainer">
            <div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
            <div class="progress-text">
                <span id="progressLabel">Uploading...</span>
                <span id="progressPercent">0%</span>
            </div>
        </div>

        <div class="error" id="error"></div>
        <div class="result" id="result">
            <div class="file-info">
                <img class="thumbnail" id="thumbnail" src="" alt="">
                <div class="details">
                    <div class="name" id="fileName"></div>
                    <div class="meta">
                        <span id="fileSize"></span> &middot; <span id="fileType"></span>
                    </div>
                    <span class="type-badge">Upload successful</span>
                </div>
            </div>
        </div>

        <div class="file-list">
            <h3>Recent Uploads</h3>
            <div id="fileList"></div>
        </div>
    </div>

    <script>
        const dropZone = document.getElementById('dropZone');
        const fileInput = document.getElementById('fileInput');
        const progressContainer = document.getElementById('progressContainer');
        const progressFill = document.getElementById('progressFill');
        const progressPercent = document.getElementById('progressPercent');
        const progressLabel = document.getElementById('progressLabel');
        const result = document.getElementById('result');
        const error = document.getElementById('error');
        const thumbnail = document.getElementById('thumbnail');
        const fileName = document.getElementById('fileName');
        const fileSize = document.getElementById('fileSize');
        const fileType = document.getElementById('fileType');

        // Click to browse
        dropZone.addEventListener('click', () => fileInput.click());
        fileInput.addEventListener('change', () => {
            if (fileInput.files.length) uploadFile(fileInput.files[0]);
        });

        // Drag and drop
        dropZone.addEventListener('dragover', (e) => {
            e.preventDefault();
            dropZone.classList.add('dragover');
        });
        dropZone.addEventListener('dragleave', () => {
            dropZone.classList.remove('dragover');
        });
        dropZone.addEventListener('drop', (e) => {
            e.preventDefault();
            dropZone.classList.remove('dragover');
            if (e.dataTransfer.files.length) uploadFile(e.dataTransfer.files[0]);
        });

        function uploadFile(file) {
            // Reset UI
            error.style.display = 'none';
            result.style.display = 'none';
            progressContainer.style.display = 'block';
            progressFill.style.width = '0%';
            progressPercent.textContent = '0%';
            document.body.classList.add('uploading');

            const formData = new FormData();
            formData.append('file', file);

            const xhr = new XMLHttpRequest();

            xhr.upload.addEventListener('progress', (e) => {
                if (e.lengthComputable) {
                    const pct = Math.round((e.loaded / e.total) * 100);
                    progressFill.style.width = pct + '%';
                    progressPercent.textContent = pct + '%';
                    progressLabel.textContent = `Uploading ${file.name}...`;
                }
            });

            xhr.addEventListener('load', () => {
                document.body.classList.remove('uploading');
                progressContainer.style.display = 'none';

                if (xhr.status === 200) {
                    const data = JSON.parse(xhr.responseText);
                    showResult(data);
                } else {
                    try {
                        const data = JSON.parse(xhr.responseText);
                        showError(data.error || 'Upload failed');
                    } catch {
                        showError('Upload failed — server error');
                    }
                }
                loadFileList();
            });

            xhr.addEventListener('error', () => {
                document.body.classList.remove('uploading');
                progressContainer.style.display = 'none';
                showError('Network error — check your connection');
            });

            xhr.open('POST', '/upload', true);
            xhr.send(formData);
        }

        function showResult(data) {
            result.style.display = 'block';
            fileName.textContent = data.original_name;
            fileSize.textContent = data.size_formatted;
            fileType.textContent = data.type;

            if (data.is_image && data.thumbnail_url) {
                thumbnail.src = data.thumbnail_url;
                thumbnail.style.display = 'block';
            } else {
                thumbnail.style.display = 'none';
            }
        }

        function showError(msg) {
            error.textContent = msg;
            error.style.display = 'block';
        }

        async function loadFileList() {
            try {
                const res = await fetch('/files');
                const data = await res.json();
                const list = document.getElementById('fileList');
                list.innerHTML = data.files.slice(0, 10).map(f =>
                    `<div class="file-item"><span class="fname">${f.name}</span><span class="fsize">${f.size_formatted}</span></div>`
                ).join('');
            } catch {}
        }

        loadFileList();
    </script>
</body>
</html>

Step 5: Run

mkdir uploads thumbnails
python app.py

Open http://localhost:5000. Drag an image file onto the drop zone — you’ll see the progress bar fill up, then a result card showing the filename, size, and a thumbnail preview.

Expected output:

  • Drag a JPEG file → progress bar animates → thumbnail appears
  • Drag a PDF → progress bar → result shows PDF type, no thumbnail
  • Drag a .exe file → error: “File type not allowed”
  • Upload a file → it appears in the “Recent Uploads” list

Architecture


sequenceDiagram
    participant User as User (Browser)
    participant JS as JavaScript
    participant Server as Flask Server
    participant Disk as File System

    User->>JS: Drag file onto drop zone
    JS->>JS: Create FormData with file
    JS->>JS: XMLHttpRequest with progress listener
    JS->>Server: POST /upload (multipart/form-data)
    Server->>Server: Check file extension
    Server->>Disk: Save file with secure name
    Server->>Disk: Detect MIME type (python-magic)
    Server->>Server: Validate MIME matches extension
    Server->>Disk: Create thumbnail (Pillow)
    Server-->>JS: JSON response with file info
    JS->>JS: Show result card
    JS->>JS: Update file list

    Server->>Disk: Delete file if validation fails

Common Errors

1. “File content mismatch” The file’s extension says .jpg but python-magic detects it as something else (e.g., a renamed .exe). This is a security feature — attackers rename malicious files to look like images. The server deletes the mismatched file and returns an error. To bypass for legit files, add the correct MIME type to ALLOWED_EXTENSIONS.

2. Progress bar goes to 100% but upload fails The XHR progress event reports client-side upload progress only. Once the server receives all data, it processes the file — validation errors happen after 100% upload. The load event handler checks the response status code to distinguish success from validation failure.

3. “413 Request Entity Too Large” Flask’s MAX_CONTENT_LENGTH (50 MB) is exceeded. The server rejects the request before any processing. Increase the limit or add client-side validation: check file.size in JavaScript before uploading and show an immediate error.

4. Thumbnail fails for corrupted images Pillow raises an exception for truncated or corrupted image files. The server catches this and returns success without a thumbnail. You could add PIL.ImageFile.LOAD_TRUNCATED_IMAGES = True to attempt partial loading, but it’s safer to reject corrupt files.

Practice Questions

1. Why do we generate a random filename instead of using the original? Security. Original filenames may contain path traversal sequences (../../etc/passwd), special characters that break URLs, or personally identifiable information. The secrets.token_hex(16) gives us a 128-bit random name that’s unique and safe.

2. How does python-magic differ from checking the file extension? Extensions can be faked (rename .exe to .jpg). python-magic reads the file’s magic bytes — the first few bytes that uniquely identify the file format. A real JPEG starts with FF D8 FF regardless of its extension. We check both: first the extension for a quick reject, then the content for confirmation.

3. What’s the difference between our progress bar approach and Fetch API? We use XMLHttpRequest because it supports upload.progress events natively. The Fetch API with Response.body can track download progress but not upload progress. For file upload progress bars, XHR is still the best choice.

4. Challenge: Add chunked upload for large files Split files into 5 MB chunks, upload each chunk sequentially with a chunk index, reassemble on the server. Store chunks in a temp directory and combine them when all chunks arrive. This allows resumable uploads for large files.

5. Challenge: Add file expiration Add an expires_at column. Run a background thread that checks every 5 minutes and deletes expired files and thumbnails. Add a “This file has expired” page at the download URL.

FAQ

How do I store files in cloud storage instead of disk?
Install boto3 for AWS S3 or google-cloud-storage for GCS. Replace file.save(str(filepath)) with bucket.upload_fileobj(file.stream, secure_name). Serve files via send_from_directory or a CDN URL. Update the download URL to point to the cloud storage endpoint.
How do I handle multiple concurrent uploads?
The current approach handles one file at a time per browser tab. For queue management, add a queue array in JavaScript and process files sequentially. For server-side concurrent processing, use a task queue like Celery or RQ to handle file processing in the background.
How do I add user authentication?
Link uploads to user accounts. Add a user_id column to your upload metadata. Use Flask-Login for session management. Each user sees only their own files. For authenticated download URLs, generate signed URLs with expiry.

Next Steps

  • Add AWS S3 cloud storage integration
  • Implement authentication with Auth0
  • Build the Real-Time Dashboard to show upload metrics
  • Explore Docker for containerized deployment

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro