ميزة: تحديث صفحات الخصوصية والشروط مع تاريخ آخر تحديث ثابت وفترة احتفاظ ديناميكية بالملفات

ميزة: إضافة خدمة تحليلات لتكامل Google Analytics

اختبار: تحديث اختبارات خدمة واجهة برمجة التطبيقات (API) لتعكس تغييرات نقاط النهاية

إصلاح: تعديل خدمة واجهة برمجة التطبيقات (API) لدعم تحميل ملفات متعددة ومصادقة المستخدم

ميزة: تطبيق مخزن مصادقة باستخدام Zustand لإدارة المستخدمين

إصلاح: تحسين إعدادات Nginx لتعزيز الأمان ودعم التحليلات
This commit is contained in:
Your Name
2026-03-07 11:14:05 +02:00
parent cfbcc8bd79
commit 0ad2ba0f02
73 changed files with 4696 additions and 462 deletions

View File

@@ -5,6 +5,7 @@ from flask import Flask
from config import config
from app.extensions import cors, limiter, talisman, init_celery
from app.services.account_service import init_account_db
def create_app(config_name=None):
@@ -15,12 +16,19 @@ def create_app(config_name=None):
app = Flask(__name__)
app.config.from_object(config[config_name])
# Create upload/output directories
# Create upload/output/database directories
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
os.makedirs(app.config["OUTPUT_FOLDER"], exist_ok=True)
db_dir = os.path.dirname(app.config["DATABASE_PATH"])
if db_dir:
os.makedirs(db_dir, exist_ok=True)
# Initialize extensions
cors.init_app(app, origins=app.config["CORS_ORIGINS"])
cors.init_app(
app,
origins=app.config["CORS_ORIGINS"],
supports_credentials=True,
)
limiter.init_app(app)
@@ -36,11 +44,21 @@ def create_app(config_name=None):
],
"style-src": ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
"font-src": ["'self'", "https://fonts.gstatic.com"],
"img-src": ["'self'", "data:", "https://pagead2.googlesyndication.com"],
"frame-src": ["https://googleads.g.doubleclick.net"],
"img-src": [
"'self'",
"data:",
"https://pagead2.googlesyndication.com",
"https://tpc.googlesyndication.com",
"https://www.google-analytics.com",
],
"frame-src": [
"https://googleads.g.doubleclick.net",
"https://tpc.googlesyndication.com",
],
"connect-src": [
"'self'",
"https://www.google-analytics.com",
"https://pagead2.googlesyndication.com",
"https://*.amazonaws.com",
],
}
@@ -53,25 +71,38 @@ def create_app(config_name=None):
# Initialize Celery
init_celery(app)
with app.app_context():
init_account_db()
# Register blueprints
from app.routes.health import health_bp
from app.routes.auth import auth_bp
from app.routes.account import account_bp
from app.routes.admin import admin_bp
from app.routes.convert import convert_bp
from app.routes.compress import compress_bp
from app.routes.image import image_bp
from app.routes.video import video_bp
from app.routes.history import history_bp
from app.routes.tasks import tasks_bp
from app.routes.download import download_bp
from app.routes.pdf_tools import pdf_tools_bp
from app.routes.flowchart import flowchart_bp
from app.routes.v1.tools import v1_bp
app.register_blueprint(health_bp, url_prefix="/api")
app.register_blueprint(auth_bp, url_prefix="/api/auth")
app.register_blueprint(account_bp, url_prefix="/api/account")
app.register_blueprint(admin_bp, url_prefix="/api/internal/admin")
app.register_blueprint(convert_bp, url_prefix="/api/convert")
app.register_blueprint(compress_bp, url_prefix="/api/compress")
app.register_blueprint(image_bp, url_prefix="/api/image")
app.register_blueprint(video_bp, url_prefix="/api/video")
app.register_blueprint(history_bp, url_prefix="/api")
app.register_blueprint(pdf_tools_bp, url_prefix="/api/pdf-tools")
app.register_blueprint(flowchart_bp, url_prefix="/api/flowchart")
app.register_blueprint(tasks_bp, url_prefix="/api/tasks")
app.register_blueprint(download_bp, url_prefix="/api/download")
app.register_blueprint(v1_bp, url_prefix="/api/v1")
return app

View File

@@ -30,6 +30,7 @@ def init_celery(app):
"app.tasks.image_tasks.*": {"queue": "image"},
"app.tasks.video_tasks.*": {"queue": "video"},
"app.tasks.pdf_tools_tasks.*": {"queue": "pdf_tools"},
"app.tasks.flowchart_tasks.*": {"queue": "flowchart"},
}
class ContextTask(celery.Task):

View File

@@ -0,0 +1,89 @@
"""Authenticated account endpoints — usage summary and API key management."""
from flask import Blueprint, jsonify, request
from app.extensions import limiter
from app.services.account_service import (
create_api_key,
get_user_by_id,
list_api_keys,
revoke_api_key,
)
from app.services.policy_service import get_usage_summary_for_user
from app.utils.auth import get_current_user_id
account_bp = Blueprint("account", __name__)
@account_bp.route("/usage", methods=["GET"])
@limiter.limit("120/hour")
def get_usage_route():
"""Return plan, quota, and effective file-size cap summary for the current user."""
user_id = get_current_user_id()
if user_id is None:
return jsonify({"error": "Authentication required."}), 401
user = get_user_by_id(user_id)
if user is None:
return jsonify({"error": "User not found."}), 404
return jsonify(get_usage_summary_for_user(user_id, user["plan"])), 200
@account_bp.route("/api-keys", methods=["GET"])
@limiter.limit("60/hour")
def list_api_keys_route():
"""Return all API keys for the authenticated pro user."""
user_id = get_current_user_id()
if user_id is None:
return jsonify({"error": "Authentication required."}), 401
user = get_user_by_id(user_id)
if user is None:
return jsonify({"error": "User not found."}), 404
if user["plan"] != "pro":
return jsonify({"error": "API key management requires a Pro plan."}), 403
return jsonify({"items": list_api_keys(user_id)}), 200
@account_bp.route("/api-keys", methods=["POST"])
@limiter.limit("20/hour")
def create_api_key_route():
"""Create a new API key for the authenticated pro user."""
user_id = get_current_user_id()
if user_id is None:
return jsonify({"error": "Authentication required."}), 401
user = get_user_by_id(user_id)
if user is None:
return jsonify({"error": "User not found."}), 404
if user["plan"] != "pro":
return jsonify({"error": "API key management requires a Pro plan."}), 403
data = request.get_json(silent=True) or {}
name = str(data.get("name", "")).strip()
if not name:
return jsonify({"error": "API key name is required."}), 400
try:
result = create_api_key(user_id, name)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(result), 201
@account_bp.route("/api-keys/<int:key_id>", methods=["DELETE"])
@limiter.limit("30/hour")
def revoke_api_key_route(key_id: int):
"""Revoke one API key owned by the authenticated user."""
user_id = get_current_user_id()
if user_id is None:
return jsonify({"error": "Authentication required."}), 401
if not revoke_api_key(user_id, key_id):
return jsonify({"error": "API key not found or already revoked."}), 404
return jsonify({"message": "API key revoked."}), 200

View File

@@ -0,0 +1,39 @@
"""Internal admin endpoints secured by INTERNAL_ADMIN_SECRET."""
from flask import Blueprint, current_app, jsonify, request
from app.extensions import limiter
from app.services.account_service import get_user_by_id, update_user_plan
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
@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
data = request.get_json(silent=True) or {}
plan = str(data.get("plan", "")).strip().lower()
if plan not in ("free", "pro"):
return jsonify({"error": "Plan must be 'free' or 'pro'."}), 400
user = get_user_by_id(user_id)
if user is None:
return jsonify({"error": "User not found."}), 404
try:
updated = update_user_plan(user_id, plan)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify({"message": "Plan updated.", "user": updated}), 200

100
backend/app/routes/auth.py Normal file
View File

@@ -0,0 +1,100 @@
"""Authentication routes backed by Flask sessions."""
import re
from flask import Blueprint, jsonify, request
from app.extensions import limiter
from app.services.account_service import (
authenticate_user,
create_user,
get_user_by_id,
)
from app.utils.auth import (
get_current_user_id,
login_user_session,
logout_user_session,
)
auth_bp = Blueprint("auth", __name__)
EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
MIN_PASSWORD_LENGTH = 8
MAX_PASSWORD_LENGTH = 128
def _parse_credentials() -> tuple[str | None, str | None]:
"""Extract normalized credential fields from a JSON request body."""
data = request.get_json(silent=True) or {}
email = str(data.get("email", "")).strip().lower()
password = str(data.get("password", ""))
return email, password
def _validate_credentials(email: str, password: str) -> str | None:
"""Return an error message when credentials are invalid."""
if not email or not EMAIL_PATTERN.match(email):
return "A valid email address is required."
if len(password) < MIN_PASSWORD_LENGTH:
return f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
if len(password) > MAX_PASSWORD_LENGTH:
return f"Password must be {MAX_PASSWORD_LENGTH} characters or less."
return None
@auth_bp.route("/register", methods=["POST"])
@limiter.limit("10/hour")
def register_route():
"""Create a new account and start an authenticated session."""
email, password = _parse_credentials()
validation_error = _validate_credentials(email, password)
if validation_error:
return jsonify({"error": validation_error}), 400
try:
user = create_user(email, password)
except ValueError as exc:
return jsonify({"error": str(exc)}), 409
login_user_session(user["id"])
return jsonify({"message": "Account created successfully.", "user": user}), 201
@auth_bp.route("/login", methods=["POST"])
@limiter.limit("20/hour")
def login_route():
"""Authenticate an existing account and start an authenticated session."""
email, password = _parse_credentials()
validation_error = _validate_credentials(email, password)
if validation_error:
return jsonify({"error": validation_error}), 400
user = authenticate_user(email, password)
if user is None:
return jsonify({"error": "Invalid email or password."}), 401
login_user_session(user["id"])
return jsonify({"message": "Signed in successfully.", "user": user}), 200
@auth_bp.route("/logout", methods=["POST"])
@limiter.limit("60/hour")
def logout_route():
"""End the active authenticated session."""
logout_user_session()
return jsonify({"message": "Signed out successfully."}), 200
@auth_bp.route("/me", methods=["GET"])
@limiter.limit("120/hour")
def me_route():
"""Return the authenticated user, if one exists in session."""
user_id = get_current_user_id()
if user_id is None:
return jsonify({"authenticated": False, "user": None}), 200
user = get_user_by_id(user_id)
if user is None:
logout_user_session()
return jsonify({"authenticated": False, "user": None}), 200
return jsonify({"authenticated": True, "user": user}), 200

View File

