feat: Initialize frontend with React, Vite, and Tailwind CSS
- Set up main entry point for React application. - Create About, Home, NotFound, Privacy, and Terms pages with SEO support. - Implement API service for file uploads and task management. - Add global styles using Tailwind CSS. - Create utility functions for SEO and text processing. - Configure Vite for development and production builds. - Set up Nginx configuration for serving frontend and backend. - Add scripts for cleanup of expired files and sitemap generation. - Implement deployment script for production environment.
This commit is contained in:
1
backend/app/routes/__init__.py
Normal file
1
backend/app/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Backend application routes."""
|
||||
47
backend/app/routes/compress.py
Normal file
47
backend/app/routes/compress.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""PDF compression routes."""
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from app.extensions import limiter
|
||||
from app.utils.file_validator import validate_file, FileValidationError
|
||||
from app.utils.sanitizer import generate_safe_path
|
||||
from app.tasks.compress_tasks import compress_pdf_task
|
||||
|
||||
compress_bp = Blueprint("compress", __name__)
|
||||
|
||||
|
||||
@compress_bp.route("/pdf", methods=["POST"])
|
||||
@limiter.limit("10/minute")
|
||||
def compress_pdf_route():
|
||||
"""
|
||||
Compress a PDF file.
|
||||
|
||||
Accepts: multipart/form-data with 'file' field (PDF)
|
||||
Optional form field 'quality': "low", "medium", "high" (default: "medium")
|
||||
Returns: JSON with task_id for polling
|
||||
"""
|
||||
if "file" not in request.files:
|
||||
return jsonify({"error": "No file provided."}), 400
|
||||
|
||||
file = request.files["file"]
|
||||
quality = request.form.get("quality", "medium")
|
||||
|
||||
# Validate quality parameter
|
||||
if quality not in ("low", "medium", "high"):
|
||||
quality = "medium"
|
||||
|
||||
try:
|
||||
original_filename, ext = validate_file(file, allowed_types=["pdf"])
|
||||
except FileValidationError as e:
|
||||
return jsonify({"error": e.message}), e.code
|
||||
|
||||
# Save file to temp location
|
||||
task_id, input_path = generate_safe_path(ext, folder_type="upload")
|
||||
file.save(input_path)
|
||||
|
||||
# Dispatch async task
|
||||
task = compress_pdf_task.delay(input_path, task_id, original_filename, quality)
|
||||
|
||||
return jsonify({
|
||||
"task_id": task.id,
|
||||
"message": "Compression started. Poll /api/tasks/{task_id}/status for progress.",
|
||||
}), 202
|
||||
73
backend/app/routes/convert.py
Normal file
73
backend/app/routes/convert.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""PDF conversion routes (PDF↔Word)."""
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from app.extensions import limiter
|
||||
from app.utils.file_validator import validate_file, FileValidationError
|
||||
from app.utils.sanitizer import generate_safe_path
|
||||
from app.tasks.convert_tasks import convert_pdf_to_word, convert_word_to_pdf
|
||||
|
||||
convert_bp = Blueprint("convert", __name__)
|
||||
|
||||
|
||||
@convert_bp.route("/pdf-to-word", methods=["POST"])
|
||||
@limiter.limit("10/minute")
|
||||
def pdf_to_word_route():
|
||||
"""
|
||||
Convert a PDF file to Word (DOCX).
|
||||
|
||||
Accepts: multipart/form-data with 'file' field (PDF)
|
||||
Returns: JSON with task_id for polling
|
||||
"""
|
||||
if "file" not in request.files:
|
||||
return jsonify({"error": "No file provided."}), 400
|
||||
|
||||
file = request.files["file"]
|
||||
|
||||
try:
|
||||
original_filename, ext = validate_file(file, allowed_types=["pdf"])
|
||||
except FileValidationError as e:
|
||||
return jsonify({"error": e.message}), e.code
|
||||
|
||||
# Save file to temp location
|
||||
task_id, input_path = generate_safe_path(ext, folder_type="upload")
|
||||
file.save(input_path)
|
||||
|
||||
# Dispatch async task
|
||||
task = convert_pdf_to_word.delay(input_path, task_id, original_filename)
|
||||
|
||||
return jsonify({
|
||||
"task_id": task.id,
|
||||
"message": "Conversion started. Poll /api/tasks/{task_id}/status for progress.",
|
||||
}), 202
|
||||
|
||||
|
||||
@convert_bp.route("/word-to-pdf", methods=["POST"])
|
||||
@limiter.limit("10/minute")
|
||||
def word_to_pdf_route():
|
||||
"""
|
||||
Convert a Word (DOC/DOCX) file to PDF.
|
||||
|
||||
Accepts: multipart/form-data with 'file' field (DOC/DOCX)
|
||||
Returns: JSON with task_id for polling
|
||||
"""
|
||||
if "file" not in request.files:
|
||||
return jsonify({"error": "No file provided."}), 400
|
||||
|
||||
file = request.files["file"]
|
||||
|
||||
try:
|
||||
original_filename, ext = validate_file(
|
||||
file, allowed_types=["doc", "docx"]
|
||||
)
|
||||
except FileValidationError as e:
|
||||
return jsonify({"error": e.message}), e.code
|
||||
|
||||
task_id, input_path = generate_safe_path(ext, folder_type="upload")
|
||||
file.save(input_path)
|
||||
|
||||
task = convert_word_to_pdf.delay(input_path, task_id, original_filename)
|
||||
|
||||
return jsonify({
|
||||
"task_id": task.id,
|
||||
"message": "Conversion started. Poll /api/tasks/{task_id}/status for progress.",
|
||||
}), 202
|
||||
35
backend/app/routes/download.py
Normal file
35
backend/app/routes/download.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Local file download route — used when S3 is not configured."""
|
||||
import os
|
||||
|
||||
from flask import Blueprint, send_file, abort, request, current_app
|
||||
|
||||
download_bp = Blueprint("download", __name__)
|
||||
|
||||
|
||||
@download_bp.route("/<task_id>/<filename>", methods=["GET"])
|
||||
def download_file(task_id: str, filename: str):
|
||||
"""
|
||||
Serve a processed file from local filesystem.
|
||||
|
||||
Only active in development (when S3 is not configured).
|
||||
"""
|
||||
# Security: sanitize inputs
|
||||
# Only allow UUID-style task IDs and safe filenames
|
||||
if ".." in task_id or "/" in task_id or "\\" in task_id:
|
||||
abort(400, "Invalid task ID.")
|
||||
if ".." in filename or "/" in filename or "\\" in filename:
|
||||
abort(400, "Invalid filename.")
|
||||
|
||||
output_dir = current_app.config["OUTPUT_FOLDER"]
|
||||
file_path = os.path.join(output_dir, task_id, filename)
|
||||
|
||||
if not os.path.isfile(file_path):
|
||||
abort(404, "File not found or expired.")
|
||||
|
||||
download_name = request.args.get("name", filename)
|
||||
|
||||
return send_file(
|
||||
file_path,
|
||||
as_attachment=True,
|
||||
download_name=download_name,
|
||||
)
|
||||
14
backend/app/routes/health.py
Normal file
14
backend/app/routes/health.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Health check endpoint."""
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
health_bp = Blueprint("health", __name__)
|
||||
|
||||
|
||||
@health_bp.route("/health", methods=["GET"])
|
||||
def health_check():
|
||||
"""Simple health check — returns 200 if the service is running."""
|
||||
return jsonify({
|
||||
"status": "healthy",
|
||||
"service": "SaaS-PDF API",
|
||||
"version": "1.0.0",
|
||||
})
|
||||
122
backend/app/routes/image.py
Normal file
122
backend/app/routes/image.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Image processing routes."""
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from app.extensions import limiter
|
||||
from app.utils.file_validator import validate_file, FileValidationError
|
||||
from app.utils.sanitizer import generate_safe_path
|
||||
from app.tasks.image_tasks import convert_image_task, resize_image_task
|
||||
|
||||
image_bp = Blueprint("image", __name__)
|
||||
|
||||
ALLOWED_IMAGE_TYPES = ["png", "jpg", "jpeg", "webp"]
|
||||
ALLOWED_OUTPUT_FORMATS = ["jpg", "png", "webp"]
|
||||
|
||||
|
||||
@image_bp.route("/convert", methods=["POST"])
|
||||
@limiter.limit("10/minute")
|
||||
def convert_image_route():
|
||||
"""
|
||||
Convert an image to a different format.
|
||||
|
||||
Accepts: multipart/form-data with:
|
||||
- 'file': Image file (PNG, JPG, JPEG, WebP)
|
||||
- 'format': Target format ("jpg", "png", "webp")
|
||||
- 'quality' (optional): Quality 1-100 (default: 85)
|
||||
Returns: JSON with task_id for polling
|
||||
"""
|
||||
if "file" not in request.files:
|
||||
return jsonify({"error": "No file provided."}), 400
|
||||
|
||||
file = request.files["file"]
|
||||
output_format = request.form.get("format", "").lower()
|
||||
quality = request.form.get("quality", "85")
|
||||
|
||||
# Validate output format
|
||||
if output_format not in ALLOWED_OUTPUT_FORMATS:
|
||||
return jsonify({
|
||||
"error": f"Invalid format. Supported: {', '.join(ALLOWED_OUTPUT_FORMATS)}"
|
||||
}), 400
|
||||
|
||||
# Validate quality
|
||||
try:
|
||||
quality = max(1, min(100, int(quality)))
|
||||
except ValueError:
|
||||
quality = 85
|
||||
|
||||
try:
|
||||
original_filename, ext = validate_file(file, allowed_types=ALLOWED_IMAGE_TYPES)
|
||||
except FileValidationError as e:
|
||||
return jsonify({"error": e.message}), e.code
|
||||
|
||||
# Save file
|
||||
task_id, input_path = generate_safe_path(ext, folder_type="upload")
|
||||
file.save(input_path)
|
||||
|
||||
# Dispatch task
|
||||
task = convert_image_task.delay(
|
||||
input_path, task_id, original_filename, output_format, quality
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"task_id": task.id,
|
||||
"message": "Image conversion started. Poll /api/tasks/{task_id}/status for progress.",
|
||||
}), 202
|
||||
|
||||
|
||||
@image_bp.route("/resize", methods=["POST"])
|
||||
@limiter.limit("10/minute")
|
||||
def resize_image_route():
|
||||
"""
|
||||
Resize an image.
|
||||
|
||||
Accepts: multipart/form-data with:
|
||||
- 'file': Image file
|
||||
- 'width' (optional): Target width
|
||||
- 'height' (optional): Target height
|
||||
- 'quality' (optional): Quality 1-100 (default: 85)
|
||||
Returns: JSON with task_id for polling
|
||||
"""
|
||||
if "file" not in request.files:
|
||||
return jsonify({"error": "No file provided."}), 400
|
||||
|
||||
file = request.files["file"]
|
||||
width = request.form.get("width")
|
||||
height = request.form.get("height")
|
||||
quality = request.form.get("quality", "85")
|
||||
|
||||
# Validate dimensions
|
||||
try:
|
||||
width = int(width) if width else None
|
||||
height = int(height) if height else None
|
||||
except ValueError:
|
||||
return jsonify({"error": "Width and height must be integers."}), 400
|
||||
|
||||
if width is None and height is None:
|
||||
return jsonify({"error": "At least one of width or height is required."}), 400
|
||||
|
||||
if width and (width < 1 or width > 10000):
|
||||
return jsonify({"error": "Width must be between 1 and 10000."}), 400
|
||||
if height and (height < 1 or height > 10000):
|
||||
return jsonify({"error": "Height must be between 1 and 10000."}), 400
|
||||
|
||||
try:
|
||||
quality = max(1, min(100, int(quality)))
|
||||
except ValueError:
|
||||
quality = 85
|
||||
|
||||
try:
|
||||
original_filename, ext = validate_file(file, allowed_types=ALLOWED_IMAGE_TYPES)
|
||||
except FileValidationError as e:
|
||||
return jsonify({"error": e.message}), e.code
|
||||
|
||||
task_id, input_path = generate_safe_path(ext, folder_type="upload")
|
||||
file.save(input_path)
|
||||
|
||||
task = resize_image_task.delay(
|
||||
input_path, task_id, original_filename, width, height, quality
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"task_id": task.id,
|
||||
"message": "Image resize started. Poll /api/tasks/{task_id}/status for progress.",
|
||||
}), 202
|
||||
39
backend/app/routes/tasks.py
Normal file
39
backend/app/routes/tasks.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Task status polling endpoint."""
|
||||
from flask import Blueprint, jsonify
|
||||
from celery.result import AsyncResult
|
||||
|
||||
from app.extensions import celery
|
||||
|
||||
tasks_bp = Blueprint("tasks", __name__)
|
||||
|
||||
|
||||
@tasks_bp.route("/<task_id>/status", methods=["GET"])
|
||||
def get_task_status(task_id: str):
|
||||
"""
|
||||
Get the status of an async task.
|
||||
|
||||
Returns:
|
||||
JSON with task state and result (if completed)
|
||||
"""
|
||||
result = AsyncResult(task_id, app=celery)
|
||||
|
||||
response = {
|
||||
"task_id": task_id,
|
||||
"state": result.state,
|
||||
}
|
||||
|
||||
if result.state == "PENDING":
|
||||
response["progress"] = "Task is waiting in queue..."
|
||||
|
||||
elif result.state == "PROCESSING":
|
||||
meta = result.info or {}
|
||||
response["progress"] = meta.get("step", "Processing...")
|
||||
|
||||
elif result.state == "SUCCESS":
|
||||
task_result = result.result or {}
|
||||
response["result"] = task_result
|
||||
|
||||
elif result.state == "FAILURE":
|
||||
response["error"] = str(result.info) if result.info else "Task failed."
|
||||
|
||||
return jsonify(response)
|
||||
70
backend/app/routes/video.py
Normal file
70
backend/app/routes/video.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Video processing routes."""
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from app.extensions import limiter
|
||||
from app.utils.file_validator import validate_file, FileValidationError
|
||||
from app.utils.sanitizer import generate_safe_path
|
||||
from app.tasks.video_tasks import create_gif_task
|
||||
|
||||
video_bp = Blueprint("video", __name__)
|
||||
|
||||
ALLOWED_VIDEO_TYPES = ["mp4", "webm"]
|
||||
|
||||
|
||||
@video_bp.route("/to-gif", methods=["POST"])
|
||||
@limiter.limit("5/minute")
|
||||
def video_to_gif_route():
|
||||
"""
|
||||
Convert a video clip to an animated GIF.
|
||||
|
||||
Accepts: multipart/form-data with:
|
||||
- 'file': Video file (MP4, WebM, max 50MB)
|
||||
- 'start_time' (optional): Start time in seconds (default: 0)
|
||||
- 'duration' (optional): Duration in seconds, max 15 (default: 5)
|
||||
- 'fps' (optional): Frames per second, max 20 (default: 10)
|
||||
- 'width' (optional): Output width, max 640 (default: 480)
|
||||
Returns: JSON with task_id for polling
|
||||
"""
|
||||
if "file" not in request.files:
|
||||
return jsonify({"error": "No file provided."}), 400
|
||||
|
||||
file = request.files["file"]
|
||||
|
||||
# Parse and validate parameters
|
||||
try:
|
||||
start_time = float(request.form.get("start_time", 0))
|
||||
duration = float(request.form.get("duration", 5))
|
||||
fps = int(request.form.get("fps", 10))
|
||||
width = int(request.form.get("width", 480))
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({"error": "Invalid parameters. Must be numeric."}), 400
|
||||
|
||||
# Enforce limits
|
||||
if start_time < 0:
|
||||
return jsonify({"error": "Start time cannot be negative."}), 400
|
||||
if duration <= 0 or duration > 15:
|
||||
return jsonify({"error": "Duration must be between 0.5 and 15 seconds."}), 400
|
||||
if fps < 1 or fps > 20:
|
||||
return jsonify({"error": "FPS must be between 1 and 20."}), 400
|
||||
if width < 100 or width > 640:
|
||||
return jsonify({"error": "Width must be between 100 and 640 pixels."}), 400
|
||||
|
||||
try:
|
||||
original_filename, ext = validate_file(file, allowed_types=ALLOWED_VIDEO_TYPES)
|
||||
except FileValidationError as e:
|
||||
return jsonify({"error": e.message}), e.code
|
||||
|
||||
# Save file
|
||||
task_id, input_path = generate_safe_path(ext, folder_type="upload")
|
||||
file.save(input_path)
|
||||
|
||||
# Dispatch task
|
||||
task = create_gif_task.delay(
|
||||
input_path, task_id, original_filename,
|
||||
start_time, duration, fps, width,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"task_id": task.id,
|
||||
"message": "GIF creation started. Poll /api/tasks/{task_id}/status for progress.",
|
||||
}), 202
|
||||
Reference in New Issue
Block a user