diff --git a/requirements.txt b/requirements.txt index 7ca401c..8eaecc6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,10 @@ aiohttp jinja2 aiohttp_session bcrypt +python-dotenv +aiosmtplib +aiojobs pytest pytest-aiohttp aiohttp-test-utils +pytest-mock diff --git a/retoors/main.py b/retoors/main.py index 65d2928..3f4c9ce 100644 --- a/retoors/main.py +++ b/retoors/main.py @@ -5,11 +5,13 @@ from pathlib import Path from aiohttp_session import setup as setup_session from aiohttp_session.cookie_storage import EncryptedCookieStorage import os +import aiojobs # Import aiojobs from .routes import setup_routes from .services.user_service import UserService from .services.config_service import ConfigService from .middlewares import user_middleware, error_middleware +from .helpers.env_manager import ensure_env_file_exists async def setup_services(app: web.Application): @@ -17,10 +19,19 @@ async def setup_services(app: web.Application): data_path = base_path.parent / "data" app["user_service"] = UserService(data_path / "users.json") app["config_service"] = ConfigService(data_path / "config.json") + + # Setup aiojobs scheduler + app["scheduler"] = await aiojobs.create_scheduler() yield + # Cleanup aiojobs scheduler + await app["scheduler"].close() def create_app(): + # Ensure .env file exists before loading any configurations + project_root = Path(__file__).parent.parent + ensure_env_file_exists(project_root / ".env") + app = web.Application() # The order of middleware registration matters. @@ -57,4 +68,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/retoors/routes.py b/retoors/routes.py index 1e5eda3..3186922 100644 --- a/retoors/routes.py +++ b/retoors/routes.py @@ -1,4 +1,4 @@ -from .views.auth import LoginView, RegistrationView, LogoutView +from .views.auth import LoginView, RegistrationView, LogoutView, ForgotPasswordView, ResetPasswordView from .views.site import SiteView, OrderView @@ -6,6 +6,8 @@ def setup_routes(app): app.router.add_view("/login", LoginView, name="login") app.router.add_view("/register", RegistrationView, name="register") app.router.add_view("/logout", LogoutView, name="logout") + app.router.add_view("/forgot_password", ForgotPasswordView, name="forgot_password") + app.router.add_view("/reset_password/{token}", ResetPasswordView, name="reset_password") app.router.add_view("/", SiteView, name="index") app.router.add_view("/solutions", SiteView, name="solutions") app.router.add_view("/support", SiteView, name="support") diff --git a/retoors/services/config_service.py b/retoors/services/config_service.py index f1e0f8e..5eea3a2 100644 --- a/retoors/services/config_service.py +++ b/retoors/services/config_service.py @@ -1,16 +1,53 @@ import json +import os from pathlib import Path +from dotenv import load_dotenv class ConfigService: def __init__(self, config_path: Path): + load_dotenv() # Load environment variables from .env file self._config_path = config_path self._config = self._load_config() def _load_config(self): - if not self._config_path.exists(): - return {} - with open(self._config_path, "r") as f: - return json.load(f) + config_from_file = {} + if self._config_path.exists(): + with open(self._config_path, "r") as f: + config_from_file = json.load(f) + + # Override with environment variables + config_from_env = { + "price_per_gb": float(os.getenv("PRICE_PER_GB", config_from_file.get("price_per_gb", 0.0))), + "smtp_host": os.getenv("SMTP_HOST", config_from_file.get("smtp_host")), + "smtp_port": int(os.getenv("SMTP_PORT", config_from_file.get("smtp_port", 587))), + "smtp_username": os.getenv("SMTP_USERNAME", config_from_file.get("smtp_username")), + "smtp_password": os.getenv("SMTP_PASSWORD", config_from_file.get("smtp_password")), + "smtp_use_tls": os.getenv("SMTP_USE_TLS", str(config_from_file.get("smtp_use_tls", "True"))).lower() == "true", + "smtp_sender_email": os.getenv("SMTP_SENDER_EMAIL", config_from_file.get("smtp_sender_email")), + } + + # Merge file config with environment config, environment variables take precedence + merged_config = {**config_from_file, **{k: v for k, v in config_from_env.items() if v is not None}} + + return merged_config def get_price_per_gb(self) -> float: return self._config.get("price_per_gb", 0.0) + + def get_smtp_host(self) -> str | None: + return self._config.get("smtp_host") + + def get_smtp_port(self) -> int: + return self._config.get("smtp_port", 587) + + def get_smtp_username(self) -> str | None: + return self._config.get("smtp_username") + + def get_smtp_password(self) -> str | None: + return self._config.get("smtp_password") + + def get_smtp_use_tls(self) -> bool: + return self._config.get("smtp_use_tls", True) + + def get_smtp_sender_email(self) -> str | None: + return self._config.get("smtp_sender_email") diff --git a/retoors/services/user_service.py b/retoors/services/user_service.py index 3c641a6..abde494 100644 --- a/retoors/services/user_service.py +++ b/retoors/services/user_service.py @@ -1,7 +1,10 @@ import json from pathlib import Path from typing import List, Dict, Optional, Any -import hashlib +import bcrypt # Import bcrypt +import secrets # For generating secure tokens +import datetime # For token expiry + class UserService: def __init__(self, users_path: Path): @@ -25,13 +28,16 @@ class UserService: if self.get_user_by_email(email): raise ValueError("User with this email already exists") - hashed_password = hashlib.sha256(password.encode()).hexdigest() + # Hash password with bcrypt + hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') user = { "full_name": full_name, "email": email, "password": hashed_password, "storage_quota_gb": 5, # Default quota - "storage_used_gb": 0 + "storage_used_gb": 0, + "reset_token": None, + "reset_token_expiry": None, } self._users.append(user) self._save_users() @@ -41,8 +47,57 @@ class UserService: user = self.get_user_by_email(email) if not user: return False - hashed_password = hashlib.sha256(password.encode()).hexdigest() - return user["password"] == hashed_password + # Verify password with bcrypt + return bcrypt.checkpw(password.encode('utf-8'), user["password"].encode('utf-8')) + + def get_user_by_reset_token(self, token: str) -> Optional[Dict[str, Any]]: + for user in self._users: + if user.get("reset_token") == token: + expiry_str = user.get("reset_token_expiry") + if expiry_str: + expiry = datetime.datetime.fromisoformat(expiry_str) + if expiry > datetime.datetime.now(datetime.timezone.utc): + return user + return None + + def generate_reset_token(self, email: str) -> Optional[str]: + user = self.get_user_by_email(email) + if not user: + return None + + token = secrets.token_urlsafe(32) + expiry = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1) # Token valid for 1 hour + user["reset_token"] = token + user["reset_token_expiry"] = expiry.isoformat() + self._save_users() + return token + + def validate_reset_token(self, email: str, token: str) -> bool: + user = self.get_user_by_email(email) + if not user or user.get("reset_token") != token: + return False + + expiry_str = user.get("reset_token_expiry") + if not expiry_str: + return False + + expiry = datetime.datetime.fromisoformat(expiry_str) + return expiry > datetime.datetime.now(datetime.timezone.utc) + + def reset_password(self, email: str, token: str, new_password: str) -> bool: + if not self.validate_reset_token(email, token): + return False + + user = self.get_user_by_email(email) + if not user: # Should not happen if validate_reset_token passed, but for type safety + return False + + hashed_password = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + user["password"] = hashed_password + user["reset_token"] = None + user["reset_token_expiry"] = None + self._save_users() + return True def update_user_quota(self, email: str, new_quota_gb: float): user = self.get_user_by_email(email) @@ -51,4 +106,3 @@ class UserService: user["storage_quota_gb"] = new_quota_gb self._save_users() - diff --git a/retoors/templates/layouts/base.html b/retoors/templates/layouts/base.html index 7272aec..8ba0808 100644 --- a/retoors/templates/layouts/base.html +++ b/retoors/templates/layouts/base.html @@ -5,16 +5,14 @@