289 lines
11 KiB
Python
289 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional
|
|
|
|
import phonenumbers
|
|
from sqlalchemy.orm import Session
|
|
|
|
from adapters.db.repositories.user_repo import UserRepository
|
|
from adapters.db.repositories.api_key_repo import ApiKeyRepository
|
|
from app.core.security import hash_password, verify_password, generate_api_key, consteq
|
|
from app.core.settings import get_settings
|
|
from app.services.captcha_service import validate_captcha
|
|
from adapters.db.repositories.password_reset_repo import PasswordResetRepository
|
|
import hashlib
|
|
|
|
|
|
def _normalize_email(email: str | None) -> str | None:
|
|
return email.lower().strip() if email else None
|
|
|
|
|
|
def _normalize_mobile(mobile: str | None) -> str | None:
|
|
if not mobile:
|
|
return None
|
|
# Clean input: keep digits and leading plus
|
|
raw = mobile.strip()
|
|
raw = ''.join(ch for ch in raw if ch.isdigit() or ch == '+')
|
|
try:
|
|
from app.core.settings import get_settings
|
|
settings = get_settings()
|
|
region = None if raw.startswith('+') else settings.default_phone_region
|
|
num = phonenumbers.parse(raw, region)
|
|
if not phonenumbers.is_valid_number(num):
|
|
return None
|
|
return phonenumbers.format_number(num, phonenumbers.PhoneNumberFormat.E164)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _detect_identifier(identifier: str) -> tuple[str, str | None, str | None]:
|
|
identifier = identifier.strip()
|
|
if "@" in identifier:
|
|
return "email", _normalize_email(identifier), None
|
|
mobile = _normalize_mobile(identifier)
|
|
return ("mobile", None, mobile) if mobile else ("invalid", None, None)
|
|
|
|
|
|
def _generate_referral_code(db: Session) -> str:
|
|
from secrets import token_urlsafe
|
|
repo = UserRepository(db)
|
|
# try a few times to ensure uniqueness
|
|
for _ in range(10):
|
|
code = token_urlsafe(8).replace('-', '').replace('_', '')[:10]
|
|
if not repo.get_by_referral_code(code):
|
|
return code
|
|
# fallback longer code
|
|
return token_urlsafe(12).replace('-', '').replace('_', '')[:12]
|
|
|
|
|
|
def register_user(*, db: Session, first_name: str | None, last_name: str | None, email: str | None, mobile: str | None, password: str, captcha_id: str, captcha_code: str, referrer_code: str | None = None) -> int:
|
|
if not validate_captcha(db, captcha_id, captcha_code):
|
|
from app.core.responses import ApiError
|
|
raise ApiError("INVALID_CAPTCHA", "Invalid captcha code")
|
|
|
|
email_n = _normalize_email(email)
|
|
mobile_n = _normalize_mobile(mobile)
|
|
if not email_n and not mobile_n:
|
|
from app.core.responses import ApiError
|
|
# اگر کاربر موبایل وارد کرده اما نامعتبر بوده، پیام دقیقتر بدهیم
|
|
if mobile and mobile.strip():
|
|
raise ApiError("INVALID_MOBILE", "Invalid mobile number")
|
|
# در غیر این صورت، هیچ شناسهٔ معتبری ارائه نشده است
|
|
raise ApiError("IDENTIFIER_REQUIRED", "Email or mobile is required")
|
|
|
|
repo = UserRepository(db)
|
|
if email_n and repo.get_by_email(email_n):
|
|
from app.core.responses import ApiError
|
|
raise ApiError("EMAIL_IN_USE", "Email is already in use")
|
|
if mobile_n and repo.get_by_mobile(mobile_n):
|
|
from app.core.responses import ApiError
|
|
raise ApiError("MOBILE_IN_USE", "Mobile is already in use")
|
|
|
|
pwd_hash = hash_password(password)
|
|
referred_by_user_id = None
|
|
if referrer_code:
|
|
ref_user = repo.get_by_referral_code(referrer_code)
|
|
if ref_user:
|
|
# prevent self-referral at signup theoretically not applicable; rule kept for safety
|
|
referred_by_user_id = ref_user.id
|
|
referral_code = _generate_referral_code(db)
|
|
user = repo.create(
|
|
email=email_n,
|
|
mobile=mobile_n,
|
|
password_hash=pwd_hash,
|
|
first_name=first_name,
|
|
last_name=last_name,
|
|
referral_code=referral_code,
|
|
referred_by_user_id=referred_by_user_id,
|
|
)
|
|
return user.id
|
|
|
|
|
|
def login_user(*, db: Session, identifier: str, password: str, captcha_id: str, captcha_code: str, device_id: str | None, user_agent: str | None, ip: str | None) -> tuple[str, datetime | None, dict]:
|
|
if not validate_captcha(db, captcha_id, captcha_code):
|
|
from app.core.responses import ApiError
|
|
raise ApiError("INVALID_CAPTCHA", "Invalid captcha code")
|
|
|
|
kind, email, mobile = _detect_identifier(identifier)
|
|
if kind == "invalid":
|
|
from app.core.responses import ApiError
|
|
raise ApiError("INVALID_IDENTIFIER", "Identifier must be a valid email or mobile number")
|
|
|
|
repo = UserRepository(db)
|
|
user = repo.get_by_email(email) if email else repo.get_by_mobile(mobile) # type: ignore[arg-type]
|
|
if not user or not verify_password(password, user.password_hash):
|
|
from app.core.responses import ApiError
|
|
raise ApiError("INVALID_CREDENTIALS", "Invalid credentials")
|
|
if not user.is_active:
|
|
from app.core.responses import ApiError
|
|
raise ApiError("ACCOUNT_DISABLED", "Your account is disabled")
|
|
|
|
settings = get_settings()
|
|
api_key, key_hash = generate_api_key()
|
|
expires_at = None # could be set from settings later
|
|
api_repo = ApiKeyRepository(db)
|
|
api_repo.create_session_key(user_id=user.id, key_hash=key_hash, device_id=device_id, user_agent=user_agent, ip=ip, expires_at=expires_at)
|
|
|
|
user_data = {
|
|
"id": user.id,
|
|
"first_name": user.first_name,
|
|
"last_name": user.last_name,
|
|
"email": user.email,
|
|
"mobile": user.mobile,
|
|
"referral_code": getattr(user, "referral_code", None),
|
|
}
|
|
return api_key, expires_at, user_data
|
|
|
|
|
|
def _hash_reset_token(token: str) -> str:
|
|
settings = get_settings()
|
|
return hashlib.sha256(f"{settings.captcha_secret}:{token}".encode("utf-8")).hexdigest()
|
|
|
|
|
|
def create_password_reset(*, db: Session, identifier: str, captcha_id: str, captcha_code: str) -> str:
|
|
if not validate_captcha(db, captcha_id, captcha_code):
|
|
from app.core.responses import ApiError
|
|
raise ApiError("INVALID_CAPTCHA", "Invalid captcha code")
|
|
|
|
kind, email, mobile = _detect_identifier(identifier)
|
|
if kind == "invalid":
|
|
from app.core.responses import ApiError
|
|
raise ApiError("INVALID_IDENTIFIER", "Identifier must be a valid email or mobile number")
|
|
|
|
repo = UserRepository(db)
|
|
user = repo.get_by_email(email) if email else repo.get_by_mobile(mobile) # type: ignore[arg-type]
|
|
# Always respond OK to avoid user enumeration; but skip creation if user not found
|
|
if not user:
|
|
return ""
|
|
|
|
settings = get_settings()
|
|
from secrets import token_urlsafe
|
|
token = token_urlsafe(32)
|
|
token_hash = _hash_reset_token(token)
|
|
expires_at = datetime.utcnow() + timedelta(seconds=settings.reset_password_ttl_seconds)
|
|
pr_repo = PasswordResetRepository(db)
|
|
pr_repo.create(user_id=user.id, token_hash=token_hash, expires_at=expires_at)
|
|
return token
|
|
|
|
|
|
def reset_password(*, db: Session, token: str, new_password: str, captcha_id: str, captcha_code: str) -> None:
|
|
if not validate_captcha(db, captcha_id, captcha_code):
|
|
from app.core.responses import ApiError
|
|
raise ApiError("INVALID_CAPTCHA", "Invalid captcha code")
|
|
|
|
pr_repo = PasswordResetRepository(db)
|
|
token_hash = _hash_reset_token(token)
|
|
pr = pr_repo.get_by_hash(token_hash)
|
|
if not pr or pr.expires_at < datetime.utcnow() or pr.used_at is not None:
|
|
from app.core.responses import ApiError
|
|
raise ApiError("RESET_TOKEN_INVALID_OR_EXPIRED", "Reset token is invalid or expired")
|
|
|
|
# Update user password
|
|
from adapters.db.models.user import User
|
|
user = db.get(User, pr.user_id)
|
|
if not user:
|
|
from app.core.responses import ApiError
|
|
raise ApiError("RESET_TOKEN_INVALID_OR_EXPIRED", "Reset token is invalid or expired")
|
|
user.password_hash = hash_password(new_password)
|
|
db.add(user)
|
|
db.commit()
|
|
|
|
pr_repo.mark_used(pr)
|
|
|
|
|
|
|
|
|
|
def change_password(*, db: Session, user_id: int, current_password: str, new_password: str, confirm_password: str, translator=None) -> None:
|
|
"""
|
|
تغییر کلمه عبور کاربر
|
|
"""
|
|
# بررسی تطبیق کلمه عبور جدید و تکرار آن
|
|
if new_password != confirm_password:
|
|
from app.core.responses import ApiError
|
|
raise ApiError("PASSWORDS_DO_NOT_MATCH", "New password and confirm password do not match", translator=translator)
|
|
|
|
# بررسی اینکه کلمه عبور جدید با کلمه عبور فعلی متفاوت باشد
|
|
if current_password == new_password:
|
|
from app.core.responses import ApiError
|
|
raise ApiError("SAME_PASSWORD", "New password must be different from current password", translator=translator)
|
|
|
|
# دریافت کاربر
|
|
from adapters.db.models.user import User
|
|
user = db.get(User, user_id)
|
|
if not user:
|
|
from app.core.responses import ApiError
|
|
raise ApiError("USER_NOT_FOUND", "User not found", translator=translator)
|
|
|
|
# بررسی کلمه عبور فعلی
|
|
if not verify_password(current_password, user.password_hash):
|
|
from app.core.responses import ApiError
|
|
raise ApiError("INVALID_CURRENT_PASSWORD", "Current password is incorrect", translator=translator)
|
|
|
|
# بررسی اینکه کاربر فعال باشد
|
|
if not user.is_active:
|
|
from app.core.responses import ApiError
|
|
raise ApiError("ACCOUNT_DISABLED", "Your account is disabled", translator=translator)
|
|
|
|
# تغییر کلمه عبور
|
|
user.password_hash = hash_password(new_password)
|
|
db.add(user)
|
|
db.commit()
|
|
|
|
|
|
def referral_stats(*, db: Session, user_id: int, start: datetime | None = None, end: datetime | None = None) -> dict:
|
|
from adapters.db.repositories.user_repo import UserRepository
|
|
repo = UserRepository(db)
|
|
# totals
|
|
total = repo.count_referred(user_id)
|
|
# month
|
|
now = datetime.utcnow()
|
|
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
next_month = (month_start.replace(day=28) + timedelta(days=4)).replace(day=1)
|
|
month_count = repo.count_referred_between(user_id, month_start, next_month)
|
|
# today
|
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
tomorrow = today_start + timedelta(days=1)
|
|
today_count = repo.count_referred_between(user_id, today_start, tomorrow)
|
|
# custom range
|
|
custom = None
|
|
if start and end:
|
|
custom = repo.count_referred_between(user_id, start, end)
|
|
return {
|
|
"total": total,
|
|
"this_month": month_count,
|
|
"today": today_count,
|
|
"range": custom,
|
|
}
|
|
|
|
|
|
def referral_list(*, db: Session, user_id: int, start: datetime | None = None, end: datetime | None = None, search: str | None = None, page: int = 1, limit: int = 20) -> dict:
|
|
from adapters.db.repositories.user_repo import UserRepository
|
|
repo = UserRepository(db)
|
|
page = max(1, page)
|
|
limit = max(1, min(100, limit))
|
|
offset = (page - 1) * limit
|
|
items = repo.list_referred(user_id, start_dt=start, end_dt=end, search=search, offset=offset, limit=limit)
|
|
total = repo.count_referred_filtered(user_id, start_dt=start, end_dt=end, search=search)
|
|
def mask_email(email: str | None) -> str | None:
|
|
if not email:
|
|
return None
|
|
try:
|
|
local, _, domain = email.partition('@')
|
|
if len(local) <= 2:
|
|
masked_local = local[0] + "*"
|
|
else:
|
|
masked_local = local[0] + "*" * (len(local) - 2) + local[-1]
|
|
return masked_local + "@" + domain
|
|
except Exception:
|
|
return email
|
|
result = []
|
|
for u in items:
|
|
result.append({
|
|
"id": u.id,
|
|
"first_name": u.first_name,
|
|
"last_name": u.last_name,
|
|
"email": mask_email(u.email),
|
|
"created_at": u.created_at.isoformat(),
|
|
})
|
|
return {"items": result, "total": total, "page": page, "limit": limit} |