{{ error }}
+ {% endfor %} +{{ success }}
+diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6cf3ece --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +DATABASE_URL=mysql+pymysql://evosync:evosync@db:3306/evosync +SECRET_KEY=your-random-secret-key-here +BASE_URL=http://localhost:8000 + +DB_ROOT_PASSWORD=rootpass +DB_NAME=evosync +DB_USER=evosync +DB_PASSWORD=evosync diff --git a/.gitignore b/.gitignore index cece179..1a40508 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ run/test.log vk/whitelist logs/ passwords.txt +.env +__pycache__/ +*.pyc diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 0000000..0214f15 --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY web/ ./web/ + +CMD ["uvicorn", "web.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..166015e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +version: "3.8" + +services: + db: + image: mariadb:10.11 + environment: + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootpass} + MYSQL_DATABASE: ${DB_NAME:-evosync} + MYSQL_USER: ${DB_USER:-evosync} + MYSQL_PASSWORD: ${DB_PASSWORD:-evosync} + volumes: + - db_data:/var/lib/mysql + ports: + - "3306:3306" + + web: + build: + context: . + dockerfile: Dockerfile.web + ports: + - "8000:8000" + depends_on: + - db + environment: + - DATABASE_URL=mysql+pymysql://${DB_USER:-evosync}:${DB_PASSWORD:-evosync}@db:3306/${DB_NAME:-evosync} + - SECRET_KEY=${SECRET_KEY:-change-me-in-production} + - BASE_URL=${BASE_URL:-http://localhost:8000} + volumes: + - ./web:/app/web + + sync: + build: + context: . + dockerfile: Dockerfile + volumes: + - ./evo:/var/www/evo + - ./vk:/var/www/vk + - ./run:/var/www/run + - ./logs:/var/www/logs + +volumes: + db_data: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..763d5ad --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.0 +sqlalchemy==2.0.35 +pymysql==1.1.1 +jinja2==3.1.4 +python-multipart==0.0.12 +passlib[bcrypt]==1.7.4 +bcrypt==4.2.0 +pydantic-settings==2.5.2 diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/auth.py b/web/auth.py new file mode 100644 index 0000000..165f71c --- /dev/null +++ b/web/auth.py @@ -0,0 +1,23 @@ +from fastapi import Request, Depends +from sqlalchemy.orm import Session +from passlib.context import CryptContext + +from web.database import get_db +from web.models import User + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def get_current_user(request: Request, db: Session = Depends(get_db)) -> User | None: + user_id = request.session.get("user_id") + if not user_id: + return None + return db.query(User).filter(User.id == user_id).first() diff --git a/web/config.py b/web/config.py new file mode 100644 index 0000000..8dc8126 --- /dev/null +++ b/web/config.py @@ -0,0 +1,13 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + DATABASE_URL: str = "mysql+pymysql://evosync:evosync@localhost:3306/evosync" + SECRET_KEY: str = "change-me-in-production" + BASE_URL: str = "http://localhost:8000" + PASSWORD_RESET_EXPIRE_MINUTES: int = 60 + + model_config = {"env_file": ".env"} + + +settings = Settings() diff --git a/web/database.py b/web/database.py new file mode 100644 index 0000000..16e5b89 --- /dev/null +++ b/web/database.py @@ -0,0 +1,19 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase + +from web.config import settings + +engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/web/main.py b/web/main.py new file mode 100644 index 0000000..a317f06 --- /dev/null +++ b/web/main.py @@ -0,0 +1,22 @@ +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from starlette.middleware.sessions import SessionMiddleware + +from web.config import settings +from web.database import engine, Base +from web.models import User # noqa: F401 — registers model with Base +from web.routes import auth, profile, reset + +app = FastAPI(title="EvoSync — Личный кабинет") + +app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY) +app.mount("/static", StaticFiles(directory="web/static"), name="static") + +app.include_router(auth.router) +app.include_router(profile.router) +app.include_router(reset.router) + + +@app.on_event("startup") +def on_startup(): + Base.metadata.create_all(bind=engine) diff --git a/web/models.py b/web/models.py new file mode 100644 index 0000000..d584ca9 --- /dev/null +++ b/web/models.py @@ -0,0 +1,21 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy.sql import func + +from web.database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, autoincrement=True) + first_name = Column(String(100), nullable=False) + last_name = Column(String(100), nullable=False) + email = Column(String(255), unique=True, nullable=False, index=True) + phone = Column(String(20), unique=True, nullable=False, index=True) + password_hash = Column(String(255), nullable=False) + is_email_confirmed = Column(Boolean, default=False, nullable=False) + email_confirm_token = Column(String(255), nullable=True) + password_reset_token = Column(String(255), nullable=True) + password_reset_expires = Column(DateTime, nullable=True) + created_at = Column(DateTime, server_default=func.now(), nullable=False) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) diff --git a/web/routes/__init__.py b/web/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/routes/auth.py b/web/routes/auth.py new file mode 100644 index 0000000..c3d7a29 --- /dev/null +++ b/web/routes/auth.py @@ -0,0 +1,123 @@ +import uuid + +from fastapi import APIRouter, Request, Depends +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from web.auth import hash_password, verify_password, get_current_user +from web.config import settings +from web.database import get_db +from web.models import User +from web.schemas import validate_registration, validate_login + +router = APIRouter() +templates = Jinja2Templates(directory="web/templates") + + +@router.get("/register") +def register_form(request: Request, user: User | None = Depends(get_current_user)): + if user: + return RedirectResponse("/profile", 303) + return templates.TemplateResponse("register.html", {"request": request, "user": None}) + + +@router.post("/register") +async def register_submit(request: Request, db: Session = Depends(get_db)): + form = await request.form() + data = dict(form) + + errors = validate_registration(data) + + if not errors: + existing = db.query(User).filter( + (User.email == data["email"].strip()) | (User.phone == data["phone"].strip()) + ).first() + if existing: + if existing.email == data["email"].strip(): + errors.append("Пользователь с таким email уже существует") + else: + errors.append("Пользователь с таким телефоном уже существует") + + if errors: + return templates.TemplateResponse("register.html", { + "request": request, "user": None, "errors": errors, "form": data, + }) + + token = uuid.uuid4().hex + user = User( + first_name=data["first_name"].strip(), + last_name=data["last_name"].strip(), + email=data["email"].strip(), + phone=data["phone"].strip(), + password_hash=hash_password(data["password"]), + email_confirm_token=token, + ) + db.add(user) + db.commit() + + confirm_url = f"{settings.BASE_URL}/confirm-email?token={token}" + print("=" * 40) + print("ПОДТВЕРЖДЕНИЕ EMAIL") + print(f"Пользователь: {user.email}") + print(f"Ссылка: {confirm_url}") + print("=" * 40) + + return templates.TemplateResponse("confirm_email.html", {"request": request, "user": None}) + + +@router.get("/confirm-email") +def confirm_email(request: Request, token: str, db: Session = Depends(get_db)): + user = db.query(User).filter(User.email_confirm_token == token).first() + if not user: + return templates.TemplateResponse("message.html", { + "request": request, "user": None, + "title": "Ошибка", "message": "Неверная или устаревшая ссылка.", + }) + + user.is_email_confirmed = True + user.email_confirm_token = None + db.commit() + + return templates.TemplateResponse("email_confirmed.html", {"request": request, "user": None}) + + +@router.get("/login") +def login_form(request: Request, user: User | None = Depends(get_current_user)): + if user: + return RedirectResponse("/profile", 303) + return templates.TemplateResponse("login.html", {"request": request, "user": None}) + + +@router.post("/login") +async def login_submit(request: Request, db: Session = Depends(get_db)): + form = await request.form() + data = dict(form) + + errors = validate_login(data) + if errors: + return templates.TemplateResponse("login.html", { + "request": request, "user": None, "errors": errors, "form": data, + }) + + user = db.query(User).filter(User.email == data["email"].strip()).first() + if not user or not verify_password(data["password"], user.password_hash): + return templates.TemplateResponse("login.html", { + "request": request, "user": None, + "errors": ["Неверный email или пароль"], "form": data, + }) + + if not user.is_email_confirmed: + return templates.TemplateResponse("login.html", { + "request": request, "user": None, + "errors": ["Пожалуйста, подтвердите ваш email"], "form": data, + }) + + request.session["user_id"] = user.id + return RedirectResponse("/profile", 303) + + +@router.get("/logout") +def logout(request: Request): + request.session.clear() + return RedirectResponse("/login", 303) diff --git a/web/routes/profile.py b/web/routes/profile.py new file mode 100644 index 0000000..76409f0 --- /dev/null +++ b/web/routes/profile.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter, Request, Depends +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from web.auth import get_current_user +from web.database import get_db +from web.models import User +from web.schemas import validate_profile + +router = APIRouter() +templates = Jinja2Templates(directory="web/templates") + + +@router.get("/profile") +def profile_view(request: Request, user: User | None = Depends(get_current_user)): + if not user: + return RedirectResponse("/login", 303) + return templates.TemplateResponse("profile.html", {"request": request, "user": user}) + + +@router.post("/profile") +async def profile_update( + request: Request, + db: Session = Depends(get_db), + user: User | None = Depends(get_current_user), +): + if not user: + return RedirectResponse("/login", 303) + + form = await request.form() + data = dict(form) + + errors = validate_profile(data) + + if not errors: + existing = db.query(User).filter( + User.phone == data["phone"].strip(), User.id != user.id + ).first() + if existing: + errors.append("Пользователь с таким телефоном уже существует") + + if errors: + return templates.TemplateResponse("profile.html", { + "request": request, "user": user, "errors": errors, "form": data, + }) + + user.first_name = data["first_name"].strip() + user.last_name = data["last_name"].strip() + user.phone = data["phone"].strip() + db.commit() + + return templates.TemplateResponse("profile.html", { + "request": request, "user": user, "success": "Профиль обновлен", + }) diff --git a/web/routes/reset.py b/web/routes/reset.py new file mode 100644 index 0000000..e17454a --- /dev/null +++ b/web/routes/reset.py @@ -0,0 +1,108 @@ +import uuid +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Request, Depends +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from web.auth import hash_password +from web.config import settings +from web.database import get_db +from web.models import User +from web.schemas import validate_reset_password + +router = APIRouter() +templates = Jinja2Templates(directory="web/templates") + + +@router.get("/forgot-password") +def forgot_form(request: Request): + return templates.TemplateResponse("forgot_password.html", {"request": request, "user": None}) + + +@router.post("/forgot-password") +async def forgot_submit(request: Request, db: Session = Depends(get_db)): + form = await request.form() + email = form.get("email", "").strip() + + if email: + user = db.query(User).filter(User.email == email).first() + if user: + token = uuid.uuid4().hex + user.password_reset_token = token + user.password_reset_expires = datetime.now(timezone.utc) + timedelta( + minutes=settings.PASSWORD_RESET_EXPIRE_MINUTES + ) + db.commit() + + reset_url = f"{settings.BASE_URL}/reset-password?token={token}" + print("=" * 40) + print("СБРОС ПАРОЛЯ") + print(f"Пользователь: {user.email}") + print(f"Ссылка: {reset_url}") + print(f"Действительна: {settings.PASSWORD_RESET_EXPIRE_MINUTES} мин.") + print("=" * 40) + + return templates.TemplateResponse("message.html", { + "request": request, "user": None, + "title": "Сброс пароля", + "message": "Если аккаунт с таким email существует, ссылка для сброса пароля выведена в консоль сервера.", + }) + + +@router.get("/reset-password") +def reset_form(request: Request, token: str, db: Session = Depends(get_db)): + user = db.query(User).filter(User.password_reset_token == token).first() + if not user or not user.password_reset_expires: + return templates.TemplateResponse("message.html", { + "request": request, "user": None, + "title": "Ошибка", "message": "Неверная или устаревшая ссылка.", + }) + + if datetime.now(timezone.utc) > user.password_reset_expires.replace(tzinfo=timezone.utc): + return templates.TemplateResponse("message.html", { + "request": request, "user": None, + "title": "Ошибка", "message": "Срок действия ссылки истек.", + }) + + return templates.TemplateResponse("reset_password.html", { + "request": request, "user": None, "token": token, + }) + + +@router.post("/reset-password") +async def reset_submit(request: Request, token: str, db: Session = Depends(get_db)): + user = db.query(User).filter(User.password_reset_token == token).first() + if not user or not user.password_reset_expires: + return templates.TemplateResponse("message.html", { + "request": request, "user": None, + "title": "Ошибка", "message": "Неверная или устаревшая ссылка.", + }) + + if datetime.now(timezone.utc) > user.password_reset_expires.replace(tzinfo=timezone.utc): + return templates.TemplateResponse("message.html", { + "request": request, "user": None, + "title": "Ошибка", "message": "Срок действия ссылки истек.", + }) + + form = await request.form() + data = dict(form) + errors = validate_reset_password(data) + + if errors: + return templates.TemplateResponse("reset_password.html", { + "request": request, "user": None, "token": token, "errors": errors, + }) + + user.password_hash = hash_password(data["password"]) + user.password_reset_token = None + user.password_reset_expires = None + db.commit() + + return templates.TemplateResponse("message.html", { + "request": request, "user": None, + "title": "Пароль изменен", + "message": "Ваш пароль успешно изменен. Теперь вы можете войти.", + "link": "/login", "link_text": "Войти", + }) diff --git a/web/schemas.py b/web/schemas.py new file mode 100644 index 0000000..34af63b --- /dev/null +++ b/web/schemas.py @@ -0,0 +1,52 @@ +import re + + +def validate_registration(data: dict) -> list[str]: + errors = [] + if not data.get("first_name", "").strip(): + errors.append("Введите имя") + if not data.get("last_name", "").strip(): + errors.append("Введите фамилию") + email = data.get("email", "").strip() + if not email or not re.match(r"^[^@]+@[^@]+\.[^@]+$", email): + errors.append("Введите корректный email") + phone = data.get("phone", "").strip() + if not phone or not re.match(r"^\+?[\d\s\-()]{7,20}$", phone): + errors.append("Введите корректный телефон") + password = data.get("password", "") + if len(password) < 8: + errors.append("Пароль должен быть не менее 8 символов") + if password != data.get("password_confirm", ""): + errors.append("Пароли не совпадают") + return errors + + +def validate_login(data: dict) -> list[str]: + errors = [] + if not data.get("email", "").strip(): + errors.append("Введите email") + if not data.get("password", ""): + errors.append("Введите пароль") + return errors + + +def validate_reset_password(data: dict) -> list[str]: + errors = [] + password = data.get("password", "") + if len(password) < 8: + errors.append("Пароль должен быть не менее 8 символов") + if password != data.get("password_confirm", ""): + errors.append("Пароли не совпадают") + return errors + + +def validate_profile(data: dict) -> list[str]: + errors = [] + if not data.get("first_name", "").strip(): + errors.append("Введите имя") + if not data.get("last_name", "").strip(): + errors.append("Введите фамилию") + phone = data.get("phone", "").strip() + if not phone or not re.match(r"^\+?[\d\s\-()]{7,20}$", phone): + errors.append("Введите корректный телефон") + return errors diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..059228d --- /dev/null +++ b/web/static/style.css @@ -0,0 +1,215 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + color: #333; + background: #fff; + line-height: 1.6; +} + +.container { + max-width: 960px; + margin: 0 auto; + padding: 0 20px; +} + +/* Navbar */ +.navbar { + background: #fff; + border-bottom: 2px solid #F05023; + padding: 14px 0; +} + +.nav-content { + display: flex; + justify-content: space-between; + align-items: center; +} + +.nav-logo { + font-size: 22px; + font-weight: 700; + color: #F05023; + text-decoration: none; +} + +.nav-links a { + margin-left: 24px; + color: #333; + text-decoration: none; + font-size: 15px; +} + +.nav-links a:hover { + color: #F05023; +} + +/* Forms */ +.form-card { + max-width: 440px; + margin: 60px auto; + background: #F4F6F8; + border-radius: 8px; + padding: 36px; +} + +.form-card h1 { + font-size: 24px; + margin-bottom: 24px; + color: #333; +} + +.form-group { + margin-bottom: 18px; +} + +.form-group label { + display: block; + margin-bottom: 6px; + font-size: 14px; + font-weight: 500; + color: #333; +} + +.form-group input { + width: 100%; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 15px; + transition: border-color 0.2s; +} + +.form-group input:focus { + outline: none; + border-color: #F05023; +} + +.btn { + display: inline-block; + padding: 10px 24px; + border: none; + border-radius: 4px; + font-size: 15px; + font-weight: 500; + cursor: pointer; + text-decoration: none; + transition: opacity 0.2s; +} + +.btn:hover { + opacity: 0.85; +} + +.btn-primary { + background: #F05023; + color: #fff; + width: 100%; +} + +.btn-secondary { + background: #0986E2; + color: #fff; +} + +/* Alerts */ +.alert { + padding: 14px 18px; + border-radius: 4px; + margin: 20px 0; + font-size: 14px; +} + +.alert-error { + background: #fef2f0; + border: 1px solid #F05023; + color: #c0392b; +} + +.alert-success { + background: #eafaf3; + border: 1px solid #4DD1A2; + color: #1a7a4c; +} + +.alert p { + margin: 2px 0; +} + +/* Links */ +.form-links { + margin-top: 18px; + text-align: center; + font-size: 14px; +} + +.form-links a { + color: #0986E2; + text-decoration: none; +} + +.form-links a:hover { + text-decoration: underline; +} + +/* Message page */ +.message-card { + max-width: 500px; + margin: 80px auto; + text-align: center; + background: #F4F6F8; + border-radius: 8px; + padding: 40px; +} + +.message-card h1 { + font-size: 22px; + margin-bottom: 16px; + color: #333; +} + +.message-card p { + font-size: 15px; + color: #555; + margin-bottom: 12px; +} + +.message-card a { + color: #0986E2; + text-decoration: none; +} + +.message-card a:hover { + text-decoration: underline; +} + +/* Profile */ +.profile-card { + max-width: 540px; + margin: 60px auto; + background: #F4F6F8; + border-radius: 8px; + padding: 36px; +} + +.profile-card h1 { + font-size: 24px; + margin-bottom: 24px; + color: #333; +} + +.profile-info { + margin-bottom: 18px; + font-size: 15px; +} + +.profile-info span { + color: #777; + display: block; + font-size: 13px; + margin-bottom: 2px; +} diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..a16f083 --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,43 @@ + + +
+ + +{{ error }}
+ {% endfor %} +{{ success }}
+