تم الانتهاء من آخر دفعة تحسينات على المشروع، وتشمل:

تحويل لوحة الإدارة الداخلية من secret header إلى session auth حقيقي مع صلاحيات admin.
إضافة دعم إدارة الأدوار من داخل لوحة الإدارة نفسها، مع حماية الحسابات المعتمدة عبر INTERNAL_ADMIN_EMAILS.
تحسين بيانات المستخدم في الواجهة والباكند لتشمل role وis_allowlisted_admin.
إضافة اختبار frontend مخصص لصفحة /internal/admin بدل الاعتماد فقط على build واختبار routes.
تحسين إضافي في الأداء عبر إزالة الاعتماد على pdfjs-dist/pdf.worker في عدّ صفحات PDF واستبداله بمسار أخف باستخدام pdf-lib.
تحسين تقسيم الـ chunks في build لتقليل أثر الحزم الكبيرة وفصل أجزاء مثل network, icons, pdf-core, وeditor.
التحقق الذي تم:

نجاح build للواجهة.
نجاح اختبار صفحة الإدارة الداخلية في frontend.
نجاح اختبارات auth/admin في backend.
نجاح full backend suite مسبقًا مع EXIT:0.
ولو تريد نسخة أقصر جدًا، استخدم هذه:

آخر التحديثات:
تم تحسين نظام الإدارة الداخلية ليعتمد على صلاحيات وجلسات حقيقية بدل secret header، مع إضافة إدارة أدوار من لوحة admin نفسها، وإضافة اختبارات frontend مخصصة للوحة، وتحسين أداء الواجهة عبر إزالة pdf.worker وتحسين تقسيم الـ chunks في build. جميع الاختبارات والتحققات الأساسية المطلوبة نجح
This commit is contained in:
Your Name
2026-03-16 13:50:45 +02:00
parent b5d97324a9
commit 957d37838c
85 changed files with 9915 additions and 119 deletions

View File

@@ -1,27 +1,100 @@
"""Internal admin endpoints secured by INTERNAL_ADMIN_SECRET."""
from flask import Blueprint, current_app, jsonify, request
"""Internal admin endpoints secured by authenticated admin sessions."""
from flask import Blueprint, jsonify, request
from app.extensions import limiter
from app.services.account_service import get_user_by_id, update_user_plan
from app.services.account_service import get_user_by_id, is_user_admin, set_user_role, update_user_plan
from app.services.admin_service import (
get_admin_overview,
list_admin_contacts,
list_admin_users,
mark_admin_contact_read,
)
from app.services.ai_cost_service import get_monthly_spend
from app.utils.auth import get_current_user_id
admin_bp = Blueprint("admin", __name__)
def _check_admin_secret() -> bool:
"""Return whether the request carries the correct admin secret."""
secret = current_app.config.get("INTERNAL_ADMIN_SECRET", "")
if not secret:
return False
return request.headers.get("X-Admin-Secret", "") == secret
def _require_admin_session():
"""Return an error response unless the request belongs to an authenticated admin."""
user_id = get_current_user_id()
if user_id is None:
return jsonify({"error": "Authentication required."}), 401
if not is_user_admin(user_id):
return jsonify({"error": "Admin access required."}), 403
return None
@admin_bp.route("/overview", methods=["GET"])
@limiter.limit("60/hour")
def admin_overview_route():
"""Return the internal admin dashboard overview."""
auth_error = _require_admin_session()
if auth_error:
return auth_error
return jsonify(get_admin_overview()), 200
@admin_bp.route("/users", methods=["GET"])
@limiter.limit("60/hour")
def admin_users_route():
"""Return recent users plus usage summaries for the admin dashboard."""
auth_error = _require_admin_session()
if auth_error:
return auth_error
query = request.args.get("query", "")
try:
limit = max(1, min(int(request.args.get("limit", 25)), 100))
except ValueError:
limit = 25
return jsonify({"items": list_admin_users(limit=limit, query=query)}), 200
@admin_bp.route("/contacts", methods=["GET"])
@limiter.limit("60/hour")
def admin_contacts_route():
"""Return paginated contact messages for the admin dashboard."""
auth_error = _require_admin_session()
if auth_error:
return auth_error
try:
page = max(1, int(request.args.get("page", 1)))
except ValueError:
page = 1
try:
per_page = max(1, min(int(request.args.get("per_page", 20)), 100))
except ValueError:
per_page = 20
return jsonify(list_admin_contacts(page=page, per_page=per_page)), 200
@admin_bp.route("/contacts/<int:message_id>/read", methods=["POST"])
@limiter.limit("120/hour")
def admin_contacts_mark_read_route(message_id: int):
"""Mark one contact message as read."""
auth_error = _require_admin_session()
if auth_error:
return auth_error
if not mark_admin_contact_read(message_id):
return jsonify({"error": "Message not found."}), 404
return jsonify({"message": "Message marked as read."}), 200
@admin_bp.route("/users/<int:user_id>/plan", methods=["POST"])
@limiter.limit("30/hour")
def update_plan_route(user_id: int):
"""Change the plan for one user — secured by X-Admin-Secret header."""
if not _check_admin_secret():
return jsonify({"error": "Unauthorized."}), 401
"""Change the plan for one user — admin session required."""
auth_error = _require_admin_session()
if auth_error:
return auth_error
data = request.get_json(silent=True) or {}
plan = str(data.get("plan", "")).strip().lower()
@@ -40,12 +113,45 @@ def update_plan_route(user_id: int):
return jsonify({"message": "Plan updated.", "user": updated}), 200
@admin_bp.route("/users/<int:user_id>/role", methods=["POST"])
@limiter.limit("30/hour")
def update_role_route(user_id: int):
"""Change the role for one user — admin session required."""
auth_error = _require_admin_session()
if auth_error:
return auth_error
actor_user_id = get_current_user_id()
data = request.get_json(silent=True) or {}
role = str(data.get("role", "")).strip().lower()
if role not in ("user", "admin"):
return jsonify({"error": "Role must be 'user' or 'admin'."}), 400
user = get_user_by_id(user_id)
if user is None:
return jsonify({"error": "User not found."}), 404
if bool(user.get("is_allowlisted_admin")):
return jsonify({"error": "Allowlisted admin access is managed by INTERNAL_ADMIN_EMAILS."}), 400
if actor_user_id == user_id and role != "admin":
return jsonify({"error": "You cannot remove your own admin role."}), 400
try:
updated = set_user_role(user_id, role)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify({"message": "Role updated.", "user": updated}), 200
@admin_bp.route("/ai-cost", methods=["GET"])
@limiter.limit("60/hour")
def ai_cost_dashboard():
"""Return the current month's AI spending summary."""
if not _check_admin_secret():
return jsonify({"error": "Unauthorized."}), 401
auth_error = _require_admin_session()
if auth_error:
return auth_error
spend = get_monthly_spend()
return jsonify(spend), 200

