تم الانتهاء من آخر دفعة تحسينات على المشروع، وتشمل:
تحويل لوحة الإدارة الداخلية من 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:
@@ -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
|
||||
|
||||
70
backend/app/routes/barcode.py
Normal file
70
backend/app/routes/barcode.py
Normal 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
|
||||
43
backend/app/routes/contact.py
Normal file
43
backend/app/routes/contact.py
Normal 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
|
||||
147
backend/app/routes/image_extra.py
Normal file
147
backend/app/routes/image_extra.py
Normal 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
|
||||
217
backend/app/routes/pdf_convert.py
Normal file
217
backend/app/routes/pdf_convert.py
Normal 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
|
||||
209
backend/app/routes/pdf_extra.py
Normal file
209
backend/app/routes/pdf_extra.py
Normal 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
|
||||
17
backend/app/routes/stats.py
Normal file
17
backend/app/routes/stats.py
Normal 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
|
||||
85
backend/app/routes/stripe.py
Normal file
85
backend/app/routes/stripe.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user