videostudioart's picture
Update app.py
08d0f70 verified
Raw
History Blame Contribute Delete
46.9 kB
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from typing import List, Optional, Dict, Any
import subprocess
import os
import uuid
import shutil
import asyncio
import logging
from datetime import datetime, timedelta
from pathlib import Path
import secrets
import time
import zipfile
import io
from PyPDF2 import PdfReader, PdfWriter
from PIL import Image
# =====================================================
# CONFIGURATION
# =====================================================
# File limits
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
MAX_FILES_PER_REQUEST = 10
# All supported image formats
IMAGE_EXTENSIONS = {
'jpg', 'jpeg', 'png', 'webp', 'avif', 'heic', 'heif', 'gif', 'bmp',
'tiff', 'tif', 'svg', 'ico', 'psd', 'psb', 'ai', 'eps', 'raw', 'dng',
'cr2', 'cr3', 'nef', 'nrw', 'arw', 'sr2', 'srf', 'srw', 'orf', 'rw2',
'raf', 'rwl', 'pef', 'dcr', 'kdc', 'erf', 'mef', 'mos', 'mrw', 'x3f',
'3fr', 'iiq', 'bay', 'cap', 'dcm', 'dicm', 'fits', 'hdr', 'exr',
'jp2', 'j2k', 'jpf', 'jpm', 'mj2', 'jxr', 'hdp', 'wdp', 'tga',
'icb', 'vda', 'vst', 'pcx', 'pic', 'pict', 'pct', 'pnm', 'pbm',
'pgm', 'ppm', 'pam', 'xbm', 'xpm', 'dds', 'ktx', 'astc', 'bpg',
'flif', 'qoi', 'cur', 'ani', 'wbmp', 'sgi', 'rgb', 'rgba', 'bw',
'cin', 'sun', 'ras', 'emf', 'wmf', 'cgm', 'odg', 'apng', 'mng'
}
# Document extensions
DOCUMENT_EXTENSIONS = {'pdf', 'doc', 'docx', 'odt', 'rtf', 'txt', 'html', 'ppt', 'pptx', 'xls', 'xlsx'}
# All allowed extensions
ALLOWED_EXTENSIONS = DOCUMENT_EXTENSIONS | IMAGE_EXTENSIONS
# Output formats (all image formats + common document formats)
ALLOWED_OUTPUT_FORMATS = {
'pdf', 'docx', 'odt', 'rtf', 'txt', 'html',
'jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'tiff', 'tif',
'ico', 'jp2', 'j2k', 'pcx', 'tga', 'ppm', 'pgm', 'pbm', 'pnm'
}
# Directories
TEMP_DIR = "temp"
PROGRESS_DIR = "progress"
LOG_DIR = "logs"
# Retention
FILE_RETENTION_HOURS = 24 # Keep files for 24 hours
CLEANUP_INTERVAL_HOURS = 1 # Run cleanup every hour
PROGRESS_RETENTION_HOURS = 48 # Keep progress data for 48 hours
# Create directories
os.makedirs(TEMP_DIR, exist_ok=True)
os.makedirs(PROGRESS_DIR, exist_ok=True)
os.makedirs(LOG_DIR, exist_ok=True)
# =====================================================
# LOGGING SETUP
# =====================================================
# Configure logging with both file and console output
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(os.path.join(LOG_DIR, f"app_{datetime.now().strftime('%Y%m%d')}.log")),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
# Separate error log
error_handler = logging.FileHandler(os.path.join(LOG_DIR, f"errors_{datetime.now().strftime('%Y%m%d')}.log"))
error_handler.setLevel(logging.ERROR)
error_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
error_handler.setFormatter(error_formatter)
logger.addHandler(error_handler)
# =====================================================
# PROGRESS STORE
# =====================================================
progress_store: Dict[str, Dict[str, Any]] = {}
class ProgressTracker:
def __init__(self, job_id: str):
self.job_id = job_id
self.progress = 0
self.status = "pending"
self.message = "Job started"
self.result_file = None
self.result_files = []
self.error = None
self.start_time = time.time()
self.is_bulk = False
def update(self, progress: int, message: str = None):
self.progress = progress
if message:
self.message = message
self.save()
logger.info(f"Job {self.job_id} - Progress: {progress}% - {message}")
def complete(self, result_file: str, result_files: List[str] = None):
self.progress = 100
self.status = "completed"
self.message = "Conversion completed successfully"
self.result_file = result_file
if result_files:
self.result_files = result_files
duration = time.time() - self.start_time
self.save()
logger.info(f"Job {self.job_id} - Completed in {duration:.2f}s - Output: {result_file}")
def complete_bulk(self, result_files: List[str], zip_file: str):
self.progress = 100
self.status = "completed"
self.message = "Bulk conversion completed successfully"
self.result_file = zip_file
self.result_files = result_files
self.is_bulk = True
duration = time.time() - self.start_time
self.save()
logger.info(f"Job {self.job_id} - Bulk completed in {duration:.2f}s - {len(result_files)} files")
def fail(self, error: str):
self.status = "failed"
self.message = error
self.error = error
duration = time.time() - self.start_time
self.save()
logger.error(f"Job {self.job_id} - Failed after {duration:.2f}s - Error: {error}")
def save(self):
progress_store[self.job_id] = {
"progress": self.progress,
"status": self.status,
"message": self.message,
"result_file": self.result_file,
"result_files": self.result_files,
"is_bulk": self.is_bulk,
"error": self.error,
"timestamp": datetime.now().isoformat(),
"duration_seconds": round(time.time() - self.start_time, 2)
}
# =====================================================
# FILE SIZE & SECURITY HELPERS
# =====================================================
def get_safe_filename(original_filename: str) -> tuple[str, str]:
"""Generate safe filename with extension validation"""
# Extract extension securely
clean_name = Path(original_filename).name
ext = clean_name.split(".")[-1].lower() if "." in clean_name else ""
# Validate extension
if ext not in ALLOWED_EXTENSIONS:
logger.warning(f"Invalid extension rejected: {ext} from {original_filename}")
raise HTTPException(400, f"File type {ext} not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}")
# Generate secure random filename
random_name = secrets.token_urlsafe(16)
safe_filename = f"{random_name}.{ext}"
return safe_filename, ext
def get_output_filename(original_filename: str, output_format: str) -> str:
"""Generate output filename preserving original name"""
# Remove extension from original
base_name = Path(original_filename).stem
# Clean the filename (remove special characters)
clean_name = "".join(c for c in base_name if c.isalnum() or c in " ._-")
# Add output extension
return f"{clean_name}.{output_format.lower()}"
async def save_file_with_limits(upload_file: UploadFile, max_size: int = MAX_FILE_SIZE) -> tuple[str, str, int]:
"""Save uploaded file with size limits and security checks"""
# Read file content
content = await upload_file.read()
file_size = len(content)
# Check file size
if file_size > max_size:
logger.warning(f"File too large: {file_size} bytes from {upload_file.filename}")
raise HTTPException(
400,
f"File too large. Max size: {max_size // 1024 // 1024}MB, Your file: {file_size // 1024 // 1024}MB"
)
# Generate safe filename
safe_filename, ext = get_safe_filename(upload_file.filename)
filepath = os.path.join(TEMP_DIR, safe_filename)
# Save file
with open(filepath, "wb") as buffer:
buffer.write(content)
logger.info(f"File saved: {safe_filename} (Original: {upload_file.filename}, Size: {file_size} bytes)")
return filepath, upload_file.filename, file_size
def get_file_size_mb(filepath: str) -> float:
"""Get file size in MB"""
return os.path.getsize(filepath) / (1024 * 1024)
def get_image_format(ext: str) -> str:
"""Map file extension to PIL format"""
format_map = {
'jpg': 'JPEG', 'jpeg': 'JPEG', 'png': 'PNG', 'gif': 'GIF',
'bmp': 'BMP', 'tiff': 'TIFF', 'tif': 'TIFF', 'webp': 'WEBP',
'ico': 'ICO', 'pcx': 'PCX', 'tga': 'TGA', 'ppm': 'PPM',
'pgm': 'PGM', 'pbm': 'PBM', 'pnm': 'PNM', 'jp2': 'JPEG2000',
'j2k': 'JPEG2000', 'exr': 'EXR'
}
return format_map.get(ext.lower(), ext.upper())
# =====================================================
# CONVERSION HELPERS
# =====================================================
def libreoffice_convert(input_file: str, output_format: str, output_filename: str = None) -> Optional[str]:
"""Convert file using LibreOffice with timeout"""
try:
logger.info(f"Starting LibreOffice conversion: {input_file} -> {output_format}")
# Run conversion with timeout
result = subprocess.run(
[
"libreoffice",
"--headless",
"--convert-to",
output_format,
input_file,
"--outdir",
TEMP_DIR,
],
check=True,
capture_output=True,
text=True,
timeout=300 # 5 minute timeout
)
base_name = os.path.splitext(os.path.basename(input_file))[0]
temp_output = os.path.join(TEMP_DIR, f"{base_name}.{output_format}")
if os.path.exists(temp_output):
# If output_filename is provided, rename the file
if output_filename:
output_file = os.path.join(TEMP_DIR, output_filename)
os.rename(temp_output, output_file)
logger.info(f"LibreOffice conversion successful: {output_file} ({get_file_size_mb(output_file):.2f}MB)")
return output_file
else:
logger.info(f"LibreOffice conversion successful: {temp_output} ({get_file_size_mb(temp_output):.2f}MB)")
return temp_output
else:
logger.error(f"LibreOffice output file not found: {temp_output}")
return None
except subprocess.TimeoutExpired:
logger.error(f"LibreOffice conversion timeout after 300s: {input_file}")
return None
except subprocess.CalledProcessError as e:
logger.error(f"LibreOffice error (code {e.returncode}): {e.stderr}")
return None
except Exception as e:
logger.error(f"LibreOffice conversion exception: {str(e)}", exc_info=True)
return None
def convert_image_to_image(input_path: str, output_path: str, output_format: str) -> bool:
"""Convert between image formats using PIL"""
try:
logger.info(f"Converting image: {input_path} -> {output_format}")
# Open the image
image = Image.open(input_path)
# Convert RGBA to RGB for JPEG
if output_format.lower() in ['jpg', 'jpeg'] and image.mode == 'RGBA':
# Create a white background
background = Image.new('RGB', image.size, (255, 255, 255))
background.paste(image, mask=image.split()[3]) # Use alpha channel as mask
image = background
elif output_format.lower() in ['jpg', 'jpeg'] and image.mode != 'RGB':
image = image.convert('RGB')
# Handle GIF optimization
if output_format.lower() == 'gif':
image.save(output_path, 'GIF', optimize=True)
logger.info(f"Image converted successfully: {output_path} ({get_file_size_mb(output_path):.2f}MB)")
return True
# Save with appropriate format
save_format = get_image_format(output_format)
# Special handling for some formats
if output_format.lower() in ['ico']:
# ICO needs specific sizes
image.save(output_path, format='ICO', sizes=[(image.width, image.height)])
else:
# Default save
image.save(output_path, format=save_format, quality=95, optimize=True)
logger.info(f"Image converted successfully: {output_path} ({get_file_size_mb(output_path):.2f}MB)")
return True
except Exception as e:
logger.error(f"Image conversion failed: {str(e)}", exc_info=True)
return False
def convert_image_to_pdf(input_path: str, output_path: str) -> bool:
"""Convert single image to PDF"""
try:
logger.info(f"Converting image to PDF: {input_path}")
image = Image.open(input_path).convert("RGB")
image.save(output_path, "PDF")
logger.info(f"Image to PDF successful: {output_path} ({get_file_size_mb(output_path):.2f}MB)")
return True
except Exception as e:
logger.error(f"Image to PDF conversion failed: {str(e)}", exc_info=True)
return False
def convert_pdf_to_image(input_path: str, output_path: str, output_format: str = 'jpeg', page_num: int = 0) -> bool:
"""Convert PDF to image"""
try:
from pdf2image import convert_from_path
logger.info(f"Converting PDF to image: {input_path}")
pages = convert_from_path(input_path, timeout=60)
if pages and page_num < len(pages):
image = pages[page_num]
# Handle format-specific conversions
if output_format.lower() in ['jpg', 'jpeg'] and image.mode == 'RGBA':
background = Image.new('RGB', image.size, (255, 255, 255))
background.paste(image, mask=image.split()[3])
image = background
elif output_format.lower() in ['jpg', 'jpeg'] and image.mode != 'RGB':
image = image.convert('RGB')
save_format = get_image_format(output_format)
image.save(output_path, format=save_format, quality=95)
logger.info(f"PDF to image successful: {output_path} ({get_file_size_mb(output_path):.2f}MB)")
return True
logger.error(f"PDF has no pages or page {page_num} not found. Total pages: {len(pages)}")
return False
except Exception as e:
logger.error(f"PDF to image conversion failed: {str(e)}", exc_info=True)
return False
def merge_pdf_files(file_paths: List[str], output_path: str) -> bool:
"""Merge multiple PDF files"""
try:
logger.info(f"Merging {len(file_paths)} PDF files")
writer = PdfWriter()
for file_path in file_paths:
reader = PdfReader(file_path)
for page in reader.pages:
writer.add_page(page)
with open(output_path, "wb") as f:
writer.write(f)
logger.info(f"PDF merge successful: {output_path} ({get_file_size_mb(output_path):.2f}MB)")
return True
except Exception as e:
logger.error(f"PDF merge failed: {str(e)}", exc_info=True)
return False
def split_pdf_file(input_path: str, output_path: str, page_numbers: List[int]) -> bool:
"""Split PDF by page numbers"""
try:
logger.info(f"Splitting PDF: {input_path} - Pages: {page_numbers}")
reader = PdfReader(input_path)
writer = PdfWriter()
for page_num in page_numbers:
if 1 <= page_num <= len(reader.pages):
writer.add_page(reader.pages[page_num - 1])
else:
logger.warning(f"Page {page_num} not found in PDF (total pages: {len(reader.pages)})")
with open(output_path, "wb") as f:
writer.write(f)
logger.info(f"PDF split successful: {output_path} ({get_file_size_mb(output_path):.2f}MB)")
return True
except Exception as e:
logger.error(f"PDF split failed: {str(e)}", exc_info=True)
return False
def is_image_format(extension: str) -> bool:
"""Check if extension is an image format"""
return extension.lower() in IMAGE_EXTENSIONS
def create_zip_file(files: List[str], zip_path: str) -> bool:
"""Create a zip file from a list of files"""
try:
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for file_path in files:
if os.path.exists(file_path):
arcname = os.path.basename(file_path)
zipf.write(file_path, arcname)
logger.info(f"Created zip file: {zip_path} with {len(files)} files")
return True
except Exception as e:
logger.error(f"Failed to create zip file: {str(e)}", exc_info=True)
return False
# =====================================================
# BACKGROUND JOB PROCESSING
# =====================================================
def process_conversion(job_id: str, input_path: str, original_filename: str, output_format: str, options: dict = None):
"""Background task for conversion with progress tracking"""
tracker = ProgressTracker(job_id)
try:
# Validate output format
if output_format.lower() not in ALLOWED_OUTPUT_FORMATS:
raise Exception(f"Unsupported output format: {output_format}. Allowed: {', '.join(ALLOWED_OUTPUT_FORMATS)}")
# Step 1: Validate input
tracker.update(10, "Validating input file")
if not os.path.exists(input_path):
raise Exception("Input file not found")
input_size_mb = get_file_size_mb(input_path)
logger.info(f"Processing job {job_id}: {original_filename} ({input_size_mb:.2f}MB) -> {output_format}")
# Step 2: Check format and process accordingly
tracker.update(20, f"Preparing conversion to {output_format.upper()}")
input_ext = original_filename.split(".")[-1].lower()
output_file = None
# Generate output filename preserving original name
output_filename = get_output_filename(original_filename, output_format)
# Determine conversion type
is_input_image = is_image_format(input_ext)
is_output_image = is_image_format(output_format.lower())
is_output_pdf = output_format.lower() == 'pdf'
is_input_pdf = input_ext == 'pdf'
# Handle different conversion types
if is_input_image and is_output_pdf:
# Image to PDF
tracker.update(30, "Converting image to PDF")
output_file = os.path.join(TEMP_DIR, output_filename)
success = convert_image_to_pdf(input_path, output_file)
if not success:
raise Exception("Failed to convert image to PDF")
tracker.update(80, "Image converted successfully")
elif is_input_pdf and is_output_image:
# PDF to Image
tracker.update(30, f"Converting PDF to {output_format.upper()}")
output_file = os.path.join(TEMP_DIR, output_filename)
success = convert_pdf_to_image(input_path, output_file, output_format)
if not success:
raise Exception(f"Failed to convert PDF to {output_format}")
tracker.update(80, "PDF converted to image successfully")
elif is_input_image and is_output_image:
# Image to Image
tracker.update(30, f"Converting image to {output_format.upper()}")
output_file = os.path.join(TEMP_DIR, output_filename)
success = convert_image_to_image(input_path, output_file, output_format)
if not success:
raise Exception(f"Failed to convert image to {output_format}")
tracker.update(80, "Image converted successfully")
elif output_format.lower() == "pdf" and options and options.get("merge"):
# Merge PDFs
tracker.update(30, "Merging PDF files")
output_file = os.path.join(TEMP_DIR, output_filename)
files_to_merge = options.get("files", [input_path])
success = merge_pdf_files(files_to_merge, output_file)
if not success:
raise Exception("Failed to merge PDF files")
tracker.update(80, "PDFs merged successfully")
elif output_format.lower() == "pdf" and options and options.get("split"):
# Split PDF
tracker.update(30, "Splitting PDF file")
output_file = os.path.join(TEMP_DIR, output_filename)
pages = options.get("pages", [1])
success = split_pdf_file(input_path, output_file, pages)
if not success:
raise Exception("Failed to split PDF file")
tracker.update(80, "PDF split successfully")
else:
# Use LibreOffice for document conversions
tracker.update(40, "Starting LibreOffice conversion")
output_file = libreoffice_convert(input_path, output_format.lower(), output_filename)
if not output_file:
raise Exception(f"LibreOffice conversion failed for {output_format}")
tracker.update(70, "LibreOffice conversion completed")
# Step 3: Verify output
tracker.update(90, "Verifying output file")
if not output_file or not os.path.exists(output_file):
raise Exception("Output file was not created")
output_size_mb = get_file_size_mb(output_file)
logger.info(f"Job {job_id} output size: {output_size_mb:.2f}MB")
# Step 4: Complete
tracker.complete(output_file)
except Exception as e:
logger.error(f"Job {job_id} failed: {str(e)}", exc_info=True)
tracker.fail(str(e))
finally:
# Clean up input file
try:
if os.path.exists(input_path):
os.remove(input_path)
logger.debug(f"Cleaned up input file: {input_path}")
except Exception as e:
logger.warning(f"Failed to cleanup input file {input_path}: {e}")
def process_bulk_conversion(job_id: str, input_files: List[tuple], output_format: str):
"""Background task for bulk conversion with progress tracking"""
tracker = ProgressTracker(job_id)
tracker.is_bulk = True
converted_files = []
try:
# Validate output format
if output_format.lower() not in ALLOWED_OUTPUT_FORMATS:
raise Exception(f"Unsupported output format: {output_format}. Allowed: {', '.join(ALLOWED_OUTPUT_FORMATS)}")
total_files = len(input_files)
logger.info(f"Processing bulk job {job_id}: {total_files} files -> {output_format}")
for idx, (input_path, original_filename) in enumerate(input_files):
progress = 10 + int((idx / total_files) * 70)
tracker.update(progress, f"Converting file {idx + 1}/{total_files}: {original_filename}")
try:
# Get output filename preserving original name
output_filename = get_output_filename(original_filename, output_format)
output_path = os.path.join(TEMP_DIR, f"{job_id}_{idx}_{output_filename}")
input_ext = original_filename.split(".")[-1].lower()
is_input_image = is_image_format(input_ext)
is_output_image = is_image_format(output_format.lower())
is_output_pdf = output_format.lower() == 'pdf'
is_input_pdf = input_ext == 'pdf'
success = False
if is_input_image and is_output_pdf:
# Image to PDF
success = convert_image_to_pdf(input_path, output_path)
elif is_input_pdf and is_output_image:
# PDF to Image
success = convert_pdf_to_image(input_path, output_path, output_format)
elif is_input_image and is_output_image:
# Image to Image
success = convert_image_to_image(input_path, output_path, output_format)
else:
# Use LibreOffice for documents
output_file = libreoffice_convert(input_path, output_format.lower(), output_filename)
if output_file and os.path.exists(output_file):
# Rename to unique name for bulk
unique_path = os.path.join(TEMP_DIR, f"{job_id}_{idx}_{output_filename}")
os.rename(output_file, unique_path)
output_path = unique_path
success = True
if success and os.path.exists(output_path):
converted_files.append(output_path)
logger.info(f"Bulk conversion {idx+1}/{total_files}: {original_filename} -> {output_filename}")
else:
logger.warning(f"Bulk conversion failed for: {original_filename}")
except Exception as e:
logger.error(f"Error converting {original_filename}: {str(e)}")
# Continue with other files
tracker.update(90, "Creating zip archive")
# Create zip file
zip_filename = f"converted_files_{job_id}.zip"
zip_path = os.path.join(TEMP_DIR, zip_filename)
if converted_files:
success = create_zip_file(converted_files, zip_path)
if not success:
raise Exception("Failed to create zip file")
tracker.complete_bulk(converted_files, zip_path)
else:
raise Exception("No files were successfully converted")
except Exception as e:
logger.error(f"Bulk job {job_id} failed: {str(e)}", exc_info=True)
tracker.fail(str(e))
finally:
# Clean up input files
for input_path, _ in input_files:
try:
if os.path.exists(input_path):
os.remove(input_path)
except:
pass
# =====================================================
# AUTOMATIC CLEANUP
# =====================================================
async def cleanup_old_files():
"""Remove files older than retention period"""
while True:
try:
await asyncio.sleep(CLEANUP_INTERVAL_HOURS * 3600)
cutoff_time = datetime.now() - timedelta(hours=FILE_RETENTION_HOURS)
deleted_count = 0
freed_space = 0
logger.info(f"Starting cleanup - Removing files older than {FILE_RETENTION_HOURS} hours")
# Clean temp directory
for filename in os.listdir(TEMP_DIR):
filepath = os.path.join(TEMP_DIR, filename)
try:
file_mtime = datetime.fromtimestamp(os.path.getmtime(filepath))
if file_mtime < cutoff_time:
file_size = os.path.getsize(filepath)
os.remove(filepath)
deleted_count += 1
freed_space += file_size
logger.debug(f"Deleted old file: {filename} ({file_size / (1024*1024):.2f}MB)")
except Exception as e:
logger.warning(f"Failed to delete {filename}: {e}")
# Clean up old progress entries
expired_jobs = []
for job_id, job_data in progress_store.items():
if 'timestamp' in job_data:
job_time = datetime.fromisoformat(job_data['timestamp'])
if datetime.now() - job_time > timedelta(hours=PROGRESS_RETENTION_HOURS):
expired_jobs.append(job_id)
for job_id in expired_jobs:
# Also delete result file if it exists
result_file = progress_store[job_id].get('result_file')
if result_file and os.path.exists(result_file):
try:
os.remove(result_file)
logger.debug(f"Deleted result file for expired job: {result_file}")
except:
pass
# Delete individual result files
result_files = progress_store[job_id].get('result_files', [])
for file_path in result_files:
if os.path.exists(file_path):
try:
os.remove(file_path)
except:
pass
del progress_store[job_id]
logger.info(f"Cleanup completed - Deleted {deleted_count} files, freed {freed_space / (1024*1024):.2f}MB, removed {len(expired_jobs)} expired progress entries")
except Exception as e:
logger.error(f"Cleanup task failed: {str(e)}", exc_info=True)
# =====================================================
# CREATE FASTAPI APP
# =====================================================
app = FastAPI(
title="Universal Document Converter API",
description="Production-ready document conversion API with file size limits, health checks, and automatic cleanup",
version="2.0.0"
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Configure appropriately for production
allow_credentials=True,
allow_methods=["GET", "POST", "DELETE"],
allow_headers=["*"],
)
# =====================================================
# API ENDPOINTS
# =====================================================
@app.get("/")
async def home():
"""API information endpoint"""
return {
"service": "Universal Document Converter API",
"version": "2.0.0",
"status": "running",
"timestamp": datetime.now().isoformat(),
"limits": {
"max_file_size_mb": MAX_FILE_SIZE // (1024 * 1024),
"max_files_per_request": MAX_FILES_PER_REQUEST,
"allowed_extensions": list(ALLOWED_EXTENSIONS),
"output_formats": list(ALLOWED_OUTPUT_FORMATS),
"file_retention_hours": FILE_RETENTION_HOURS
},
"features": {
"bulk_conversion": True,
"preserve_filenames": True,
"zip_download": True
},
"endpoints": {
"/convert": "POST - Convert single file with progress tracking",
"/convert/bulk": "POST - Convert multiple files with progress tracking",
"/progress/{job_id}": "GET - Check conversion progress",
"/download/{job_id}": "GET - Download converted file(s)",
"/health": "GET - Health check endpoint",
"/stats": "GET - System statistics",
"/cleanup/{job_id}": "DELETE - Cleanup job files"
}
}
@app.get("/health")
async def health_check():
"""Production health check endpoint"""
checks = {
"libreoffice": False,
"temp_dir": os.path.exists(TEMP_DIR),
"disk_space": True,
"memory_status": "ok"
}
status_code = 200
# Check LibreOffice availability
try:
result = subprocess.run(
["libreoffice", "--headless", "--version"],
capture_output=True,
text=True,
timeout=10
)
checks["libreoffice"] = result.returncode == 0
if checks["libreoffice"]:
logger.debug("LibreOffice health check passed")
else:
logger.warning("LibreOffice health check failed")
except Exception as e:
logger.warning(f"LibreOffice health check error: {e}")
checks["libreoffice"] = False
# Check disk space
try:
import shutil
usage = shutil.disk_usage(TEMP_DIR)
free_gb = usage.free / (1024**3)
total_gb = usage.total / (1024**3)
if usage.free < 100 * 1024 * 1024: # Less than 100MB free
checks["disk_space"] = False
checks["disk_warning"] = f"Only {free_gb:.1f}GB free of {total_gb:.1f}GB total"
status_code = 503
logger.warning(f"Low disk space: {free_gb:.1f}GB free")
else:
logger.debug(f"Disk space OK: {free_gb:.1f}GB free of {total_gb:.1f}GB total")
except Exception as e:
logger.error(f"Disk space check failed: {e}")
# Check progress store size
if len(progress_store) > 1000:
checks["memory_status"] = "high_load"
logger.warning(f"High number of active jobs: {len(progress_store)}")
# Determine overall status
if all([checks["libreoffice"], checks["temp_dir"], checks["disk_space"]]):
status = "healthy"
elif checks["libreoffice"] is False:
status = "degraded"
status_code = 503
else:
status = "unhealthy"
status_code = 503
logger.info(f"Health check: {status} (code {status_code})")
return JSONResponse(
status_code=status_code,
content={
"status": status,
"timestamp": datetime.now().isoformat(),
"checks": checks,
"stats": {
"active_jobs": len(progress_store),
"temp_files": len(os.listdir(TEMP_DIR)),
"file_retention_hours": FILE_RETENTION_HOURS
}
}
)
@app.post("/convert")
async def convert_file(
background_tasks: BackgroundTasks,
output_format: str = Form(...),
file: Optional[UploadFile] = File(None),
merge_files: Optional[List[UploadFile]] = File(None),
split_pages: Optional[str] = Form(None)
):
"""
Convert documents with file size limits and progress tracking
Limits:
- Max file size: 50MB
- Allowed extensions: All common document and image formats
Returns job_id for progress tracking
"""
job_id = str(uuid.uuid4())
options = {}
try:
# Validate output format
if output_format.lower() not in ALLOWED_OUTPUT_FORMATS:
raise HTTPException(
400,
f"Unsupported output format: {output_format}. Allowed: {', '.join(ALLOWED_OUTPUT_FORMATS)}"
)
# Handle merge operation
if merge_files and output_format.lower() == "pdf":
if len(merge_files) > MAX_FILES_PER_REQUEST:
raise HTTPException(400, f"Too many files. Max {MAX_FILES_PER_REQUEST} files per request")
options["merge"] = True
options["files"] = []
for upload_file in merge_files:
if not upload_file.filename.lower().endswith('.pdf'):
raise HTTPException(400, f"File {upload_file.filename} is not a PDF")
path, original, size = await save_file_with_limits(upload_file)
options["files"].append(path)
if not options["files"]:
raise HTTPException(400, "No valid PDF files provided for merge")
input_path = options["files"][0]
original_filename = f"merged_{len(options['files'])}_files.pdf"
# Handle split operation
elif split_pages and output_format.lower() == "pdf":
if not file:
raise HTTPException(400, "File required for split operation")
options["split"] = True
try:
pages = [int(p.strip()) for p in split_pages.split(",")]
if not pages:
raise ValueError("No pages specified")
options["pages"] = pages
except ValueError:
raise HTTPException(400, "Invalid page numbers format. Use comma-separated numbers (e.g., '1,3,5')")
input_path, original_filename, file_size = await save_file_with_limits(file)
# Handle single file conversion
elif file:
input_path, original_filename, file_size = await save_file_with_limits(file)
else:
raise HTTPException(400, "No file provided")
# Initialize progress
progress_store[job_id] = {
"progress": 0,
"status": "pending",
"message": "Job queued",
"result_file": None,
"result_files": [],
"is_bulk": False,
"error": None,
"timestamp": datetime.now().isoformat()
}
# Start background task
background_tasks.add_task(
process_conversion,
job_id,
input_path,
original_filename,
output_format,
options
)
logger.info(f"Job {job_id} queued: {original_filename} -> {output_format}")
return JSONResponse({
"job_id": job_id,
"status": "processing",
"message": "Conversion started",
"check_progress": f"/progress/{job_id}",
"estimated_time": "5-30 seconds depending on file size"
})
except HTTPException:
raise
except Exception as e:
logger.error(f"Job {job_id} initialization failed: {str(e)}", exc_info=True)
raise HTTPException(500, f"Failed to process request: {str(e)}")
@app.post("/convert/bulk")
async def convert_bulk_files(
background_tasks: BackgroundTasks,
output_format: str = Form(...),
files: List[UploadFile] = File(...)
):
"""
Convert multiple files with progress tracking
Limits:
- Max file size per file: 50MB
- Max files: 10
- Allowed extensions: All common document and image formats
Returns job_id for progress tracking
"""
job_id = str(uuid.uuid4())
try:
# Validate output format
if output_format.lower() not in ALLOWED_OUTPUT_FORMATS:
raise HTTPException(
400,
f"Unsupported output format: {output_format}. Allowed: {', '.join(ALLOWED_OUTPUT_FORMATS)}"
)
# Check number of files
if len(files) > MAX_FILES_PER_REQUEST:
raise HTTPException(400, f"Too many files. Max {MAX_FILES_PER_REQUEST} files per request")
if len(files) == 0:
raise HTTPException(400, "No files provided")
# Save all files
saved_files = []
for upload_file in files:
path, original, size = await save_file_with_limits(upload_file)
saved_files.append((path, original))
# Initialize progress
progress_store[job_id] = {
"progress": 0,
"status": "pending",
"message": "Bulk job queued",
"result_file": None,
"result_files": [],
"is_bulk": True,
"error": None,
"timestamp": datetime.now().isoformat()
}
# Start background task
background_tasks.add_task(
process_bulk_conversion,
job_id,
saved_files,
output_format
)
logger.info(f"Bulk job {job_id} queued: {len(saved_files)} files -> {output_format}")
return JSONResponse({
"job_id": job_id,
"status": "processing",
"message": f"Bulk conversion started for {len(saved_files)} files",
"check_progress": f"/progress/{job_id}",
"estimated_time": f"{len(saved_files) * 5}-{len(saved_files) * 30} seconds depending on file sizes"
})
except HTTPException:
raise
except Exception as e:
logger.error(f"Bulk job {job_id} initialization failed: {str(e)}", exc_info=True)
raise HTTPException(500, f"Failed to process request: {str(e)}")
@app.get("/progress/{job_id}")
async def get_progress(job_id: str):
"""Check conversion progress"""
if job_id not in progress_store:
raise HTTPException(404, f"Job {job_id} not found")
progress_data = progress_store[job_id]
response = {
"job_id": job_id,
"progress": progress_data["progress"],
"status": progress_data["status"],
"message": progress_data["message"],
"completed": progress_data["progress"] == 100,
"is_bulk": progress_data.get("is_bulk", False),
"error": progress_data.get("error"),
"timestamp": progress_data["timestamp"],
"duration_seconds": progress_data.get("duration_seconds", 0)
}
if progress_data["status"] == "completed" and progress_data.get("result_file"):
response["download_url"] = f"/download/{job_id}"
if progress_data.get("is_bulk") and progress_data.get("result_files"):
response["files_converted"] = len(progress_data["result_files"])
return JSONResponse(response)
@app.get("/download/{job_id}")
async def download_file(job_id: str):
"""Download converted file(s)"""
if job_id not in progress_store:
raise HTTPException(404, f"Job {job_id} not found")
job_data = progress_store[job_id]
if job_data["status"] != "completed":
raise HTTPException(400, f"File not ready. Status: {job_data['status']}, Progress: {job_data['progress']}%")
if not job_data.get("result_file") or not os.path.exists(job_data["result_file"]):
raise HTTPException(404, "Output file not found or has been cleaned up")
filename = os.path.basename(job_data["result_file"])
file_size_mb = get_file_size_mb(job_data["result_file"])
logger.info(f"Downloading {filename} ({file_size_mb:.2f}MB) for job {job_id}")
# If it's a zip file for bulk conversion
if job_data.get("is_bulk") and filename.endswith('.zip'):
return FileResponse(
job_data["result_file"],
filename=filename,
media_type="application/zip"
)
return FileResponse(
job_data["result_file"],
filename=filename,
media_type="application/octet-stream"
)
@app.get("/stats")
async def get_stats():
"""Get system statistics"""
total_temp_files = len(os.listdir(TEMP_DIR))
total_temp_size = sum(os.path.getsize(os.path.join(TEMP_DIR, f)) for f in os.listdir(TEMP_DIR) if os.path.isfile(os.path.join(TEMP_DIR, f)))
completed_jobs = sum(1 for job in progress_store.values() if job["status"] == "completed")
failed_jobs = sum(1 for job in progress_store.values() if job["status"] == "failed")
pending_jobs = sum(1 for job in progress_store.values() if job["status"] == "pending")
bulk_jobs = sum(1 for job in progress_store.values() if job.get("is_bulk", False))
return {
"timestamp": datetime.now().isoformat(),
"jobs": {
"total": len(progress_store),
"completed": completed_jobs,
"failed": failed_jobs,
"pending": pending_jobs,
"bulk": bulk_jobs
},
"storage": {
"temp_files": total_temp_files,
"temp_size_mb": round(total_temp_size / (1024 * 1024), 2),
"file_retention_hours": FILE_RETENTION_HOURS
},
"limits": {
"max_file_size_mb": MAX_FILE_SIZE // (1024 * 1024),
"max_files_per_request": MAX_FILES_PER_REQUEST,
"allowed_extensions": len(ALLOWED_EXTENSIONS)
}
}
@app.delete("/cleanup/{job_id}")
async def cleanup_job(job_id: str):
"""Manually clean up job files"""
if job_id not in progress_store:
raise HTTPException(404, f"Job {job_id} not found")
job_data = progress_store[job_id]
# Clean up main result file
result_file = job_data.get("result_file")
if result_file and os.path.exists(result_file):
try:
os.remove(result_file)
logger.info(f"Manually cleaned up result file for job {job_id}: {result_file}")
except Exception as e:
logger.warning(f"Failed to cleanup result file for job {job_id}: {e}")
# Clean up individual result files
result_files = job_data.get("result_files", [])
for file_path in result_files:
if os.path.exists(file_path):
try:
os.remove(file_path)
logger.info(f"Manually cleaned up individual file for job {job_id}: {file_path}")
except Exception as e:
logger.warning(f"Failed to cleanup individual file for job {job_id}: {e}")
del progress_store[job_id]
return JSONResponse({"message": f"Job {job_id} cleaned up successfully"})
# =====================================================
# STARTUP EVENT
# =====================================================
@app.on_event("startup")
async def startup_event():
"""Initialize production environment"""
logger.info("=" * 60)
logger.info("Starting Universal Document Converter API")
logger.info(f"Version: 2.0.0")
logger.info(f"Configuration:")
logger.info(f" - Max file size: {MAX_FILE_SIZE // (1024 * 1024)}MB")
logger.info(f" - Max files per bulk: {MAX_FILES_PER_REQUEST}")
logger.info(f" - File retention: {FILE_RETENTION_HOURS} hours")
logger.info(f" - Cleanup interval: {CLEANUP_INTERVAL_HOURS} hours")
logger.info(f" - Allowed extensions: {len(ALLOWED_EXTENSIONS)} types")
logger.info(f" - Temp directory: {TEMP_DIR}")
logger.info(f" - Log directory: {LOG_DIR}")
logger.info(f"Features:")
logger.info(f" - Bulk conversion: Enabled")
logger.info(f" - Preserve filenames: Enabled")
logger.info(f" - ZIP download: Enabled")
# Verify LibreOffice availability
try:
result = subprocess.run(["libreoffice", "--headless", "--version"], capture_output=True, text=True)
if result.returncode == 0:
logger.info(f"LibreOffice available: {result.stdout.strip()}")
else:
logger.error("LibreOffice not available - conversions will fail!")
except Exception as e:
logger.error(f"Failed to check LibreOffice: {e}")
# Start background cleanup task
asyncio.create_task(cleanup_old_files())
logger.info("Background cleanup task started")
logger.info("API startup complete - Ready to accept requests")
logger.info("=" * 60)
# =====================================================
# MAIN (for development)
# =====================================================
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app:app",
host="0.0.0.0",
port=8000,
reload=True,
log_level="info"
)