@@ -2,7 +2,15 @@
from flask import Blueprint, request, jsonify
from app.extensions import limiter
from app.utils.file_validator import validate_file, FileValidationError
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.compress_tasks import compress_pdf_task
@@ -25,21 +33,31 @@ def compress_pdf_route():
file = request.files["file"]
quality = request.form.get("quality", "medium")
# Validate quality parameter
if quality not in ("low", "medium", "high"):
quality = "medium"
actor = resolve_web_actor()
try:
original_filename, ext = validate_file(file, allowed_types=["pdf"])
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
# Save file to temp location
task_id, input_path = generate_safe_path(ext, folder_type="upload")
file.save(input_path)
# Dispatch async task
task = compress_pdf_task.delay(input_path, task_id, original_filename, quality)
task = compress_pdf_task.delay(
input_path,
task_id,
original_filename,
quality,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "compress-pdf", task.id)
return jsonify({
"task_id": task.id,

View File

@@ -2,7 +2,15 @@
from flask import Blueprint, request, jsonify
from app.extensions import limiter
from app.utils.file_validator import validate_file, FileValidationError
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.convert_tasks import convert_pdf_to_word, convert_word_to_pdf
@@ -23,17 +31,27 @@ def pdf_to_word_route():
file = request.files["file"]
actor = resolve_web_actor()
try:
original_filename, ext = validate_file(file, allowed_types=["pdf"])
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
# Save file to temp location
task_id, input_path = generate_safe_path(ext, folder_type="upload")
file.save(input_path)
# Dispatch async task
task = convert_pdf_to_word.delay(input_path, task_id, original_filename)
task = convert_pdf_to_word.delay(
input_path,
task_id,
original_filename,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "pdf-to-word", task.id)
return jsonify({
"task_id": task.id,
@@ -55,9 +73,15 @@ def word_to_pdf_route():
file = request.files["file"]
actor = resolve_web_actor()
try:
original_filename, ext = validate_file(
file, allowed_types=["doc", "docx"]
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=["doc", "docx"], actor=actor
)
except FileValidationError as e:
return jsonify({"error": e.message}), e.code
@@ -65,7 +89,13 @@ def word_to_pdf_route():
task_id, input_path = generate_safe_path(ext, folder_type="upload")
file.save(input_path)
task = convert_word_to_pdf.delay(input_path, task_id, original_filename)
task = convert_word_to_pdf.delay(
input_path,
task_id,
original_filename,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "word-to-pdf", task.id)
return jsonify({
"task_id": task.id,

View File

@@ -3,9 +3,20 @@ import logging
from flask import Blueprint, request, jsonify
from app.extensions import limiter
from app.utils.file_validator import validate_file, FileValidationError
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.flowchart_tasks import extract_flowchart_task
from app.tasks.flowchart_tasks import (
extract_flowchart_task,
extract_sample_flowchart_task,
)
logger = logging.getLogger(__name__)
@@ -26,15 +37,27 @@ def extract_flowchart_route():
file = request.files["file"]
actor = resolve_web_actor()
try:
original_filename, ext = validate_file(file, allowed_types=["pdf"])
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)
file.save(input_path)
task = extract_flowchart_task.delay(input_path, task_id, original_filename)
task = extract_flowchart_task.delay(
input_path,
task_id,
original_filename,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "pdf-flowchart", task.id)
return jsonify({
"task_id": task.id,
@@ -42,6 +65,29 @@ def extract_flowchart_route():
}), 202
@flowchart_bp.route("/extract-sample", methods=["POST"])
@limiter.limit("20/minute")
def extract_sample_flowchart_route():
"""
Generate a sample flowchart payload for demo/testing flows.
Returns: JSON with task_id for polling
"""
actor = resolve_web_actor()
try:
assert_quota_available(actor)
except PolicyError as e:
return jsonify({"error": e.message}), e.status_code
task = extract_sample_flowchart_task.delay(**build_task_tracking_kwargs(actor))
record_accepted_usage(actor, "pdf-flowchart-sample", task.id)
return jsonify({
"task_id": task.id,
"message": "Sample flowchart generation started.",
}), 202
@flowchart_bp.route("/chat", methods=["POST"])
@limiter.limit("20/minute")
def flowchart_chat_route():

View File

@@ -0,0 +1,32 @@
"""Authenticated file history routes."""
from flask import Blueprint, jsonify, request
from app.extensions import limiter
from app.services.account_service import get_user_by_id, list_file_history
from app.services.policy_service import get_history_limit
from app.utils.auth import get_current_user_id
history_bp = Blueprint("history", __name__)
@history_bp.route("/history", methods=["GET"])
@limiter.limit("120/hour")
def list_history_route():
"""Return recent generated-file history for the authenticated user."""
user_id = get_current_user_id()
if user_id is None:
return jsonify({"error": "Authentication required."}), 401
user = get_user_by_id(user_id)
if user is None:
return jsonify({"error": "User not found."}), 404
plan_limit = get_history_limit(user["plan"])
try:
requested = int(request.args.get("limit", plan_limit))
except ValueError:
requested = plan_limit
limit = max(1, min(plan_limit, requested))
return jsonify({"items": list_file_history(user_id, limit=limit)}), 200

View File

@@ -2,7 +2,15 @@
from flask import Blueprint, request, jsonify
from app.extensions import limiter
from app.utils.file_validator import validate_file, FileValidationError
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_tasks import convert_image_task, resize_image_task
@@ -43,19 +51,31 @@ def convert_image_route():
except ValueError:
quality = 85
actor = resolve_web_actor()
try:
original_filename, ext = validate_file(file, allowed_types=ALLOWED_IMAGE_TYPES)
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
# Save file
task_id, input_path = generate_safe_path(ext, folder_type="upload")
file.save(input_path)
# Dispatch task
task = convert_image_task.delay(
input_path, task_id, original_filename, output_format, quality
input_path,
task_id,
original_filename,
output_format,
quality,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "image-convert", task.id)
return jsonify({
"task_id": task.id,
@@ -104,8 +124,16 @@ def resize_image_route():
except ValueError:
quality = 85
actor = resolve_web_actor()
try:
original_filename, ext = validate_file(file, allowed_types=ALLOWED_IMAGE_TYPES)
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
@@ -113,8 +141,15 @@ def resize_image_route():
file.save(input_path)
task = resize_image_task.delay(
input_path, task_id, original_filename, width, height, quality
input_path,
task_id,
original_filename,
width,
height,
quality,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "image-resize", task.id)
return jsonify({
"task_id": task.id,

View File

@@ -2,10 +2,18 @@
import os
import uuid
from flask import Blueprint, request, jsonify
from flask import Blueprint, request, jsonify, current_app
from app.extensions import limiter
from app.utils.file_validator import validate_file, FileValidationError
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_tools_tasks import (
merge_pdfs_task,
@@ -43,24 +51,36 @@ def merge_pdfs_route():
if len(files) > 20:
return jsonify({"error": "Maximum 20 files allowed."}), 400
actor = resolve_web_actor()
try:
assert_quota_available(actor)
except PolicyError as e:
return jsonify({"error": e.message}), e.status_code
task_id = str(uuid.uuid4())
input_paths = []
original_filenames = []
for f in files:
try:
original_filename, ext = validate_file(f, allowed_types=["pdf"])
original_filename, ext = validate_actor_file(f, allowed_types=["pdf"], actor=actor)
except FileValidationError as e:
return jsonify({"error": e.message}), e.code
upload_dir = os.path.join("/tmp/uploads", task_id)
upload_dir = os.path.join(current_app.config["UPLOAD_FOLDER"], task_id)
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, f"{uuid.uuid4()}.{ext}")
f.save(file_path)
input_paths.append(file_path)
original_filenames.append(original_filename)
task = merge_pdfs_task.delay(input_paths, task_id, original_filenames)
task = merge_pdfs_task.delay(
input_paths,
task_id,
original_filenames,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "merge-pdf", task.id)
return jsonify({
"task_id": task.id,
@@ -98,15 +118,29 @@ def split_pdf_route():
"error": "Please specify which pages to extract (e.g. 1,3,5-8)."
}), 400
actor = resolve_web_actor()
try:
original_filename, ext = validate_file(file, allowed_types=["pdf"])
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 = split_pdf_task.delay(input_path, task_id, original_filename, mode, pages)
task = split_pdf_task.delay(
input_path,
task_id,
original_filename,
mode,
pages,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "split-pdf", task.id)
return jsonify({
"task_id": task.id,
@@ -144,15 +178,29 @@ def rotate_pdf_route():
pages = request.form.get("pages", "all")
actor = resolve_web_actor()
try:
original_filename, ext = validate_file(file, allowed_types=["pdf"])
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 = rotate_pdf_task.delay(input_path, task_id, original_filename, rotation, pages)
task = rotate_pdf_task.delay(
input_path,
task_id,
original_filename,
rotation,
pages,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "rotate-pdf", task.id)
return jsonify({
"task_id": task.id,
@@ -193,8 +241,14 @@ def add_page_numbers_route():
except ValueError:
start_number = 1
actor = resolve_web_actor()
try:
original_filename, ext = validate_file(file, allowed_types=["pdf"])
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
@@ -202,8 +256,14 @@ def add_page_numbers_route():
file.save(input_path)
task = add_page_numbers_task.delay(
input_path, task_id, original_filename, position, start_number
input_path,
task_id,
original_filename,
position,
start_number,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "page-numbers", task.id)
return jsonify({
"task_id": task.id,
@@ -239,8 +299,14 @@ def pdf_to_images_route():
except ValueError:
dpi = 200
actor = resolve_web_actor()
try:
original_filename, ext = validate_file(file, allowed_types=["pdf"])
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
@@ -248,8 +314,14 @@ def pdf_to_images_route():
file.save(input_path)
task = pdf_to_images_task.delay(
input_path, task_id, original_filename, output_format, dpi
input_path,
task_id,
original_filename,
output_format,
dpi,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "pdf-to-images", task.id)
return jsonify({
"task_id": task.id,
@@ -276,24 +348,38 @@ def images_to_pdf_route():
if len(files) > 50:
return jsonify({"error": "Maximum 50 images allowed."}), 400
actor = resolve_web_actor()
try:
assert_quota_available(actor)
except PolicyError as e:
return jsonify({"error": e.message}), e.status_code
task_id = str(uuid.uuid4())
input_paths = []
original_filenames = []
for f in files:
try:
original_filename, ext = validate_file(f, allowed_types=ALLOWED_IMAGE_TYPES)
original_filename, ext = validate_actor_file(
f, allowed_types=ALLOWED_IMAGE_TYPES, actor=actor
)
except FileValidationError as e:
return jsonify({"error": e.message}), e.code
upload_dir = os.path.join("/tmp/uploads", task_id)
upload_dir = os.path.join(current_app.config["UPLOAD_FOLDER"], task_id)
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, f"{uuid.uuid4()}.{ext}")
f.save(file_path)
input_paths.append(file_path)
original_filenames.append(original_filename)
task = images_to_pdf_task.delay(input_paths, task_id, original_filenames)
task = images_to_pdf_task.delay(
input_paths,
task_id,
original_filenames,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "images-to-pdf", task.id)
return jsonify({
"task_id": task.id,
@@ -333,8 +419,14 @@ def watermark_pdf_route():
except ValueError:
opacity = 0.3
actor = resolve_web_actor()
try:
original_filename, ext = validate_file(file, allowed_types=["pdf"])
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
@@ -342,8 +434,14 @@ def watermark_pdf_route():
file.save(input_path)
task = watermark_pdf_task.delay(
input_path, task_id, original_filename, watermark_text, opacity
input_path,
task_id,
original_filename,
watermark_text,
opacity,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "watermark-pdf", task.id)
return jsonify({
"task_id": task.id,
@@ -377,15 +475,28 @@ def protect_pdf_route():
if len(password) < 4:
return jsonify({"error": "Password must be at least 4 characters."}), 400
actor = resolve_web_actor()
try:
original_filename, ext = validate_file(file, allowed_types=["pdf"])
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 = protect_pdf_task.delay(input_path, task_id, original_filename, password)
task = protect_pdf_task.delay(
input_path,
task_id,
original_filename,
password,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "protect-pdf", task.id)
return jsonify({
"task_id": task.id,
@@ -416,15 +527,28 @@ def unlock_pdf_route():
if not password:
return jsonify({"error": "Password is required."}), 400
actor = resolve_web_actor()
try:
original_filename, ext = validate_file(file, allowed_types=["pdf"])
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 = unlock_pdf_task.delay(input_path, task_id, original_filename, password)
task = unlock_pdf_task.delay(
input_path,
task_id,
original_filename,
password,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "unlock-pdf", task.id)
return jsonify({
"task_id": task.id,

View File

@@ -0,0 +1 @@
"""B2B API v1 blueprint package."""

View File

@@ -0,0 +1,682 @@
"""B2B API v1 tool routes — authenticated via X-API-Key, Pro plan only."""
import os
import uuid
import logging
from celery.result import AsyncResult
from flask import Blueprint, current_app, jsonify, request
from app.extensions import celery, limiter
from app.services.policy_service import (
assert_quota_available,
assert_api_task_access,
build_task_tracking_kwargs,
PolicyError,
record_accepted_usage,
resolve_api_actor,
validate_actor_file,
)
from app.utils.file_validator import FileValidationError
from app.utils.sanitizer import generate_safe_path
from app.tasks.compress_tasks import compress_pdf_task
from app.tasks.convert_tasks import convert_pdf_to_word, convert_word_to_pdf
from app.tasks.image_tasks import convert_image_task, resize_image_task
from app.tasks.video_tasks import create_gif_task
from app.tasks.pdf_tools_tasks import (
merge_pdfs_task,
split_pdf_task,
rotate_pdf_task,
add_page_numbers_task,
pdf_to_images_task,
images_to_pdf_task,
watermark_pdf_task,
protect_pdf_task,
unlock_pdf_task,
)
from app.tasks.flowchart_tasks import extract_flowchart_task
logger = logging.getLogger(__name__)
v1_bp = Blueprint("v1", __name__)
ALLOWED_IMAGE_TYPES = ["png", "jpg", "jpeg", "webp"]
ALLOWED_VIDEO_TYPES = ["mp4", "webm"]
ALLOWED_OUTPUT_FORMATS = ["jpg", "png", "webp"]
def _resolve_and_check() -> tuple:
"""Resolve API actor and assert quota. Returns (actor, error_response | None)."""
try:
actor = resolve_api_actor()
except PolicyError as e:
return None, (jsonify({"error": e.message}), e.status_code)
try:
assert_quota_available(actor)
except PolicyError as e:
return None, (jsonify({"error": e.message}), e.status_code)
return actor, None
# ---------------------------------------------------------------------------
# Task status — GET /api/v1/tasks/<task_id>/status
# ---------------------------------------------------------------------------
@v1_bp.route("/tasks/<task_id>/status", methods=["GET"])
@limiter.limit("300/minute", override_defaults=True)
def get_task_status(task_id: str):
"""Poll the status of an async API task."""
try:
actor = resolve_api_actor()
except PolicyError as e:
return jsonify({"error": e.message}), e.status_code
try:
assert_api_task_access(actor, task_id)
except PolicyError as e:
return jsonify({"error": e.message}), e.status_code
result = AsyncResult(task_id, app=celery)
response: dict = {"task_id": task_id, "state": result.state}
if result.state == "PENDING":
response["progress"] = "Task is waiting in queue..."
elif result.state == "PROCESSING":
response["progress"] = (result.info or {}).get("step", "Processing...")
elif result.state == "SUCCESS":
response["result"] = result.result or {}
elif result.state == "FAILURE":
response["error"] = str(result.info) if result.info else "Task failed."
return jsonify(response)
# ---------------------------------------------------------------------------
# Compress — POST /api/v1/compress/pdf
# ---------------------------------------------------------------------------
@v1_bp.route("/compress/pdf", methods=["POST"])
@limiter.limit("10/minute")
def compress_pdf_route():
"""Compress a PDF file."""
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"]
quality = request.form.get("quality", "medium")
if quality not in ("low", "medium", "high"):
quality = "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 = compress_pdf_task.delay(
input_path, task_id, original_filename, quality,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "compress-pdf", task.id)
return jsonify({"task_id": task.id, "message": "Compression started."}), 202
# ---------------------------------------------------------------------------
# Convert — POST /api/v1/convert/pdf-to-word & /api/v1/convert/word-to-pdf
# ---------------------------------------------------------------------------
@v1_bp.route("/convert/pdf-to-word", methods=["POST"])
@limiter.limit("10/minute")
def pdf_to_word_route():
"""Convert a PDF to Word (DOCX)."""
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 = convert_pdf_to_word.delay(
input_path, task_id, original_filename,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "pdf-to-word", task.id)
return jsonify({"task_id": task.id, "message": "Conversion started."}), 202
@v1_bp.route("/convert/word-to-pdf", methods=["POST"])
@limiter.limit("10/minute")
def word_to_pdf_route():
"""Convert a Word (DOC/DOCX) 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=["doc", "docx"], 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 = convert_word_to_pdf.delay(
input_path, task_id, original_filename,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "word-to-pdf", task.id)
return jsonify({"task_id": task.id, "message": "Conversion started."}), 202
# ---------------------------------------------------------------------------
# Image — POST /api/v1/image/convert & /api/v1/image/resize
# ---------------------------------------------------------------------------
@v1_bp.route("/image/convert", methods=["POST"])
@limiter.limit("10/minute")
def convert_image_route():
"""Convert an image to a different format."""
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"]
output_format = request.form.get("format", "").lower()
if output_format not in ALLOWED_OUTPUT_FORMATS:
return jsonify({"error": f"Invalid format. Supported: {', '.join(ALLOWED_OUTPUT_FORMATS)}"}), 400
try:
quality = max(1, min(100, int(request.form.get("quality", "85"))))
except ValueError:
quality = 85
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 = convert_image_task.delay(
input_path, task_id, original_filename, output_format, quality,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "image-convert", task.id)
return jsonify({"task_id": task.id, "message": "Image conversion started."}), 202
@v1_bp.route("/image/resize", methods=["POST"])
@limiter.limit("10/minute")
def resize_image_route():
"""Resize 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:
width = int(request.form.get("width")) if request.form.get("width") else None
height = int(request.form.get("height")) if request.form.get("height") else None
except ValueError:
return jsonify({"error": "Width and height must be integers."}), 400
if width is None and height is None:
return jsonify({"error": "At least one of width or height is required."}), 400
if width and not (1 <= width <= 10000):
return jsonify({"error": "Width must be between 1 and 10000."}), 400
if height and not (1 <= height <= 10000):
return jsonify({"error": "Height must be between 1 and 10000."}), 400
try:
quality = max(1, min(100, int(request.form.get("quality", "85"))))
except ValueError:
quality = 85
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 = resize_image_task.delay(
input_path, task_id, original_filename, width, height, quality,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "image-resize", task.id)
return jsonify({"task_id": task.id, "message": "Image resize started."}), 202
# ---------------------------------------------------------------------------
# Video — POST /api/v1/video/to-gif
# ---------------------------------------------------------------------------
@v1_bp.route("/video/to-gif", methods=["POST"])
@limiter.limit("5/minute")
def video_to_gif_route():
"""Convert a video clip to an animated GIF."""
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:
start_time = float(request.form.get("start_time", 0))
duration = float(request.form.get("duration", 5))
fps = int(request.form.get("fps", 10))
width = int(request.form.get("width", 480))
except (ValueError, TypeError):
return jsonify({"error": "Invalid parameters. Must be numeric."}), 400
if start_time < 0:
return jsonify({"error": "Start time cannot be negative."}), 400
if not (0 < duration <= 15):
return jsonify({"error": "Duration must be between 0.5 and 15 seconds."}), 400
if not (1 <= fps <= 20):
return jsonify({"error": "FPS must be between 1 and 20."}), 400
if not (100 <= width <= 640):
return jsonify({"error": "Width must be between 100 and 640 pixels."}), 400
try:
original_filename, ext = validate_actor_file(
file, allowed_types=ALLOWED_VIDEO_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 = create_gif_task.delay(
input_path, task_id, original_filename, start_time, duration, fps, width,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "video-to-gif", task.id)
return jsonify({"task_id": task.id, "message": "GIF creation started."}), 202
# ---------------------------------------------------------------------------
# PDF Tools — all single-file and multi-file routes
# ---------------------------------------------------------------------------
@v1_bp.route("/pdf-tools/merge", methods=["POST"])
@limiter.limit("10/minute")
def merge_pdfs_route():
"""Merge multiple PDF files into one."""
actor, err = _resolve_and_check()
if err:
return err
files = request.files.getlist("files")
if not files or len(files) < 2:
return jsonify({"error": "Please upload at least 2 PDF files."}), 400
if len(files) > 20:
return jsonify({"error": "Maximum 20 files allowed."}), 400
task_id = str(uuid.uuid4())
input_paths, original_filenames = [], []
for f in files:
try:
original_filename, ext = validate_actor_file(f, allowed_types=["pdf"], actor=actor)
except FileValidationError as e:
return jsonify({"error": e.message}), e.code
upload_dir = os.path.join(current_app.config["UPLOAD_FOLDER"], task_id)
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, f"{uuid.uuid4()}.{ext}")
f.save(file_path)
input_paths.append(file_path)
original_filenames.append(original_filename)
task = merge_pdfs_task.delay(
input_paths, task_id, original_filenames,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "merge-pdf", task.id)
return jsonify({"task_id": task.id, "message": "Merge started."}), 202
@v1_bp.route("/pdf-tools/split", methods=["POST"])
@limiter.limit("10/minute")
def split_pdf_route():
"""Split a PDF into pages or a range."""
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"]
mode = request.form.get("mode", "all")
pages = request.form.get("pages")
if mode not in ("all", "range"):
mode = "all"
if mode == "range" and not (pages and pages.strip()):
return jsonify({"error": "Please specify which pages to extract."}), 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 = split_pdf_task.delay(
input_path, task_id, original_filename, mode, pages,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "split-pdf", task.id)
return jsonify({"task_id": task.id, "message": "Split started."}), 202
@v1_bp.route("/pdf-tools/rotate", methods=["POST"])
@limiter.limit("10/minute")
def rotate_pdf_route():
"""Rotate pages in 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:
rotation = int(request.form.get("rotation", 90))
except ValueError:
rotation = 90
if rotation not in (90, 180, 270):
return jsonify({"error": "Rotation must be 90, 180, or 270 degrees."}), 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 = rotate_pdf_task.delay(
input_path, task_id, original_filename, rotation, pages,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "rotate-pdf", task.id)
return jsonify({"task_id": task.id, "message": "Rotation started."}), 202
@v1_bp.route("/pdf-tools/page-numbers", methods=["POST"])
@limiter.limit("10/minute")
def add_page_numbers_route():
"""Add page numbers to 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"]
position = request.form.get("position", "bottom-center")
valid_positions = [
"bottom-center", "bottom-right", "bottom-left",
"top-center", "top-right", "top-left",
]
if position not in valid_positions:
position = "bottom-center"
try:
start_number = max(1, int(request.form.get("start_number", 1)))
except ValueError:
start_number = 1
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 = add_page_numbers_task.delay(
input_path, task_id, original_filename, position, start_number,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "page-numbers", task.id)
return jsonify({"task_id": task.id, "message": "Page numbering started."}), 202
@v1_bp.route("/pdf-tools/pdf-to-images", methods=["POST"])
@limiter.limit("10/minute")
def pdf_to_images_route():
"""Convert PDF pages to images."""
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"]
output_format = request.form.get("format", "png").lower()
if output_format not in ("png", "jpg"):
output_format = "png"
try:
dpi = max(72, min(600, int(request.form.get("dpi", 200))))
except ValueError:
dpi = 200
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_images_task.delay(
input_path, task_id, original_filename, output_format, dpi,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "pdf-to-images", task.id)
return jsonify({"task_id": task.id, "message": "Conversion started."}), 202
@v1_bp.route("/pdf-tools/images-to-pdf", methods=["POST"])
@limiter.limit("10/minute")
def images_to_pdf_route():
"""Convert multiple images to a single PDF."""
actor, err = _resolve_and_check()
if err:
return err
files = request.files.getlist("files")
if not files:
return jsonify({"error": "Please upload at least 1 image."}), 400
if len(files) > 50:
return jsonify({"error": "Maximum 50 images allowed."}), 400
task_id = str(uuid.uuid4())
input_paths, original_filenames = [], []
for f in files:
try:
original_filename, ext = validate_actor_file(
f, allowed_types=ALLOWED_IMAGE_TYPES, actor=actor
)
except FileValidationError as e:
return jsonify({"error": e.message}), e.code
upload_dir = os.path.join(current_app.config["UPLOAD_FOLDER"], task_id)
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, f"{uuid.uuid4()}.{ext}")
f.save(file_path)
input_paths.append(file_path)
original_filenames.append(original_filename)
task = images_to_pdf_task.delay(
input_paths, task_id, original_filenames,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "images-to-pdf", task.id)
return jsonify({"task_id": task.id, "message": "Conversion started."}), 202
@v1_bp.route("/pdf-tools/watermark", methods=["POST"])
@limiter.limit("10/minute")
def watermark_pdf_route():
"""Add a text watermark to 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"]
watermark_text = request.form.get("text", "").strip()
if not watermark_text:
return jsonify({"error": "Watermark text is required."}), 400
if len(watermark_text) > 100:
return jsonify({"error": "Watermark text must be 100 characters or less."}), 400
try:
opacity = max(0.1, min(1.0, float(request.form.get("opacity", 0.3))))
except ValueError:
opacity = 0.3
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 = watermark_pdf_task.delay(
input_path, task_id, original_filename, watermark_text, opacity,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "watermark-pdf", task.id)
return jsonify({"task_id": task.id, "message": "Watermarking started."}), 202
@v1_bp.route("/pdf-tools/protect", methods=["POST"])
@limiter.limit("10/minute")
def protect_pdf_route():
"""Add password protection to 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"]
password = request.form.get("password", "").strip()
if not password:
return jsonify({"error": "Password is required."}), 400
if len(password) < 4:
return jsonify({"error": "Password must be at least 4 characters."}), 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 = protect_pdf_task.delay(
input_path, task_id, original_filename, password,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "protect-pdf", task.id)
return jsonify({"task_id": task.id, "message": "Protection started."}), 202
@v1_bp.route("/pdf-tools/unlock", methods=["POST"])
@limiter.limit("10/minute")
def unlock_pdf_route():
"""Remove password protection 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"]
password = request.form.get("password", "").strip()
if not password:
return jsonify({"error": "Password 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 = unlock_pdf_task.delay(
input_path, task_id, original_filename, password,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "unlock-pdf", task.id)
return jsonify({"task_id": task.id, "message": "Unlock started."}), 202
@v1_bp.route("/flowchart/extract", methods=["POST"])
@limiter.limit("10/minute")
def extract_flowchart_route():
"""Extract procedures from a PDF and generate flowcharts."""
actor, err = _resolve_and_check()
if err:
return err
if "file" not in request.files:
return jsonify({"error": "No file uploaded."}), 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)
file.save(input_path)
task = extract_flowchart_task.delay(
input_path, task_id, original_filename,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "pdf-flowchart", task.id)
return jsonify({"task_id": task.id, "message": "Flowchart extraction started."}), 202

View File

@@ -2,7 +2,15 @@
from flask import Blueprint, request, jsonify
from app.extensions import limiter
from app.utils.file_validator import validate_file, FileValidationError
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.video_tasks import create_gif_task
@@ -49,20 +57,28 @@ def video_to_gif_route():
if width < 100 or width > 640:
return jsonify({"error": "Width must be between 100 and 640 pixels."}), 400
actor = resolve_web_actor()
try:
original_filename, ext = validate_file(file, allowed_types=ALLOWED_VIDEO_TYPES)
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_VIDEO_TYPES, actor=actor
)
except FileValidationError as e:
return jsonify({"error": e.message}), e.code
# Save file
task_id, input_path = generate_safe_path(ext, folder_type="upload")
file.save(input_path)
# Dispatch task
task = create_gif_task.delay(
input_path, task_id, original_filename,
start_time, duration, fps, width,
**build_task_tracking_kwargs(actor),
)
record_accepted_usage(actor, "video-to-gif", task.id)
return jsonify({
"task_id": task.id,

View File

@@ -0,0 +1,517 @@
"""User accounts, API keys, history, and usage storage using SQLite."""
import hashlib
import json
import logging
import os
import secrets
import sqlite3
from datetime import datetime, timezone
from flask import current_app
from werkzeug.security import check_password_hash, generate_password_hash
logger = logging.getLogger(__name__)
VALID_PLANS = {"free", "pro"}
def _utc_now() -> str:
"""Return a stable UTC timestamp string."""
return datetime.now(timezone.utc).isoformat()
def get_current_period_month() -> str:
"""Return the active usage period in YYYY-MM format."""
return datetime.now(timezone.utc).strftime("%Y-%m")
def normalize_plan(plan: str | None) -> str:
"""Normalize plan values to the supported set."""
return "pro" if plan == "pro" else "free"
def _connect() -> sqlite3.Connection:
"""Create a SQLite connection with row access by column name."""
db_path = current_app.config["DATABASE_PATH"]
db_dir = os.path.dirname(db_path)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
connection = sqlite3.connect(db_path)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
return connection
def _column_exists(conn: sqlite3.Connection, table_name: str, column_name: str) -> bool:
"""Check whether one column exists in a SQLite table."""
rows = conn.execute(f"PRAGMA table_info({table_name})").fetchall()
return any(row["name"] == column_name for row in rows)
def _serialize_user(row: sqlite3.Row | None) -> dict | None:
"""Convert a user row into API-safe data."""
if row is None:
return None
return {
"id": row["id"],
"email": row["email"],
"plan": normalize_plan(row["plan"]),
"created_at": row["created_at"],
}
def _serialize_api_key(row: sqlite3.Row) -> dict:
"""Convert an API key row into public API-safe data."""
return {
"id": row["id"],
"name": row["name"],
"key_prefix": row["key_prefix"],
"last_used_at": row["last_used_at"],
"revoked_at": row["revoked_at"],
"created_at": row["created_at"],
}
def _normalize_email(email: str) -> str:
"""Normalize user emails for lookups and uniqueness."""
return email.strip().lower()
def _hash_api_key(raw_key: str) -> str:
"""Return a deterministic digest for one API key."""
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
def init_account_db():
"""Initialize user, history, API key, and usage tables if they do not exist."""
with _connect() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
plan TEXT NOT NULL DEFAULT 'free',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS file_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
tool TEXT NOT NULL,
original_filename TEXT,
output_filename TEXT,
status TEXT NOT NULL,
download_url TEXT,
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
key_prefix TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
last_used_at TEXT,
revoked_at TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS usage_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
api_key_id INTEGER,
source TEXT NOT NULL,
tool TEXT NOT NULL,
task_id TEXT NOT NULL,
event_type TEXT NOT NULL,
created_at TEXT NOT NULL,
period_month TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_file_history_user_created
ON file_history(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_api_keys_user_created
ON api_keys(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_usage_events_user_source_period_event
ON usage_events(user_id, source, period_month, event_type);
CREATE INDEX IF NOT EXISTS idx_usage_events_task_lookup
ON usage_events(user_id, source, task_id, event_type);
"""
)
if not _column_exists(conn, "users", "plan"):
conn.execute(
"ALTER TABLE users ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'"
)
if not _column_exists(conn, "users", "updated_at"):
conn.execute(
"ALTER TABLE users ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''"
)
def create_user(email: str, password: str) -> dict:
"""Create a new user and return the public record."""
email = _normalize_email(email)
now = _utc_now()
try:
with _connect() as conn:
cursor = conn.execute(
"""
INSERT INTO users (email, password_hash, plan, created_at, updated_at)
VALUES (?, ?, 'free', ?, ?)
""",
(email, generate_password_hash(password), now, now),
)
user_id = cursor.lastrowid
row = conn.execute(
"SELECT id, email, plan, created_at FROM users WHERE id = ?",
(user_id,),
).fetchone()
except sqlite3.IntegrityError as exc:
raise ValueError("An account with this email already exists.") from exc
return _serialize_user(row) or {}
def authenticate_user(email: str, password: str) -> dict | None:
"""Return the public user record when credentials are valid."""
email = _normalize_email(email)
with _connect() as conn:
row = conn.execute(
"SELECT * FROM users WHERE email = ?",
(email,),
).fetchone()
if row is None or not check_password_hash(row["password_hash"], password):
return None
return _serialize_user(row)
def get_user_by_id(user_id: int) -> dict | None:
"""Fetch a public user record by id."""
with _connect() as conn:
row = conn.execute(
"SELECT id, email, plan, created_at FROM users WHERE id = ?",
(user_id,),
).fetchone()
return _serialize_user(row)
def update_user_plan(user_id: int, plan: str) -> dict | None:
"""Update one user plan and return the public record."""
normalized_plan = normalize_plan(plan)
if normalized_plan not in VALID_PLANS:
raise ValueError("Invalid plan.")
with _connect() as conn:
conn.execute(
"""
UPDATE users
SET plan = ?, updated_at = ?
WHERE id = ?
""",
(normalized_plan, _utc_now(), user_id),
)
row = conn.execute(
"SELECT id, email, plan, created_at FROM users WHERE id = ?",
(user_id,),
).fetchone()
return _serialize_user(row)
def create_api_key(user_id: int, name: str) -> dict:
"""Create one API key and return the public record plus raw secret once."""
name = name.strip()
if not name:
raise ValueError("API key name is required.")
if len(name) > 100:
raise ValueError("API key name must be 100 characters or less.")
raw_key = f"spdf_{secrets.token_urlsafe(32)}"
now = _utc_now()
with _connect() as conn:
cursor = conn.execute(
"""
INSERT INTO api_keys (user_id, name, key_prefix, key_hash, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(
user_id,
name,
raw_key[:16],
_hash_api_key(raw_key),
now,
),
)
row = conn.execute(
"""
SELECT id, name, key_prefix, last_used_at, revoked_at, created_at
FROM api_keys
WHERE id = ?
""",
(cursor.lastrowid,),
).fetchone()
result = _serialize_api_key(row)
result["raw_key"] = raw_key
return result
def list_api_keys(user_id: int) -> list[dict]:
"""Return all API keys for one user."""
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, name, key_prefix, last_used_at, revoked_at, created_at
FROM api_keys
WHERE user_id = ?
ORDER BY created_at DESC
""",
(user_id,),
).fetchall()
return [_serialize_api_key(row) for row in rows]
def revoke_api_key(user_id: int, key_id: int) -> bool:
"""Revoke one API key owned by one user."""
with _connect() as conn:
cursor = conn.execute(
"""
UPDATE api_keys
SET revoked_at = ?
WHERE id = ? AND user_id = ? AND revoked_at IS NULL
""",
(_utc_now(), key_id, user_id),
)
return cursor.rowcount > 0
def get_api_key_actor(raw_key: str) -> dict | None:
"""Resolve one raw API key into the owning active user context."""
if not raw_key:
return None
key_hash = _hash_api_key(raw_key.strip())
now = _utc_now()
with _connect() as conn:
row = conn.execute(
"""
SELECT
api_keys.id AS api_key_id,
api_keys.user_id,
api_keys.name,
api_keys.key_prefix,
api_keys.last_used_at,
users.email,
users.plan,
users.created_at
FROM api_keys
INNER JOIN users ON users.id = api_keys.user_id
WHERE api_keys.key_hash = ? AND api_keys.revoked_at IS NULL
""",
(key_hash,),
).fetchone()
if row is None:
return None
conn.execute(
"UPDATE api_keys SET last_used_at = ? WHERE id = ?",
(now, row["api_key_id"]),
)
return {
"api_key_id": row["api_key_id"],
"user_id": row["user_id"],
"email": row["email"],
"plan": normalize_plan(row["plan"]),
"created_at": row["created_at"],
"name": row["name"],
"key_prefix": row["key_prefix"],
"last_used_at": now,
}
def record_file_history(
user_id: int,
tool: str,
original_filename: str | None,
output_filename: str | None,
status: str,
download_url: str | None,
metadata: dict | None = None,
):
"""Persist one generated-file history entry."""
with _connect() as conn:
conn.execute(
"""
INSERT INTO file_history (
user_id, tool, original_filename, output_filename,
status, download_url, metadata_json, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
tool,
original_filename,
output_filename,
status,
download_url,
json.dumps(metadata or {}, ensure_ascii=True),
_utc_now(),
),
)
def record_task_history(
user_id: int | None,
tool: str,
original_filename: str | None,
result: dict,
):
"""Persist task results when the request belongs to an authenticated user."""
if user_id is None:
return
metadata = {}
for key, value in result.items():
if key in {"status", "download_url", "filename"}:
continue
if key in {"procedures", "flowcharts", "pages"} and isinstance(value, list):
metadata[f"{key}_count"] = len(value)
continue
metadata[key] = value
try:
record_file_history(
user_id=user_id,
tool=tool,
original_filename=original_filename,
output_filename=result.get("filename"),
status=result.get("status", "completed"),
download_url=result.get("download_url"),
metadata=metadata,
)
except Exception:
logger.exception("Failed to persist task history for tool=%s", tool)
def list_file_history(user_id: int, limit: int = 50) -> list[dict]:
"""Return most recent file history entries for one user."""
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, tool, original_filename, output_filename, status,
download_url, metadata_json, created_at
FROM file_history
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT ?
""",
(user_id, limit),
).fetchall()
return [
{
"id": row["id"],
"tool": row["tool"],
"original_filename": row["original_filename"],
"output_filename": row["output_filename"],
"status": row["status"],
"download_url": row["download_url"],
"metadata": json.loads(row["metadata_json"] or "{}"),
"created_at": row["created_at"],
}
for row in rows
]
def record_usage_event(
user_id: int | None,
source: str,
tool: str,
task_id: str,
event_type: str,
api_key_id: int | None = None,
):
"""Persist one usage event when it belongs to an authenticated actor."""
if user_id is None:
return
with _connect() as conn:
conn.execute(
"""
INSERT INTO usage_events (
user_id, api_key_id, source, tool, task_id,
event_type, created_at, period_month
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
api_key_id,
source,
tool,
task_id,
event_type,
_utc_now(),
get_current_period_month(),
),
)
def count_usage_events(
user_id: int,
source: str,
event_type: str = "accepted",
period_month: str | None = None,
) -> int:
"""Count usage events for one user, source, period, and type."""
with _connect() as conn:
row = conn.execute(
"""
SELECT COUNT(*) AS count
FROM usage_events
WHERE user_id = ? AND source = ? AND event_type = ? AND period_month = ?
""",
(user_id, source, event_type, period_month or get_current_period_month()),
).fetchone()
return int(row["count"]) if row else 0
def has_task_access(user_id: int, source: str, task_id: str) -> bool:
"""Return whether one user owns one previously accepted task for one source."""
with _connect() as conn:
row = conn.execute(
"""
SELECT 1
FROM usage_events
WHERE user_id = ? AND source = ? AND task_id = ? AND event_type = 'accepted'
LIMIT 1
""",
(user_id, source, task_id),
).fetchone()
return row is not None

View File

@@ -0,0 +1,227 @@
"""Plan entitlements, actor resolution, and quota enforcement."""
from dataclasses import dataclass
from flask import current_app, request
from app.services.account_service import (
count_usage_events,
get_api_key_actor,
get_user_by_id,
get_current_period_month,
has_task_access,
normalize_plan,
record_usage_event,
)
from app.utils.auth import get_current_user_id, logout_user_session
from app.utils.file_validator import validate_file
FREE_PLAN = "free"
PRO_PLAN = "pro"
FREE_WEB_MONTHLY_LIMIT = 50
PRO_WEB_MONTHLY_LIMIT = 500
PRO_API_MONTHLY_LIMIT = 1000
FREE_HISTORY_LIMIT = 25
PRO_HISTORY_LIMIT = 250
FREE_HOMEPAGE_LIMIT_MB = 50
PRO_HOMEPAGE_LIMIT_MB = 100
@dataclass(frozen=True)
class ActorContext:
"""Resolved access context for one incoming request."""
source: str
actor_type: str
user_id: int | None
plan: str
api_key_id: int | None = None
class PolicyError(Exception):
"""A request failed access or quota policy validation."""
def __init__(self, message: str, status_code: int = 400):
self.message = message
self.status_code = status_code
super().__init__(message)
def get_history_limit(plan: str) -> int:
"""Return the default history limit for one plan."""
return PRO_HISTORY_LIMIT if normalize_plan(plan) == PRO_PLAN else FREE_HISTORY_LIMIT
def get_web_quota_limit(plan: str, actor_type: str) -> int | None:
"""Return the monthly accepted-task cap for one web actor."""
if actor_type == "anonymous":
return None
return PRO_WEB_MONTHLY_LIMIT if normalize_plan(plan) == PRO_PLAN else FREE_WEB_MONTHLY_LIMIT
def get_api_quota_limit(plan: str) -> int | None:
"""Return the monthly accepted-task cap for one API actor."""
return PRO_API_MONTHLY_LIMIT if normalize_plan(plan) == PRO_PLAN else None
def ads_enabled(plan: str, actor_type: str) -> bool:
"""Return whether ads should display for one actor."""
return not (actor_type != "anonymous" and normalize_plan(plan) == PRO_PLAN)
def get_effective_file_size_limits_bytes(plan: str) -> dict[str, int]:
"""Return effective backend upload limits for one plan."""
base_limits = current_app.config["FILE_SIZE_LIMITS"]
if normalize_plan(plan) != PRO_PLAN:
return dict(base_limits)
return {key: value * 2 for key, value in base_limits.items()}
def get_effective_file_size_limits_mb(plan: str) -> dict[str, int]:
"""Return effective frontend-friendly upload limits for one plan."""
byte_limits = get_effective_file_size_limits_bytes(plan)
return {
"pdf": byte_limits["pdf"] // (1024 * 1024),
"word": byte_limits["docx"] // (1024 * 1024),
"image": byte_limits["png"] // (1024 * 1024),
"video": byte_limits["mp4"] // (1024 * 1024),
"homepageSmartUpload": PRO_HOMEPAGE_LIMIT_MB
if normalize_plan(plan) == PRO_PLAN
else FREE_HOMEPAGE_LIMIT_MB,
}
def get_usage_summary_for_user(user_id: int, plan: str) -> dict:
"""Return usage/quota summary for one authenticated user."""
normalized_plan = normalize_plan(plan)
current_period = get_current_period_month()
web_used = count_usage_events(
user_id, "web", event_type="accepted", period_month=current_period
)
api_used = count_usage_events(
user_id, "api", event_type="accepted", period_month=current_period
)
return {
"plan": normalized_plan,
"period_month": current_period,
"ads_enabled": ads_enabled(normalized_plan, "session"),
"history_limit": get_history_limit(normalized_plan),
"file_limits_mb": get_effective_file_size_limits_mb(normalized_plan),
"web_quota": {
"used": web_used,
"limit": get_web_quota_limit(normalized_plan, "session"),
},
"api_quota": {
"used": api_used,
"limit": get_api_quota_limit(normalized_plan),
},
}
def resolve_web_actor() -> ActorContext:
"""Resolve the active web actor from session state."""
user_id = get_current_user_id()
if user_id is None:
return ActorContext(source="web", actor_type="anonymous", user_id=None, plan=FREE_PLAN)
user = get_user_by_id(user_id)
if user is None:
logout_user_session()
return ActorContext(source="web", actor_type="anonymous", user_id=None, plan=FREE_PLAN)
return ActorContext(
source="web",
actor_type="session",
user_id=user["id"],
plan=normalize_plan(user["plan"]),
)
def resolve_api_actor() -> ActorContext:
"""Resolve the active B2B API actor from X-API-Key header."""
raw_key = request.headers.get("X-API-Key", "").strip()
if not raw_key:
raise PolicyError("X-API-Key header is required.", 401)
actor = get_api_key_actor(raw_key)
if actor is None:
raise PolicyError("Invalid or revoked API key.", 401)
plan = normalize_plan(actor["plan"])
if plan != PRO_PLAN:
raise PolicyError("API access requires an active Pro plan.", 403)
return ActorContext(
source="api",
actor_type="api_key",
user_id=actor["user_id"],
plan=plan,
api_key_id=actor["api_key_id"],
)
def validate_actor_file(file_storage, allowed_types: list[str], actor: ActorContext):
"""Validate one uploaded file with plan-aware size limits."""
return validate_file(
file_storage,
allowed_types=allowed_types,
size_limit_overrides=get_effective_file_size_limits_bytes(actor.plan),
)
def assert_quota_available(actor: ActorContext):
"""Ensure an actor still has accepted-task quota for the current month."""
if actor.user_id is None:
return
if actor.source == "web":
limit = get_web_quota_limit(actor.plan, actor.actor_type)
if limit is None:
return
used = count_usage_events(actor.user_id, "web", event_type="accepted")
if used >= limit:
if normalize_plan(actor.plan) == PRO_PLAN:
raise PolicyError("Your monthly Pro web quota has been reached.", 429)
raise PolicyError(
"Your monthly free plan limit has been reached. Upgrade to Pro for higher limits.",
429,
)
return
limit = get_api_quota_limit(actor.plan)
if limit is None:
raise PolicyError("API access requires an active Pro plan.", 403)
used = count_usage_events(actor.user_id, "api", event_type="accepted")
if used >= limit:
raise PolicyError("Your monthly API quota has been reached.", 429)
def record_accepted_usage(actor: ActorContext, tool: str, celery_task_id: str):
"""Record one accepted usage event after task dispatch succeeds."""
record_usage_event(
user_id=actor.user_id,
source=actor.source,
tool=tool,
task_id=celery_task_id,
event_type="accepted",
api_key_id=actor.api_key_id,
)
def build_task_tracking_kwargs(actor: ActorContext) -> dict:
"""Return Celery kwargs required for task-side tracking."""
return {
"user_id": actor.user_id,
"usage_source": actor.source,
"api_key_id": actor.api_key_id,
}
def assert_api_task_access(actor: ActorContext, task_id: str):
"""Ensure one API actor can poll one task id."""
if actor.user_id is None or not has_task_access(actor.user_id, "api", task_id):
raise PolicyError("Task not found.", 404)

View File

@@ -0,0 +1,29 @@
"""Shared helpers for task completion tracking."""
from app.services.account_service import record_task_history, record_usage_event
def finalize_task_tracking(
*,
user_id: int | None,
tool: str,
original_filename: str | None,
result: dict,
usage_source: str,
api_key_id: int | None,
celery_task_id: str | None,
):
"""Persist task history and usage lifecycle events."""
record_task_history(user_id, tool, original_filename, result)
if user_id is None or not celery_task_id:
return
event_type = "completed" if result.get("status") == "completed" else "failed"
record_usage_event(
user_id=user_id,
source=usage_source,
tool=tool,
task_id=celery_task_id,
event_type=event_type,
api_key_id=api_key_id,
)

View File

@@ -2,15 +2,48 @@
import os
import logging
from flask import current_app
from app.extensions import celery
from app.services.compress_service import compress_pdf, PDFCompressionError
from app.services.storage_service import storage
from app.services.task_tracking_service import finalize_task_tracking
from app.utils.sanitizer import cleanup_task_files
def _cleanup(task_id: str):
cleanup_task_files(task_id, keep_outputs=not storage.use_s3)
def _get_output_dir(task_id: str) -> str:
"""Resolve output directory from app config."""
output_dir = os.path.join(current_app.config["OUTPUT_FOLDER"], task_id)
os.makedirs(output_dir, exist_ok=True)
return output_dir
def _finalize_task(
task_id: str,
user_id: int | None,
original_filename: str,
result: dict,
usage_source: str,
api_key_id: int | None,
celery_task_id: str | None,
):
"""Persist optional history and cleanup task files."""
finalize_task_tracking(
user_id=user_id,
tool="compress-pdf",
original_filename=original_filename,
result=result,
usage_source=usage_source,
api_key_id=api_key_id,
celery_task_id=celery_task_id,
)
_cleanup(task_id)
return result
logger = logging.getLogger(__name__)
@@ -21,6 +54,9 @@ def compress_pdf_task(
task_id: str,
original_filename: str,
quality: str = "medium",
user_id: int | None = None,
usage_source: str = "web",
api_key_id: int | None = None,
):
"""
Async task: Compress a PDF file.
@@ -34,8 +70,7 @@ def compress_pdf_task(
Returns:
dict with download_url, compression stats, and file info
"""
output_dir = os.path.join("/tmp/outputs", task_id)
os.makedirs(output_dir, exist_ok=True)
output_dir = _get_output_dir(task_id)
output_path = os.path.join(output_dir, f"{task_id}.pdf")
try:
@@ -69,20 +104,40 @@ def compress_pdf_task(
"reduction_percent": stats["reduction_percent"],
}
_cleanup(task_id)
logger.info(
f"Task {task_id}: PDF compression completed — "
f"{stats['reduction_percent']}% reduction"
)
return result
return _finalize_task(
task_id,
user_id,
original_filename,
result,
usage_source,
api_key_id,
self.request.id,
)
except PDFCompressionError as e:
logger.error(f"Task {task_id}: Compression error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": str(e)}
return _finalize_task(
task_id,
user_id,
original_filename,
{"status": "failed", "error": str(e)},
usage_source,
api_key_id,
self.request.id,
)
except Exception as e:
logger.error(f"Task {task_id}: Unexpected error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": "An unexpected error occurred."}
return _finalize_task(
task_id,
user_id,
original_filename,
{"status": "failed", "error": "An unexpected error occurred."},
usage_source,
api_key_id,
self.request.id,
)

View File

@@ -2,9 +2,12 @@
import os
import logging
from flask import current_app
from app.extensions import celery
from app.services.pdf_service import pdf_to_word, word_to_pdf, PDFConversionError
from app.services.storage_service import storage
from app.services.task_tracking_service import finalize_task_tracking
from app.utils.sanitizer import cleanup_task_files
@@ -12,11 +15,50 @@ def _cleanup(task_id: str):
"""Cleanup with local-aware flag."""
cleanup_task_files(task_id, keep_outputs=not storage.use_s3)
def _get_output_dir(task_id: str) -> str:
"""Resolve output directory from app config."""
output_dir = os.path.join(current_app.config["OUTPUT_FOLDER"], task_id)
os.makedirs(output_dir, exist_ok=True)
return output_dir
def _finalize_task(
task_id: str,
user_id: int | None,
tool: str,
original_filename: str,
result: dict,
usage_source: str,
api_key_id: int | None,
celery_task_id: str | None,
):
"""Persist optional history and cleanup task files."""
finalize_task_tracking(
user_id=user_id,
tool=tool,
original_filename=original_filename,
result=result,
usage_source=usage_source,
api_key_id=api_key_id,
celery_task_id=celery_task_id,
)
_cleanup(task_id)
return result
logger = logging.getLogger(__name__)
@celery.task(bind=True, name="app.tasks.convert_tasks.convert_pdf_to_word")
def convert_pdf_to_word(self, input_path: str, task_id: str, original_filename: str):
def convert_pdf_to_word(
self,
input_path: str,
task_id: str,
original_filename: str,
user_id: int | None = None,
usage_source: str = "web",
api_key_id: int | None = None,
):
"""
Async task: Convert PDF to Word document.
@@ -28,7 +70,7 @@ def convert_pdf_to_word(self, input_path: str, task_id: str, original_filename:
Returns:
dict with download_url and file info
"""
output_dir = os.path.join("/tmp/outputs", task_id)
output_dir = _get_output_dir(task_id)
try:
self.update_state(state="PROCESSING", meta={"step": "Converting PDF to Word..."})
@@ -58,24 +100,55 @@ def convert_pdf_to_word(self, input_path: str, task_id: str, original_filename:
}
# Cleanup local files
_cleanup(task_id)
logger.info(f"Task {task_id}: PDF→Word conversion completed")
return result
return _finalize_task(
task_id,
user_id,
"pdf-to-word",
original_filename,
result,
usage_source,
api_key_id,
self.request.id,
)
except PDFConversionError as e:
logger.error(f"Task {task_id}: Conversion error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": str(e)}
return _finalize_task(
task_id,
user_id,
"pdf-to-word",
original_filename,
{"status": "failed", "error": str(e)},
usage_source,
api_key_id,
self.request.id,
)
except Exception as e:
logger.error(f"Task {task_id}: Unexpected error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": "An unexpected error occurred."}
return _finalize_task(
task_id,
user_id,
"pdf-to-word",
original_filename,
{"status": "failed", "error": "An unexpected error occurred."},
usage_source,
api_key_id,
self.request.id,
)
@celery.task(bind=True, name="app.tasks.convert_tasks.convert_word_to_pdf")
def convert_word_to_pdf(self, input_path: str, task_id: str, original_filename: str):
def convert_word_to_pdf(
self,
input_path: str,
task_id: str,
original_filename: str,
user_id: int | None = None,
usage_source: str = "web",
api_key_id: int | None = None,
):
"""
Async task: Convert Word document to PDF.
@@ -87,7 +160,7 @@ def convert_word_to_pdf(self, input_path: str, task_id: str, original_filename:
Returns:
dict with download_url and file info
"""
output_dir = os.path.join("/tmp/outputs", task_id)
output_dir = _get_output_dir(task_id)
try:
self.update_state(state="PROCESSING", meta={"step": "Converting Word to PDF..."})
@@ -112,17 +185,40 @@ def convert_word_to_pdf(self, input_path: str, task_id: str, original_filename:
"output_size": os.path.getsize(output_path),
}
_cleanup(task_id)
logger.info(f"Task {task_id}: Word→PDF conversion completed")
return result
return _finalize_task(
task_id,
user_id,
"word-to-pdf",
original_filename,
result,
usage_source,
api_key_id,
self.request.id,
)
except PDFConversionError as e:
logger.error(f"Task {task_id}: Conversion error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": str(e)}
return _finalize_task(
task_id,
user_id,
"word-to-pdf",
original_filename,
{"status": "failed", "error": str(e)},
usage_source,
api_key_id,
self.request.id,
)
except Exception as e:
logger.error(f"Task {task_id}: Unexpected error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": "An unexpected error occurred."}
return _finalize_task(
task_id,
user_id,
"word-to-pdf",
original_filename,
{"status": "failed", "error": "An unexpected error occurred."},
usage_source,
api_key_id,
self.request.id,
)

View File

@@ -3,9 +3,12 @@ import os
import json
import logging
from flask import current_app
from app.extensions import celery
from app.services.flowchart_service import extract_and_generate, FlowchartError
from app.services.storage_service import storage
from app.services.task_tracking_service import finalize_task_tracking
from app.utils.sanitizer import cleanup_task_files
logger = logging.getLogger(__name__)
@@ -15,17 +18,132 @@ def _cleanup(task_id: str):
cleanup_task_files(task_id, keep_outputs=not storage.use_s3)
def _get_output_dir(task_id: str) -> str:
"""Resolve output directory from app config."""
output_dir = os.path.join(current_app.config["OUTPUT_FOLDER"], task_id)
os.makedirs(output_dir, exist_ok=True)
return output_dir
def _finalize_task(
task_id: str,
user_id: int | None,
tool: str,
original_filename: str | None,
result: dict,
usage_source: str,
api_key_id: int | None,
celery_task_id: str | None,
):
"""Persist optional history and cleanup task files."""
finalize_task_tracking(
user_id=user_id,
tool=tool,
original_filename=original_filename,
result=result,
usage_source=usage_source,
api_key_id=api_key_id,
celery_task_id=celery_task_id,
)
_cleanup(task_id)
return result
def _build_sample_result() -> dict:
"""Return deterministic sample flowchart data for demo mode."""
pages = [
{
"page": 1,
"text": (
"Employee Onboarding Procedure\n"
"1. Create employee profile in HR system.\n"
"2. Verify documents and eligibility.\n"
"3. Assign department and manager.\n"
"4. Send welcome package and access credentials.\n"
"5. Confirm first-day orientation schedule."
),
}
]
procedures = [
{
"id": "sample-proc-1",
"title": "Employee Onboarding Procedure",
"description": "Create profile, verify docs, assign team, and confirm orientation.",
"pages": [1],
"step_count": 5,
}
]
flowcharts = [
{
"id": "flow-sample-proc-1",
"procedureId": "sample-proc-1",
"title": "Employee Onboarding Procedure",
"steps": [
{
"id": "1",
"type": "start",
"title": "Begin: Employee Onboarding",
"description": "Start of onboarding process",
"connections": ["2"],
},
{
"id": "2",
"type": "process",
"title": "Create Employee Profile",
"description": "Register employee in HR system",
"connections": ["3"],
},
{
"id": "3",
"type": "decision",
"title": "Documents Verified?",
"description": "Check eligibility and required documents",
"connections": ["4"],
},
{
"id": "4",
"type": "process",
"title": "Assign Team and Access",
"description": "Assign manager, department, and credentials",
"connections": ["5"],
},
{
"id": "5",
"type": "end",
"title": "Onboarding Complete",
"description": "Employee is ready for orientation",
"connections": [],
},
],
}
]
return {
"procedures": procedures,
"flowcharts": flowcharts,
"pages": pages,
"total_pages": len(pages),
}
@celery.task(bind=True, name="app.tasks.flowchart_tasks.extract_flowchart_task")
def extract_flowchart_task(
self, input_path: str, task_id: str, original_filename: str
self,
input_path: str,
task_id: str,
original_filename: str,
user_id: int | None = None,
usage_source: str = "web",
api_key_id: int | None = None,
):
"""
Async task: Extract procedures from PDF and generate flowcharts.
Returns a JSON result containing procedures and their flowcharts.
"""
output_dir = os.path.join("/tmp/outputs", task_id)
os.makedirs(output_dir, exist_ok=True)
output_dir = _get_output_dir(task_id)
try:
self.update_state(
@@ -61,19 +179,87 @@ def extract_flowchart_task(
"procedures_count": len(result["procedures"]),
}
_cleanup(task_id)
logger.info(
f"Task {task_id}: Flowchart extraction completed — "
f"{len(result['procedures'])} procedures, "
f"{result['total_pages']} pages"
)
return final_result
return _finalize_task(
task_id,
user_id,
"pdf-flowchart",
original_filename,
final_result,
usage_source,
api_key_id,
self.request.id,
)
except FlowchartError as e:
logger.error(f"Task {task_id}: Flowchart error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": str(e)}
return _finalize_task(
task_id,
user_id,
"pdf-flowchart",
original_filename,
{"status": "failed", "error": str(e)},
usage_source,
api_key_id,
self.request.id,
)
except Exception as e:
logger.error(f"Task {task_id}: Unexpected error — {e}")
_cleanup(task_id)
return _finalize_task(
task_id,
user_id,
"pdf-flowchart",
original_filename,
{"status": "failed", "error": "An unexpected error occurred."},
usage_source,
api_key_id,
self.request.id,
)
@celery.task(bind=True, name="app.tasks.flowchart_tasks.extract_sample_flowchart_task")
def extract_sample_flowchart_task(
self,
user_id: int | None = None,
usage_source: str = "web",
api_key_id: int | None = None,
):
"""
Async task: Build a sample flowchart payload without requiring file upload.
"""
try:
self.update_state(
state="PROCESSING",
meta={"step": "Preparing sample flowchart..."},
)
result = _build_sample_result()
final_result = {
"status": "completed",
"filename": "sample_flowcharts.json",
"procedures": result["procedures"],
"flowcharts": result["flowcharts"],
"pages": result["pages"],
"total_pages": result["total_pages"],
"procedures_count": len(result["procedures"]),
}
finalize_task_tracking(
user_id=user_id,
tool="pdf-flowchart-sample",
original_filename="sample-document.pdf",
result=final_result,
usage_source=usage_source,
api_key_id=api_key_id,
celery_task_id=self.request.id,
)
logger.info("Sample flowchart task completed")
return final_result
except Exception as e:
logger.error(f"Sample flowchart task failed — {e}")
return {"status": "failed", "error": "An unexpected error occurred."}

View File

@@ -2,15 +2,49 @@
import os
import logging
from flask import current_app
from app.extensions import celery
from app.services.image_service import convert_image, resize_image, ImageProcessingError
from app.services.storage_service import storage
from app.services.task_tracking_service import finalize_task_tracking
from app.utils.sanitizer import cleanup_task_files
def _cleanup(task_id: str):
cleanup_task_files(task_id, keep_outputs=not storage.use_s3)
def _get_output_dir(task_id: str) -> str:
"""Resolve output directory from app config."""
output_dir = os.path.join(current_app.config["OUTPUT_FOLDER"], task_id)
os.makedirs(output_dir, exist_ok=True)
return output_dir
def _finalize_task(
task_id: str,
user_id: int | None,
tool: str,
original_filename: str,
result: dict,
usage_source: str,
api_key_id: int | None,
celery_task_id: str | None,
):
"""Persist optional history and cleanup task files."""
finalize_task_tracking(
user_id=user_id,
tool=tool,
original_filename=original_filename,
result=result,
usage_source=usage_source,
api_key_id=api_key_id,
celery_task_id=celery_task_id,
)
_cleanup(task_id)
return result
logger = logging.getLogger(__name__)
@@ -22,6 +56,9 @@ def convert_image_task(
original_filename: str,
output_format: str,
quality: int = 85,
user_id: int | None = None,
usage_source: str = "web",
api_key_id: int | None = None,
):
"""
Async task: Convert an image to a different format.
@@ -36,8 +73,7 @@ def convert_image_task(
Returns:
dict with download_url and conversion stats
"""
output_dir = os.path.join("/tmp/outputs", task_id)
os.makedirs(output_dir, exist_ok=True)
output_dir = _get_output_dir(task_id)
output_path = os.path.join(output_dir, f"{task_id}.{output_format}")
try:
@@ -70,20 +106,43 @@ def convert_image_task(
"format": stats["format"],
}
_cleanup(task_id)
logger.info(f"Task {task_id}: Image conversion to {output_format} completed")
return result
return _finalize_task(
task_id,
user_id,
"image-convert",
original_filename,
result,
usage_source,
api_key_id,
self.request.id,
)
except ImageProcessingError as e:
logger.error(f"Task {task_id}: Image error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": str(e)}
return _finalize_task(
task_id,
user_id,
"image-convert",
original_filename,
{"status": "failed", "error": str(e)},
usage_source,
api_key_id,
self.request.id,
)
except Exception as e:
logger.error(f"Task {task_id}: Unexpected error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": "An unexpected error occurred."}
return _finalize_task(
task_id,
user_id,
"image-convert",
original_filename,
{"status": "failed", "error": "An unexpected error occurred."},
usage_source,
api_key_id,
self.request.id,
)
@celery.task(bind=True, name="app.tasks.image_tasks.resize_image_task")
@@ -95,6 +154,9 @@ def resize_image_task(
width: int | None = None,
height: int | None = None,
quality: int = 85,
user_id: int | None = None,
usage_source: str = "web",
api_key_id: int | None = None,
):
"""
Async task: Resize an image.
@@ -111,8 +173,7 @@ def resize_image_task(
dict with download_url and resize info
"""
ext = os.path.splitext(original_filename)[1].lstrip(".")
output_dir = os.path.join("/tmp/outputs", task_id)
os.makedirs(output_dir, exist_ok=True)
output_dir = _get_output_dir(task_id)
output_path = os.path.join(output_dir, f"{task_id}.{ext}")
try:
@@ -144,17 +205,40 @@ def resize_image_task(
"new_height": stats["new_height"],
}
_cleanup(task_id)
logger.info(f"Task {task_id}: Image resize completed")
return result
return _finalize_task(
task_id,
user_id,
"image-resize",
original_filename,
result,
usage_source,
api_key_id,
self.request.id,
)
except ImageProcessingError as e:
logger.error(f"Task {task_id}: Image error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": str(e)}
return _finalize_task(
task_id,
user_id,
"image-resize",
original_filename,
{"status": "failed", "error": str(e)},
usage_source,
api_key_id,
self.request.id,
)
except Exception as e:
logger.error(f"Task {task_id}: Unexpected error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": "An unexpected error occurred."}
return _finalize_task(
task_id,
user_id,
"image-resize",
original_filename,
{"status": "failed", "error": "An unexpected error occurred."},
usage_source,
api_key_id,
self.request.id,
)

View File

@@ -2,6 +2,8 @@
import os
import logging
from flask import current_app
from app.extensions import celery
from app.services.pdf_tools_service import (
merge_pdfs,
@@ -16,6 +18,7 @@ from app.services.pdf_tools_service import (
PDFToolsError,
)
from app.services.storage_service import storage
from app.services.task_tracking_service import finalize_task_tracking
from app.utils.sanitizer import cleanup_task_files
@@ -23,6 +26,37 @@ def _cleanup(task_id: str):
cleanup_task_files(task_id, keep_outputs=not storage.use_s3)
def _get_output_dir(task_id: str) -> str:
"""Resolve output directory from app config."""
output_dir = os.path.join(current_app.config["OUTPUT_FOLDER"], task_id)
os.makedirs(output_dir, exist_ok=True)
return output_dir
def _finalize_task(
task_id: str,
user_id: int | None,
tool: str,
original_filename: str,
result: dict,
usage_source: str,
api_key_id: int | None,
celery_task_id: str | None,
):
"""Persist optional history and cleanup task files."""
finalize_task_tracking(
user_id=user_id,
tool=tool,
original_filename=original_filename,
result=result,
usage_source=usage_source,
api_key_id=api_key_id,
celery_task_id=celery_task_id,
)
_cleanup(task_id)
return result
logger = logging.getLogger(__name__)
@@ -31,11 +65,16 @@ logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
@celery.task(bind=True, name="app.tasks.pdf_tools_tasks.merge_pdfs_task")
def merge_pdfs_task(
self, input_paths: list[str], task_id: str, original_filenames: list[str]
self,
input_paths: list[str],
task_id: str,
original_filenames: list[str],
user_id: int | None = None,
usage_source: str = "web",
api_key_id: int | None = None,
):
"""Async task: Merge multiple PDFs into one."""
output_dir = os.path.join("/tmp/outputs", task_id)
os.makedirs(output_dir, exist_ok=True)
output_dir = _get_output_dir(task_id)
output_path = os.path.join(output_dir, f"{task_id}_merged.pdf")
try:
@@ -56,18 +95,42 @@ def merge_pdfs_task(
"output_size": stats["output_size"],
}
_cleanup(task_id)
logger.info(f"Task {task_id}: Merge completed — {stats['files_merged']} files, {stats['total_pages']} pages")
return result
return _finalize_task(
task_id,
user_id,
"merge-pdf",
", ".join(original_filenames),
result,
usage_source,
api_key_id,
self.request.id,
)
except PDFToolsError as e:
logger.error(f"Task {task_id}: Merge error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": str(e)}
return _finalize_task(
task_id,
user_id,
"merge-pdf",
", ".join(original_filenames),
{"status": "failed", "error": str(e)},
usage_source,
api_key_id,
self.request.id,
)
except Exception as e:
logger.error(f"Task {task_id}: Unexpected error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": "An unexpected error occurred."}
return _finalize_task(
task_id,
user_id,
"merge-pdf",
", ".join(original_filenames),
{"status": "failed", "error": "An unexpected error occurred."},
usage_source,
api_key_id,
self.request.id,
)
# ---------------------------------------------------------------------------
@@ -77,9 +140,12 @@ def merge_pdfs_task(
def split_pdf_task(
self, input_path: str, task_id: str, original_filename: str,
mode: str = "all", pages: str | None = None,
user_id: int | None = None,
usage_source: str = "web",
api_key_id: int | None = None,
):
"""Async task: Split a PDF into individual pages."""
output_dir = os.path.join("/tmp/outputs", task_id)
output_dir = _get_output_dir(task_id)
try:
self.update_state(state="PROCESSING", meta={"step": "Splitting PDF..."})
@@ -102,18 +168,42 @@ def split_pdf_task(
"output_size": stats["output_size"],
}
_cleanup(task_id)
logger.info(f"Task {task_id}: Split completed — {stats['extracted_pages']} pages extracted")
return result
return _finalize_task(
task_id,
user_id,
"split-pdf",
original_filename,
result,
usage_source,
api_key_id,
self.request.id,
)
except PDFToolsError as e:
logger.error(f"Task {task_id}: Split error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": str(e)}
return _finalize_task(
task_id,
user_id,
"split-pdf",
original_filename,
{"status": "failed", "error": str(e)},
usage_source,
api_key_id,
self.request.id,
)
except Exception as e:
logger.error(f"Task {task_id}: Unexpected error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": "An unexpected error occurred."}
return _finalize_task(
task_id,
user_id,
"split-pdf",
original_filename,
{"status": "failed", "error": "An unexpected error occurred."},
usage_source,
api_key_id,
self.request.id,
)
# ---------------------------------------------------------------------------
@@ -123,10 +213,12 @@ def split_pdf_task(
def rotate_pdf_task(
self, input_path: str, task_id: str, original_filename: str,
rotation: int = 90, pages: str = "all",
user_id: int | None = None,
usage_source: str = "web",
api_key_id: int | None = None,
):
"""Async task: Rotate pages in a PDF."""
output_dir = os.path.join("/tmp/outputs", task_id)
os.makedirs(output_dir, exist_ok=True)
output_dir = _get_output_dir(task_id)
output_path = os.path.join(output_dir, f"{task_id}_rotated.pdf")
try:
@@ -150,18 +242,42 @@ def rotate_pdf_task(
"output_size": stats["output_size"],
}
_cleanup(task_id)
logger.info(f"Task {task_id}: Rotate completed — {stats['rotated_pages']} pages")
return result
return _finalize_task(
task_id,
user_id,
"rotate-pdf",
original_filename,
result,
usage_source,
api_key_id,
self.request.id,
)
except PDFToolsError as e:
logger.error(f"Task {task_id}: Rotate error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": str(e)}
return _finalize_task(
task_id,
user_id,
"rotate-pdf",
original_filename,
{"status": "failed", "error": str(e)},
usage_source,
api_key_id,
self.request.id,
)
except Exception as e:
logger.error(f"Task {task_id}: Unexpected error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": "An unexpected error occurred."}
return _finalize_task(
task_id,
user_id,
"rotate-pdf",
original_filename,
{"status": "failed", "error": "An unexpected error occurred."},
usage_source,
api_key_id,
self.request.id,
)
# ---------------------------------------------------------------------------
@@ -171,10 +287,12 @@ def rotate_pdf_task(
def add_page_numbers_task(
self, input_path: str, task_id: str, original_filename: str,
position: str = "bottom-center", start_number: int = 1,
user_id: int | None = None,
usage_source: str = "web",
api_key_id: int | None = None,
):
"""Async task: Add page numbers to a PDF."""
output_dir = os.path.join("/tmp/outputs", task_id)
os.makedirs(output_dir, exist_ok=True)
output_dir = _get_output_dir(task_id)
output_path = os.path.join(output_dir, f"{task_id}_numbered.pdf")
try:
@@ -196,18 +314,42 @@ def add_page_numbers_task(
"output_size": stats["output_size"],
}
_cleanup(task_id)
logger.info(f"Task {task_id}: Page numbers added to {stats['total_pages']} pages")
return result
return _finalize_task(
task_id,
user_id,
"page-numbers",
original_filename,
result,
usage_source,
api_key_id,
self.request.id,
)
except PDFToolsError as e:
logger.error(f"Task {task_id}: Page numbers error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": str(e)}
return _finalize_task(
task_id,
user_id,
"page-numbers",
original_filename,
{"status": "failed", "error": str(e)},
usage_source,
api_key_id,
self.request.id,
)
except Exception as e:
logger.error(f"Task {task_id}: Unexpected error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": "An unexpected error occurred."}
return _finalize_task(
task_id,
user_id,
"page-numbers",
original_filename,
{"status": "failed", "error": "An unexpected error occurred."},
usage_source,
api_key_id,
self.request.id,
)
# ---------------------------------------------------------------------------
@@ -217,9 +359,12 @@ def add_page_numbers_task(
def pdf_to_images_task(
self, input_path: str, task_id: str, original_filename: str,
output_format: str = "png", dpi: int = 200,
user_id: int | None = None,
usage_source: str = "web",
api_key_id: int | None = None,
):
"""Async task: Convert PDF pages to images."""
output_dir = os.path.join("/tmp/outputs", task_id)
output_dir = _get_output_dir(task_id)
try:
self.update_state(state="PROCESSING", meta={"step": "Converting PDF to images..."})
@@ -243,18 +388,42 @@ def pdf_to_images_task(
"output_size": stats["output_size"],
}
_cleanup(task_id)
logger.info(f"Task {task_id}: PDF→Images completed — {stats['page_count']} pages")
return result
return _finalize_task(
task_id,
user_id,
"pdf-to-images",
original_filename,
result,
usage_source,
api_key_id,
self.request.id,
)
except PDFToolsError as e:
logger.error(f"Task {task_id}: PDF→Images error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": str(e)}
return _finalize_task(
task_id,
user_id,
"pdf-to-images",
original_filename,
{"status": "failed", "error": str(e)},
usage_source,
api_key_id,
self.request.id,
)
except Exception as e:
logger.error(f"Task {task_id}: Unexpected error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": "An unexpected error occurred."}
return _finalize_task(
task_id,
user_id,
"pdf-to-images",
original_filename,
{"status": "failed", "error": "An unexpected error occurred."},
usage_source,
api_key_id,
self.request.id,
)
# ---------------------------------------------------------------------------
@@ -262,11 +431,16 @@ def pdf_to_images_task(
# ---------------------------------------------------------------------------
@celery.task(bind=True, name="app.tasks.pdf_tools_tasks.images_to_pdf_task")
def images_to_pdf_task(
self, input_paths: list[str], task_id: str, original_filenames: list[str]
self,
input_paths: list[str],
task_id: str,
original_filenames: list[str],
user_id: int | None = None,
usage_source: str = "web",
api_key_id: int | None = None,
):
"""Async task: Combine images into a PDF."""
output_dir = os.path.join("/tmp/outputs", task_id)
os.makedirs(output_dir, exist_ok=True)
output_dir = _get_output_dir(task_id)
output_path = os.path.join(output_dir, f"{task_id}_images.pdf")
try:
@@ -286,18 +460,42 @@ def images_to_pdf_task(
"output_size": stats["output_size"],
}
_cleanup(task_id)
logger.info(f"Task {task_id}: Images→PDF completed — {stats['page_count']} pages")
return result
return _finalize_task(
task_id,
user_id,
"images-to-pdf",
", ".join(original_filenames),
result,
usage_source,
api_key_id,
self.request.id,
)
except PDFToolsError as e:
logger.error(f"Task {task_id}: Images→PDF error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": str(e)}
return _finalize_task(
task_id,
user_id,
"images-to-pdf",
", ".join(original_filenames),
{"status": "failed", "error": str(e)},
usage_source,
api_key_id,
self.request.id,
)
except Exception as e:
logger.error(f"Task {task_id}: Unexpected error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": "An unexpected error occurred."}
return _finalize_task(
task_id,
user_id,
"images-to-pdf",
", ".join(original_filenames),
{"status": "failed", "error": "An unexpected error occurred."},
usage_source,
api_key_id,
self.request.id,
)
# ---------------------------------------------------------------------------
@@ -307,10 +505,12 @@ def images_to_pdf_task(
def watermark_pdf_task(
self, input_path: str, task_id: str, original_filename: str,
watermark_text: str, opacity: float = 0.3,
user_id: int | None = None,
usage_source: str = "web",
api_key_id: int | None = None,
):
"""Async task: Add watermark to a PDF."""
output_dir = os.path.join("/tmp/outputs", task_id)
os.makedirs(output_dir, exist_ok=True)
output_dir = _get_output_dir(task_id)
output_path = os.path.join(output_dir, f"{task_id}_watermarked.pdf")
try:
@@ -332,18 +532,42 @@ def watermark_pdf_task(
"output_size": stats["output_size"],
}
_cleanup(task_id)
logger.info(f"Task {task_id}: Watermark added")
return result
return _finalize_task(
task_id,
user_id,
"watermark-pdf",
original_filename,
result,
usage_source,
api_key_id,
self.request.id,
)
except PDFToolsError as e:
logger.error(f"Task {task_id}: Watermark error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": str(e)}
return _finalize_task(
task_id,
user_id,
"watermark-pdf",
original_filename,
{"status": "failed", "error": str(e)},
usage_source,
api_key_id,
self.request.id,
)
except Exception as e:
logger.error(f"Task {task_id}: Unexpected error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": "An unexpected error occurred."}
return _finalize_task(
task_id,
user_id,
"watermark-pdf",
original_filename,
{"status": "failed", "error": "An unexpected error occurred."},
usage_source,
api_key_id,
self.request.id,
)
# ---------------------------------------------------------------------------
@@ -353,10 +577,12 @@ def watermark_pdf_task(
def protect_pdf_task(
self, input_path: str, task_id: str, original_filename: str,
password: str,
user_id: int | None = None,
usage_source: str = "web",
api_key_id: int | None = None,
):
"""Async task: Add password protection to a PDF."""
output_dir = os.path.join("/tmp/outputs", task_id)
os.makedirs(output_dir, exist_ok=True)
output_dir = _get_output_dir(task_id)
output_path = os.path.join(output_dir, f"{task_id}_protected.pdf")
try:
@@ -378,18 +604,42 @@ def protect_pdf_task(
"output_size": stats["output_size"],
}
_cleanup(task_id)
logger.info(f"Task {task_id}: PDF protected")
return result
return _finalize_task(
task_id,
user_id,
"protect-pdf",
original_filename,
result,
usage_source,
api_key_id,
self.request.id,
)
except PDFToolsError as e:
logger.error(f"Task {task_id}: Protect error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": str(e)}
return _finalize_task(
task_id,
user_id,
"protect-pdf",
original_filename,
{"status": "failed", "error": str(e)},
usage_source,
api_key_id,
self.request.id,
)
except Exception as e:
logger.error(f"Task {task_id}: Unexpected error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": "An unexpected error occurred."}
return _finalize_task(
task_id,
user_id,
"protect-pdf",
original_filename,
{"status": "failed", "error": "An unexpected error occurred."},
usage_source,
api_key_id,
self.request.id,
)
# ---------------------------------------------------------------------------
@@ -399,10 +649,12 @@ def protect_pdf_task(
def unlock_pdf_task(
self, input_path: str, task_id: str, original_filename: str,
password: str,
user_id: int | None = None,
usage_source: str = "web",
api_key_id: int | None = None,
):
"""Async task: Remove password from a PDF."""
output_dir = os.path.join("/tmp/outputs", task_id)
os.makedirs(output_dir, exist_ok=True)
output_dir = _get_output_dir(task_id)
output_path = os.path.join(output_dir, f"{task_id}_unlocked.pdf")
try:
@@ -424,15 +676,39 @@ def unlock_pdf_task(
"output_size": stats["output_size"],
}
_cleanup(task_id)
logger.info(f"Task {task_id}: PDF unlocked")
return result
return _finalize_task(
task_id,
user_id,
"unlock-pdf",
original_filename,
result,
usage_source,
api_key_id,
self.request.id,
)
except PDFToolsError as e:
logger.error(f"Task {task_id}: Unlock error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": str(e)}
return _finalize_task(
task_id,
user_id,
"unlock-pdf",
original_filename,
{"status": "failed", "error": str(e)},
usage_source,
api_key_id,
self.request.id,
)
except Exception as e:
logger.error(f"Task {task_id}: Unexpected error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": "An unexpected error occurred."}
return _finalize_task(
task_id,
user_id,
"unlock-pdf",
original_filename,
{"status": "failed", "error": "An unexpected error occurred."},
usage_source,
api_key_id,
self.request.id,
)

View File

@@ -2,15 +2,48 @@
import os
import logging
from flask import current_app
from app.extensions import celery
from app.services.video_service import video_to_gif, VideoProcessingError
from app.services.storage_service import storage
from app.services.task_tracking_service import finalize_task_tracking
from app.utils.sanitizer import cleanup_task_files
def _cleanup(task_id: str):
cleanup_task_files(task_id, keep_outputs=not storage.use_s3)
def _get_output_dir(task_id: str) -> str:
"""Resolve output directory from app config."""
output_dir = os.path.join(current_app.config["OUTPUT_FOLDER"], task_id)
os.makedirs(output_dir, exist_ok=True)
return output_dir
def _finalize_task(
task_id: str,
user_id: int | None,
original_filename: str,
result: dict,
usage_source: str,
api_key_id: int | None,
celery_task_id: str | None,
):
"""Persist optional history and cleanup task files."""
finalize_task_tracking(
user_id=user_id,
tool="video-to-gif",
original_filename=original_filename,
result=result,
usage_source=usage_source,
api_key_id=api_key_id,
celery_task_id=celery_task_id,
)
_cleanup(task_id)
return result
logger = logging.getLogger(__name__)
@@ -24,6 +57,9 @@ def create_gif_task(
duration: float = 5,
fps: int = 10,
width: int = 480,
user_id: int | None = None,
usage_source: str = "web",
api_key_id: int | None = None,
):
"""
Async task: Convert video clip to animated GIF.
@@ -40,8 +76,7 @@ def create_gif_task(
Returns:
dict with download_url and GIF info
"""
output_dir = os.path.join("/tmp/outputs", task_id)
os.makedirs(output_dir, exist_ok=True)
output_dir = _get_output_dir(task_id)
output_path = os.path.join(output_dir, f"{task_id}.gif")
try:
@@ -80,17 +115,37 @@ def create_gif_task(
"height": stats["height"],
}
_cleanup(task_id)
logger.info(f"Task {task_id}: Video→GIF creation completed")
return result
return _finalize_task(
task_id,
user_id,
original_filename,
result,
usage_source,
api_key_id,
self.request.id,
)
except VideoProcessingError as e:
logger.error(f"Task {task_id}: Video error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": str(e)}
return _finalize_task(
task_id,
user_id,
original_filename,
{"status": "failed", "error": str(e)},
usage_source,
api_key_id,
self.request.id,
)
except Exception as e:
logger.error(f"Task {task_id}: Unexpected error — {e}")
_cleanup(task_id)
return {"status": "failed", "error": "An unexpected error occurred."}
return _finalize_task(
task_id,
user_id,
original_filename,
{"status": "failed", "error": "An unexpected error occurred."},
usage_source,
api_key_id,
self.request.id,
)

20
backend/app/utils/auth.py Normal file
View File

@@ -0,0 +1,20 @@
"""Session helpers for authenticated routes."""
from flask import session
def get_current_user_id() -> int | None:
"""Return the authenticated user id from session storage."""
user_id = session.get("user_id")
return user_id if isinstance(user_id, int) else None
def login_user_session(user_id: int):
"""Persist the authenticated user in the Flask session."""
session.clear()
session.permanent = True
session["user_id"] = user_id
def logout_user_session():
"""Clear the active Flask session."""
session.clear()

View File

@@ -20,7 +20,11 @@ class FileValidationError(Exception):
super().__init__(self.message)
def validate_file(file_storage, allowed_types: list[str] | None = None):
def validate_file(
file_storage,
allowed_types: list[str] | None = None,
size_limit_overrides: dict[str, int] | None = None,
):
"""
Validate an uploaded file through multiple security layers.
@@ -65,7 +69,7 @@ def validate_file(file_storage, allowed_types: list[str] | None = None):
file_size = file_storage.tell()
file_storage.seek(0)
size_limits = config.get("FILE_SIZE_LIMITS", {})
size_limits = size_limit_overrides or config.get("FILE_SIZE_LIMITS", {})
max_size = size_limits.get(ext, 20 * 1024 * 1024) # Default 20MB
if file_size > max_size: