From 0ad2ba0f0202ab6b22df2a0701164faf90ee6e12 Mon Sep 17 00:00:00 2001 From: Your Name <119736744+aborayan2022@users.noreply.github.com> Date: Sat, 7 Mar 2026 11:14:05 +0200 Subject: [PATCH] =?UTF-8?q?=D9=85=D9=8A=D8=B2=D8=A9:=20=D8=AA=D8=AD=D8=AF?= =?UTF-8?q?=D9=8A=D8=AB=20=D8=B5=D9=81=D8=AD=D8=A7=D8=AA=20=D8=A7=D9=84?= =?UTF-8?q?=D8=AE=D8=B5=D9=88=D8=B5=D9=8A=D8=A9=20=D9=88=D8=A7=D9=84=D8=B4?= =?UTF-8?q?=D8=B1=D9=88=D8=B7=20=D9=85=D8=B9=20=D8=AA=D8=A7=D8=B1=D9=8A?= =?UTF-8?q?=D8=AE=20=D8=A2=D8=AE=D8=B1=20=D8=AA=D8=AD=D8=AF=D9=8A=D8=AB=20?= =?UTF-8?q?=D8=AB=D8=A7=D8=A8=D8=AA=20=D9=88=D9=81=D8=AA=D8=B1=D8=A9=20?= =?UTF-8?q?=D8=A7=D8=AD=D8=AA=D9=81=D8=A7=D8=B8=20=D8=AF=D9=8A=D9=86=D8=A7?= =?UTF-8?q?=D9=85=D9=8A=D9=83=D9=8A=D8=A9=20=D8=A8=D8=A7=D9=84=D9=85=D9=84?= =?UTF-8?q?=D9=81=D8=A7=D8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ميزة: إضافة خدمة تحليلات لتكامل Google Analytics اختبار: تحديث اختبارات خدمة واجهة برمجة التطبيقات (API) لتعكس تغييرات نقاط النهاية إصلاح: تعديل خدمة واجهة برمجة التطبيقات (API) لدعم تحميل ملفات متعددة ومصادقة المستخدم ميزة: تطبيق مخزن مصادقة باستخدام Zustand لإدارة المستخدمين إصلاح: تحسين إعدادات Nginx لتعزيز الأمان ودعم التحليلات --- .env.example | 10 +- .gitignore | 1 + README.md | 47 +- backend/Dockerfile | 4 +- backend/app/__init__.py | 39 +- backend/app/extensions.py | 1 + backend/app/routes/account.py | 89 +++ backend/app/routes/admin.py | 39 + backend/app/routes/auth.py | 100 +++ backend/app/routes/compress.py | 30 +- backend/app/routes/convert.py | 46 +- backend/app/routes/flowchart.py | 54 +- backend/app/routes/history.py | 32 + backend/app/routes/image.py | 49 +- backend/app/routes/pdf_tools.py | 168 ++++- backend/app/routes/v1/__init__.py | 1 + backend/app/routes/v1/tools.py | 682 ++++++++++++++++++ backend/app/routes/video.py | 24 +- backend/app/services/account_service.py | 517 +++++++++++++ backend/app/services/policy_service.py | 227 ++++++ backend/app/services/task_tracking_service.py | 29 + backend/app/tasks/compress_tasks.py | 73 +- backend/app/tasks/convert_tasks.py | 132 +++- backend/app/tasks/flowchart_tasks.py | 202 +++++- backend/app/tasks/image_tasks.py | 120 ++- backend/app/tasks/pdf_tools_tasks.py | 420 +++++++++-- backend/app/tasks/video_tasks.py | 73 +- backend/app/utils/auth.py | 20 + backend/app/utils/file_validator.py | 8 +- backend/celery_worker.py | 2 + backend/config/__init__.py | 17 +- backend/tests/conftest.py | 24 +- backend/tests/test_auth.py | 67 ++ backend/tests/test_compress_tasks.py | 8 +- backend/tests/test_convert_tasks.py | 8 +- backend/tests/test_flowchart_tasks.py | 54 ++ backend/tests/test_history.py | 68 ++ backend/tests/test_image_tasks.py | 16 +- backend/tests/test_load.py | 8 +- backend/tests/test_pdf_tools.py | 48 +- backend/tests/test_pdf_tools_tasks.py | 28 +- backend/tests/test_video.py | 24 +- backend/tests/test_video_tasks.py | 8 +- docker-compose.prod.yml | 14 +- docker-compose.yml | 8 +- frontend/.env.example | 6 + frontend/index.html | 4 +- frontend/src/App.tsx | 19 +- frontend/src/components/layout/AdSlot.tsx | 40 +- frontend/src/components/layout/Header.tsx | 26 +- .../src/components/shared/DownloadButton.tsx | 4 + .../src/components/shared/HeroUploadZone.tsx | 5 +- frontend/src/components/tools/ImagesToPdf.tsx | 16 +- frontend/src/components/tools/MergePdf.tsx | 17 +- frontend/src/components/tools/PdfEditor.tsx | 45 +- .../src/components/tools/PdfFlowchart.tsx | 22 +- .../tools/pdf-flowchart/FlowChat.tsx | 9 +- frontend/src/config/toolLimits.ts | 9 + frontend/src/hooks/useFileUpload.ts | 29 +- frontend/src/hooks/useTaskPolling.ts | 5 + frontend/src/i18n/ar.json | 84 ++- frontend/src/i18n/en.json | 84 ++- frontend/src/i18n/fr.json | 84 ++- frontend/src/pages/AboutPage.tsx | 18 +- frontend/src/pages/AccountPage.tsx | 643 +++++++++++++++++ frontend/src/pages/PrivacyPage.tsx | 7 +- frontend/src/pages/TermsPage.tsx | 7 +- frontend/src/services/analytics.ts | 67 ++ frontend/src/services/api.test.ts | 21 +- frontend/src/services/api.ts | 173 +++++ frontend/src/stores/authStore.ts | 71 ++ nginx/nginx.conf | 2 +- nginx/nginx.prod.conf | 2 +- 73 files changed, 4696 insertions(+), 462 deletions(-) create mode 100644 backend/app/routes/account.py create mode 100644 backend/app/routes/admin.py create mode 100644 backend/app/routes/auth.py create mode 100644 backend/app/routes/history.py create mode 100644 backend/app/routes/v1/__init__.py create mode 100644 backend/app/routes/v1/tools.py create mode 100644 backend/app/services/account_service.py create mode 100644 backend/app/services/policy_service.py create mode 100644 backend/app/services/task_tracking_service.py create mode 100644 backend/app/utils/auth.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_flowchart_tasks.py create mode 100644 backend/tests/test_history.py create mode 100644 frontend/.env.example create mode 100644 frontend/src/config/toolLimits.ts create mode 100644 frontend/src/pages/AccountPage.tsx create mode 100644 frontend/src/services/analytics.ts create mode 100644 frontend/src/stores/authStore.ts diff --git a/.env.example b/.env.example index d1c403f..59c2a5d 100644 --- a/.env.example +++ b/.env.example @@ -21,9 +21,15 @@ MAX_CONTENT_LENGTH_MB=50 UPLOAD_FOLDER=/tmp/uploads OUTPUT_FOLDER=/tmp/outputs FILE_EXPIRY_SECONDS=1800 +DATABASE_PATH=/app/data/saas_pdf.db # CORS CORS_ORIGINS=http://localhost:5173,http://localhost:3000 -# AdSense -ADSENSE_CLIENT_ID=ca-pub-XXXXXXXXXXXXXXXX +# Frontend Analytics / Ads (Vite) +VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX +VITE_ADSENSE_CLIENT_ID=ca-pub-XXXXXXXXXXXXXXXX +VITE_ADSENSE_SLOT_HOME_TOP=1234567890 +VITE_ADSENSE_SLOT_HOME_BOTTOM=1234567891 +VITE_ADSENSE_SLOT_TOP_BANNER=1234567892 +VITE_ADSENSE_SLOT_BOTTOM_BANNER=1234567893 diff --git a/.gitignore b/.gitignore index ec3b6d8..a2424aa 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ docker-compose.override.yml uploads/ tmp/ *.tmp +backend/data/*.db # Logs *.log diff --git a/README.md b/README.md index f9c4044..692f937 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,18 @@ A free SaaS platform offering PDF, image, video, and text processing tools. Built with **Python Flask** (backend) and **React + Vite** (frontend), powered by **Celery + Redis** for async processing, and deployed on **AWS**. -## 🛠 Tools (MVP) +## 🛠 Tools (Current) -1. **PDF to Word / Word to PDF** — Convert between PDF and Word documents -2. **PDF Compressor** — Reduce PDF file size with quality options -3. **Image Converter** — Convert between JPG, PNG, WebP formats -4. **Video to GIF** — Create animated GIFs from video clips -5. **Text Tools** — Word counter, text cleaner, case converter (client-side) +1. **PDF Conversion** — PDF↔Word +2. **PDF Optimization** — Compress PDF +3. **PDF Utilities** — Merge, split, rotate, page numbers, watermark +4. **PDF Security** — Protect and unlock PDF +5. **PDF/Image Tools** — PDF→Images, Images→PDF +6. **Image Tools** — Convert and resize images +7. **Video Tools** — Video→GIF +8. **Text Tools** — Word counter and text cleaner +9. **Flowchart Tools** — Extract procedures from PDF and generate flowcharts (+ sample mode) +10. **Accounts & History** — Email/password sign-in with recent generated-file history ## 🏗 Tech Stack @@ -19,7 +24,7 @@ A free SaaS platform offering PDF, image, video, and text processing tools. Buil | File Processing | LibreOffice, Ghostscript, Pillow, ffmpeg | | Frontend | React 18 + Vite 5 + TypeScript | | Styling | Tailwind CSS (RTL support) | -| i18n | react-i18next (Arabic + English) | +| i18n | react-i18next (Arabic + English + French) | | Storage | AWS S3 (temp files with auto-cleanup) | | CDN | AWS CloudFront | | Server | AWS EC2 + Nginx | @@ -33,6 +38,7 @@ cd SaaS-PDF # 2. Copy environment file cp .env.example .env +cp frontend/.env.example frontend/.env # 3. Start all services with Docker docker-compose up --build @@ -43,6 +49,31 @@ docker-compose up --build # Celery Flower: http://localhost:5555 ``` +## ⚙️ Runtime Limits (Default) + +- File retention: **30 minutes** (`FILE_EXPIRY_SECONDS=1800`) +- PDF max size: **20MB** +- Word max size: **15MB** +- Image max size: **10MB** +- Video max size: **50MB** + +## 🔐 Accounts & Sessions + +- Session-backed authentication via `/api/auth/*` +- Free account creation with email + password +- Recent generated-file history via `/api/history` +- Persistent SQLite storage at `DATABASE_PATH` (defaults to `backend/data/saas_pdf.db` locally) + +## 📈 Analytics & Ads Env + +- `VITE_GA_MEASUREMENT_ID` +- `VITE_ADSENSE_CLIENT_ID` +- `VITE_ADSENSE_SLOT_HOME_TOP` +- `VITE_ADSENSE_SLOT_HOME_BOTTOM` +- `VITE_ADSENSE_SLOT_TOP_BANNER` +- `VITE_ADSENSE_SLOT_BOTTOM_BANNER` +- `DATABASE_PATH` + ## 📁 Project Structure ``` @@ -59,7 +90,7 @@ SaaS-PDF/ ## 💰 Revenue Model - **Google AdSense** — Ads on result/download pages -- **Freemium** (planned) — Pro features: no ads, higher limits, API access +- **Freemium** (next phase) — Pro features: no ads, higher limits, API access ## 📄 License diff --git a/backend/Dockerfile b/backend/Dockerfile index 5806966..2d20964 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -28,8 +28,8 @@ RUN pip install --no-cache-dir -r requirements.txt \ # Copy application code COPY . . -# Create temp directories -RUN mkdir -p /tmp/uploads /tmp/outputs +# Create temp and persistence directories +RUN mkdir -p /tmp/uploads /tmp/outputs /app/data # Expose port EXPOSE 5000 diff --git a/backend/app/__init__.py b/backend/app/__init__.py index ef05379..c7fc813 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -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 diff --git a/backend/app/extensions.py b/backend/app/extensions.py index 42446b3..a2dfe7c 100644 --- a/backend/app/extensions.py +++ b/backend/app/extensions.py @@ -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): diff --git a/backend/app/routes/account.py b/backend/app/routes/account.py new file mode 100644 index 0000000..1237c40 --- /dev/null +++ b/backend/app/routes/account.py @@ -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/", 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 diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py new file mode 100644 index 0000000..05f1ff1 --- /dev/null +++ b/backend/app/routes/admin.py @@ -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//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 diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py new file mode 100644 index 0000000..5e7dabb --- /dev/null +++ b/backend/app/routes/auth.py @@ -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 diff --git a/backend/app/routes/compress.py b/backend/app/routes/compress.py index 0ac3785..3020103 100644 --- a/backend/app/routes/compress.py +++ b/backend/app/routes/compress.py @@ -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, diff --git a/backend/app/routes/convert.py b/backend/app/routes/convert.py index 33620bc..2d55c8e 100644 --- a/backend/app/routes/convert.py +++ b/backend/app/routes/convert.py @@ -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, diff --git a/backend/app/routes/flowchart.py b/backend/app/routes/flowchart.py index 5d7886d..735c252 100644 --- a/backend/app/routes/flowchart.py +++ b/backend/app/routes/flowchart.py @@ -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(): diff --git a/backend/app/routes/history.py b/backend/app/routes/history.py new file mode 100644 index 0000000..68eb43b --- /dev/null +++ b/backend/app/routes/history.py @@ -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 diff --git a/backend/app/routes/image.py b/backend/app/routes/image.py index 7608cfc..4fc158f 100644 --- a/backend/app/routes/image.py +++ b/backend/app/routes/image.py @@ -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, diff --git a/backend/app/routes/pdf_tools.py b/backend/app/routes/pdf_tools.py index 308e47d..87d4acf 100644 --- a/backend/app/routes/pdf_tools.py +++ b/backend/app/routes/pdf_tools.py @@ -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, diff --git a/backend/app/routes/v1/__init__.py b/backend/app/routes/v1/__init__.py new file mode 100644 index 0000000..3101536 --- /dev/null +++ b/backend/app/routes/v1/__init__.py @@ -0,0 +1 @@ +"""B2B API v1 blueprint package.""" diff --git a/backend/app/routes/v1/tools.py b/backend/app/routes/v1/tools.py new file mode 100644 index 0000000..80d602f --- /dev/null +++ b/backend/app/routes/v1/tools.py @@ -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//status +# --------------------------------------------------------------------------- + +@v1_bp.route("/tasks//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 diff --git a/backend/app/routes/video.py b/backend/app/routes/video.py index 009340c..0c6275f 100644 --- a/backend/app/routes/video.py +++ b/backend/app/routes/video.py @@ -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, diff --git a/backend/app/services/account_service.py b/backend/app/services/account_service.py new file mode 100644 index 0000000..2a99cac --- /dev/null +++ b/backend/app/services/account_service.py @@ -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 diff --git a/backend/app/services/policy_service.py b/backend/app/services/policy_service.py new file mode 100644 index 0000000..09e2833 --- /dev/null +++ b/backend/app/services/policy_service.py @@ -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) diff --git a/backend/app/services/task_tracking_service.py b/backend/app/services/task_tracking_service.py new file mode 100644 index 0000000..ce86c9c --- /dev/null +++ b/backend/app/services/task_tracking_service.py @@ -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, + ) diff --git a/backend/app/tasks/compress_tasks.py b/backend/app/tasks/compress_tasks.py index 20cc9f5..1ab1ec1 100644 --- a/backend/app/tasks/compress_tasks.py +++ b/backend/app/tasks/compress_tasks.py @@ -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, + ) diff --git a/backend/app/tasks/convert_tasks.py b/backend/app/tasks/convert_tasks.py index caf17d1..9d19c38 100644 --- a/backend/app/tasks/convert_tasks.py +++ b/backend/app/tasks/convert_tasks.py @@ -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, + ) diff --git a/backend/app/tasks/flowchart_tasks.py b/backend/app/tasks/flowchart_tasks.py index 04a3fdd..2d392b9 100644 --- a/backend/app/tasks/flowchart_tasks.py +++ b/backend/app/tasks/flowchart_tasks.py @@ -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."} diff --git a/backend/app/tasks/image_tasks.py b/backend/app/tasks/image_tasks.py index 09a0e58..661bab9 100644 --- a/backend/app/tasks/image_tasks.py +++ b/backend/app/tasks/image_tasks.py @@ -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, + ) diff --git a/backend/app/tasks/pdf_tools_tasks.py b/backend/app/tasks/pdf_tools_tasks.py index ff55c61..781a7e8 100644 --- a/backend/app/tasks/pdf_tools_tasks.py +++ b/backend/app/tasks/pdf_tools_tasks.py @@ -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, + ) diff --git a/backend/app/tasks/video_tasks.py b/backend/app/tasks/video_tasks.py index 61fc47c..4da84e5 100644 --- a/backend/app/tasks/video_tasks.py +++ b/backend/app/tasks/video_tasks.py @@ -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, + ) diff --git a/backend/app/utils/auth.py b/backend/app/utils/auth.py new file mode 100644 index 0000000..d116ef9 --- /dev/null +++ b/backend/app/utils/auth.py @@ -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() diff --git a/backend/app/utils/file_validator.py b/backend/app/utils/file_validator.py index 389fdc8..3dd1fa7 100644 --- a/backend/app/utils/file_validator.py +++ b/backend/app/utils/file_validator.py @@ -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: diff --git a/backend/celery_worker.py b/backend/celery_worker.py index 7af6ccf..1a528d9 100644 --- a/backend/celery_worker.py +++ b/backend/celery_worker.py @@ -9,3 +9,5 @@ import app.tasks.convert_tasks # noqa: F401 import app.tasks.compress_tasks # noqa: F401 import app.tasks.image_tasks # noqa: F401 import app.tasks.video_tasks # noqa: F401 +import app.tasks.pdf_tools_tasks # noqa: F401 +import app.tasks.flowchart_tasks # noqa: F401 diff --git a/backend/config/__init__.py b/backend/config/__init__.py index 4518df8..6860a1e 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -1,18 +1,31 @@ import os +from datetime import timedelta from dotenv import load_dotenv load_dotenv() +BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + class BaseConfig: """Base configuration.""" SECRET_KEY = os.getenv("SECRET_KEY", "change-me-in-production") + INTERNAL_ADMIN_SECRET = os.getenv("INTERNAL_ADMIN_SECRET", "") # File upload settings - MAX_CONTENT_LENGTH = int(os.getenv("MAX_CONTENT_LENGTH_MB", 50)) * 1024 * 1024 + MAX_CONTENT_LENGTH = int( + os.getenv("ABSOLUTE_MAX_CONTENT_LENGTH_MB", 100) + ) * 1024 * 1024 UPLOAD_FOLDER = os.getenv("UPLOAD_FOLDER", "/tmp/uploads") OUTPUT_FOLDER = os.getenv("OUTPUT_FOLDER", "/tmp/outputs") FILE_EXPIRY_SECONDS = int(os.getenv("FILE_EXPIRY_SECONDS", 1800)) + DATABASE_PATH = os.getenv( + "DATABASE_PATH", os.path.join(BASE_DIR, "data", "saas_pdf.db") + ) + PERMANENT_SESSION_LIFETIME = timedelta(days=30) + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = "Lax" + SESSION_COOKIE_SECURE = False # Allowed file extensions and MIME types ALLOWED_EXTENSIONS = { @@ -84,6 +97,7 @@ class ProductionConfig(BaseConfig): """Production configuration.""" DEBUG = False TESTING = False + SESSION_COOKIE_SECURE = True # Stricter rate limits in production RATELIMIT_DEFAULT = "60/hour" @@ -94,6 +108,7 @@ class TestingConfig(BaseConfig): TESTING = True UPLOAD_FOLDER = "/tmp/test_uploads" OUTPUT_FOLDER = "/tmp/test_outputs" + DATABASE_PATH = "/tmp/test_saas_pdf.db" # Disable Redis-backed rate limiting; use in-memory instead RATELIMIT_STORAGE_URI = "memory://" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 62279d9..edc4ff6 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,21 +1,35 @@ import io import os import shutil +import tempfile import pytest from unittest.mock import patch, MagicMock from app import create_app +from app.services.account_service import init_account_db @pytest.fixture def app(): """Create application for testing.""" os.environ['FLASK_ENV'] = 'testing' + test_root = tempfile.mkdtemp(prefix='saas-pdf-tests-') + db_path = os.path.join(test_root, 'test_saas_pdf.db') + upload_folder = os.path.join(test_root, 'uploads') + output_folder = os.path.join(test_root, 'outputs') + os.environ['DATABASE_PATH'] = db_path + os.environ['UPLOAD_FOLDER'] = upload_folder + os.environ['OUTPUT_FOLDER'] = output_folder + app = create_app('testing') app.config.update({ 'TESTING': True, - 'UPLOAD_FOLDER': '/tmp/test_uploads', - 'OUTPUT_FOLDER': '/tmp/test_outputs', + 'UPLOAD_FOLDER': upload_folder, + 'OUTPUT_FOLDER': output_folder, + 'DATABASE_PATH': db_path, }) + with app.app_context(): + init_account_db() + # Create temp directories os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) os.makedirs(app.config['OUTPUT_FOLDER'], exist_ok=True) @@ -23,8 +37,10 @@ def app(): yield app # Cleanup temp directories - shutil.rmtree(app.config['UPLOAD_FOLDER'], ignore_errors=True) - shutil.rmtree(app.config['OUTPUT_FOLDER'], ignore_errors=True) + shutil.rmtree(test_root, ignore_errors=True) + os.environ.pop('DATABASE_PATH', None) + os.environ.pop('UPLOAD_FOLDER', None) + os.environ.pop('OUTPUT_FOLDER', None) @pytest.fixture diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..f00c324 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,67 @@ +"""Tests for session-backed authentication routes.""" + + +class TestAuthRoutes: + def test_register_success(self, client): + response = client.post( + '/api/auth/register', + json={'email': 'user@example.com', 'password': 'secretpass123'}, + ) + + assert response.status_code == 201 + data = response.get_json() + assert data['user']['email'] == 'user@example.com' + assert data['user']['plan'] == 'free' + + def test_register_duplicate_email(self, client): + client.post( + '/api/auth/register', + json={'email': 'user@example.com', 'password': 'secretpass123'}, + ) + response = client.post( + '/api/auth/register', + json={'email': 'user@example.com', 'password': 'secretpass123'}, + ) + + assert response.status_code == 409 + assert 'already exists' in response.get_json()['error'].lower() + + def test_login_and_me(self, client): + client.post( + '/api/auth/register', + json={'email': 'user@example.com', 'password': 'secretpass123'}, + ) + client.post('/api/auth/logout') + + login_response = client.post( + '/api/auth/login', + json={'email': 'user@example.com', 'password': 'secretpass123'}, + ) + me_response = client.get('/api/auth/me') + + assert login_response.status_code == 200 + assert me_response.status_code == 200 + me_data = me_response.get_json() + assert me_data['authenticated'] is True + assert me_data['user']['email'] == 'user@example.com' + + def test_login_invalid_password(self, client): + client.post( + '/api/auth/register', + json={'email': 'user@example.com', 'password': 'secretpass123'}, + ) + client.post('/api/auth/logout') + + response = client.post( + '/api/auth/login', + json={'email': 'user@example.com', 'password': 'wrongpass123'}, + ) + + assert response.status_code == 401 + assert 'invalid email or password' in response.get_json()['error'].lower() + + def test_me_without_session(self, client): + response = client.get('/api/auth/me') + + assert response.status_code == 200 + assert response.get_json() == {'authenticated': False, 'user': None} diff --git a/backend/tests/test_compress_tasks.py b/backend/tests/test_compress_tasks.py index 283bb5e..60446d3 100644 --- a/backend/tests/test_compress_tasks.py +++ b/backend/tests/test_compress_tasks.py @@ -16,8 +16,8 @@ class TestCompressTaskRoute: mock_task.id = 'compress-task-id' monkeypatch.setattr( - 'app.routes.compress.validate_file', - lambda f, allowed_types: ('test.pdf', 'pdf'), + 'app.routes.compress.validate_actor_file', + lambda f, allowed_types, actor: ('test.pdf', 'pdf'), ) monkeypatch.setattr( 'app.routes.compress.generate_safe_path', @@ -47,8 +47,8 @@ class TestCompressTaskRoute: mock_delay = MagicMock(return_value=mock_task) monkeypatch.setattr( - 'app.routes.compress.validate_file', - lambda f, allowed_types: ('test.pdf', 'pdf'), + 'app.routes.compress.validate_actor_file', + lambda f, allowed_types, actor: ('test.pdf', 'pdf'), ) monkeypatch.setattr( 'app.routes.compress.generate_safe_path', diff --git a/backend/tests/test_convert_tasks.py b/backend/tests/test_convert_tasks.py index a7c7c45..a2c66c3 100644 --- a/backend/tests/test_convert_tasks.py +++ b/backend/tests/test_convert_tasks.py @@ -10,8 +10,8 @@ class TestConvertTaskRoutes: mock_task.id = 'convert-pdf-word-id' monkeypatch.setattr( - 'app.routes.convert.validate_file', - lambda f, allowed_types: ('document.pdf', 'pdf'), + 'app.routes.convert.validate_actor_file', + lambda f, allowed_types, actor: ('document.pdf', 'pdf'), ) monkeypatch.setattr( 'app.routes.convert.generate_safe_path', @@ -39,8 +39,8 @@ class TestConvertTaskRoutes: mock_task.id = 'convert-word-pdf-id' monkeypatch.setattr( - 'app.routes.convert.validate_file', - lambda f, allowed_types: ('report.docx', 'docx'), + 'app.routes.convert.validate_actor_file', + lambda f, allowed_types, actor: ('report.docx', 'docx'), ) monkeypatch.setattr( 'app.routes.convert.generate_safe_path', diff --git a/backend/tests/test_flowchart_tasks.py b/backend/tests/test_flowchart_tasks.py new file mode 100644 index 0000000..0e0e7a8 --- /dev/null +++ b/backend/tests/test_flowchart_tasks.py @@ -0,0 +1,54 @@ +"""Tests for flowchart task routes.""" +import io +from unittest.mock import MagicMock + + +class TestFlowchartTaskRoutes: + def test_extract_flowchart_dispatches_task(self, client, monkeypatch): + """Should dispatch extraction task for uploaded PDF.""" + mock_task = MagicMock() + mock_task.id = "flow-task-id" + mock_delay = MagicMock(return_value=mock_task) + + monkeypatch.setattr( + "app.routes.flowchart.validate_actor_file", + lambda f, allowed_types, actor: ("manual.pdf", "pdf"), + ) + monkeypatch.setattr( + "app.routes.flowchart.generate_safe_path", + lambda ext: ("flow-task-id", "/tmp/test.pdf"), + ) + monkeypatch.setattr( + "app.routes.flowchart.extract_flowchart_task.delay", + mock_delay, + ) + + response = client.post( + "/api/flowchart/extract", + data={"file": (io.BytesIO(b"%PDF-1.4"), "manual.pdf")}, + content_type="multipart/form-data", + ) + + assert response.status_code == 202 + body = response.get_json() + assert body["task_id"] == "flow-task-id" + + args = mock_delay.call_args[0] + assert args[0] == "/tmp/test.pdf" + assert args[1] == "flow-task-id" + assert args[2] == "manual.pdf" + + def test_extract_sample_dispatches_task(self, client, monkeypatch): + """Should dispatch sample extraction task without file upload.""" + mock_task = MagicMock() + mock_task.id = "sample-flow-task-id" + + monkeypatch.setattr( + "app.routes.flowchart.extract_sample_flowchart_task.delay", + MagicMock(return_value=mock_task), + ) + + response = client.post("/api/flowchart/extract-sample") + assert response.status_code == 202 + body = response.get_json() + assert body["task_id"] == "sample-flow-task-id" diff --git a/backend/tests/test_history.py b/backend/tests/test_history.py new file mode 100644 index 0000000..0186b1b --- /dev/null +++ b/backend/tests/test_history.py @@ -0,0 +1,68 @@ +"""Tests for authenticated file history routes.""" +from app.services.account_service import record_file_history + + +class TestHistoryRoutes: + def test_history_requires_auth(self, client): + response = client.get('/api/history') + + assert response.status_code == 401 + assert 'authentication required' in response.get_json()['error'].lower() + + def test_history_returns_items(self, client, app): + register_response = client.post( + '/api/auth/register', + json={'email': 'user@example.com', 'password': 'secretpass123'}, + ) + user_id = register_response.get_json()['user']['id'] + + with app.app_context(): + record_file_history( + user_id=user_id, + tool='pdf-to-word', + original_filename='report.pdf', + output_filename='report.docx', + status='completed', + download_url='/api/download/123/report.docx', + metadata={'output_size': 2048}, + ) + + response = client.get('/api/history?limit=10') + + assert response.status_code == 200 + data = response.get_json() + assert len(data['items']) == 1 + assert data['items'][0]['tool'] == 'pdf-to-word' + assert data['items'][0]['output_filename'] == 'report.docx' + + def test_history_limit_is_applied(self, client, app): + register_response = client.post( + '/api/auth/register', + json={'email': 'user@example.com', 'password': 'secretpass123'}, + ) + user_id = register_response.get_json()['user']['id'] + + with app.app_context(): + record_file_history( + user_id=user_id, + tool='pdf-to-word', + original_filename='first.pdf', + output_filename='first.docx', + status='completed', + download_url='/api/download/1/first.docx', + metadata=None, + ) + record_file_history( + user_id=user_id, + tool='word-to-pdf', + original_filename='second.docx', + output_filename='second.pdf', + status='completed', + download_url='/api/download/2/second.pdf', + metadata=None, + ) + + response = client.get('/api/history?limit=1') + + assert response.status_code == 200 + assert len(response.get_json()['items']) == 1 diff --git a/backend/tests/test_image_tasks.py b/backend/tests/test_image_tasks.py index 70caa2f..5db6c31 100644 --- a/backend/tests/test_image_tasks.py +++ b/backend/tests/test_image_tasks.py @@ -10,8 +10,8 @@ class TestImageTaskRoutes: mock_task.id = 'img-convert-id' monkeypatch.setattr( - 'app.routes.image.validate_file', - lambda f, allowed_types: ('photo.png', 'png'), + 'app.routes.image.validate_actor_file', + lambda f, allowed_types, actor: ('photo.png', 'png'), ) monkeypatch.setattr( 'app.routes.image.generate_safe_path', @@ -55,8 +55,8 @@ class TestImageTaskRoutes: mock_task.id = 'img-resize-id' monkeypatch.setattr( - 'app.routes.image.validate_file', - lambda f, allowed_types: ('photo.jpg', 'jpg'), + 'app.routes.image.validate_actor_file', + lambda f, allowed_types, actor: ('photo.jpg', 'jpg'), ) monkeypatch.setattr( 'app.routes.image.generate_safe_path', @@ -83,8 +83,8 @@ class TestImageTaskRoutes: def test_resize_image_no_dimensions(self, client, monkeypatch): """Should return 400 when both width and height are missing.""" monkeypatch.setattr( - 'app.routes.image.validate_file', - lambda f, allowed_types: ('photo.jpg', 'jpg'), + 'app.routes.image.validate_actor_file', + lambda f, allowed_types, actor: ('photo.jpg', 'jpg'), ) data = { 'file': (io.BytesIO(b'\xff\xd8\xff'), 'photo.jpg'), @@ -100,8 +100,8 @@ class TestImageTaskRoutes: def test_resize_image_invalid_width(self, client, monkeypatch): """Should return 400 for width out of range.""" monkeypatch.setattr( - 'app.routes.image.validate_file', - lambda f, allowed_types: ('photo.jpg', 'jpg'), + 'app.routes.image.validate_actor_file', + lambda f, allowed_types, actor: ('photo.jpg', 'jpg'), ) data = { 'file': (io.BytesIO(b'\xff\xd8\xff'), 'photo.jpg'), diff --git a/backend/tests/test_load.py b/backend/tests/test_load.py index 3cc4dae..1439af2 100644 --- a/backend/tests/test_load.py +++ b/backend/tests/test_load.py @@ -76,7 +76,7 @@ class TestConcurrentRequests: return t # Apply all patches BEFORE threads start — avoids concurrent patch/unpatch - with patch('app.routes.compress.validate_file', return_value=('t.pdf', 'pdf')), \ + with patch('app.routes.compress.validate_actor_file', return_value=('t.pdf', 'pdf')), \ patch('app.routes.compress.generate_safe_path', side_effect=lambda ext, folder_type: (f'tid-x', '/tmp/up/t.pdf')), \ patch('werkzeug.datastructures.file_storage.FileStorage.save'), \ @@ -121,7 +121,7 @@ class TestConcurrentRequests: errors: list[Exception] = [] lock = threading.Lock() - with patch('app.routes.pdf_tools.validate_file', return_value=('t.pdf', 'pdf')), \ + with patch('app.routes.pdf_tools.validate_actor_file', return_value=('t.pdf', 'pdf')), \ patch('app.routes.pdf_tools.generate_safe_path', side_effect=lambda ext, folder_type: ('split-x', '/tmp/up/t.pdf')), \ patch('werkzeug.datastructures.file_storage.FileStorage.save'), \ @@ -180,8 +180,8 @@ class TestFileSizeLimits: def test_normal_size_file_is_accepted(self, client, monkeypatch): """A file within the size limit reaches the route logic.""" monkeypatch.setattr( - 'app.routes.compress.validate_file', - lambda f, allowed_types: ('t.pdf', 'pdf'), + 'app.routes.compress.validate_actor_file', + lambda f, allowed_types, actor: ('t.pdf', 'pdf'), ) monkeypatch.setattr( 'app.routes.compress.generate_safe_path', diff --git a/backend/tests/test_pdf_tools.py b/backend/tests/test_pdf_tools.py index cd62707..3e6f9cc 100644 --- a/backend/tests/test_pdf_tools.py +++ b/backend/tests/test_pdf_tools.py @@ -20,8 +20,8 @@ def _mock_validate_and_task(monkeypatch, task_module_path, task_name): # Mock file validator to accept any file monkeypatch.setattr( - 'app.routes.pdf_tools.validate_file', - lambda f, allowed_types: ('test.pdf', 'pdf'), + 'app.routes.pdf_tools.validate_actor_file', + lambda f, allowed_types, actor: ('test.pdf', 'pdf'), ) monkeypatch.setattr( 'app.routes.pdf_tools.generate_safe_path', @@ -62,8 +62,8 @@ class TestMergePdfs: mock_task = MagicMock() mock_task.id = 'merge-task-id' monkeypatch.setattr( - 'app.routes.pdf_tools.validate_file', - lambda f, allowed_types: ('test.pdf', 'pdf'), + 'app.routes.pdf_tools.validate_actor_file', + lambda f, allowed_types, actor: ('test.pdf', 'pdf'), ) monkeypatch.setattr( 'app.routes.pdf_tools.merge_pdfs_task.delay', @@ -95,8 +95,8 @@ class TestMergePdfs: def test_merge_too_many_files(self, client, monkeypatch): """Should return 400 when more than 20 files provided.""" monkeypatch.setattr( - 'app.routes.pdf_tools.validate_file', - lambda f, allowed_types: ('test.pdf', 'pdf'), + 'app.routes.pdf_tools.validate_actor_file', + lambda f, allowed_types, actor: ('test.pdf', 'pdf'), ) data = { 'files': [ @@ -166,8 +166,8 @@ class TestSplitPdf: def test_split_range_mode_requires_pages(self, client, monkeypatch): """Should return 400 when range mode is selected without pages.""" monkeypatch.setattr( - 'app.routes.pdf_tools.validate_file', - lambda f, allowed_types: ('test.pdf', 'pdf'), + 'app.routes.pdf_tools.validate_actor_file', + lambda f, allowed_types, actor: ('test.pdf', 'pdf'), ) data = { @@ -195,8 +195,8 @@ class TestRotatePdf: def test_rotate_invalid_degrees(self, client, monkeypatch): """Should reject invalid rotation angles.""" monkeypatch.setattr( - 'app.routes.pdf_tools.validate_file', - lambda f, allowed_types: ('test.pdf', 'pdf'), + 'app.routes.pdf_tools.validate_actor_file', + lambda f, allowed_types, actor: ('test.pdf', 'pdf'), ) data = { 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), @@ -327,8 +327,8 @@ class TestImagesToPdf: mock_task = MagicMock() mock_task.id = 'images-task-id' monkeypatch.setattr( - 'app.routes.pdf_tools.validate_file', - lambda f, allowed_types: ('test.png', 'png'), + 'app.routes.pdf_tools.validate_actor_file', + lambda f, allowed_types, actor: ('test.png', 'png'), ) monkeypatch.setattr( 'app.routes.pdf_tools.images_to_pdf_task.delay', @@ -356,8 +356,8 @@ class TestImagesToPdf: def test_images_to_pdf_too_many(self, client, monkeypatch): monkeypatch.setattr( - 'app.routes.pdf_tools.validate_file', - lambda f, allowed_types: ('test.png', 'png'), + 'app.routes.pdf_tools.validate_actor_file', + lambda f, allowed_types, actor: ('test.png', 'png'), ) data = { 'files': [ @@ -384,8 +384,8 @@ class TestWatermarkPdf: def test_watermark_no_text(self, client, monkeypatch): monkeypatch.setattr( - 'app.routes.pdf_tools.validate_file', - lambda f, allowed_types: ('test.pdf', 'pdf'), + 'app.routes.pdf_tools.validate_actor_file', + lambda f, allowed_types, actor: ('test.pdf', 'pdf'), ) data = { 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), @@ -401,8 +401,8 @@ class TestWatermarkPdf: def test_watermark_text_too_long(self, client, monkeypatch): monkeypatch.setattr( - 'app.routes.pdf_tools.validate_file', - lambda f, allowed_types: ('test.pdf', 'pdf'), + 'app.routes.pdf_tools.validate_actor_file', + lambda f, allowed_types, actor: ('test.pdf', 'pdf'), ) data = { 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), @@ -443,8 +443,8 @@ class TestProtectPdf: def test_protect_no_password(self, client, monkeypatch): monkeypatch.setattr( - 'app.routes.pdf_tools.validate_file', - lambda f, allowed_types: ('test.pdf', 'pdf'), + 'app.routes.pdf_tools.validate_actor_file', + lambda f, allowed_types, actor: ('test.pdf', 'pdf'), ) data = { 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), @@ -460,8 +460,8 @@ class TestProtectPdf: def test_protect_short_password(self, client, monkeypatch): monkeypatch.setattr( - 'app.routes.pdf_tools.validate_file', - lambda f, allowed_types: ('test.pdf', 'pdf'), + 'app.routes.pdf_tools.validate_actor_file', + lambda f, allowed_types, actor: ('test.pdf', 'pdf'), ) data = { 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), @@ -501,8 +501,8 @@ class TestUnlockPdf: def test_unlock_no_password(self, client, monkeypatch): monkeypatch.setattr( - 'app.routes.pdf_tools.validate_file', - lambda f, allowed_types: ('test.pdf', 'pdf'), + 'app.routes.pdf_tools.validate_actor_file', + lambda f, allowed_types, actor: ('test.pdf', 'pdf'), ) data = { 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), diff --git a/backend/tests/test_pdf_tools_tasks.py b/backend/tests/test_pdf_tools_tasks.py index 3717db4..1a838f4 100644 --- a/backend/tests/test_pdf_tools_tasks.py +++ b/backend/tests/test_pdf_tools_tasks.py @@ -16,8 +16,8 @@ class TestPdfToolsTaskRoutes: mock_task.id = 'split-id' mock_delay = MagicMock(return_value=mock_task) - monkeypatch.setattr('app.routes.pdf_tools.validate_file', - lambda f, allowed_types: ('test.pdf', 'pdf')) + monkeypatch.setattr('app.routes.pdf_tools.validate_actor_file', + lambda f, allowed_types, actor: ('test.pdf', 'pdf')) monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path', lambda ext, folder_type: ('split-id', '/tmp/test.pdf')) monkeypatch.setattr('app.routes.pdf_tools.split_pdf_task.delay', mock_delay) @@ -41,8 +41,8 @@ class TestPdfToolsTaskRoutes: mock_task.id = 'rotate-id' mock_delay = MagicMock(return_value=mock_task) - monkeypatch.setattr('app.routes.pdf_tools.validate_file', - lambda f, allowed_types: ('test.pdf', 'pdf')) + monkeypatch.setattr('app.routes.pdf_tools.validate_actor_file', + lambda f, allowed_types, actor: ('test.pdf', 'pdf')) monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path', lambda ext, folder_type: ('rotate-id', '/tmp/test.pdf')) monkeypatch.setattr('app.routes.pdf_tools.rotate_pdf_task.delay', mock_delay) @@ -66,8 +66,8 @@ class TestPdfToolsTaskRoutes: mock_task.id = 'wm-id' mock_delay = MagicMock(return_value=mock_task) - monkeypatch.setattr('app.routes.pdf_tools.validate_file', - lambda f, allowed_types: ('test.pdf', 'pdf')) + monkeypatch.setattr('app.routes.pdf_tools.validate_actor_file', + lambda f, allowed_types, actor: ('test.pdf', 'pdf')) monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path', lambda ext, folder_type: ('wm-id', '/tmp/test.pdf')) monkeypatch.setattr('app.routes.pdf_tools.watermark_pdf_task.delay', mock_delay) @@ -91,8 +91,8 @@ class TestPdfToolsTaskRoutes: mock_task.id = 'protect-id' mock_delay = MagicMock(return_value=mock_task) - monkeypatch.setattr('app.routes.pdf_tools.validate_file', - lambda f, allowed_types: ('test.pdf', 'pdf')) + monkeypatch.setattr('app.routes.pdf_tools.validate_actor_file', + lambda f, allowed_types, actor: ('test.pdf', 'pdf')) monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path', lambda ext, folder_type: ('protect-id', '/tmp/test.pdf')) monkeypatch.setattr('app.routes.pdf_tools.protect_pdf_task.delay', mock_delay) @@ -113,8 +113,8 @@ class TestPdfToolsTaskRoutes: mock_task.id = 'unlock-id' mock_delay = MagicMock(return_value=mock_task) - monkeypatch.setattr('app.routes.pdf_tools.validate_file', - lambda f, allowed_types: ('test.pdf', 'pdf')) + monkeypatch.setattr('app.routes.pdf_tools.validate_actor_file', + lambda f, allowed_types, actor: ('test.pdf', 'pdf')) monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path', lambda ext, folder_type: ('unlock-id', '/tmp/test.pdf')) monkeypatch.setattr('app.routes.pdf_tools.unlock_pdf_task.delay', mock_delay) @@ -133,8 +133,8 @@ class TestPdfToolsTaskRoutes: mock_task.id = 'pn-id' mock_delay = MagicMock(return_value=mock_task) - monkeypatch.setattr('app.routes.pdf_tools.validate_file', - lambda f, allowed_types: ('test.pdf', 'pdf')) + monkeypatch.setattr('app.routes.pdf_tools.validate_actor_file', + lambda f, allowed_types, actor: ('test.pdf', 'pdf')) monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path', lambda ext, folder_type: ('pn-id', '/tmp/test.pdf')) monkeypatch.setattr('app.routes.pdf_tools.add_page_numbers_task.delay', mock_delay) @@ -157,8 +157,8 @@ class TestPdfToolsTaskRoutes: mock_task.id = 'p2i-id' mock_delay = MagicMock(return_value=mock_task) - monkeypatch.setattr('app.routes.pdf_tools.validate_file', - lambda f, allowed_types: ('test.pdf', 'pdf')) + monkeypatch.setattr('app.routes.pdf_tools.validate_actor_file', + lambda f, allowed_types, actor: ('test.pdf', 'pdf')) monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path', lambda ext, folder_type: ('p2i-id', '/tmp/test.pdf')) monkeypatch.setattr('app.routes.pdf_tools.pdf_to_images_task.delay', mock_delay) diff --git a/backend/tests/test_video.py b/backend/tests/test_video.py index 4252688..8467f9f 100644 --- a/backend/tests/test_video.py +++ b/backend/tests/test_video.py @@ -14,8 +14,8 @@ class TestVideoToGif: def test_to_gif_invalid_params(self, client, monkeypatch): """Should return 400 for non-numeric parameters.""" monkeypatch.setattr( - 'app.routes.video.validate_file', - lambda f, allowed_types: ('test.mp4', 'mp4'), + 'app.routes.video.validate_actor_file', + lambda f, allowed_types, actor: (r'test.mp4', r'mp4'), ) data = { 'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'test.mp4'), @@ -32,8 +32,8 @@ class TestVideoToGif: def test_to_gif_negative_start(self, client, monkeypatch): """Should reject negative start time.""" monkeypatch.setattr( - 'app.routes.video.validate_file', - lambda f, allowed_types: ('test.mp4', 'mp4'), + 'app.routes.video.validate_actor_file', + lambda f, allowed_types, actor: (r'test.mp4', r'mp4'), ) data = { 'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'test.mp4'), @@ -52,8 +52,8 @@ class TestVideoToGif: def test_to_gif_duration_too_long(self, client, monkeypatch): """Should reject duration > 15 seconds.""" monkeypatch.setattr( - 'app.routes.video.validate_file', - lambda f, allowed_types: ('test.mp4', 'mp4'), + 'app.routes.video.validate_actor_file', + lambda f, allowed_types, actor: (r'test.mp4', r'mp4'), ) data = { 'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'test.mp4'), @@ -73,8 +73,8 @@ class TestVideoToGif: def test_to_gif_fps_out_of_range(self, client, monkeypatch): """Should reject FPS > 20.""" monkeypatch.setattr( - 'app.routes.video.validate_file', - lambda f, allowed_types: ('test.mp4', 'mp4'), + 'app.routes.video.validate_actor_file', + lambda f, allowed_types, actor: (r'test.mp4', r'mp4'), ) data = { 'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'test.mp4'), @@ -93,8 +93,8 @@ class TestVideoToGif: def test_to_gif_width_out_of_range(self, client, monkeypatch): """Should reject width > 640.""" monkeypatch.setattr( - 'app.routes.video.validate_file', - lambda f, allowed_types: ('test.mp4', 'mp4'), + 'app.routes.video.validate_actor_file', + lambda f, allowed_types, actor: (r'test.mp4', r'mp4'), ) data = { 'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'test.mp4'), @@ -116,8 +116,8 @@ class TestVideoToGif: mock_task.id = 'gif-task-id' monkeypatch.setattr( - 'app.routes.video.validate_file', - lambda f, allowed_types: ('test.mp4', 'mp4'), + 'app.routes.video.validate_actor_file', + lambda f, allowed_types, actor: (r'test.mp4', r'mp4'), ) monkeypatch.setattr( 'app.routes.video.generate_safe_path', diff --git a/backend/tests/test_video_tasks.py b/backend/tests/test_video_tasks.py index 06d7e36..4a47647 100644 --- a/backend/tests/test_video_tasks.py +++ b/backend/tests/test_video_tasks.py @@ -11,8 +11,8 @@ class TestVideoTaskRoutes: mock_delay = MagicMock(return_value=mock_task) monkeypatch.setattr( - 'app.routes.video.validate_file', - lambda f, allowed_types: ('video.mp4', 'mp4'), + 'app.routes.video.validate_actor_file', + lambda f, allowed_types, actor: ('video.mp4', 'mp4'), ) monkeypatch.setattr( 'app.routes.video.generate_safe_path', @@ -53,8 +53,8 @@ class TestVideoTaskRoutes: mock_delay = MagicMock(return_value=mock_task) monkeypatch.setattr( - 'app.routes.video.validate_file', - lambda f, allowed_types: ('video.mp4', 'mp4'), + 'app.routes.video.validate_actor_file', + lambda f, allowed_types, actor: ('video.mp4', 'mp4'), ) monkeypatch.setattr( 'app.routes.video.generate_safe_path', diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index cec4960..173ede3 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -26,6 +26,7 @@ services: volumes: - upload_data:/tmp/uploads - output_data:/tmp/outputs + - db_data:/app/data depends_on: redis: condition: service_healthy @@ -40,7 +41,7 @@ services: celery -A celery_worker.celery worker --loglevel=warning --concurrency=4 - -Q default,convert,compress,image,video,pdf_tools + -Q default,convert,compress,image,video,pdf_tools,flowchart env_file: - .env environment: @@ -51,6 +52,7 @@ services: volumes: - upload_data:/tmp/uploads - output_data:/tmp/outputs + - db_data:/app/data depends_on: redis: condition: service_healthy @@ -71,6 +73,8 @@ services: - REDIS_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/1 + volumes: + - db_data:/app/data depends_on: redis: condition: service_healthy @@ -97,6 +101,13 @@ services: context: ./frontend dockerfile: Dockerfile target: build + environment: + - VITE_GA_MEASUREMENT_ID=${VITE_GA_MEASUREMENT_ID:-} + - VITE_ADSENSE_CLIENT_ID=${VITE_ADSENSE_CLIENT_ID:-} + - VITE_ADSENSE_SLOT_HOME_TOP=${VITE_ADSENSE_SLOT_HOME_TOP:-} + - VITE_ADSENSE_SLOT_HOME_BOTTOM=${VITE_ADSENSE_SLOT_HOME_BOTTOM:-} + - VITE_ADSENSE_SLOT_TOP_BANNER=${VITE_ADSENSE_SLOT_TOP_BANNER:-} + - VITE_ADSENSE_SLOT_BOTTOM_BANNER=${VITE_ADSENSE_SLOT_BOTTOM_BANNER:-} volumes: - frontend_build:/app/dist @@ -104,4 +115,5 @@ volumes: redis_data: upload_data: output_data: + db_data: frontend_build: diff --git a/docker-compose.yml b/docker-compose.yml index 2d25e12..9cc9adc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,7 +44,7 @@ services: celery -A celery_worker.celery worker --loglevel=info --concurrency=2 - -Q default,convert,compress,image,video,pdf_tools + -Q default,convert,compress,image,video,pdf_tools,flowchart env_file: - .env environment: @@ -80,6 +80,12 @@ services: - /app/node_modules environment: - NODE_ENV=development + - VITE_GA_MEASUREMENT_ID=${VITE_GA_MEASUREMENT_ID:-} + - VITE_ADSENSE_CLIENT_ID=${VITE_ADSENSE_CLIENT_ID:-} + - VITE_ADSENSE_SLOT_HOME_TOP=${VITE_ADSENSE_SLOT_HOME_TOP:-} + - VITE_ADSENSE_SLOT_HOME_BOTTOM=${VITE_ADSENSE_SLOT_HOME_BOTTOM:-} + - VITE_ADSENSE_SLOT_TOP_BANNER=${VITE_ADSENSE_SLOT_TOP_BANNER:-} + - VITE_ADSENSE_SLOT_BOTTOM_BANNER=${VITE_ADSENSE_SLOT_BOTTOM_BANNER:-} # --- Nginx Reverse Proxy --- nginx: diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..c983eb1 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,6 @@ +VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX +VITE_ADSENSE_CLIENT_ID=ca-pub-XXXXXXXXXXXXXXXX +VITE_ADSENSE_SLOT_HOME_TOP=1234567890 +VITE_ADSENSE_SLOT_HOME_BOTTOM=1234567891 +VITE_ADSENSE_SLOT_TOP_BANNER=1234567892 +VITE_ADSENSE_SLOT_BOTTOM_BANNER=1234567893 diff --git a/frontend/index.html b/frontend/index.html index c7981d4..0516005 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,11 +10,11 @@ - + - + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3245be3..b222193 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,10 @@ -import { lazy, Suspense } from 'react'; -import { Routes, Route } from 'react-router-dom'; +import { lazy, Suspense, useEffect } from 'react'; +import { Routes, Route, useLocation } from 'react-router-dom'; import Header from '@/components/layout/Header'; import Footer from '@/components/layout/Footer'; import { useDirection } from '@/hooks/useDirection'; +import { initAnalytics, trackPageView } from '@/services/analytics'; +import { useAuthStore } from '@/stores/authStore'; // Pages const HomePage = lazy(() => import('@/pages/HomePage')); @@ -10,6 +12,7 @@ const AboutPage = lazy(() => import('@/pages/AboutPage')); const PrivacyPage = lazy(() => import('@/pages/PrivacyPage')); const NotFoundPage = lazy(() => import('@/pages/NotFoundPage')); const TermsPage = lazy(() => import('@/pages/TermsPage')); +const AccountPage = lazy(() => import('@/pages/AccountPage')); // Tool Pages const PdfToWord = lazy(() => import('@/components/tools/PdfToWord')); @@ -41,6 +44,17 @@ function LoadingFallback() { export default function App() { useDirection(); + const location = useLocation(); + const refreshUser = useAuthStore((state) => state.refreshUser); + + useEffect(() => { + initAnalytics(); + void refreshUser(); + }, [refreshUser]); + + useEffect(() => { + trackPageView(`${location.pathname}${location.search}`); + }, [location.pathname, location.search]); return (
@@ -52,6 +66,7 @@ export default function App() { {/* Pages */} } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/layout/AdSlot.tsx b/frontend/src/components/layout/AdSlot.tsx index 0aaf4b8..bd1b7d4 100644 --- a/frontend/src/components/layout/AdSlot.tsx +++ b/frontend/src/components/layout/AdSlot.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react'; +import { useAuthStore } from '@/stores/authStore'; interface AdSlotProps { /** AdSense ad slot ID */ @@ -21,21 +22,50 @@ export default function AdSlot({ responsive = true, className = '', }: AdSlotProps) { + const user = useAuthStore((s) => s.user); const adRef = useRef(null); const isLoaded = useRef(false); + const clientId = (import.meta.env.VITE_ADSENSE_CLIENT_ID || '').trim(); + const slotMap: Record = { + 'home-top': import.meta.env.VITE_ADSENSE_SLOT_HOME_TOP, + 'home-bottom': import.meta.env.VITE_ADSENSE_SLOT_HOME_BOTTOM, + 'top-banner': import.meta.env.VITE_ADSENSE_SLOT_TOP_BANNER, + 'bottom-banner': import.meta.env.VITE_ADSENSE_SLOT_BOTTOM_BANNER, + }; + const resolvedSlot = /^\d+$/.test(slot) ? slot : slotMap[slot]; useEffect(() => { - if (isLoaded.current) return; + if (isLoaded.current || !clientId || !resolvedSlot) return; + + const existingScript = document.querySelector( + `script[data-adsense-client="${clientId}"]` + ); + + if (!existingScript) { + const script = document.createElement('script'); + script.async = true; + script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${clientId}`; + script.crossOrigin = 'anonymous'; + script.setAttribute('data-adsense-client', clientId); + document.head.appendChild(script); + } try { // Push ad to AdSense queue - const adsbygoogle = (window as any).adsbygoogle || []; + const adsWindow = window as Window & { adsbygoogle?: unknown[] }; + const adsbygoogle = adsWindow.adsbygoogle || []; adsbygoogle.push({}); + adsWindow.adsbygoogle = adsbygoogle; isLoaded.current = true; } catch { // AdSense not loaded (e.g., ad blocker) } - }, []); + }, [clientId, resolvedSlot]); + + if (!clientId || !resolvedSlot) return null; + + // Pro users see no ads + if (user?.plan === 'pro') return null; return (
@@ -43,8 +73,8 @@ export default function AdSlot({ ref={adRef} className="adsbygoogle" style={{ display: 'block' }} - data-ad-client={import.meta.env.VITE_ADSENSE_CLIENT_ID || ''} - data-ad-slot={slot} + data-ad-client={clientId} + data-ad-slot={resolvedSlot} data-ad-format={format} data-full-width-responsive={responsive ? 'true' : 'false'} /> diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 8d66472..ecab928 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,8 +1,8 @@ import { useState, useEffect, useRef } from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { FileText, Moon, Sun, Menu, X, ChevronDown } from 'lucide-react'; -// ...existing code... +import { FileText, Moon, Sun, Menu, X, ChevronDown, UserRound } from 'lucide-react'; +import { useAuthStore } from '@/stores/authStore'; interface LangOption { code: string; label: string; @@ -40,6 +40,7 @@ function useDarkMode() { export default function Header() { const { t, i18n } = useTranslation(); const { isDark, toggle: toggleDark } = useDarkMode(); + const user = useAuthStore((state) => state.user); const [langOpen, setLangOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); const langRef = useRef(null); @@ -85,10 +86,24 @@ export default function Header() { > {t('common.about')} + + {t('common.account')} + {/* Actions */}
+ + + {user?.email || t('common.account')} + + {/* Dark Mode Toggle */}
- {/* Toolbar Preview */} -
-

- {t('tools.pdfEditor.thumbnails')} -

-
- {toolbarButtons.map((btn) => { - const Icon = btn.icon; - return ( -
- - {btn.label} -
- ); - })} -
-
- {/* Upload Button */} +
+
+ + + + {/* Usage / Quota Cards */} + {usage && ( +
+
+

+ {t('account.webQuotaTitle')} +

+

+ {usage.web_quota.used} + / {usage.web_quota.limit ?? '∞'} +

+ {usage.web_quota.limit != null && ( +
+
+
+ )} +

{t('account.quotaPeriod')}: {usage.period_month}

+
+ {usage.api_quota.limit != null && ( +
+

+ {t('account.apiQuotaTitle')} +

+

+ {usage.api_quota.used} + / {usage.api_quota.limit} +

+
+
+
+

{t('account.quotaPeriod')}: {usage.period_month}

+
+ )} +
+ )} + + {/* API Key Management — Pro only */} + {user.plan === 'pro' && ( +
+
+
+ +
+

+ {t('account.apiKeysTitle')} +

+

+ {t('account.apiKeysSubtitle')} +

+
+
+
+
+ {/* Create key form */} +
+ setNewKeyName(e.target.value)} + placeholder={t('account.apiKeyNamePlaceholder')} + maxLength={100} + className="input flex-1" + /> + +
+ {newKeyError && ( +

{newKeyError}

+ )} + {/* Revealed key — shown once after creation */} + {revealedKey && ( +
+ + {revealedKey} + + + +
+ )} + {revealedKey && ( +

{t('account.apiKeyCopyWarning')}

+ )} + {/* Key list */} + {apiKeysLoading ? ( +

{t('account.historyLoading')}

+ ) : apiKeys.length === 0 ? ( +

{t('account.apiKeysEmpty')}

+ ) : ( +
    + {apiKeys.map((key) => ( +
  • +
    +

    {key.name}

    +

    {key.key_prefix}…

    + {key.revoked_at && ( +

    {t('account.apiKeyRevoked')}

    + )} +
    + {!key.revoked_at && ( + + )} +
  • + ))} +
+ )} +
+
+ )} + +
+
+
+ +
+

+ {t('account.historyTitle')} +

+

+ {t('account.historySubtitle')} +

+
+
+
+ +
+ {historyLoading ? ( +

{t('account.historyLoading')}

+ ) : historyError ? ( +
+ {historyError} +
+ ) : historyItems.length === 0 ? ( +
+

{t('account.historyEmpty')}

+
+ ) : ( + historyItems.map((item) => { + const metadataError = + typeof item.metadata?.error === 'string' ? item.metadata.error : null; + + return ( +
+
+
+

+ {formatHistoryTool(item.tool, t)} +

+

+ {item.output_filename || item.original_filename || formatHistoryTool(item.tool, t)} +

+

+ {t('account.createdAt')}: {dateFormatter.format(new Date(item.created_at))} +

+
+ + + {item.status === 'completed' + ? t('account.statusCompleted') + : t('account.statusFailed')} + +
+ +
+
+

+ {t('account.originalFile')} +

+

+ {item.original_filename || '—'} +

+
+
+

+ {t('account.outputFile')} +

+

+ {item.output_filename || '—'} +

+
+
+ + {metadataError ? ( +

+ {metadataError} +

+ ) : null} + + {item.download_url && item.status === 'completed' ? ( +
+ + {t('account.downloadResult')} + + ) : null} +
+ ); + }) + )} +
+
+ + ) : ( +
+
+
+
+ + {t('account.benefitsTitle')} +
+

+ {t('account.heroTitle')} +

+

+ {t('account.heroSubtitle')} +

+
+ +
+ {[t('account.benefit1'), t('account.benefit2'), t('account.benefit3')].map((benefit) => ( +
+ +

{benefit}

+
+ ))} +
+
+ +
+
+ + +
+ +
+
+

+ {mode === 'login' ? t('account.signInTitle') : t('account.registerTitle')} +

+

+ {t('account.formSubtitle')} +

+
+ +
+ + + + + {mode === 'register' ? ( + + ) : null} + + {submitError ? ( +
+ {submitError} +
+ ) : null} + + +
+
+
+
+ )} + + ); +} diff --git a/frontend/src/pages/PrivacyPage.tsx b/frontend/src/pages/PrivacyPage.tsx index 827f683..3ca792c 100644 --- a/frontend/src/pages/PrivacyPage.tsx +++ b/frontend/src/pages/PrivacyPage.tsx @@ -1,5 +1,8 @@ import { useTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet-async'; +import { FILE_RETENTION_MINUTES } from '@/config/toolLimits'; + +const LAST_UPDATED = '2026-03-06'; export default function PrivacyPage() { const { t } = useTranslation(); @@ -13,7 +16,7 @@ export default function PrivacyPage() {

{t('common.privacy')}

-

Last updated: {new Date().toISOString().split('T')[0]}

+

Last updated: {LAST_UPDATED}

1. Data Collection

@@ -24,7 +27,7 @@ export default function PrivacyPage() {

2. File Processing & Storage

  • Uploaded files are processed on our secure servers.
  • -
  • All uploaded and output files are automatically deleted within 2 hours.
  • +
  • All uploaded and output files are automatically deleted within {FILE_RETENTION_MINUTES} minutes.
  • Files are stored in encrypted cloud storage during processing.
  • We do not access, read, or share the content of your files.
diff --git a/frontend/src/pages/TermsPage.tsx b/frontend/src/pages/TermsPage.tsx index dc65cc5..6cf0939 100644 --- a/frontend/src/pages/TermsPage.tsx +++ b/frontend/src/pages/TermsPage.tsx @@ -1,5 +1,8 @@ import { useTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet-async'; +import { FILE_RETENTION_MINUTES } from '@/config/toolLimits'; + +const LAST_UPDATED = '2026-03-06'; export default function TermsPage() { const { t } = useTranslation(); @@ -13,7 +16,7 @@ export default function TermsPage() {

{t('common.terms')}

-

Last updated: {new Date().toISOString().split('T')[0]}

+

Last updated: {LAST_UPDATED}

1. Acceptance of Terms

@@ -37,7 +40,7 @@ export default function TermsPage() {

4. File Handling

    -
  • All uploaded and processed files are automatically deleted within 2 hours.
  • +
  • All uploaded and processed files are automatically deleted within {FILE_RETENTION_MINUTES} minutes.
  • We are not responsible for any data loss during processing.
  • You are responsible for maintaining your own file backups.
diff --git a/frontend/src/services/analytics.ts b/frontend/src/services/analytics.ts new file mode 100644 index 0000000..3211ea3 --- /dev/null +++ b/frontend/src/services/analytics.ts @@ -0,0 +1,67 @@ +type AnalyticsValue = string | number | boolean | undefined; + +declare global { + interface Window { + dataLayer: unknown[]; + gtag?: (...args: unknown[]) => void; + } +} + +const GA_MEASUREMENT_ID = (import.meta.env.VITE_GA_MEASUREMENT_ID || '').trim(); +let initialized = false; + +function ensureGtagShim() { + window.dataLayer = window.dataLayer || []; + window.gtag = + window.gtag || + function gtag(...args: unknown[]) { + window.dataLayer.push(args); + }; +} + +function loadGaScript() { + if (!GA_MEASUREMENT_ID) return; + + const existing = document.querySelector( + `script[data-ga4-id="${GA_MEASUREMENT_ID}"]` + ); + if (existing) return; + + const script = document.createElement('script'); + script.async = true; + script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`; + script.setAttribute('data-ga4-id', GA_MEASUREMENT_ID); + document.head.appendChild(script); +} + +export function initAnalytics() { + if (initialized || !GA_MEASUREMENT_ID || typeof window === 'undefined') return; + + ensureGtagShim(); + loadGaScript(); + window.gtag?.('js', new Date()); + window.gtag?.('config', GA_MEASUREMENT_ID, { send_page_view: false }); + initialized = true; +} + +export function trackPageView(path: string) { + if (!initialized || !window.gtag) return; + + window.gtag('event', 'page_view', { + page_path: path, + page_location: `${window.location.origin}${path}`, + page_title: document.title, + }); +} + +export function trackEvent( + eventName: string, + params: Record = {} +) { + if (!initialized || !window.gtag) return; + window.gtag('event', eventName, params); +} + +export function analyticsEnabled() { + return Boolean(GA_MEASUREMENT_ID); +} diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index ee4406e..ffba517 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -103,14 +103,13 @@ describe('API Service — Endpoint Format Tests', () => { // PDF Tools endpoints // ---------------------------------------------------------- describe('PDF Tools API', () => { - it('Merge: should POST multiple files to /api/pdf-tools/merge', () => { - // MergePdf.tsx uses fetch('/api/pdf-tools/merge') directly, not api.post + it('Merge: should POST multiple files to /pdf-tools/merge', () => { const formData = new FormData(); formData.append('files', new Blob(['%PDF-1.4']), 'a.pdf'); formData.append('files', new Blob(['%PDF-1.4']), 'b.pdf'); - const url = '/api/pdf-tools/merge'; + const url = '/pdf-tools/merge'; - expect(url).toBe('/api/pdf-tools/merge'); + expect(url).toBe('/pdf-tools/merge'); expect(formData.getAll('files').length).toBe(2); }); @@ -159,14 +158,13 @@ describe('API Service — Endpoint Format Tests', () => { expect(formData.get('format')).toBe('png'); }); - it('Images to PDF: should POST multiple files to /api/pdf-tools/images-to-pdf', () => { - // ImagesToPdf.tsx uses fetch('/api/pdf-tools/images-to-pdf') directly + it('Images to PDF: should POST multiple files to /pdf-tools/images-to-pdf', () => { const formData = new FormData(); formData.append('files', new Blob(['\x89PNG']), 'img1.png'); formData.append('files', new Blob(['\x89PNG']), 'img2.png'); - const url = '/api/pdf-tools/images-to-pdf'; + const url = '/pdf-tools/images-to-pdf'; - expect(url).toBe('/api/pdf-tools/images-to-pdf'); + expect(url).toBe('/pdf-tools/images-to-pdf'); expect(formData.getAll('files').length).toBe(2); }); @@ -264,9 +262,8 @@ describe('Frontend Tool → Backend Endpoint Mapping', () => { AddPageNumbers: { method: 'POST', endpoint: '/pdf-tools/page-numbers', fieldName: 'file' }, PdfToImages: { method: 'POST', endpoint: '/pdf-tools/pdf-to-images', fieldName: 'file' }, VideoToGif: { method: 'POST', endpoint: '/video/to-gif', fieldName: 'file' }, - // Multi-file tools use fetch() directly with full path: - MergePdf: { method: 'POST', endpoint: '/api/pdf-tools/merge', fieldName: 'files' }, - ImagesToPdf: { method: 'POST', endpoint: '/api/pdf-tools/images-to-pdf', fieldName: 'files' }, + MergePdf: { method: 'POST', endpoint: '/pdf-tools/merge', fieldName: 'files' }, + ImagesToPdf: { method: 'POST', endpoint: '/pdf-tools/images-to-pdf', fieldName: 'files' }, }; Object.entries(toolEndpointMap).forEach(([tool, config]) => { @@ -276,4 +273,4 @@ describe('Frontend Tool → Backend Endpoint Mapping', () => { expect(config.fieldName).toMatch(/^(file|files)$/); }); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index a4a59f6..74ae9de 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -3,6 +3,7 @@ import axios from 'axios'; const api = axios.create({ baseURL: '/api', timeout: 120000, // 2 minute timeout for file processing + withCredentials: true, headers: { Accept: 'application/json', }, @@ -77,6 +78,38 @@ export interface TaskResult { total_pages?: number; } +export interface AuthUser { + id: number; + email: string; + plan: string; + created_at: string; +} + +interface AuthResponse { + message: string; + user: AuthUser; +} + +interface AuthSessionResponse { + authenticated: boolean; + user: AuthUser | null; +} + +interface HistoryResponse { + items: HistoryEntry[]; +} + +export interface HistoryEntry { + id: number; + tool: string; + original_filename: string | null; + output_filename: string | null; + status: 'completed' | 'failed' | string; + download_url: string | null; + metadata: Record; + created_at: string; +} + /** * Upload a file and start a processing task. */ @@ -108,6 +141,87 @@ export async function uploadFile( return response.data; } +/** + * Upload multiple files and start a processing task. + */ +export async function uploadFiles( + endpoint: string, + files: File[], + fileField = 'files', + extraData?: Record, + onProgress?: (percent: number) => void +): Promise { + const formData = new FormData(); + files.forEach((file) => formData.append(fileField, file)); + + if (extraData) { + Object.entries(extraData).forEach(([key, value]) => { + formData.append(key, value); + }); + } + + const response = await api.post(endpoint, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: (event) => { + if (event.total && onProgress) { + const percent = Math.round((event.loaded / event.total) * 100); + onProgress(percent); + } + }, + }); + + return response.data; +} + +/** + * Start a task endpoint that does not require file upload. + */ +export async function startTask(endpoint: string): Promise { + const response = await api.post(endpoint); + return response.data; +} + +/** + * Create a new account and return the authenticated user. + */ +export async function registerUser(email: string, password: string): Promise { + const response = await api.post('/auth/register', { email, password }); + return response.data.user; +} + +/** + * Sign in and return the authenticated user. + */ +export async function loginUser(email: string, password: string): Promise { + const response = await api.post('/auth/login', { email, password }); + return response.data.user; +} + +/** + * End the current authenticated session. + */ +export async function logoutUser(): Promise { + await api.post('/auth/logout'); +} + +/** + * Return the current authenticated user, if any. + */ +export async function getCurrentUser(): Promise { + const response = await api.get('/auth/me'); + return response.data.user; +} + +/** + * Return recent authenticated file history. + */ +export async function getHistory(limit = 50): Promise { + const response = await api.get('/history', { + params: { limit }, + }); + return response.data.items; +} + /** * Poll task status. */ @@ -128,4 +242,63 @@ export async function checkHealth(): Promise { } } +// --- Account / Usage / API Keys --- + +export interface UsageSummary { + plan: string; + period_month: string; + ads_enabled: boolean; + history_limit: number; + file_limits_mb: { + pdf: number; + word: number; + image: number; + video: number; + homepageSmartUpload: number; + }; + web_quota: { used: number; limit: number | null }; + api_quota: { used: number; limit: number | null }; +} + +export interface ApiKey { + id: number; + name: string; + key_prefix: string; + last_used_at: string | null; + revoked_at: string | null; + created_at: string; + raw_key?: string; // only present on creation +} + +/** + * Return the current user's plan, quota, and file-limit summary. + */ +export async function getUsage(): Promise { + const response = await api.get('/account/usage'); + return response.data; +} + +/** + * Return all API keys for the authenticated pro user. + */ +export async function getApiKeys(): Promise { + const response = await api.get<{ items: ApiKey[] }>('/account/api-keys'); + return response.data.items; +} + +/** + * Create a new API key with the given name. Returns the key including raw_key once. + */ +export async function createApiKey(name: string): Promise { + const response = await api.post('/account/api-keys', { name }); + return response.data; +} + +/** + * Revoke one API key by id. + */ +export async function revokeApiKey(keyId: number): Promise { + await api.delete(`/account/api-keys/${keyId}`); +} + export default api; diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts new file mode 100644 index 0000000..7d8308b --- /dev/null +++ b/frontend/src/stores/authStore.ts @@ -0,0 +1,71 @@ +import { create } from 'zustand'; +import { + getCurrentUser, + loginUser, + logoutUser, + registerUser, + type AuthUser, +} from '@/services/api'; + +interface AuthState { + user: AuthUser | null; + isLoading: boolean; + initialized: boolean; + refreshUser: () => Promise; + login: (email: string, password: string) => Promise; + register: (email: string, password: string) => Promise; + logout: () => Promise; +} + +export const useAuthStore = create((set) => ({ + user: null, + isLoading: false, + initialized: false, + + refreshUser: async () => { + set({ isLoading: true }); + try { + const user = await getCurrentUser(); + set({ user, isLoading: false, initialized: true }); + return user; + } catch { + set({ user: null, isLoading: false, initialized: true }); + return null; + } + }, + + login: async (email: string, password: string) => { + set({ isLoading: true }); + try { + const user = await loginUser(email, password); + set({ user, isLoading: false, initialized: true }); + return user; + } catch (error) { + set({ isLoading: false, initialized: true }); + throw error; + } + }, + + register: async (email: string, password: string) => { + set({ isLoading: true }); + try { + const user = await registerUser(email, password); + set({ user, isLoading: false, initialized: true }); + return user; + } catch (error) { + set({ isLoading: false, initialized: true }); + throw error; + } + }, + + logout: async () => { + set({ isLoading: true }); + try { + await logoutUser(); + set({ user: null, isLoading: false, initialized: true }); + } catch (error) { + set({ isLoading: false }); + throw error; + } + }, +})); diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 06d35fd..064a307 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -17,7 +17,7 @@ server { add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://pagead2.googlesyndication.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'self'" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://pagead2.googlesyndication.com https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://pagead2.googlesyndication.com https://www.google-analytics.com; connect-src 'self' https://www.google-analytics.com https://pagead2.googlesyndication.com; frame-src https://googleads.g.doubleclick.net https://tpc.googlesyndication.com; frame-ancestors 'self'" always; # API requests → Flask backend location /api/ { diff --git a/nginx/nginx.prod.conf b/nginx/nginx.prod.conf index a739c7b..41d2b06 100644 --- a/nginx/nginx.prod.conf +++ b/nginx/nginx.prod.conf @@ -30,7 +30,7 @@ server { add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://pagead2.googlesyndication.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'self'" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://pagead2.googlesyndication.com https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://pagead2.googlesyndication.com https://www.google-analytics.com; connect-src 'self' https://www.google-analytics.com https://pagead2.googlesyndication.com; frame-src https://googleads.g.doubleclick.net https://tpc.googlesyndication.com; frame-ancestors 'self'" always; # Gzip gzip on;