From 060211f023ecabf8f672150c6fd2694d960fbdce Mon Sep 17 00:00:00 2001 From: Ivan Budakov Date: Mon, 19 Oct 2026 19:41:03 +0300 Subject: [PATCH] feat: implement local auth, email verification, password reset, session management --- app/config.py | 16 +- app/main.py | 578 ++++++++++++++++++----------- app/models.py | 20 +- app/templates/admin_users.html | 37 +- app/templates/forgot_password.html | 32 ++ app/templates/index.html | 6 +- app/templates/login.html | 50 +++ app/templates/profile.html | 22 +- app/templates/register.html | 59 +++ app/templates/reset_password.html | 28 ++ app/templates/sessions.html | 51 +++ 11 files changed, 648 insertions(+), 251 deletions(-) create mode 100644 app/templates/forgot_password.html create mode 100644 app/templates/login.html create mode 100644 app/templates/register.html create mode 100644 app/templates/reset_password.html create mode 100644 app/templates/sessions.html diff --git a/app/config.py b/app/config.py index 588b934..2f8671f 100644 --- a/app/config.py +++ b/app/config.py @@ -9,15 +9,19 @@ POSTGRES_DB = os.getenv("POSTGRES_DB", "morozovka_portal") POSTGRES_HOST = os.getenv("POSTGRES_HOST", "127.0.0.1") POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432") +# Добавляем psycopg2 в URL DATABASE_URL = f"postgresql+psycopg2://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}" -# Настройки Gitea -GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000") # Внутренний адрес для API -GITEA_PUBLIC_URL = os.getenv("GITEA_PUBLIC_URL", "https://git.morozovka.space") # Для редиректа браузера - -# Вставь сюда свои Client ID и Client Secret из Gitea +GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000") +GITEA_PUBLIC_URL = os.getenv("GITEA_PUBLIC_URL", "https://git.morozovka.space") GITEA_CLIENT_ID = "66697efe-d88b-41fe-b3d5-6498d978e1fa" GITEA_CLIENT_SECRET = "gto_okjv2yj2jjrsqo6qeniz6nbnqulloo4x2rrwex6p4cqx7azrc27a" -# Секретный ключ для куки-сессий Flask (можешь оставить этот) WEB_SECRET = os.getenv("WEB_SECRET", "super-secret-morozovka-key-2026") + +# Настройки SMTP (если пусто, письма будут падать в консоль) +SMTP_SERVER = os.getenv("SMTP_SERVER", "") +SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) +SMTP_USER = os.getenv("SMTP_USER", "") +SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") +SMTP_FROM = os.getenv("SMTP_FROM", "noreply@morozovka.space") diff --git a/app/main.py b/app/main.py index ce744bf..d4830d9 100644 --- a/app/main.py +++ b/app/main.py @@ -1,19 +1,22 @@ - import os import uuid +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from itsdangerous import URLSafeTimedSerializer +from werkzeug.security import generate_password_hash, check_password_hash from flask import Flask, render_template, jsonify, redirect, url_for, request, session, send_from_directory -from werkzeug.utils import secure_filename from .db import init_db, engine from . import config, auth, assistant from sqlmodel import Session, select -from .models import User, Event, GlobalStatus, Project, PortalFile +from .models import User, Event, GlobalStatus, Project, PortalFile, UserSession from datetime import datetime, timezone from functools import wraps app = Flask(__name__) app.secret_key = config.WEB_SECRET +serializer = URLSafeTimedSerializer(app.secret_key) -# Настройки для загрузки файлов (макс 100 МБ) UPLOAD_FOLDER = '/var/lib/gitea/portal-files' app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 @@ -26,6 +29,41 @@ with app.app_context(): db_session.add(GlobalStatus(status="free", message="Вне расписания")) db_session.commit() +@app.before_request +def validate_session(): + if "session_id" in session: + with Session(engine) as db_session: + db_sess = db_session.exec(select(UserSession).where(UserSession.session_token == session["session_id"])).first() + if not db_sess: + session.clear() + return redirect(url_for("login")) + +def send_email(to_email, subject, body): + if not config.SMTP_SERVER: + print("\n" + "="*50, flush=True) + print(f"📧 EMAIL ДЛЯ: {to_email}", flush=True) + print(f"Тема: {subject}", flush=True) + print(body, flush=True) + print("="*50 + "\n", flush=True) + return True + + try: + msg = MIMEMultipart() + msg['From'] = config.SMTP_FROM + msg['To'] = to_email + msg['Subject'] = subject + msg.attach(MIMEText(body, 'plain')) + + server = smtplib.SMTP(config.SMTP_SERVER, config.SMTP_PORT) + server.starttls() + server.login(config.SMTP_USER, config.SMTP_PASSWORD) + server.send_message(msg) + server.quit() + return True + except Exception as e: + print(f"Ошибка отправки email: {e}", flush=True) + return False + def require_role(allowed_roles): if isinstance(allowed_roles, str): allowed_roles = [allowed_roles] @@ -33,7 +71,7 @@ def require_role(allowed_roles): @wraps(f) def decorated_function(*args, **kwargs): if session.get("role") not in allowed_roles: - return "403: Доступ запрещен. Недостаточно прав.", 403 + return "403: Доступ запрещен.", 403 return f(*args, **kwargs) return decorated_function return decorator @@ -48,16 +86,102 @@ def login_required(f): @app.route("/healthz") def healthz(): - return jsonify({"status": "ok", "service": "morozovka-portal"}) + return jsonify({"status": "ok"}) @app.route("/") def index(): - current_user = session.get("username") - return render_template("index.html", username=current_user) + return render_template("index.html", username=session.get("username")) -# --- АВТОРИЗАЦИЯ --- -@app.route("/login") +# --- АУТЕНТИФИКАЦИЯ --- +@app.route("/register", methods=["GET", "POST"]) +def register(): + if request.method == "POST": + username = request.form.get("username") + email = request.form.get("email") + password = request.form.get("password") + full_name = request.form.get("full_name") + organization = request.form.get("organization") + + with Session(engine) as db_session: + if db_session.exec(select(User).where(User.username == username)).first(): + return render_template("register.html", error="Логин уже занят") + if db_session.exec(select(User).where(User.email == email)).first(): + return render_template("register.html", error="Email уже используется") + + new_user = User( + username=username, + email=email, + password_hash=generate_password_hash(password), + full_name=full_name, + organization=organization, + role="student", + is_verified=False + ) + db_session.add(new_user) + db_session.commit() + + token = serializer.dumps(email, salt='email-confirm') + confirm_url = url_for('verify_email', token=token, _external=True) + confirm_url = confirm_url.replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://") + + body = f"Привет, {full_name}!\n\nДля подтверждения почты перейди по ссылке:\n{confirm_url}\n" + send_email(email, "Подтверждение почты в Morozovka Portal", body) + + return render_template("login.html", msg="Регистрация успешна. Проверьте почту для подтверждения.") + return render_template("register.html") + +@app.route("/verify_email/") +def verify_email(token): + try: + email = serializer.loads(token, salt='email-confirm', max_age=86400) + except Exception: + return "Ссылка недействительна или устарела.", 400 + + with Session(engine) as db_session: + user = db_session.exec(select(User).where(User.email == email)).first() + if user: + user.is_verified = True + db_session.add(user) + db_session.commit() + return redirect(url_for("login", msg="Почта подтверждена! Теперь можно войти.")) + return "Пользователь не найден.", 404 + +@app.route("/login", methods=["GET", "POST"]) def login(): + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + + with Session(engine) as db_session: + user = db_session.exec(select(User).where(User.username == username)).first() + + if not user or not user.password_hash or not check_password_hash(user.password_hash, password): + return render_template("login.html", error="Неверный логин или пароль") + + if not user.is_verified: + return render_template("login.html", error="Подтвердите почту перед входом") + + session_token = str(uuid.uuid4()) + db_session.add(UserSession( + session_token=session_token, + user_id=user.id, + ip_address=request.remote_addr, + user_agent=request.user_agent.string + )) + db_session.commit() + + session["user_id"] = user.id + session["username"] = user.username + session["role"] = user.role + session["session_id"] = session_token + + return redirect(url_for("index")) + + msg = request.args.get("msg") + return render_template("login.html", msg=msg) + +@app.route("/login/gitea") +def login_gitea(): gitea_auth_url = ( f"{config.GITEA_PUBLIC_URL}/login/oauth/authorize" f"?client_id={config.GITEA_CLIENT_ID}&response_type=code" @@ -70,236 +194,150 @@ def login(): def auth_callback(): code = request.args.get("code") if not code: - return "Ошибка: Gitea не передала код авторизации.", 400 + return "Ошибка авторизации Gitea", 400 token = auth.get_gitea_token(code) if not token: - return "Ошибка: не удалось получить токен от Gitea.", 400 + return "Ошибка токена Gitea", 400 gitea_user = auth.get_gitea_user(token) if not gitea_user: - return "Ошибка: не удалось получить профиль пользователя.", 400 + return "Ошибка профиля Gitea", 400 with Session(engine) as db_session: user = db_session.exec(select(User).where(User.gitea_id == gitea_user["id"])).first() if not user: - new_user = User( - gitea_id=gitea_user["id"], - username=gitea_user["login"], - full_name=gitea_user.get("full_name"), - email=gitea_user.get("email"), - role="admin" if gitea_user["login"] == "git_admin" else "student" - ) - db_session.add(new_user) + user = db_session.exec(select(User).where(User.email == gitea_user.get("email"))).first() + if user: + user.gitea_id = gitea_user["id"] + else: + user = User( + gitea_id=gitea_user["id"], + username=gitea_user["login"], + full_name=gitea_user.get("full_name"), + email=gitea_user.get("email"), + role="admin" if gitea_user["login"] == "git_admin" else "student", + is_verified=True + ) + db_session.add(user) db_session.commit() - user = new_user - else: - user.username = gitea_user["login"] - user.full_name = gitea_user.get("full_name") - user.email = gitea_user.get("email") - db_session.add(user) - db_session.commit() - + + session_token = str(uuid.uuid4()) + db_session.add(UserSession( + session_token=session_token, + user_id=user.id, + ip_address=request.remote_addr, + user_agent=request.user_agent.string + )) + db_session.commit() + session["user_id"] = user.id session["username"] = user.username session["role"] = user.role + session["session_id"] = session_token - return redirect(url_for("profile")) + return redirect(url_for("index")) +@app.route("/logout") +def logout(): + if "session_id" in session: + with Session(engine) as db_session: + db_sess = db_session.exec(select(UserSession).where(UserSession.session_token == session["session_id"])).first() + if db_sess: + db_session.delete(db_sess) + db_session.commit() + session.clear() + return redirect(url_for("index")) + +# --- СБРОС ПАРОЛЯ --- +@app.route("/forgot_password", methods=["GET", "POST"]) +def forgot_password(): + if request.method == "POST": + email = request.form.get("email") + with Session(engine) as db_session: + user = db_session.exec(select(User).where(User.email == email)).first() + if user: + token = serializer.dumps(email, salt='password-reset') + reset_url = url_for('reset_password', token=token, _external=True) + reset_url = reset_url.replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://") + + body = f"Для сброса пароля перейди по ссылке:\n{reset_url}\n" + send_email(email, "Сброс пароля Morozovka Portal", body) + + return render_template("login.html", msg="Если такой email есть, инструкция отправлена.") + return render_template("forgot_password.html") + +@app.route("/reset_password/", methods=["GET", "POST"]) +def reset_password(token): + try: + email = serializer.loads(token, salt='password-reset', max_age=3600) + except Exception: + return "Ссылка недействительна или устарела.", 400 + + if request.method == "POST": + new_password = request.form.get("password") + with Session(engine) as db_session: + user = db_session.exec(select(User).where(User.email == email)).first() + if user: + user.password_hash = generate_password_hash(new_password) + db_session.add(user) + db_session.commit() + return redirect(url_for("login", msg="Пароль успешно изменен!")) + return render_template("reset_password.html", token=token) + +# --- УПРАВЛЕНИЕ СЕССИЯМИ --- +@app.route("/sessions") +@login_required +def sessions_list(): + with Session(engine) as db_session: + sessions = db_session.exec( + select(UserSession) + .where(UserSession.user_id == session.get("user_id")) + .order_by(UserSession.created_at.desc()) + ).all() + return render_template("sessions.html", sessions=sessions, current_token=session.get("session_id")) + +@app.route("/sessions/logout_all", methods=["POST"]) +@login_required +def logout_all(): + with Session(engine) as db_session: + user_sessions = db_session.exec(select(UserSession).where(UserSession.user_id == session.get("user_id"))).all() + for s in user_sessions: + db_session.delete(s) + db_session.commit() + session.clear() + return redirect(url_for("login", msg="Вы вышли со всех устройств.")) + +# --- ПРОФИЛЬ --- @app.route("/profile") @login_required def profile(): with Session(engine) as db_session: user = db_session.get(User, session.get("user_id")) - if not user: - session.clear() - return redirect(url_for("login")) return render_template("profile.html", user=user) -@app.route("/logout") -def logout(): - session.clear() - return redirect(url_for("index")) - -# --- ФАЙЛОХРАНИЛИЩЕ --- -@app.route("/files") -@login_required -def files_list(): - with Session(engine) as db_session: - # Сортируем по дате (новые сверху), джойним с User чтобы знать кто загрузил - files = db_session.exec( - select(PortalFile, User.username) - .join(User, PortalFile.uploaded_by == User.id) - .order_by(PortalFile.uploaded_at.desc()) - ).all() - return render_template("files.html", files=files) - -@app.route("/files/upload", methods=["POST"]) -@login_required -def file_upload(): - if 'file' not in request.files: - return redirect(url_for('files_list')) - - file = request.files['file'] - if file.filename == '': - return redirect(url_for('files_list')) - - if file: - original_name = file.filename - # Генерируем уникальное имя для хранения на диске (чтобы файлы с одинаковыми именами не перезаписывали друг друга) - ext = os.path.splitext(original_name)[1] - stored_name = f"{uuid.uuid4().hex}{ext}" - - filepath = os.path.join(app.config['UPLOAD_FOLDER'], stored_name) - file.save(filepath) - - size_bytes = os.path.getsize(filepath) - - with Session(engine) as db_session: - new_file = PortalFile( - original_name=original_name, - stored_name=stored_name, - size_bytes=size_bytes, - uploaded_by=session.get("user_id") - ) - db_session.add(new_file) - db_session.commit() - - return redirect(url_for('files_list')) - -@app.route("/files/download/") -@login_required -def file_download(file_id): - with Session(engine) as db_session: - db_file = db_session.get(PortalFile, file_id) - if db_file: - return send_from_directory( - app.config['UPLOAD_FOLDER'], - db_file.stored_name, - as_attachment=True, - download_name=db_file.original_name - ) - return "Файл не найден", 404 - -@app.route("/files//delete", methods=["POST"]) -@login_required -def file_delete(file_id): - with Session(engine) as db_session: - db_file = db_session.get(PortalFile, file_id) - if db_file: - # Удалять может только админ или тот, кто загрузил - if session.get("role") == "admin" or db_file.uploaded_by == session.get("user_id"): - filepath = os.path.join(app.config['UPLOAD_FOLDER'], db_file.stored_name) - if os.path.exists(filepath): - os.remove(filepath) # Удаляем с диска - db_session.delete(db_file) # Удаляем из БД - db_session.commit() - return redirect(url_for("files_list")) - -# --- ПРОЕКТЫ --- -@app.route("/projects") -def projects_list(): - with Session(engine) as db_session: - projects = db_session.exec(select(Project).order_by(Project.created_at.desc())).all() - return render_template("projects.html", projects=projects, role=session.get("role")) - -@app.route("/projects/new", methods=["GET", "POST"]) -@require_role(["admin", "professor", "lead"]) -def project_new(): - if request.method == "POST": - title = request.form.get("title") - description = request.form.get("description") - status = request.form.get("status") - gitea_url = request.form.get("gitea_url") - is_public = request.form.get("is_public") == "on" - - with Session(engine) as db_session: - new_proj = Project( - title=title, - description=description, - status=status, - gitea_url=gitea_url, - is_public=is_public, - owner_id=session.get("user_id") - ) - db_session.add(new_proj) - db_session.commit() - return redirect(url_for("projects_list")) - return render_template("project_form.html") - -@app.route("/projects//delete", methods=["POST"]) -@require_role(["admin", "professor", "lead"]) -def project_delete(project_id): - with Session(engine) as db_session: - proj = db_session.get(Project, project_id) - if proj: - if session.get("role") == "admin" or proj.owner_id == session.get("user_id"): - db_session.delete(proj) - db_session.commit() - return redirect(url_for("projects_list")) - -# --- АДМИНКА: ПОЛЬЗОВАТЕЛИ --- -@app.route("/admin/users") -@require_role("admin") -def admin_users(): - with Session(engine) as db_session: - users = db_session.exec(select(User).order_by(User.username)).all() - return render_template("admin_users.html", users=users) - -@app.route("/admin/users//role", methods=["POST"]) -@require_role("admin") -def change_user_role(user_id): - new_role = request.form.get("role") - valid_roles = ["admin", "professor", "lead", "member", "student", "guest"] - if new_role not in valid_roles: - return "Ошибка: неверная роль", 400 - - with Session(engine) as db_session: - user = db_session.get(User, user_id) - if user: - if user.id == session.get("user_id") and new_role != "admin": - return "Ошибка: нельзя лишить самого себя прав администратора", 400 - user.role = new_role - db_session.add(user) - db_session.commit() - return redirect(url_for("admin_users")) - -# --- РАСПИСАНИЕ: ПУБЛИЧНАЯ ЧАСТЬ --- +# --- РАСПИСАНИЕ --- @app.route("/schedule") def public_schedule(): with Session(engine) as db_session: current_status = db_session.exec(select(GlobalStatus)).first() now = datetime.now(timezone.utc) - events = db_session.exec( - select(Event) - .where(Event.is_public == True) - .where(Event.start_time >= now) - .order_by(Event.start_time) - .limit(10) - ).all() + events = db_session.exec(select(Event).where(Event.is_public == True).where(Event.start_time >= now).order_by(Event.start_time).limit(10)).all() return render_template("schedule.html", status=current_status, events=events) -# --- АССИСТЕНТ: API --- @app.route("/api/assistant", methods=["POST"]) def api_assistant(): data = request.get_json() - if not data or "message" not in data: - return jsonify({"error": "Нет сообщения"}), 400 - reply = assistant.get_response(data["message"]) - return jsonify({"reply": reply}) + if not data or "message" not in data: return jsonify({"error": "Нет сообщения"}), 400 + return jsonify({"reply": assistant.get_response(data["message"])}) -# --- РАСПИСАНИЕ: АДМИН-ПАНЕЛЬ --- @app.route("/admin/schedule") @require_role(["admin", "professor"]) def admin_schedule(): with Session(engine) as db_session: current_status = db_session.exec(select(GlobalStatus)).first() now = datetime.now(timezone.utc) - events = db_session.exec( - select(Event) - .where(Event.start_time >= now) - .order_by(Event.start_time) - ).all() + events = db_session.exec(select(Event).where(Event.start_time >= now).order_by(Event.start_time)).all() return render_template("admin_schedule.html", status=current_status, events=events) @app.route("/admin/schedule/status", methods=["POST"]) @@ -308,7 +346,6 @@ def update_status(): status_val = request.form.get("status") delay = int(request.form.get("delay_minutes") or 0) message = request.form.get("message") - with Session(engine) as db_session: current_status = db_session.exec(select(GlobalStatus)).first() if current_status: @@ -325,26 +362,10 @@ def update_status(): def add_event(): title = request.form.get("title") event_type = request.form.get("event_type") - start_str = request.form.get("start_time") - end_str = request.form.get("end_time") - location = request.form.get("location") - description = request.form.get("description") - is_public = request.form.get("is_public") == "on" - - start_time = datetime.strptime(start_str, "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc) - end_time = datetime.strptime(end_str, "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc) - + start_time = datetime.strptime(request.form.get("start_time"), "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc) + end_time = datetime.strptime(request.form.get("end_time"), "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc) with Session(engine) as db_session: - new_event = Event( - title=title, - event_type=event_type, - start_time=start_time, - end_time=end_time, - location=location, - description=description, - is_public=is_public - ) - db_session.add(new_event) + db_session.add(Event(title=title, event_type=event_type, start_time=start_time, end_time=end_time, location=request.form.get("location"), description=request.form.get("description"), is_public=request.form.get("is_public") == "on")) db_session.commit() return redirect(url_for("admin_schedule")) @@ -357,3 +378,120 @@ def delete_event(event_id): db_session.delete(event) db_session.commit() return redirect(url_for("admin_schedule")) + +# --- ПРОЕКТЫ --- +@app.route("/projects") +def projects_list(): + with Session(engine) as db_session: + projects = db_session.exec(select(Project).order_by(Project.created_at.desc())).all() + return render_template("projects.html", projects=projects, role=session.get("role")) + +@app.route("/projects/new", methods=["GET", "POST"]) +@require_role(["admin", "professor", "lead"]) +def project_new(): + if request.method == "POST": + with Session(engine) as db_session: + db_session.add(Project(title=request.form.get("title"), description=request.form.get("description"), status=request.form.get("status"), gitea_url=request.form.get("gitea_url"), is_public=request.form.get("is_public") == "on", owner_id=session.get("user_id"))) + db_session.commit() + return redirect(url_for("projects_list")) + return render_template("project_form.html") + +@app.route("/projects//delete", methods=["POST"]) +@require_role(["admin", "professor", "lead"]) +def project_delete(project_id): + with Session(engine) as db_session: + proj = db_session.get(Project, project_id) + if proj and (session.get("role") == "admin" or proj.owner_id == session.get("user_id")): + db_session.delete(proj) + db_session.commit() + return redirect(url_for("projects_list")) + +# --- АДМИНКА: ПОЛЬЗОВАТЕЛИ --- +@app.route("/admin/users") +@require_role("admin") +def admin_users(): + with Session(engine) as db_session: + users = db_session.exec(select(User).order_by(User.username)).all() + return render_template("admin_users.html", users=users) + +@app.route("/admin/users//role", methods=["POST"]) +@require_role("admin") +def change_user_role(user_id): + new_role = request.form.get("role") + valid_roles = ["admin", "professor", "lead", "member", "student", "guest"] + if new_role not in valid_roles: return "Ошибка", 400 + with Session(engine) as db_session: + user = db_session.get(User, user_id) + if user: + if user.id == session.get("user_id") and new_role != "admin": return "Нельзя снять с себя админа", 400 + user.role = new_role + db_session.add(user) + db_session.commit() + return redirect(url_for("admin_users")) + +# --- НОВЫЙ РОУТ: УДАЛЕНИЕ ПОЛЬЗОВАТЕЛЯ --- +@app.route("/admin/users//delete", methods=["POST"]) +@require_role("admin") +def delete_user(user_id): + if user_id == session.get("user_id"): + return "Ошибка: нельзя удалить самого себя", 400 + + with Session(engine) as db_session: + # Сначала удаляем все сессии пользователя (чтобы не было ошибки внешнего ключа) + sessions = db_session.exec(select(UserSession).where(UserSession.user_id == user_id)).all() + for s in sessions: + db_session.delete(s) + + # Затем удаляем самого пользователя + user = db_session.get(User, user_id) + if user: + db_session.delete(user) + db_session.commit() + + return redirect(url_for("admin_users")) + +# --- ФАЙЛЫ --- +@app.route("/files") +@login_required +def files_list(): + with Session(engine) as db_session: + files = db_session.exec(select(PortalFile, User.username).join(User, PortalFile.uploaded_by == User.id).order_by(PortalFile.uploaded_at.desc())).all() + return render_template("files.html", files=files) + +@app.route("/files/upload", methods=["POST"]) +@login_required +def file_upload(): + if 'file' not in request.files: return redirect(url_for('files_list')) + file = request.files['file'] + if file.filename == '': return redirect(url_for('files_list')) + if file: + original_name = file.filename + ext = os.path.splitext(original_name)[1] + stored_name = f"{uuid.uuid4().hex}{ext}" + filepath = os.path.join(app.config['UPLOAD_FOLDER'], stored_name) + file.save(filepath) + with Session(engine) as db_session: + db_session.add(PortalFile(original_name=original_name, stored_name=stored_name, size_bytes=os.path.getsize(filepath), uploaded_by=session.get("user_id"))) + db_session.commit() + return redirect(url_for('files_list')) + +@app.route("/files/download/") +@login_required +def file_download(file_id): + with Session(engine) as db_session: + db_file = db_session.get(PortalFile, file_id) + if db_file: + return send_from_directory(app.config['UPLOAD_FOLDER'], db_file.stored_name, as_attachment=True, download_name=db_file.original_name) + return "Файл не найден", 404 + +@app.route("/files//delete", methods=["POST"]) +@login_required +def file_delete(file_id): + with Session(engine) as db_session: + db_file = db_session.get(PortalFile, file_id) + if db_file and (session.get("role") == "admin" or db_file.uploaded_by == session.get("user_id")): + filepath = os.path.join(app.config['UPLOAD_FOLDER'], db_file.stored_name) + if os.path.exists(filepath): os.remove(filepath) + db_session.delete(db_file) + db_session.commit() + return redirect(url_for("files_list")) diff --git a/app/models.py b/app/models.py index 721be17..9fd5b99 100644 --- a/app/models.py +++ b/app/models.py @@ -4,12 +4,23 @@ from datetime import datetime, timezone class User(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) - gitea_id: int = Field(unique=True, index=True) - username: str = Field(index=True) + gitea_id: Optional[int] = Field(default=None, unique=True, index=True) # Теперь может быть None, если регаемся локально + username: str = Field(unique=True, index=True) + password_hash: Optional[str] = None full_name: Optional[str] = None - email: Optional[str] = None + email: Optional[str] = Field(default=None, unique=True) + organization: Optional[str] = None # ННГУ, Неймарк, Ядро и т.д. role: str = Field(default="student") is_active: bool = Field(default=True) + is_verified: bool = Field(default=False) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + +class UserSession(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + session_token: str = Field(unique=True, index=True) + user_id: int = Field(foreign_key="user.id") + ip_address: Optional[str] = None + user_agent: Optional[str] = None created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) class Event(SQLModel, table=True): @@ -41,11 +52,10 @@ class Project(SQLModel, table=True): is_public: bool = Field(default=True) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) -# --- НОВАЯ МОДЕЛЬ --- class PortalFile(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) original_name: str - stored_name: str # Имя на диске (уникальное) + stored_name: str size_bytes: int uploaded_by: int = Field(foreign_key="user.id") uploaded_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/app/templates/admin_users.html b/app/templates/admin_users.html index 90b079c..50d59fb 100644 --- a/app/templates/admin_users.html +++ b/app/templates/admin_users.html @@ -20,7 +20,10 @@ select { padding: 6px; border-radius: 4px; background: #0d1117; color: white; border: 1px solid #30363d; } button { padding: 6px 12px; background: #238636; color: white; border: none; border-radius: 4px; cursor: pointer; } button:hover { background: #2ea043; } + button.danger { background: #da3633; } + button.danger:hover { background: #f85149; } a { color: #58a6ff; text-decoration: none; } + .actions-cell { display: flex; gap: 10px; align-items: center; } @@ -35,7 +38,7 @@ ФИО Email Роль - Действие + Действия @@ -49,17 +52,27 @@ {{ u.role }} -
- - -
+
+ +
+ + +
+ + + {% if u.id != session.get('user_id') %} +
+ +
+ {% endif %} +
{% endfor %} diff --git a/app/templates/forgot_password.html b/app/templates/forgot_password.html new file mode 100644 index 0000000..fba8f0b --- /dev/null +++ b/app/templates/forgot_password.html @@ -0,0 +1,32 @@ + + + + + Сброс пароля — Morozovka Portal + + + +
+