View File

@@ -0,0 +1,70 @@
"""Routes for barcode generation."""
from flask import Blueprint, request, jsonify
from app.extensions import limiter
from app.services.policy_service import (
assert_quota_available,
build_task_tracking_kwargs,
PolicyError,
record_accepted_usage,
resolve_web_actor,
)
from app.services.barcode_service import SUPPORTED_BARCODE_TYPES
from app.tasks.barcode_tasks import generate_barcode_task
from app.utils.sanitizer import generate_safe_path
barcode_bp = Blueprint("barcode", __name__)
@barcode_bp.route("/generate", methods=["POST"])
@limiter.limit("20/minute")
def generate_barcode_route():
"""Generate a barcode image.
Accepts: JSON or form data with:
- 'data': String to encode
- 'type' (optional): Barcode type (default: code128)
- 'format' (optional): "png" or "svg" (default: png)
"""
if request.is_json:
body = request.get_json()
data = body.get("data", "").strip()
barcode_type = body.get("type", "code128").lower()
output_format = body.get("format", "png").lower()
else:
data = request.form.get("data", "").strip()
barcode_type = request.form.get("type", "code128").lower()
output_format = request.form.get("format", "png").lower()
if not data:
return jsonify({"error": "Barcode data is required."}), 400
if len(data) > 200:
return jsonify({"error": "Barcode data is too long (max 200 characters)."}), 400
if barcode_type not in SUPPORTED_BARCODE_TYPES:
return jsonify({
"error": f"Unsupported barcode type. Supported: {', '.join(SUPPORTED_BARCODE_TYPES)}"
}), 400
if output_format not in ("png", "svg"):
output_format = "png"
actor = resolve_web_actor()
try:
assert_quota_available(actor)
except PolicyError as e:
return jsonify({"error": e.message}), e.status_code
task_id, _ = generate_safe_path("tmp", folder_type="upload")
task = generate_barcode_task.delay(
data, barcode_type, task_id, output_format,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "barcode", task.id)
return jsonify({
"task_id": task.id,
"message": "Barcode generation started. Poll /api/tasks/{task_id}/status for progress.",
}), 202

View File

@@ -0,0 +1,43 @@
"""Contact form routes."""
import logging
import re
from flask import Blueprint, jsonify, request
from app.extensions import limiter
from app.services.contact_service import save_message
logger = logging.getLogger(__name__)
contact_bp = Blueprint("contact", __name__)
EMAIL_RE = re.compile(r"^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$")
@contact_bp.route("/submit", methods=["POST"])
@limiter.limit("5/hour", override_defaults=True)
def submit_contact():
"""Accept a contact form submission."""
data = request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
email = (data.get("email") or "").strip()
category = (data.get("category") or "general").strip()
subject = (data.get("subject") or "").strip()
message = (data.get("message") or "").strip()
errors = []
if not name or len(name) > 200:
errors.append("Name is required (max 200 characters).")
if not email or not EMAIL_RE.match(email):
errors.append("A valid email address is required.")
if not subject or len(subject) > 500:
errors.append("Subject is required (max 500 characters).")
if not message or len(message) > 5000:
errors.append("Message is required (max 5000 characters).")
if errors:
return jsonify({"error": errors[0], "errors": errors}), 400
result = save_message(name, email, category, subject, message)
return jsonify({"message": "Message sent successfully.", **result}), 201

View File

