feat: implement local auth, email verification, password reset, session management

This commit is contained in:
Ivan Budakov 2026-10-19 19:41:03 +03:00
parent acb111d7ff
commit 060211f023
11 changed files with 648 additions and 251 deletions

View File

@ -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")

View File

@ -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/<token>")
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/<token>", 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/<int:file_id>")
@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/<int:file_id>/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/<int:project_id>/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/<int:user_id>/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/<int:project_id>/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/<int:user_id>/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/<int:user_id>/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/<int:file_id>")
@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/<int:file_id>/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"))

View File

@ -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))

View File

@ -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; }
</style>
</head>
<body>
@ -35,7 +38,7 @@
<th>ФИО</th>
<th>Email</th>
<th>Роль</th>
<th>Действие</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
@ -49,17 +52,27 @@
<span class="badge role-{{ u.role }}">{{ u.role }}</span>
</td>
<td>
<form action="/admin/users/{{ u.id }}/role" method="POST" style="display:flex; gap: 10px;">
<select name="role">
<option value="admin" {% if u.role == 'admin' %}selected{% endif %}>admin</option>
<option value="professor" {% if u.role == 'professor' %}selected{% endif %}>professor</option>
<option value="lead" {% if u.role == 'lead' %}selected{% endif %}>lead</option>
<option value="member" {% if u.role == 'member' %}selected{% endif %}>member</option>
<option value="student" {% if u.role == 'student' %}selected{% endif %}>student</option>
<option value="guest" {% if u.role == 'guest' %}selected{% endif %}>guest</option>
</select>
<button type="submit">Сменить</button>
</form>
<div class="actions-cell">
<!-- Форма смены роли -->
<form action="/admin/users/{{ u.id }}/role" method="POST" style="display:flex; gap: 5px;">
<select name="role">
<option value="admin" {% if u.role == 'admin' %}selected{% endif %}>admin</option>
<option value="professor" {% if u.role == 'professor' %}selected{% endif %}>professor</option>
<option value="lead" {% if u.role == 'lead' %}selected{% endif %}>lead</option>
<option value="member" {% if u.role == 'member' %}selected{% endif %}>member</option>
<option value="student" {% if u.role == 'student' %}selected{% endif %}>student</option>
<option value="guest" {% if u.role == 'guest' %}selected{% endif %}>guest</option>
</select>
<button type="submit">Сменить</button>
</form>
<!-- Форма удаления (нельзя удалить себя) -->
{% if u.id != session.get('user_id') %}
<form action="/admin/users/{{ u.id }}/delete" method="POST" onsubmit="return confirm('Вы уверены, что хотите удалить пользователя {{ u.username }}?');">
<button type="submit" class="danger">Удалить</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}

View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Сброс пароля — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
.card { background: #161b22; padding: 30px; border-radius: 8px; border: 1px solid #30363d; width: 300px; }
h2 { color: #58a6ff; margin-top: 0; text-align: center; }
.form-group { margin-bottom: 15px; display: flex; flex-direction: column; }
label { margin-bottom: 5px; color: #8b949e; font-size: 0.85rem; }
input { padding: 10px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; }
button { width: 100%; padding: 10px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; }
.links { margin-top: 15px; text-align: center; font-size: 0.85rem; }
.links a { color: #58a6ff; text-decoration: none; }
</style>
</head>
<body>
<div class="card">
<h2>Сброс пароля</h2>
<p style="color: #8b949e; font-size: 0.85rem; text-align: center; margin-bottom: 15px;">Введите свой Email, и мы отправим ссылку для сброса.</p>
<form method="POST">
<div class="form-group">
<label>Email</label>
<input type="email" name="email" required>
</div>
<button type="submit">Отправить ссылку</button>
</form>
<div class="links"><a href="/login">← Назад ко входу</a></div>
</div>
</body>
</html>

View File

@ -39,14 +39,14 @@
{% endif %}
</div>
{% else %}
<p>Скелет системы успешно запущен!</p>
<p>Платформа для команды и проектов</p>
<div class="actions">
<a href="/login" class="btn">Войти через Gitea</a>
<a href="/login" class="btn">Войти / Регистрация</a>
<a href="/schedule" class="btn btn-schedule">Расписание</a>
<a href="/projects" class="btn btn-projects">Проекты</a>
</div>
{% endif %}
<div class="badge">Stage 3.2: File Storage OK</div>
<div class="badge">Stage 1 (Identity): Complete</div>
</body>
</html>

50
app/templates/login.html Normal file
View File

@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Вход — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
.card { background: #161b22; padding: 30px; border-radius: 8px; border: 1px solid #30363d; width: 300px; }
h2 { color: #58a6ff; margin-top: 0; text-align: center; }
.form-group { margin-bottom: 15px; display: flex; flex-direction: column; }
label { margin-bottom: 5px; color: #8b949e; font-size: 0.85rem; }
input { padding: 10px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; }
button { width: 100%; padding: 10px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; margin-top: 10px; }
button:hover { background: #2ea043; }
.gitea-btn { background: #30363d; margin-top: 15px; display: block; text-align: center; text-decoration: none; padding: 10px; border-radius: 5px; color: white; font-weight: bold; font-size: 0.9rem; }
.gitea-btn:hover { background: #21262d; }
.links { margin-top: 15px; text-align: center; font-size: 0.85rem; }
.links a { color: #58a6ff; text-decoration: none; display: block; margin-bottom: 5px; }
.msg { color: #3fb950; text-align: center; margin-bottom: 15px; font-size: 0.9rem; }
.error { color: #f85149; text-align: center; margin-bottom: 15px; font-size: 0.9rem; }
</style>
</head>
<body>
<div class="card">
<h2>Вход</h2>
{% if msg %}<div class="msg">{{ msg }}</div>{% endif %}
{% if error %}<div class="error">{{ error }}</div>{% endif %}
<form method="POST">
<div class="form-group">
<label>Логин</label>
<input type="text" name="username" required>
</div>
<div class="form-group">
<label>Пароль</label>
<input type="password" name="password" required>
</div>
<button type="submit">Войти</button>
</form>
<a href="/login/gitea" class="gitea-btn">Войти через Gitea</a>
<div class="links">
<a href="/register">Нет аккаунта? Регистрация</a>
<a href="/forgot_password">Забыли пароль?</a>
<a href="/" style="color: #8b949e;">← На главную</a>
</div>
</div>
</body>
</html>

View File

@ -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; }
</style>
</head>
<body>
@ -18,7 +20,7 @@
<h2>Личный кабинет</h2>
<div class="field">
<div class="label">Логин в Gitea</div>
<div class="label">Логин</div>
<div class="value">{{ user.username }}</div>
</div>
@ -29,7 +31,14 @@
<div class="field">
<div class="label">Email</div>
<div class="value">{{ user.email or 'Не указано' }}</div>
<div class="value">{{ user.email or 'Не указано' }}
{% if user.is_verified %}<span style="color: #3fb950; font-size: 0.8rem;">✓ Подтвержден</span>{% endif %}
</div>
</div>
<div class="field">
<div class="label">Организация</div>
<div class="value">{{ user.organization or 'Не указана' }}</div>
</div>
<div class="field">
@ -37,8 +46,11 @@
<div class="value">{{ user.role }}</div>
</div>
<div style="margin-top: 20px;">
<a href="/">← На главную</a> | <a href="/logout" style="color: #f85149;">Выйти</a>
<div style="margin-top: 20px; display: flex; justify-content: space-between; align-items: center;">
<div>
<a href="/">← На главную</a> | <a href="/logout" style="color: #f85149;">Выйти</a>
</div>
<a href="/sessions" class="btn-sessions">Безопасность (сессии)</a>
</div>
</div>
</body>

View File

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Регистрация — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
.card { background: #161b22; padding: 30px; border-radius: 8px; border: 1px solid #30363d; width: 350px; }
h2 { color: #58a6ff; margin-top: 0; text-align: center; }
.form-group { margin-bottom: 15px; display: flex; flex-direction: column; }
label { margin-bottom: 5px; color: #8b949e; font-size: 0.85rem; }
input, select { padding: 10px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; }
button { width: 100%; padding: 10px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; margin-top: 10px; }
button:hover { background: #2ea043; }
.error { color: #f85149; text-align: center; margin-bottom: 15px; font-size: 0.9rem; }
.links { margin-top: 15px; text-align: center; font-size: 0.85rem; }
.links a { color: #58a6ff; text-decoration: none; }
</style>
</head>
<body>
<div class="card">
<h2>Регистрация</h2>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
<form method="POST">
<div class="form-group">
<label>ФИО</label>
<input type="text" name="full_name" required>
</div>
<div class="form-group">
<label>Организация</label>
<select name="organization">
<option value="ННГУ">ННГУ</option>
<option value="Неймарк">Неймарк</option>
<option value="Ядро">Ядро</option>
<option value="Внешний">Внешний</option>
</select>
</div>
<div class="form-group">
<label>Логин</label>
<input type="text" name="username" required>
</div>
<div class="form-group">
<label>Email</label>
<input type="email" name="email" required>
</div>
<div class="form-group">
<label>Пароль</label>
<input type="password" name="password" required minlength="6">
</div>
<button type="submit">Зарегистрироваться</button>
</form>
<div class="links">
<a href="/login">Уже есть аккаунт? Войти</a>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Новый пароль — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
.card { background: #161b22; padding: 30px; border-radius: 8px; border: 1px solid #30363d; width: 300px; }
h2 { color: #58a6ff; margin-top: 0; text-align: center; }
.form-group { margin-bottom: 15px; display: flex; flex-direction: column; }
label { margin-bottom: 5px; color: #8b949e; font-size: 0.85rem; }
input { padding: 10px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; }
button { width: 100%; padding: 10px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; }
</style>
</head>
<body>
<div class="card">
<h2>Новый пароль</h2>
<form method="POST">
<div class="form-group">
<label>Введите новый пароль</label>
<input type="password" name="password" required minlength="6">
</div>
<button type="submit">Сохранить пароль</button>
</form>
</div>
</body>
</html>

View File

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Активные сессии — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
.container { max-width: 800px; margin: 0 auto; }
h2 { color: #58a6ff; }
table { width: 100%; border-collapse: collapse; background: #161b22; margin-top: 20px; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #30363d; }
th { background: #21262d; color: #8b949e; }
.current { background: rgba(35, 134, 54, 0.2); }
.btn-danger { background: #da3633; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: bold; margin-top: 20px; }
.btn-danger:hover { background: #f85149; }
.back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<a href="/profile" class="back-link">← В профиль</a>
<h2>Управление устройствами</h2>
<p style="color: #8b949e;">Список всех мест, где выполнен вход в ваш аккаунт.</p>
<table>
<thead>
<tr>
<th>Устройство (User-Agent)</th>
<th>IP-адрес</th>
<th>Дата входа</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{% for s in sessions %}
<tr class="{% if s.session_token == current_token %}current{% endif %}">
<td><small>{{ s.user_agent or 'Неизвестно' }}</small></td>
<td>{{ s.ip_address }}</td>
<td>{{ s.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
<td>{% if s.session_token == current_token %}<b style="color: #3fb950;">Текущая</b>{% else %}-{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<form action="/sessions/logout_all" method="POST" onsubmit="return confirm('Вы уверены, что хотите выйти на всех устройствах?');">
<button type="submit" class="btn-danger">Выйти везде</button>
</form>
</div>
</body>
</html>