Сброс пароля

+

Введите свой Email, и мы отправим ссылку для сброса.

+
+
+ + +
+ +
+ +
+ + diff --git a/app/templates/index.html b/app/templates/index.html index d577d4e..9fa81a9 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -39,14 +39,14 @@ {% endif %} {% else %} -

Скелет системы успешно запущен!

+

Платформа для команды и проектов

{% endif %} -
Stage 3.2: File Storage OK
+
Stage 1 (Identity): Complete
diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..22dc070 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,50 @@ + + + + + Вход — Morozovka Portal + + + +
+

Вход

+ {% if msg %}
{{ msg }}
{% endif %} + {% if error %}
{{ error }}
{% endif %} + +
+
+ + +
+
+ + +
+ +
+ + Войти через Gitea + + +
+ + diff --git a/app/templates/profile.html b/app/templates/profile.html index 8b5d72b..1e8f219 100644 --- a/app/templates/profile.html +++ b/app/templates/profile.html @@ -10,7 +10,9 @@ .field { margin-bottom: 15px; border-bottom: 1px solid #30363d; padding-bottom: 10px; } .label { color: #8b949e; font-size: 0.8rem; text-transform: uppercase; } .value { font-size: 1.1rem; font-family: monospace; } - a { color: #58a6ff; } + a { color: #58a6ff; text-decoration: none; } + .btn-sessions { display: inline-block; margin-top: 15px; padding: 8px 15px; background: #21262d; border: 1px solid #30363d; border-radius: 5px; } + .btn-sessions:hover { background: #30363d; } @@ -18,7 +20,7 @@

Личный кабинет

-
Логин в Gitea
+
Логин
{{ user.username }}
@@ -29,7 +31,14 @@
Email
-
{{ user.email or 'Не указано' }}
+
{{ user.email or 'Не указано' }} + {% if user.is_verified %}✓ Подтвержден{% endif %} +
+
+ +
+
Организация
+
{{ user.organization or 'Не указана' }}
@@ -37,8 +46,11 @@
{{ user.role }}
- diff --git a/app/templates/register.html b/app/templates/register.html new file mode 100644 index 0000000..8b2ba66 --- /dev/null +++ b/app/templates/register.html @@ -0,0 +1,59 @@ + + + + + Регистрация — Morozovka Portal + + + +
+

Регистрация

+ {% if error %}
{{ error }}
{% endif %} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ + diff --git a/app/templates/reset_password.html b/app/templates/reset_password.html new file mode 100644 index 0000000..c081f1c --- /dev/null +++ b/app/templates/reset_password.html @@ -0,0 +1,28 @@ + + + + + Новый пароль — Morozovka Portal + + + +
+

Новый пароль

+
+
+ + +
+ +
+
+ + diff --git a/app/templates/sessions.html b/app/templates/sessions.html new file mode 100644 index 0000000..d5ab872 --- /dev/null +++ b/app/templates/sessions.html @@ -0,0 +1,51 @@ + + + + + Активные сессии — Morozovka Portal + + + +
+ ← В профиль +

Управление устройствами

+

Список всех мест, где выполнен вход в ваш аккаунт.

+ + + + + + + + + + + + {% for s in sessions %} + + + + + + + {% endfor %} + +
Устройство (User-Agent)IP-адресДата входаСтатус
{{ s.user_agent or 'Неизвестно' }}{{ s.ip_address }}{{ s.created_at.strftime('%d.%m.%Y %H:%M') }}{% if s.session_token == current_token %}Текущая{% else %}-{% endif %}
+ +
+ +
+
+ +