@@ -0,0 +1,147 @@
"""Routes for image extra tools — Crop, Rotate/Flip."""
from flask import Blueprint, request, jsonify
from app.extensions import limiter
from app.services.policy_service import (
assert_quota_available,
build_task_tracking_kwargs,
PolicyError,
record_accepted_usage,
resolve_web_actor,
validate_actor_file,
)
from app.utils.file_validator import FileValidationError
from app.utils.sanitizer import generate_safe_path
from app.tasks.image_extra_tasks import crop_image_task, rotate_flip_image_task
image_extra_bp = Blueprint("image_extra", __name__)
ALLOWED_IMAGE_TYPES = ["png", "jpg", "jpeg", "webp"]
# ---------------------------------------------------------------------------
# Image Crop — POST /api/image/crop
# ---------------------------------------------------------------------------
@image_extra_bp.route("/crop", methods=["POST"])
@limiter.limit("10/minute")
def crop_image_route():
"""Crop an image to specified dimensions.
Accepts: multipart/form-data with:
- 'file': Image file
- 'left', 'top', 'right', 'bottom': Crop rectangle in pixels
"""
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
try:
left = int(request.form.get("left", 0))
top = int(request.form.get("top", 0))
right = int(request.form.get("right", 0))
bottom = int(request.form.get("bottom", 0))
except (ValueError, TypeError):
return jsonify({"error": "Crop dimensions must be integers."}), 400
if right <= left or bottom <= top:
return jsonify({"error": "Invalid crop area: right > left and bottom > top required."}), 400
try:
quality = max(1, min(100, int(request.form.get("quality", 85))))
except ValueError:
quality = 85
actor = resolve_web_actor()
try:
assert_quota_available(actor)
except PolicyError as e:
return jsonify({"error": e.message}), e.status_code
try:
original_filename, ext = validate_actor_file(
file, allowed_types=ALLOWED_IMAGE_TYPES, actor=actor
)
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 = crop_image_task.delay(
input_path, task_id, original_filename,
left, top, right, bottom, quality,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "image-crop", task.id)
return jsonify({
"task_id": task.id,
"message": "Cropping started. Poll /api/tasks/{task_id}/status for progress.",
}), 202
# ---------------------------------------------------------------------------
# Image Rotate/Flip — POST /api/image/rotate-flip
# ---------------------------------------------------------------------------
@image_extra_bp.route("/rotate-flip", methods=["POST"])
@limiter.limit("10/minute")
def rotate_flip_image_route():
"""Rotate and/or flip an image.
Accepts: multipart/form-data with:
- 'file': Image file
- 'rotation' (optional): 0, 90, 180, or 270 (default: 0)
- 'flip_horizontal' (optional): "true"/"false" (default: false)
- 'flip_vertical' (optional): "true"/"false" (default: false)
"""
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
try:
rotation = int(request.form.get("rotation", 0))
except ValueError:
rotation = 0
if rotation not in (0, 90, 180, 270):
return jsonify({"error": "Rotation must be 0, 90, 180, or 270 degrees."}), 400
flip_horizontal = request.form.get("flip_horizontal", "false").lower() == "true"
flip_vertical = request.form.get("flip_vertical", "false").lower() == "true"
if rotation == 0 and not flip_horizontal and not flip_vertical:
return jsonify({"error": "At least one transformation is required."}), 400
try:
quality = max(1, min(100, int(request.form.get("quality", 85))))
except ValueError:
quality = 85
actor = resolve_web_actor()
try:
assert_quota_available(actor)
except PolicyError as e:
return jsonify({"error": e.message}), e.status_code
try:
original_filename, ext = validate_actor_file(
file, allowed_types=ALLOWED_IMAGE_TYPES, actor=actor
)
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 = rotate_flip_image_task.delay(
input_path, task_id, original_filename,
rotation, flip_horizontal, flip_vertical, quality,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "image-rotate-flip", task.id)
return jsonify({
"task_id": task.id,
"message": "Transformation started. Poll /api/tasks/{task_id}/status for progress.",
}), 202

View File

@@ -0,0 +1,217 @@
"""Routes for new PDF conversions — PDF↔PPTX, Excel→PDF, Sign PDF."""
import os
import uuid
from flask import Blueprint, request, jsonify, current_app
from app.extensions import limiter
from app.services.policy_service import (
assert_quota_available,
build_task_tracking_kwargs,
PolicyError,
record_accepted_usage,
resolve_web_actor,
validate_actor_file,
)
from app.utils.file_validator import FileValidationError
from app.utils.sanitizer import generate_safe_path
from app.tasks.pdf_convert_tasks import (
pdf_to_pptx_task,
excel_to_pdf_task,
pptx_to_pdf_task,
sign_pdf_task,
)
pdf_convert_bp = Blueprint("pdf_convert", __name__)
ALLOWED_IMAGE_TYPES = ["png", "jpg", "jpeg", "webp"]
# ---------------------------------------------------------------------------
# PDF to PowerPoint — POST /api/convert/pdf-to-pptx
# ---------------------------------------------------------------------------
@pdf_convert_bp.route("/pdf-to-pptx", methods=["POST"])
@limiter.limit("10/minute")
def pdf_to_pptx_route():
"""Convert a PDF to PowerPoint (PPTX)."""
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
actor = resolve_web_actor()
try:
assert_quota_available(actor)
except PolicyError as e:
return jsonify({"error": e.message}), e.status_code
try:
original_filename, ext = validate_actor_file(file, allowed_types=["pdf"], actor=actor)
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 = pdf_to_pptx_task.delay(
input_path, task_id, original_filename,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "pdf-to-pptx", task.id)
return jsonify({
"task_id": task.id,
"message": "Conversion started. Poll /api/tasks/{task_id}/status for progress.",
}), 202
# ---------------------------------------------------------------------------
# Excel to PDF — POST /api/convert/excel-to-pdf
# ---------------------------------------------------------------------------
@pdf_convert_bp.route("/excel-to-pdf", methods=["POST"])
@limiter.limit("10/minute")
def excel_to_pdf_route():
"""Convert an Excel file to PDF."""
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
actor = resolve_web_actor()
try:
assert_quota_available(actor)
except PolicyError as e:
return jsonify({"error": e.message}), e.status_code
try:
original_filename, ext = validate_actor_file(
file, allowed_types=["xlsx", "xls"], actor=actor
)
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 = excel_to_pdf_task.delay(
input_path, task_id, original_filename,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "excel-to-pdf", task.id)
return jsonify({
"task_id": task.id,
"message": "Conversion started. Poll /api/tasks/{task_id}/status for progress.",
}), 202
# ---------------------------------------------------------------------------
# PowerPoint to PDF — POST /api/convert/pptx-to-pdf
# ---------------------------------------------------------------------------
@pdf_convert_bp.route("/pptx-to-pdf", methods=["POST"])
@limiter.limit("10/minute")
def pptx_to_pdf_route():
"""Convert a PowerPoint file to PDF."""
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
actor = resolve_web_actor()
try:
assert_quota_available(actor)
except PolicyError as e:
return jsonify({"error": e.message}), e.status_code
try:
original_filename, ext = validate_actor_file(
file, allowed_types=["pptx", "ppt"], actor=actor
)
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 = pptx_to_pdf_task.delay(
input_path, task_id, original_filename,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "pptx-to-pdf", task.id)
return jsonify({
"task_id": task.id,
"message": "Conversion started. Poll /api/tasks/{task_id}/status for progress.",
}), 202
# ---------------------------------------------------------------------------
# Sign PDF — POST /api/pdf-tools/sign
# ---------------------------------------------------------------------------
@pdf_convert_bp.route("/sign", methods=["POST"])
@limiter.limit("10/minute")
def sign_pdf_route():
"""Sign a PDF by overlaying a signature image.
Accepts: multipart/form-data with:
- 'file': PDF file
- 'signature': Signature image (PNG/JPG)
- 'page' (optional): 1-based page number (default: 1)
- 'x', 'y' (optional): Position in points (default: 100, 100)
- 'width', 'height' (optional): Size in points (default: 200, 80)
"""
if "file" not in request.files:
return jsonify({"error": "No PDF file provided."}), 400
if "signature" not in request.files:
return jsonify({"error": "No signature image provided."}), 400
pdf_file = request.files["file"]
sig_file = request.files["signature"]
actor = resolve_web_actor()
try:
assert_quota_available(actor)
except PolicyError as e:
return jsonify({"error": e.message}), e.status_code
try:
original_filename, ext = validate_actor_file(pdf_file, allowed_types=["pdf"], actor=actor)
except FileValidationError as e:
return jsonify({"error": e.message}), e.code
try:
_, sig_ext = validate_actor_file(sig_file, allowed_types=ALLOWED_IMAGE_TYPES, actor=actor)
except FileValidationError as e:
return jsonify({"error": f"Signature image: {e.message}"}), e.code
# Parse position parameters
try:
page = max(1, int(request.form.get("page", 1))) - 1 # Convert to 0-based
x = float(request.form.get("x", 100))
y = float(request.form.get("y", 100))
width = float(request.form.get("width", 200))
height = float(request.form.get("height", 80))
except (ValueError, TypeError):
return jsonify({"error": "Invalid position parameters."}), 400
if width <= 0 or height <= 0:
return jsonify({"error": "Width and height must be positive."}), 400
task_id = str(uuid.uuid4())
upload_dir = os.path.join(current_app.config["UPLOAD_FOLDER"], task_id)
os.makedirs(upload_dir, exist_ok=True)
input_path = os.path.join(upload_dir, f"{uuid.uuid4()}.pdf")
pdf_file.save(input_path)
signature_path = os.path.join(upload_dir, f"{uuid.uuid4()}.{sig_ext}")
sig_file.save(signature_path)
task = sign_pdf_task.delay(
input_path, signature_path, task_id, original_filename,
page, x, y, width, height,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "sign-pdf", task.id)
return jsonify({
"task_id": task.id,
"message": "Signing started. Poll /api/tasks/{task_id}/status for progress.",
}), 202

View File

@@ -0,0 +1,209 @@
"""Routes for extended PDF tools — Crop, Flatten, Repair, Metadata Editor."""
from flask import Blueprint, request, jsonify
from app.extensions import limiter
from app.services.policy_service import (
assert_quota_available,
build_task_tracking_kwargs,
PolicyError,
record_accepted_usage,
resolve_web_actor,
validate_actor_file,
)
from app.utils.file_validator import FileValidationError
from app.utils.sanitizer import generate_safe_path
from app.tasks.pdf_extra_tasks import (
crop_pdf_task,
flatten_pdf_task,
repair_pdf_task,
edit_metadata_task,
)
pdf_extra_bp = Blueprint("pdf_extra", __name__)
# ---------------------------------------------------------------------------
# Crop PDF — POST /api/pdf-tools/crop
# ---------------------------------------------------------------------------
@pdf_extra_bp.route("/crop", methods=["POST"])
@limiter.limit("10/minute")
def crop_pdf_route():
"""Crop margins from a PDF.
Accepts: multipart/form-data with:
- 'file': PDF file
- 'margin_left', 'margin_right', 'margin_top', 'margin_bottom': Points to crop
- 'pages' (optional): "all" or comma-separated page numbers
"""
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
actor = resolve_web_actor()
try:
assert_quota_available(actor)
except PolicyError as e:
return jsonify({"error": e.message}), e.status_code
try:
original_filename, ext = validate_actor_file(file, allowed_types=["pdf"], actor=actor)
except FileValidationError as e:
return jsonify({"error": e.message}), e.code
try:
margin_left = float(request.form.get("margin_left", 0))
margin_right = float(request.form.get("margin_right", 0))
margin_top = float(request.form.get("margin_top", 0))
margin_bottom = float(request.form.get("margin_bottom", 0))
except (ValueError, TypeError):
return jsonify({"error": "Margin values must be numbers."}), 400
pages = request.form.get("pages", "all")
task_id, input_path = generate_safe_path(ext, folder_type="upload")
file.save(input_path)
task = crop_pdf_task.delay(
input_path, task_id, original_filename,
margin_left, margin_right, margin_top, margin_bottom, pages,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "crop-pdf", task.id)
return jsonify({
"task_id": task.id,
"message": "Cropping started. Poll /api/tasks/{task_id}/status for progress.",
}), 202
# ---------------------------------------------------------------------------
# Flatten PDF — POST /api/pdf-tools/flatten
# ---------------------------------------------------------------------------
@pdf_extra_bp.route("/flatten", methods=["POST"])
@limiter.limit("10/minute")
def flatten_pdf_route():
"""Flatten a PDF — remove interactive forms and annotations."""
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
actor = resolve_web_actor()
try:
assert_quota_available(actor)
except PolicyError as e:
return jsonify({"error": e.message}), e.status_code
try:
original_filename, ext = validate_actor_file(file, allowed_types=["pdf"], actor=actor)
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 = flatten_pdf_task.delay(
input_path, task_id, original_filename,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "flatten-pdf", task.id)
return jsonify({
"task_id": task.id,
"message": "Flattening started. Poll /api/tasks/{task_id}/status for progress.",
}), 202
# ---------------------------------------------------------------------------
# Repair PDF — POST /api/pdf-tools/repair
# ---------------------------------------------------------------------------
@pdf_extra_bp.route("/repair", methods=["POST"])
@limiter.limit("10/minute")
def repair_pdf_route():
"""Attempt to repair a damaged PDF."""
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
actor = resolve_web_actor()
try:
assert_quota_available(actor)
except PolicyError as e:
return jsonify({"error": e.message}), e.status_code
try:
original_filename, ext = validate_actor_file(file, allowed_types=["pdf"], actor=actor)
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 = repair_pdf_task.delay(
input_path, task_id, original_filename,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "repair-pdf", task.id)
return jsonify({
"task_id": task.id,
"message": "Repair started. Poll /api/tasks/{task_id}/status for progress.",
}), 202
# ---------------------------------------------------------------------------
# Edit PDF Metadata — POST /api/pdf-tools/metadata
# ---------------------------------------------------------------------------
@pdf_extra_bp.route("/metadata", methods=["POST"])
@limiter.limit("10/minute")
def edit_metadata_route():
"""Edit PDF metadata fields.
Accepts: multipart/form-data with:
- 'file': PDF file
- 'title', 'author', 'subject', 'keywords', 'creator' (optional)
"""
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
title = request.form.get("title")
author = request.form.get("author")
subject = request.form.get("subject")
keywords = request.form.get("keywords")
creator = request.form.get("creator")
if not any([title, author, subject, keywords, creator]):
return jsonify({"error": "At least one metadata field must be provided."}), 400
# Validate string lengths
for field_name, field_val in [("title", title), ("author", author),
("subject", subject), ("keywords", keywords),
("creator", creator)]:
if field_val and len(field_val) > 500:
return jsonify({"error": f"{field_name} must be 500 characters or less."}), 400
actor = resolve_web_actor()
try:
assert_quota_available(actor)
except PolicyError as e:
return jsonify({"error": e.message}), e.status_code
try:
original_filename, ext = validate_actor_file(file, allowed_types=["pdf"], actor=actor)
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 = edit_metadata_task.delay(
input_path, task_id, original_filename,
title, author, subject, keywords, creator,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "edit-metadata", task.id)
return jsonify({
"task_id": task.id,
"message": "Metadata editing started. Poll /api/tasks/{task_id}/status for progress.",
}), 202

