Build a File Upload Service (Step by Step)
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
- Python 3.8+ installed
- Basic HTML and JavaScript
- Understanding of HTML forms and HTTP methods
Step 1: Setup
mkdir upload-service
cd upload-service
python -m venv venv
source venv/bin/activate
pip install flask Pillow python-magicPillow 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.htmlStep 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> · <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.pyOpen 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
.exefile → 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
Next Steps
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro