إنجاز: تفعيل خاصية استعادة كلمة المرور وإعادة تعيينها
- إضافة نقاط نهاية لخاصيتي استعادة كلمة المرور وإعادة تعيينها في الواجهة الخلفية. - إنشاء اختبارات لخاصية إعادة تعيين كلمة المرور لضمان كفاءتها وأمانها. - تطوير صفحات واجهة المستخدم لخاصيتي استعادة كلمة المرور وإعادة تعيينها مع معالجة النماذج. - دمج حدود تحميل ديناميكية لأنواع ملفات مختلفة بناءً على خطط المستخدمين. - تقديم أداة جديدة لتغيير حجم الصور مع إمكانية تعديل الأبعاد وإعدادات الجودة. - تحديث نظام التوجيه والتنقل ليشمل أدوات جديدة وميزات مصادقة. - تحسين تجربة المستخدم من خلال معالجة الأخطاء ورسائل التغذية الراجعة المناسبة. - إضافة دعم التدويل للميزات الجديدة باللغات الإنجليزية والعربية والفرنسية.
This commit is contained in:
@@ -89,6 +89,7 @@ def create_app(config_name=None):
|
||||
from app.routes.pdf_tools import pdf_tools_bp
|
||||
from app.routes.flowchart import flowchart_bp
|
||||
from app.routes.v1.tools import v1_bp
|
||||
from app.routes.config import config_bp
|
||||
|
||||
app.register_blueprint(health_bp, url_prefix="/api")
|
||||
app.register_blueprint(auth_bp, url_prefix="/api/auth")
|
||||
@@ -104,5 +105,6 @@ def create_app(config_name=None):
|
||||
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")
|
||||
app.register_blueprint(config_bp, url_prefix="/api/config")
|
||||
|
||||
return app
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Flask extensions initialization."""
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
from flask_cors import CORS
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
@@ -33,6 +34,14 @@ def init_celery(app):
|
||||
"app.tasks.flowchart_tasks.*": {"queue": "flowchart"},
|
||||
}
|
||||
|
||||
# Celery Beat — periodic tasks
|
||||
celery.conf.beat_schedule = {
|
||||
"cleanup-expired-files": {
|
||||
"task": "app.tasks.maintenance_tasks.cleanup_expired_files",
|
||||
"schedule": crontab(minute="*/30"),
|
||||
},
|
||||
}
|
||||
|
||||
class ContextTask(celery.Task):
|
||||
"""Make Celery tasks work with Flask app context."""
|
||||
abstract = True
|
||||
|
||||
@@ -8,7 +8,12 @@ from app.services.account_service import (
|
||||
authenticate_user,
|
||||
create_user,
|
||||
get_user_by_id,
|
||||
get_user_by_email,
|
||||
create_password_reset_token,
|
||||
verify_and_consume_reset_token,
|
||||
update_user_password,
|
||||
)
|
||||
from app.services.email_service import send_password_reset_email
|
||||
from app.utils.auth import (
|
||||
get_current_user_id,
|
||||
login_user_session,
|
||||
@@ -98,3 +103,48 @@ def me_route():
|
||||
return jsonify({"authenticated": False, "user": None}), 200
|
||||
|
||||
return jsonify({"authenticated": True, "user": user}), 200
|
||||
|
||||
|
||||
@auth_bp.route("/forgot-password", methods=["POST"])
|
||||
@limiter.limit("5/hour")
|
||||
def forgot_password_route():
|
||||
"""Send a password reset email if the account exists.
|
||||
|
||||
Always returns 200 to avoid leaking whether an email is registered.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
email = str(data.get("email", "")).strip().lower()
|
||||
|
||||
if not email or not EMAIL_PATTERN.match(email):
|
||||
return jsonify({"message": "If that email is registered, a reset link has been sent."}), 200
|
||||
|
||||
user = get_user_by_email(email)
|
||||
if user is not None:
|
||||
token = create_password_reset_token(user["id"])
|
||||
send_password_reset_email(email, token)
|
||||
|
||||
return jsonify({"message": "If that email is registered, a reset link has been sent."}), 200
|
||||
|
||||
|
||||
@auth_bp.route("/reset-password", methods=["POST"])
|
||||
@limiter.limit("10/hour")
|
||||
def reset_password_route():
|
||||
"""Consume a reset token and set a new password."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
token = str(data.get("token", "")).strip()
|
||||
password = str(data.get("password", ""))
|
||||
|
||||
if not token:
|
||||
return jsonify({"error": "Reset token is required."}), 400
|
||||
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
return jsonify({"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters."}), 400
|
||||
if len(password) > MAX_PASSWORD_LENGTH:
|
||||
return jsonify({"error": f"Password must be {MAX_PASSWORD_LENGTH} characters or less."}), 400
|
||||
|
||||
user_id = verify_and_consume_reset_token(token)
|
||||
if user_id is None:
|
||||
return jsonify({"error": "Invalid or expired reset token."}), 400
|
||||
|
||||
update_user_password(user_id, password)
|
||||
return jsonify({"message": "Password updated successfully. You can now sign in."}), 200
|
||||
|
||||
32
backend/app/routes/config.py
Normal file
32
backend/app/routes/config.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Public configuration endpoint — returns dynamic upload limits."""
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
from app.services.policy_service import (
|
||||
get_effective_file_size_limits_mb,
|
||||
get_usage_summary_for_user,
|
||||
resolve_web_actor,
|
||||
FREE_PLAN,
|
||||
)
|
||||
|
||||
config_bp = Blueprint("config", __name__)
|
||||
|
||||
|
||||
@config_bp.route("", methods=["GET"])
|
||||
def get_config():
|
||||
"""Return dynamic upload limits and (if logged-in) usage summary.
|
||||
|
||||
Anonymous callers get free-plan limits.
|
||||
Authenticated callers get plan-aware limits + quota usage.
|
||||
"""
|
||||
actor = resolve_web_actor()
|
||||
file_limits_mb = get_effective_file_size_limits_mb(actor.plan)
|
||||
|
||||
payload: dict = {
|
||||
"file_limits_mb": file_limits_mb,
|
||||
"max_upload_mb": max(file_limits_mb.values()),
|
||||
}
|
||||
|
||||
if actor.user_id is not None:
|
||||
payload["usage"] = get_usage_summary_for_user(actor.user_id, actor.plan)
|
||||
|
||||
return jsonify(payload), 200
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from flask import current_app
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
@@ -160,6 +160,35 @@ def init_account_db():
|
||||
"ALTER TABLE users ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
|
||||
# Password reset tokens
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_prt_token_hash
|
||||
ON password_reset_tokens(token_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_type TEXT NOT NULL,
|
||||
file_path TEXT,
|
||||
detail TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_file_events_created
|
||||
ON file_events(created_at DESC);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def create_user(email: str, password: str) -> dict:
|
||||
"""Create a new user and return the public record."""
|
||||
@@ -515,3 +544,99 @@ def has_task_access(user_id: int, source: str, task_id: str) -> bool:
|
||||
).fetchone()
|
||||
|
||||
return row is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password reset tokens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_user_by_email(email: str) -> dict | None:
|
||||
"""Fetch a public user record by email."""
|
||||
email = _normalize_email(email)
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, email, plan, created_at FROM users WHERE email = ?",
|
||||
(email,),
|
||||
).fetchone()
|
||||
return _serialize_user(row)
|
||||
|
||||
|
||||
def create_password_reset_token(user_id: int) -> str:
|
||||
"""Generate a password-reset token (returned raw) and store its hash."""
|
||||
raw_token = secrets.token_urlsafe(48)
|
||||
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
now = _utc_now()
|
||||
# Expire in 1 hour
|
||||
expires = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
|
||||
|
||||
with _connect() as conn:
|
||||
# Invalidate any previous unused tokens for this user
|
||||
conn.execute(
|
||||
"UPDATE password_reset_tokens SET used_at = ? WHERE user_id = ? AND used_at IS NULL",
|
||||
(now, user_id),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(user_id, token_hash, expires, now),
|
||||
)
|
||||
|
||||
return raw_token
|
||||
|
||||
|
||||
def verify_and_consume_reset_token(raw_token: str) -> int | None:
|
||||
"""Verify a reset token. Returns user_id if valid, else None. Marks it used."""
|
||||
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
now = _utc_now()
|
||||
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id, user_id, expires_at
|
||||
FROM password_reset_tokens
|
||||
WHERE token_hash = ? AND used_at IS NULL
|
||||
""",
|
||||
(token_hash,),
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
# Check expiry
|
||||
if row["expires_at"] < now:
|
||||
conn.execute(
|
||||
"UPDATE password_reset_tokens SET used_at = ? WHERE id = ?",
|
||||
(now, row["id"]),
|
||||
)
|
||||
return None
|
||||
|
||||
# Mark used
|
||||
conn.execute(
|
||||
"UPDATE password_reset_tokens SET used_at = ? WHERE id = ?",
|
||||
(now, row["id"]),
|
||||
)
|
||||
|
||||
return row["user_id"]
|
||||
|
||||
|
||||
def update_user_password(user_id: int, new_password: str) -> bool:
|
||||
"""Update a user's password hash."""
|
||||
now = _utc_now()
|
||||
password_hash = generate_password_hash(new_password)
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?",
|
||||
(password_hash, now, user_id),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def log_file_event(event_type: str, file_path: str | None = None, detail: str | None = None) -> None:
|
||||
"""Record a file lifecycle event (upload, download, cleanup, etc.)."""
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO file_events (event_type, file_path, detail, created_at) VALUES (?, ?, ?, ?)",
|
||||
(event_type, file_path, detail, _utc_now()),
|
||||
)
|
||||
|
||||
72
backend/app/services/email_service.py
Normal file
72
backend/app/services/email_service.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Email service — sends transactional emails via SMTP."""
|
||||
import logging
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from flask import current_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_smtp_config() -> dict:
|
||||
"""Read SMTP settings from Flask config."""
|
||||
return {
|
||||
"host": current_app.config.get("SMTP_HOST", ""),
|
||||
"port": current_app.config.get("SMTP_PORT", 587),
|
||||
"user": current_app.config.get("SMTP_USER", ""),
|
||||
"password": current_app.config.get("SMTP_PASSWORD", ""),
|
||||
"from_addr": current_app.config.get("SMTP_FROM", "noreply@saas-pdf.com"),
|
||||
"use_tls": current_app.config.get("SMTP_USE_TLS", True),
|
||||
}
|
||||
|
||||
|
||||
def send_email(to: str, subject: str, html_body: str) -> bool:
|
||||
"""Send an HTML email. Returns True on success."""
|
||||
cfg = _get_smtp_config()
|
||||
|
||||
if not cfg["host"]:
|
||||
logger.warning("SMTP not configured — email to %s suppressed.", to)
|
||||
return False
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = cfg["from_addr"]
|
||||
msg["To"] = to
|
||||
msg.attach(MIMEText(html_body, "html"))
|
||||
|
||||
try:
|
||||
if cfg["use_tls"]:
|
||||
server = smtplib.SMTP(cfg["host"], cfg["port"], timeout=10)
|
||||
server.starttls()
|
||||
else:
|
||||
server = smtplib.SMTP(cfg["host"], cfg["port"], timeout=10)
|
||||
|
||||
if cfg["user"]:
|
||||
server.login(cfg["user"], cfg["password"])
|
||||
|
||||
server.sendmail(cfg["from_addr"], [to], msg.as_string())
|
||||
server.quit()
|
||||
logger.info("Email sent to %s: %s", to, subject)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Failed to send email to %s", to)
|
||||
return False
|
||||
|
||||
|
||||
def send_password_reset_email(to: str, token: str) -> bool:
|
||||
"""Send a password reset link."""
|
||||
frontend = current_app.config.get("FRONTEND_URL", "http://localhost:5173")
|
||||
reset_link = f"{frontend}/reset-password?token={token}"
|
||||
|
||||
html = f"""
|
||||
<div style="font-family: sans-serif; max-width: 480px; margin: auto;">
|
||||
<h2>Password Reset</h2>
|
||||
<p>You requested a password reset for your SaaS-PDF account.</p>
|
||||
<p><a href="{reset_link}" style="display:inline-block;padding:12px 24px;background:#4f46e5;color:#fff;border-radius:8px;text-decoration:none;">
|
||||
Reset Password
|
||||
</a></p>
|
||||
<p style="color:#666;font-size:14px;">This link expires in 1 hour. If you didn't request this, you can safely ignore this email.</p>
|
||||
</div>
|
||||
"""
|
||||
return send_email(to, "Reset your SaaS-PDF password", html)
|
||||
92
backend/app/tasks/maintenance_tasks.py
Normal file
92
backend/app/tasks/maintenance_tasks.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Periodic maintenance tasks — file cleanup and logging."""
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
|
||||
from app.extensions import celery
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery.task(name="app.tasks.maintenance_tasks.cleanup_expired_files")
|
||||
def cleanup_expired_files():
|
||||
"""Remove upload/output directories older than FILE_EXPIRY_SECONDS.
|
||||
|
||||
Runs as a Celery Beat periodic task.
|
||||
Logs a summary of scanned/deleted/freed counts.
|
||||
"""
|
||||
from flask import current_app
|
||||
|
||||
expiry = current_app.config.get("FILE_EXPIRY_SECONDS", 1800)
|
||||
upload_dir = current_app.config.get("UPLOAD_FOLDER", "/tmp/uploads")
|
||||
output_dir = current_app.config.get("OUTPUT_FOLDER", "/tmp/outputs")
|
||||
|
||||
total_stats = {"scanned": 0, "deleted": 0, "freed_bytes": 0, "errors": 0}
|
||||
|
||||
for target_dir in [upload_dir, output_dir]:
|
||||
stats = _cleanup_dir(target_dir, expiry)
|
||||
for key in total_stats:
|
||||
total_stats[key] += stats[key]
|
||||
|
||||
logger.info(
|
||||
"Cleanup complete: scanned=%d deleted=%d freed=%.1fMB errors=%d",
|
||||
total_stats["scanned"],
|
||||
total_stats["deleted"],
|
||||
total_stats["freed_bytes"] / (1024 * 1024),
|
||||
total_stats["errors"],
|
||||
)
|
||||
|
||||
# Log cleanup event
|
||||
try:
|
||||
from app.services.account_service import log_file_event
|
||||
|
||||
log_file_event(
|
||||
"cleanup",
|
||||
detail=f"deleted={total_stats['deleted']} freed={total_stats['freed_bytes']} errors={total_stats['errors']}",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Could not log file_event for cleanup")
|
||||
|
||||
return total_stats
|
||||
|
||||
|
||||
def _cleanup_dir(directory: str, expiry_seconds: int) -> dict:
|
||||
"""Scan one directory and remove expired sub-directories."""
|
||||
stats = {"scanned": 0, "deleted": 0, "freed_bytes": 0, "errors": 0}
|
||||
|
||||
if not os.path.isdir(directory):
|
||||
return stats
|
||||
|
||||
now = time.time()
|
||||
|
||||
for entry in os.listdir(directory):
|
||||
full_path = os.path.join(directory, entry)
|
||||
if not os.path.isdir(full_path):
|
||||
continue
|
||||
|
||||
stats["scanned"] += 1
|
||||
try:
|
||||
mod_time = os.path.getmtime(full_path)
|
||||
except OSError:
|
||||
stats["errors"] += 1
|
||||
continue
|
||||
|
||||
if (now - mod_time) <= expiry_seconds:
|
||||
continue
|
||||
|
||||
try:
|
||||
dir_size = sum(
|
||||
os.path.getsize(os.path.join(dp, f))
|
||||
for dp, _, filenames in os.walk(full_path)
|
||||
for f in filenames
|
||||
)
|
||||
shutil.rmtree(full_path)
|
||||
stats["deleted"] += 1
|
||||
stats["freed_bytes"] += dir_size
|
||||
logger.debug("Deleted expired: %s (%.1fKB)", entry, dir_size / 1024)
|
||||
except Exception:
|
||||
logger.exception("Failed to delete %s", full_path)
|
||||
stats["errors"] += 1
|
||||
|
||||
return stats
|
||||
Reference in New Issue
Block a user