View File

@@ -0,0 +1,17 @@
"""Public site-level statistics for social proof and developer pages."""
from flask import Blueprint, jsonify
from app.extensions import limiter
from app.services.account_service import get_public_history_summary
from app.services.rating_service import get_global_rating_summary
stats_bp = Blueprint("stats", __name__)
@stats_bp.route("/summary", methods=["GET"])
@limiter.limit("120/hour")
def get_stats_summary_route():
"""Return aggregate processing and rating stats safe for public display."""
history_summary = get_public_history_summary()
rating_summary = get_global_rating_summary()
return jsonify({**history_summary, **rating_summary}), 200

View File

@@ -0,0 +1,85 @@
"""Stripe payment routes — checkout, portal, and webhooks."""
import logging
from flask import Blueprint, current_app, jsonify, request, session
from app.extensions import limiter
from app.services.stripe_service import (
create_checkout_session,
create_portal_session,
handle_webhook_event,
)
logger = logging.getLogger(__name__)
stripe_bp = Blueprint("stripe", __name__)
def _get_authenticated_user_id() -> int | None:
"""Return the logged-in user's ID or None."""
return session.get("user_id")
@stripe_bp.route("/create-checkout-session", methods=["POST"])
@limiter.limit("10/hour", override_defaults=True)
def checkout():
"""Create a Stripe Checkout Session for Pro subscription."""
user_id = _get_authenticated_user_id()
if not user_id:
return jsonify({"error": "Authentication required."}), 401
data = request.get_json(silent=True) or {}
billing = data.get("billing", "monthly")
monthly_price = current_app.config.get("STRIPE_PRICE_ID_PRO_MONTHLY", "")
yearly_price = current_app.config.get("STRIPE_PRICE_ID_PRO_YEARLY", "")
price_id = yearly_price if billing == "yearly" and yearly_price else monthly_price
if not price_id:
return jsonify({"error": "Payment is not configured yet."}), 503
frontend_url = current_app.config.get("FRONTEND_URL", "http://localhost:5173")
success_url = f"{frontend_url}/account?payment=success"
cancel_url = f"{frontend_url}/pricing?payment=cancelled"
try:
url = create_checkout_session(user_id, price_id, success_url, cancel_url)
except Exception as e:
logger.exception("Stripe checkout session creation failed")
return jsonify({"error": "Failed to create payment session."}), 500
return jsonify({"url": url})
@stripe_bp.route("/create-portal-session", methods=["POST"])
@limiter.limit("10/hour", override_defaults=True)
def portal():
"""Create a Stripe Customer Portal session."""
user_id = _get_authenticated_user_id()
if not user_id:
return jsonify({"error": "Authentication required."}), 401
frontend_url = current_app.config.get("FRONTEND_URL", "http://localhost:5173")
return_url = f"{frontend_url}/account"
try:
url = create_portal_session(user_id, return_url)
except Exception as e:
logger.exception("Stripe portal session creation failed")
return jsonify({"error": "Failed to create portal session."}), 500
return jsonify({"url": url})
@stripe_bp.route("/webhook", methods=["POST"])
def webhook():
"""Handle Stripe webhook events. No rate limit — Stripe signs each call."""
payload = request.get_data()
sig_header = request.headers.get("Stripe-Signature", "")
result = handle_webhook_event(payload, sig_header)
if result["status"] == "error":
return jsonify(result), 400
return jsonify(result), 200

View File

@@ -34,6 +34,23 @@ from app.tasks.pdf_tools_tasks import (
unlock_pdf_task,
)
from app.tasks.flowchart_tasks import extract_flowchart_task
from app.tasks.ocr_tasks import ocr_image_task, ocr_pdf_task
from app.tasks.removebg_tasks import remove_bg_task
from app.tasks.pdf_ai_tasks import (
chat_with_pdf_task, summarize_pdf_task, translate_pdf_task, extract_tables_task,
)
from app.tasks.pdf_to_excel_tasks import pdf_to_excel_task
from app.tasks.html_to_pdf_tasks import html_to_pdf_task
from app.tasks.qrcode_tasks import generate_qr_task
from app.tasks.pdf_convert_tasks import (
pdf_to_pptx_task, excel_to_pdf_task, pptx_to_pdf_task, sign_pdf_task,
)
from app.tasks.pdf_extra_tasks import (
crop_pdf_task, flatten_pdf_task, repair_pdf_task, edit_metadata_task,
)
from app.tasks.image_extra_tasks import crop_image_task, rotate_flip_image_task
from app.tasks.barcode_tasks import generate_barcode_task
from app.services.barcode_service import SUPPORTED_BARCODE_TYPES
logger = logging.getLogger(__name__)
@@ -680,3 +697,760 @@ def extract_flowchart_route():
)
record_accepted_usage(actor, "pdf-flowchart", task.id)
return jsonify({"task_id": task.id, "message": "Flowchart extraction started."}), 202
# ===========================================================================
# Phase 2: Previously uncovered existing tools
# ===========================================================================
# ---------------------------------------------------------------------------
# OCR — POST /api/v1/ocr/image & /api/v1/ocr/pdf
# ---------------------------------------------------------------------------
@v1_bp.route("/ocr/image", methods=["POST"])
@limiter.limit("10/minute")
def ocr_image_route():
"""Extract text from an image using OCR."""
actor, err = _resolve_and_check()
if err:
return err
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
lang = request.form.get("lang", "eng")
try:
original_filename, ext = validate_actor_file(
file, allowed_types=ALLOWED_IMAGE_TYPES, actor=actor
)
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 = ocr_image_task.delay(
input_path, task_id, original_filename, lang,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "ocr-image", task.id)
return jsonify({"task_id": task.id, "message": "OCR started."}), 202
@v1_bp.route("/ocr/pdf", methods=["POST"])
@limiter.limit("10/minute")
def ocr_pdf_route():
"""Extract text from a PDF using OCR."""
actor, err = _resolve_and_check()
if err:
return err
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
lang = request.form.get("lang", "eng")
try:
original_filename, ext = validate_actor_file(file, allowed_types=["pdf"], actor=actor)
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 = ocr_pdf_task.delay(
input_path, task_id, original_filename, lang,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "ocr-pdf", task.id)
return jsonify({"task_id": task.id, "message": "OCR started."}), 202
# ---------------------------------------------------------------------------
# Remove Background — POST /api/v1/image/remove-bg
# ---------------------------------------------------------------------------
@v1_bp.route("/image/remove-bg", methods=["POST"])
@limiter.limit("5/minute")
def remove_bg_route():
"""Remove background from an image."""
actor, err = _resolve_and_check()
if err:
return err
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
try:
original_filename, ext = validate_actor_file(
file, allowed_types=ALLOWED_IMAGE_TYPES, actor=actor
)
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 = remove_bg_task.delay(
input_path, task_id, original_filename,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "remove-bg", task.id)
return jsonify({"task_id": task.id, "message": "Background removal started."}), 202
# ---------------------------------------------------------------------------
# PDF AI — POST /api/v1/pdf-ai/chat, summarize, translate, extract-tables
# ---------------------------------------------------------------------------
@v1_bp.route("/pdf-ai/chat", methods=["POST"])
@limiter.limit("5/minute")
def chat_pdf_route():
"""Chat with a PDF using AI."""
actor, err = _resolve_and_check()
if err:
return err
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
question = request.form.get("question", "").strip()
if not question:
return jsonify({"error": "Question is required."}), 400
try:
original_filename, ext = validate_actor_file(file, allowed_types=["pdf"], actor=actor)
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 = chat_with_pdf_task.delay(
input_path, task_id, original_filename, question,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "chat-pdf", task.id)
return jsonify({"task_id": task.id, "message": "Chat started."}), 202
@v1_bp.route("/pdf-ai/summarize", methods=["POST"])
@limiter.limit("5/minute")
def summarize_pdf_route():
"""Summarize a PDF using AI."""
actor, err = _resolve_and_check()
if err:
return err
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
length = request.form.get("length", "medium")
if length not in ("short", "medium", "long"):
length = "medium"
try:
original_filename, ext = validate_actor_file(file, allowed_types=["pdf"], actor=actor)
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 = summarize_pdf_task.delay(
input_path, task_id, original_filename, length,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "summarize-pdf", task.id)
return jsonify({"task_id": task.id, "message": "Summarization started."}), 202
@v1_bp.route("/pdf-ai/translate", methods=["POST"])
@limiter.limit("5/minute")
def translate_pdf_route():
"""Translate a PDF using AI."""
actor, err = _resolve_and_check()
if err:
return err
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
target_language = request.form.get("target_language", "").strip()
if not target_language:
return jsonify({"error": "Target language is required."}), 400
try:
original_filename, ext = validate_actor_file(file, allowed_types=["pdf"], actor=actor)
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 = translate_pdf_task.delay(
input_path, task_id, original_filename, target_language,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "translate-pdf", task.id)
return jsonify({"task_id": task.id, "message": "Translation started."}), 202
@v1_bp.route("/pdf-ai/extract-tables", methods=["POST"])
@limiter.limit("10/minute")
def extract_tables_route():
"""Extract tables from a PDF using AI."""
actor, err = _resolve_and_check()
if err:
return err
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
try:
original_filename, ext = validate_actor_file(file, allowed_types=["pdf"], actor=actor)
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 = extract_tables_task.delay(
input_path, task_id, original_filename,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "extract-tables", task.id)
return jsonify({"task_id": task.id, "message": "Table extraction started."}), 202
# ---------------------------------------------------------------------------
# PDF to Excel — POST /api/v1/convert/pdf-to-excel
# ---------------------------------------------------------------------------
@v1_bp.route("/convert/pdf-to-excel", methods=["POST"])
@limiter.limit("10/minute")
def pdf_to_excel_route():
"""Convert a PDF to Excel."""
actor, err = _resolve_and_check()
if err:
return err
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
try:
original_filename, ext = validate_actor_file(file, allowed_types=["pdf"], actor=actor)
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 = pdf_to_excel_task.delay(
input_path, task_id, original_filename,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "pdf-to-excel", task.id)
return jsonify({"task_id": task.id, "message": "Conversion started."}), 202
# ---------------------------------------------------------------------------
# HTML to PDF — POST /api/v1/convert/html-to-pdf
# ---------------------------------------------------------------------------
@v1_bp.route("/convert/html-to-pdf", methods=["POST"])
@limiter.limit("10/minute")
def html_to_pdf_route():
"""Convert HTML to PDF."""
actor, err = _resolve_and_check()
if err:
return err
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
try:
original_filename, ext = validate_actor_file(
file, allowed_types=["html", "htm"], actor=actor
)
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 = html_to_pdf_task.delay(
input_path, task_id, original_filename,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "html-to-pdf", task.id)
return jsonify({"task_id": task.id, "message": "Conversion started."}), 202
# ---------------------------------------------------------------------------
# QR Code — POST /api/v1/qrcode/generate
# ---------------------------------------------------------------------------
@v1_bp.route("/qrcode/generate", methods=["POST"])
@limiter.limit("20/minute")
def generate_qr_route():
"""Generate a QR code."""
actor, err = _resolve_and_check()
if err:
return err
if request.is_json:
body = request.get_json()
data = body.get("data", "")
size = body.get("size", 300)
else:
data = request.form.get("data", "")
size = request.form.get("size", 300)
if not str(data).strip():
return jsonify({"error": "QR code data is required."}), 400
try:
size = max(100, min(2000, int(size)))
except (ValueError, TypeError):
size = 300
task_id = str(uuid.uuid4())
task = generate_qr_task.delay(
task_id, str(data).strip(), size, "png",
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "qr-code", task.id)
return jsonify({"task_id": task.id, "message": "QR code generation started."}), 202
# ===========================================================================
# Phase 2: New tools
# ===========================================================================
# ---------------------------------------------------------------------------
# PDF to PowerPoint — POST /api/v1/convert/pdf-to-pptx
# ---------------------------------------------------------------------------
@v1_bp.route("/convert/pdf-to-pptx", methods=["POST"])
@limiter.limit("10/minute")
def v1_pdf_to_pptx_route():
"""Convert a PDF to PowerPoint."""
actor, err = _resolve_and_check()
if err:
return err
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
try:
original_filename, ext = validate_actor_file(file, allowed_types=["pdf"], actor=actor)
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 = pdf_to_pptx_task.delay(
input_path, task_id, original_filename,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "pdf-to-pptx", task.id)
return jsonify({"task_id": task.id, "message": "Conversion started."}), 202
# ---------------------------------------------------------------------------
# Excel to PDF — POST /api/v1/convert/excel-to-pdf
# ---------------------------------------------------------------------------
@v1_bp.route("/convert/excel-to-pdf", methods=["POST"])
@limiter.limit("10/minute")
def v1_excel_to_pdf_route():
"""Convert an Excel file to PDF."""
actor, err = _resolve_and_check()
if err:
return err
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
try:
original_filename, ext = validate_actor_file(
file, allowed_types=["xlsx", "xls"], actor=actor
)
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 = excel_to_pdf_task.delay(
input_path, task_id, original_filename,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "excel-to-pdf", task.id)
return jsonify({"task_id": task.id, "message": "Conversion started."}), 202
# ---------------------------------------------------------------------------
# PowerPoint to PDF — POST /api/v1/convert/pptx-to-pdf
# ---------------------------------------------------------------------------
@v1_bp.route("/convert/pptx-to-pdf", methods=["POST"])
@limiter.limit("10/minute")
def v1_pptx_to_pdf_route():
"""Convert a PowerPoint file to PDF."""
actor, err = _resolve_and_check()
if err:
return err
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
try:
original_filename, ext = validate_actor_file(
file, allowed_types=["pptx", "ppt"], actor=actor
)
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 = pptx_to_pdf_task.delay(
input_path, task_id, original_filename,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "pptx-to-pdf", task.id)
return jsonify({"task_id": task.id, "message": "Conversion started."}), 202
# ---------------------------------------------------------------------------
# Sign PDF — POST /api/v1/pdf-tools/sign
# ---------------------------------------------------------------------------
@v1_bp.route("/pdf-tools/sign", methods=["POST"])
@limiter.limit("10/minute")
def v1_sign_pdf_route():
"""Sign a PDF with a signature image."""
actor, err = _resolve_and_check()
if err:
return err
if "file" not in request.files:
return jsonify({"error": "No PDF file provided."}), 400
if "signature" not in request.files:
return jsonify({"error": "No signature image provided."}), 400
pdf_file = request.files["file"]
sig_file = request.files["signature"]
try:
original_filename, ext = validate_actor_file(pdf_file, allowed_types=["pdf"], actor=actor)
except FileValidationError as e:
return jsonify({"error": e.message}), e.code
try:
_, sig_ext = validate_actor_file(sig_file, allowed_types=ALLOWED_IMAGE_TYPES, actor=actor)
except FileValidationError as e:
return jsonify({"error": f"Signature: {e.message}"}), e.code
try:
page = max(1, int(request.form.get("page", 1))) - 1
x = float(request.form.get("x", 100))
y = float(request.form.get("y", 100))
width = float(request.form.get("width", 200))
height = float(request.form.get("height", 80))
except (ValueError, TypeError):
return jsonify({"error": "Invalid position parameters."}), 400
task_id = str(uuid.uuid4())
upload_dir = os.path.join(current_app.config["UPLOAD_FOLDER"], task_id)
os.makedirs(upload_dir, exist_ok=True)
input_path = os.path.join(upload_dir, f"{uuid.uuid4()}.pdf")
pdf_file.save(input_path)
signature_path = os.path.join(upload_dir, f"{uuid.uuid4()}.{sig_ext}")
sig_file.save(signature_path)
task = sign_pdf_task.delay(
input_path, signature_path, task_id, original_filename,
page, x, y, width, height,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "sign-pdf", task.id)
return jsonify({"task_id": task.id, "message": "Signing started."}), 202
# ---------------------------------------------------------------------------
# Crop PDF — POST /api/v1/pdf-tools/crop
# ---------------------------------------------------------------------------
@v1_bp.route("/pdf-tools/crop", methods=["POST"])
@limiter.limit("10/minute")
def v1_crop_pdf_route():
"""Crop margins from a PDF."""
actor, err = _resolve_and_check()
if err:
return err
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
try:
margin_left = float(request.form.get("margin_left", 0))
margin_right = float(request.form.get("margin_right", 0))
margin_top = float(request.form.get("margin_top", 0))
margin_bottom = float(request.form.get("margin_bottom", 0))
except (ValueError, TypeError):
return jsonify({"error": "Margin values must be numbers."}), 400
pages = request.form.get("pages", "all")
try:
original_filename, ext = validate_actor_file(file, allowed_types=["pdf"], actor=actor)
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 = crop_pdf_task.delay(
input_path, task_id, original_filename,
margin_left, margin_right, margin_top, margin_bottom, pages,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "crop-pdf", task.id)
return jsonify({"task_id": task.id, "message": "Cropping started."}), 202
# ---------------------------------------------------------------------------
# Flatten PDF — POST /api/v1/pdf-tools/flatten
# ---------------------------------------------------------------------------
@v1_bp.route("/pdf-tools/flatten", methods=["POST"])
@limiter.limit("10/minute")
def v1_flatten_pdf_route():
"""Flatten a PDF."""
actor, err = _resolve_and_check()
if err:
return err
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
try:
original_filename, ext = validate_actor_file(file, allowed_types=["pdf"], actor=actor)
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 = flatten_pdf_task.delay(
input_path, task_id, original_filename,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "flatten-pdf", task.id)
return jsonify({"task_id": task.id, "message": "Flattening started."}), 202
# ---------------------------------------------------------------------------
# Repair PDF — POST /api/v1/pdf-tools/repair
# ---------------------------------------------------------------------------
@v1_bp.route("/pdf-tools/repair", methods=["POST"])
@limiter.limit("10/minute")
def v1_repair_pdf_route():
"""Repair a damaged PDF."""
actor, err = _resolve_and_check()
if err:
return err
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
try:
original_filename, ext = validate_actor_file(file, allowed_types=["pdf"], actor=actor)
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 = repair_pdf_task.delay(
input_path, task_id, original_filename,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "repair-pdf", task.id)
return jsonify({"task_id": task.id, "message": "Repair started."}), 202
# ---------------------------------------------------------------------------
# Edit PDF Metadata — POST /api/v1/pdf-tools/metadata
# ---------------------------------------------------------------------------
@v1_bp.route("/pdf-tools/metadata", methods=["POST"])
@limiter.limit("10/minute")
def v1_edit_metadata_route():
"""Edit PDF metadata."""
actor, err = _resolve_and_check()
if err:
return err
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
title = request.form.get("title")
author = request.form.get("author")
subject = request.form.get("subject")
keywords = request.form.get("keywords")
creator = request.form.get("creator")
if not any([title, author, subject, keywords, creator]):
return jsonify({"error": "At least one metadata field required."}), 400
try:
original_filename, ext = validate_actor_file(file, allowed_types=["pdf"], actor=actor)
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 = edit_metadata_task.delay(
input_path, task_id, original_filename,
title, author, subject, keywords, creator,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "edit-metadata", task.id)
return jsonify({"task_id": task.id, "message": "Metadata editing started."}), 202
# ---------------------------------------------------------------------------
# Image Crop — POST /api/v1/image/crop
# ---------------------------------------------------------------------------
@v1_bp.route("/image/crop", methods=["POST"])
@limiter.limit("10/minute")
def v1_crop_image_route():
"""Crop an image."""
actor, err = _resolve_and_check()
if err:
return err
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
try:
left = int(request.form.get("left", 0))
top = int(request.form.get("top", 0))
right = int(request.form.get("right", 0))
bottom = int(request.form.get("bottom", 0))
except (ValueError, TypeError):
return jsonify({"error": "Crop dimensions must be integers."}), 400
if right <= left or bottom <= top:
return jsonify({"error": "Invalid crop area."}), 400
try:
original_filename, ext = validate_actor_file(
file, allowed_types=ALLOWED_IMAGE_TYPES, actor=actor
)
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 = crop_image_task.delay(
input_path, task_id, original_filename,
left, top, right, bottom,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "image-crop", task.id)
return jsonify({"task_id": task.id, "message": "Cropping started."}), 202
# ---------------------------------------------------------------------------
# Image Rotate/Flip — POST /api/v1/image/rotate-flip
# ---------------------------------------------------------------------------
@v1_bp.route("/image/rotate-flip", methods=["POST"])
@limiter.limit("10/minute")
def v1_rotate_flip_image_route():
"""Rotate and/or flip an image."""
actor, err = _resolve_and_check()
if err:
return err
if "file" not in request.files:
return jsonify({"error": "No file provided."}), 400
file = request.files["file"]
try:
rotation = int(request.form.get("rotation", 0))
except ValueError:
rotation = 0
if rotation not in (0, 90, 180, 270):
return jsonify({"error": "Rotation must be 0, 90, 180, or 270."}), 400
flip_horizontal = request.form.get("flip_horizontal", "false").lower() == "true"
flip_vertical = request.form.get("flip_vertical", "false").lower() == "true"
try:
original_filename, ext = validate_actor_file(
file, allowed_types=ALLOWED_IMAGE_TYPES, actor=actor
)
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 = rotate_flip_image_task.delay(
input_path, task_id, original_filename,
rotation, flip_horizontal, flip_vertical,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "image-rotate-flip", task.id)
return jsonify({"task_id": task.id, "message": "Transformation started."}), 202
# ---------------------------------------------------------------------------
# Barcode — POST /api/v1/barcode/generate
# ---------------------------------------------------------------------------
@v1_bp.route("/barcode/generate", methods=["POST"])
@limiter.limit("20/minute")
def v1_generate_barcode_route():
"""Generate a barcode."""
actor, err = _resolve_and_check()
if err:
return err
if request.is_json:
body = request.get_json()
data = body.get("data", "").strip()
barcode_type = body.get("type", "code128").lower()
output_format = body.get("format", "png").lower()
else:
data = request.form.get("data", "").strip()
barcode_type = request.form.get("type", "code128").lower()
output_format = request.form.get("format", "png").lower()
if not data:
return jsonify({"error": "Barcode data is required."}), 400
if barcode_type not in SUPPORTED_BARCODE_TYPES:
return jsonify({"error": f"Unsupported type. Supported: {', '.join(SUPPORTED_BARCODE_TYPES)}"}), 400
if output_format not in ("png", "svg"):
output_format = "png"
task_id = str(uuid.uuid4())
task = generate_barcode_task.delay(
data, barcode_type, task_id, output_format,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "barcode", task.id)
return jsonify({"task_id": task.id, "message": "Barcode generation started."}), 202