From 61e7290ce8ce706e62c0425f944aeec3d3fd54a3 Mon Sep 17 00:00:00 2001 From: test2 Date: Sat, 6 Jun 2026 22:20:09 +0300 Subject: [PATCH] Add user auth, personal cabinet, admin panel and first admin bootstrap Co-authored-by: Cursor --- .env.example | 5 + Dockerfile | 2 + README.md | 56 +++- app/__init__.py | 84 ++++-- app/admin.py | 118 ++++++++ app/auth.py | 78 ++++++ app/auth_utils.py | 28 ++ app/bootstrap.py | 47 ++++ app/models.py | 72 +++++ app/routes.py | 82 +++++- app/static/css/style.css | 319 ++++++++++++++++++++++ app/templates/admin/_nav.html | 5 + app/templates/admin/dashboard.html | 80 ++++++ app/templates/admin/photos.html | 22 ++ app/templates/admin/users.html | 79 ++++++ app/templates/auth/login.html | 36 +++ app/templates/auth/register.html | 40 +++ app/templates/base.html | 14 +- app/templates/cabinet/index.html | 71 +++++ app/templates/cabinet/profile.html | 59 ++++ app/templates/index.html | 87 ++---- app/templates/macros.html | 12 + app/templates/partials/alerts.html | 9 + app/templates/partials/photo_gallery.html | 50 ++++ docker-compose.yml | 3 + requirements.txt | 1 + 26 files changed, 1351 insertions(+), 108 deletions(-) create mode 100644 app/admin.py create mode 100644 app/auth.py create mode 100644 app/auth_utils.py create mode 100644 app/bootstrap.py create mode 100644 app/models.py create mode 100644 app/templates/admin/_nav.html create mode 100644 app/templates/admin/dashboard.html create mode 100644 app/templates/admin/photos.html create mode 100644 app/templates/admin/users.html create mode 100644 app/templates/auth/login.html create mode 100644 app/templates/auth/register.html create mode 100644 app/templates/cabinet/index.html create mode 100644 app/templates/cabinet/profile.html create mode 100644 app/templates/macros.html create mode 100644 app/templates/partials/alerts.html create mode 100644 app/templates/partials/photo_gallery.html diff --git a/.env.example b/.env.example index 451b958..5d742d9 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,8 @@ SECRET_KEY=change_me_random_secret_key_min_32_chars MAX_UPLOAD_MB=10 UPLOAD_FOLDER=/app/uploads APP_PORT=8080 + +# First admin (created automatically on first startup if no admin exists) +ADMIN_USERNAME=admin +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=change_me_admin_password diff --git a/Dockerfile b/Dockerfile index 9c2f035..46e8de6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,8 @@ RUN mkdir -p /app/uploads && adduser --disabled-password --gecos "" appuser \ USER appuser +ENV FLASK_APP=wsgi:app + EXPOSE 8000 CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120", "wsgi:app"] diff --git a/README.md b/README.md index bf7db26..5a24794 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,10 @@ Современный фото-хостинг на **Python (Flask)**, **PostgreSQL** и **Docker Compose**. - Красивая главная страница с drag-and-drop загрузкой +- **Регистрация и авторизация** пользователей +- **Личный кабинет** — загрузка и управление своими фото +- **Админ-панель** — пользователи, фото, статистика +- **Автоматическое создание первого администратора** через `.env` - Галерея загруженных фото - Копирование прямых ссылок на изображения - Хранение метаданных в PostgreSQL, файлов — в Docker volume @@ -14,8 +18,13 @@ ``` fotohost/ ├── app/ -│ ├── __init__.py # Flask-приложение и модель Photo -│ ├── routes.py # Маршруты (загрузка, галерея, API) +│ ├── __init__.py # Flask-приложение, Flask-Login +│ ├── models.py # User, Photo +│ ├── auth.py # Регистрация, вход, выход +│ ├── routes.py # Главная, загрузка, личный кабинет +│ ├── admin.py # Админ-панель +│ ├── bootstrap.py # Миграция схемы, первый admin +│ ├── auth_utils.py # Декораторы доступа │ ├── templates/ # HTML-шаблоны │ └── static/ # CSS и JavaScript ├── uploads/ # Локальная папка (в Docker — volume) @@ -132,6 +141,11 @@ DATABASE_URL=postgresql://photohost:YOUR_STRONG_DB_PASSWORD@db:5432/photohost SECRET_KEY=random_string_min_32_chars MAX_UPLOAD_MB=10 APP_PORT=8080 + +# Первый администратор (создаётся автоматически при первом запуске) +ADMIN_USERNAME=admin +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=YOUR_STRONG_ADMIN_PASSWORD ``` Сгенерировать случайный `SECRET_KEY`: @@ -140,6 +154,14 @@ APP_PORT=8080 python3 -c "import secrets; print(secrets.token_hex(32))" ``` +> **Первый администратор:** при первом запуске, если в базе нет ни одного admin, создаётся пользователь из `ADMIN_USERNAME` / `ADMIN_EMAIL` / `ADMIN_PASSWORD`. Обязательно смените пароль в `.env` до деплоя. + +Альтернатива — создать admin вручную через CLI: + +```bash +docker compose exec web flask create-admin +``` + ### 8. Запуск приложения ```bash @@ -175,6 +197,8 @@ http://YOUR_SERVER_IP:8080 Загрузите тестовое изображение — оно должно появиться в галерее. +Войдите как admin (`/auth/login`) → откройте **Админку** (`/admin`). + ### 10. Открытие порта в файрволе (UFW) Если включён UFW: @@ -197,6 +221,26 @@ sudo systemctl start docker --- +## Регистрация, авторизация и роли + +| URL | Описание | +|-----|----------| +| `/auth/register` | Регистрация нового пользователя | +| `/auth/login` | Вход (логин или email) | +| `/auth/logout` | Выход | +| `/cabinet/` | Личный кабинет — мои фото | +| `/cabinet/profile` | Настройки профиля, смена пароля | +| `/admin/` | Панель администратора (только admin) | +| `/admin/users` | Управление пользователями | +| `/admin/photos` | Все фото на сервере | + +**Права доступа:** +- Загрузка фото — только авторизованным пользователям +- Удаление фото — владелец или администратор +- Админка — только пользователи с `is_admin=True` + +--- + ## Полезные команды | Действие | Команда | @@ -321,7 +365,11 @@ python wsgi.py | Метод | URL | Описание | |-------|---------------|-----------------------------| | GET | `/` | Главная страница | -| POST | `/upload` | Загрузка фото (form-data) | +| POST | `/auth/register` | Регистрация | +| POST | `/auth/login` | Вход | +| GET | `/cabinet/` | Личный кабинет | +| GET | `/admin/` | Админ-панель | +| POST | `/upload` | Загрузка фото (auth) | | GET | `/uploads/` | Прямая ссылка на файл | | GET | `/api/photos` | JSON-список всех фото | | POST | `/delete/` | Удаление фото | @@ -377,7 +425,7 @@ docker compose down && docker compose up -d ## Технологии -- Python 3.12, Flask 3, Gunicorn +- Python 3.12, Flask 3, Flask-Login, Gunicorn - PostgreSQL 16 - SQLAlchemy, Pillow - Docker & Docker Compose diff --git a/app/__init__.py b/app/__init__.py index ca204f1..83ef06a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,13 +1,24 @@ import os -from datetime import datetime, timezone from flask import Flask +from flask_login import LoginManager from flask_sqlalchemy import SQLAlchemy from dotenv import load_dotenv load_dotenv() db = SQLAlchemy() +login_manager = LoginManager() +login_manager.login_view = "auth.login" +login_manager.login_message = "Войдите для доступа к этой странице." +login_manager.login_message_category = "error" + + +@login_manager.user_loader +def load_user(user_id): + from app.models import User + + return db.session.get(User, int(user_id)) def create_app(): @@ -26,40 +37,61 @@ def create_app(): os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True) db.init_app(app) + login_manager.init_app(app) - from .routes import bp + from .routes import bp as main_bp, cabinet_bp + from .auth import bp as auth_bp + from .admin import bp as admin_bp - app.register_blueprint(bp) + app.register_blueprint(main_bp) + app.register_blueprint(cabinet_bp) + app.register_blueprint(auth_bp) + app.register_blueprint(admin_bp) + + register_cli(app) with app.app_context(): + from app.models import Photo, User # noqa: F401 + db.create_all() + from app.bootstrap import create_first_admin, ensure_schema + + ensure_schema() + create_first_admin(app) return app -class Photo(db.Model): - __tablename__ = "photos" +def register_cli(app): + @app.cli.command("create-admin") + def create_admin_command(): + """Create or update admin user interactively.""" + from getpass import getpass - id = db.Column(db.Integer, primary_key=True) - filename = db.Column(db.String(255), nullable=False) - original_name = db.Column(db.String(255), nullable=False) - file_size = db.Column(db.Integer, nullable=False, default=0) - mime_type = db.Column(db.String(100), nullable=False, default="image/jpeg") - created_at = db.Column( - db.DateTime, - nullable=False, - default=lambda: datetime.now(timezone.utc), - ) + from app.models import User - @property - def url(self): - return f"/uploads/{self.filename}" + username = input("Username: ").strip() + email = input("Email: ").strip() + password = getpass("Password: ") + password2 = getpass("Confirm password: ") - @property - def size_human(self): - size = self.file_size - for unit in ("Б", "КБ", "МБ", "ГБ"): - if size < 1024: - return f"{size:.0f} {unit}" if unit == "Б" else f"{size:.1f} {unit}" - size /= 1024 - return f"{size:.1f} ТБ" + if not username or not email or not password: + print("All fields are required.") + return + if password != password2: + print("Passwords do not match.") + return + + user = User.query.filter_by(username=username).first() + if user: + user.email = email + user.is_admin = True + user.set_password(password) + print(f"User '{username}' updated and promoted to admin.") + else: + user = User(username=username, email=email, is_admin=True) + user.set_password(password) + db.session.add(user) + print(f"Admin '{username}' created.") + + db.session.commit() diff --git a/app/admin.py b/app/admin.py new file mode 100644 index 0000000..fabbda8 --- /dev/null +++ b/app/admin.py @@ -0,0 +1,118 @@ +import os + +from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for +from flask_login import current_user +from sqlalchemy import func + +from app import db +from app.auth_utils import admin_required +from app.models import Photo, User + +bp = Blueprint("admin", __name__, url_prefix="/admin") + + +@bp.route("/") +@admin_required +def dashboard(): + stats = { + "users": User.query.count(), + "photos": Photo.query.count(), + "admins": User.query.filter_by(is_admin=True).count(), + "storage": int( + db.session.query(func.coalesce(func.sum(Photo.file_size), 0)).scalar() or 0 + ), + } + recent_users = User.query.order_by(User.created_at.desc()).limit(5).all() + recent_photos = Photo.query.order_by(Photo.created_at.desc()).limit(8).all() + return render_template( + "admin/dashboard.html", + stats=stats, + recent_users=recent_users, + recent_photos=recent_photos, + ) + + +@bp.route("/users") +@admin_required +def users(): + all_users = User.query.order_by(User.created_at.desc()).all() + return render_template("admin/users.html", users=all_users) + + +@bp.route("/users//toggle-admin", methods=["POST"]) +@admin_required +def toggle_admin(user_id): + user = User.query.get_or_404(user_id) + if user.id == current_user.id: + flash("Нельзя снять права администратора с самого себя", "error") + return redirect(url_for("admin.users")) + + admin_count = User.query.filter_by(is_admin=True).count() + if user.is_admin and admin_count <= 1: + flash("Нельзя удалить последнего администратора", "error") + return redirect(url_for("admin.users")) + + user.is_admin = not user.is_admin + db.session.commit() + action = "назначен администратором" if user.is_admin else "лишён прав администратора" + flash(f"Пользователь {user.username} {action}", "success") + return redirect(url_for("admin.users")) + + +@bp.route("/users//toggle-active", methods=["POST"]) +@admin_required +def toggle_active(user_id): + user = User.query.get_or_404(user_id) + if user.id == current_user.id: + flash("Нельзя заблокировать самого себя", "error") + return redirect(url_for("admin.users")) + + user.is_active = not user.is_active + db.session.commit() + action = "разблокирован" if user.is_active else "заблокирован" + flash(f"Пользователь {user.username} {action}", "success") + return redirect(url_for("admin.users")) + + +@bp.route("/users//delete", methods=["POST"]) +@admin_required +def delete_user(user_id): + user = User.query.get_or_404(user_id) + if user.id == current_user.id: + flash("Нельзя удалить самого себя", "error") + return redirect(url_for("admin.users")) + + if user.is_admin and User.query.filter_by(is_admin=True).count() <= 1: + flash("Нельзя удалить последнего администратора", "error") + return redirect(url_for("admin.users")) + + for photo in user.photos.all(): + filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], photo.filename) + if os.path.exists(filepath): + os.remove(filepath) + db.session.delete(photo) + + db.session.delete(user) + db.session.commit() + flash(f"Пользователь {user.username} удалён", "success") + return redirect(url_for("admin.users")) + + +@bp.route("/photos") +@admin_required +def photos(): + all_photos = Photo.query.order_by(Photo.created_at.desc()).all() + return render_template("admin/photos.html", photos=all_photos) + + +@bp.route("/photos//delete", methods=["POST"]) +@admin_required +def delete_photo(photo_id): + photo = Photo.query.get_or_404(photo_id) + filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], photo.filename) + if os.path.exists(filepath): + os.remove(filepath) + db.session.delete(photo) + db.session.commit() + flash("Фото удалено", "success") + return redirect(url_for("admin.photos")) diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..f3f71c1 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,78 @@ +from flask import Blueprint, flash, redirect, render_template, request, url_for +from flask_login import current_user, login_user, logout_user + +from app import db +from app.models import User + +bp = Blueprint("auth", __name__, url_prefix="/auth") + + +@bp.route("/register", methods=["GET", "POST"]) +def register(): + if current_user.is_authenticated: + return redirect(url_for("cabinet.index")) + + if request.method == "POST": + username = request.form.get("username", "").strip() + email = request.form.get("email", "").strip().lower() + password = request.form.get("password", "") + password2 = request.form.get("password2", "") + + if len(username) < 3: + flash("Имя пользователя — минимум 3 символа", "error") + elif len(password) < 6: + flash("Пароль — минимум 6 символов", "error") + elif password != password2: + flash("Пароли не совпадают", "error") + elif User.query.filter_by(username=username).first(): + flash("Это имя пользователя уже занято", "error") + elif User.query.filter_by(email=email).first(): + flash("Этот email уже зарегистрирован", "error") + else: + user = User(username=username, email=email) + user.set_password(password) + db.session.add(user) + db.session.commit() + login_user(user) + flash("Регистрация успешна. Добро пожаловать!", "success") + return redirect(url_for("cabinet.index")) + + return render_template("auth/register.html") + + +@bp.route("/login", methods=["GET", "POST"]) +def login(): + if current_user.is_authenticated: + return redirect(url_for("cabinet.index")) + + if request.method == "POST": + login = request.form.get("login", "").strip() + password = request.form.get("password", "") + remember = request.form.get("remember") == "on" + + user = User.query.filter( + (User.username == login) | (User.email == login.lower()) + ).first() + + if user is None or not user.check_password(password): + flash("Неверный логин или пароль", "error") + elif not user.is_active: + flash("Аккаунт заблокирован", "error") + else: + login_user(user, remember=remember) + flash(f"Добро пожаловать, {user.username}!", "success") + next_page = request.args.get("next") + if next_page: + return redirect(next_page) + if user.is_admin: + return redirect(url_for("admin.dashboard")) + return redirect(url_for("cabinet.index")) + + return render_template("auth/login.html") + + +@bp.route("/logout") +def logout(): + logout_user() + flash("Вы вышли из аккаунта", "success") + return redirect(url_for("main.index")) diff --git a/app/auth_utils.py b/app/auth_utils.py new file mode 100644 index 0000000..cbe7f59 --- /dev/null +++ b/app/auth_utils.py @@ -0,0 +1,28 @@ +from functools import wraps + +from flask import abort, flash, redirect, url_for +from flask_login import current_user + + +def admin_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not current_user.is_authenticated or not current_user.is_admin: + flash("Доступ только для администраторов", "error") + return redirect(url_for("main.index")) + return f(*args, **kwargs) + + return decorated + + +def can_manage_photo(photo): + if not current_user.is_authenticated: + return False + if current_user.is_admin: + return True + return photo.user_id == current_user.id + + +def photo_owner_or_admin(photo): + if not can_manage_photo(photo): + abort(403) diff --git a/app/bootstrap.py b/app/bootstrap.py new file mode 100644 index 0000000..8e0c6ff --- /dev/null +++ b/app/bootstrap.py @@ -0,0 +1,47 @@ +import os + +from sqlalchemy import inspect, text + +from app import db +from app.models import User + + +def ensure_schema(): + inspector = inspect(db.engine) + tables = inspector.get_table_names() + + if "photos" in tables: + columns = {col["name"] for col in inspector.get_columns("photos")} + if "user_id" not in columns: + db.session.execute( + text("ALTER TABLE photos ADD COLUMN user_id INTEGER REFERENCES users(id)") + ) + db.session.commit() + + +def create_first_admin(app): + username = os.getenv("ADMIN_USERNAME", "").strip() + email = os.getenv("ADMIN_EMAIL", "").strip() + password = os.getenv("ADMIN_PASSWORD", "").strip() + + if not username or not email or not password: + app.logger.info("ADMIN_* env vars not set — first admin was not created automatically") + return None + + if User.query.filter_by(is_admin=True).first(): + return None + + if User.query.filter_by(username=username).first(): + user = User.query.filter_by(username=username).first() + user.is_admin = True + user.set_password(password) + db.session.commit() + app.logger.info("Existing user '%s' promoted to admin", username) + return user + + user = User(username=username, email=email, is_admin=True) + user.set_password(password) + db.session.add(user) + db.session.commit() + app.logger.info("First admin '%s' created", username) + return user diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..60eaf87 --- /dev/null +++ b/app/models.py @@ -0,0 +1,72 @@ +from datetime import datetime, timezone + +from flask_login import UserMixin +from werkzeug.security import check_password_hash, generate_password_hash + +from app import db + + +class User(UserMixin, db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False, index=True) + email = db.Column(db.String(120), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(256), nullable=False) + is_admin = db.Column(db.Boolean, nullable=False, default=False) + is_active = db.Column(db.Boolean, nullable=False, default=True) + created_at = db.Column( + db.DateTime, + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + + photos = db.relationship("Photo", backref="owner", lazy="dynamic") + + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password_hash, password) + + @property + def photo_count(self): + return self.photos.count() + + @property + def total_size(self): + from sqlalchemy import func + + result = db.session.query(func.coalesce(func.sum(Photo.file_size), 0)).filter( + Photo.user_id == self.id + ).scalar() + return int(result or 0) + + +class Photo(db.Model): + __tablename__ = "photos" + + id = db.Column(db.Integer, primary_key=True) + filename = db.Column(db.String(255), nullable=False) + original_name = db.Column(db.String(255), nullable=False) + file_size = db.Column(db.Integer, nullable=False, default=0) + mime_type = db.Column(db.String(100), nullable=False, default="image/jpeg") + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) + created_at = db.Column( + db.DateTime, + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + + @property + def url(self): + return f"/uploads/{self.filename}" + + @property + def size_human(self): + size = self.file_size + for unit in ("Б", "КБ", "МБ", "ГБ"): + if size < 1024: + return f"{size:.0f} {unit}" if unit == "Б" else f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} ТБ" diff --git a/app/routes.py b/app/routes.py index 350a9b5..6f7540d 100644 --- a/app/routes.py +++ b/app/routes.py @@ -13,9 +13,12 @@ from flask import ( send_from_directory, url_for, ) +from flask_login import current_user, login_required from werkzeug.utils import secure_filename -from app import Photo, db +from app import db +from app.auth_utils import photo_owner_or_admin +from app.models import Photo bp = Blueprint("main", __name__) @@ -29,31 +32,33 @@ def allowed_file(filename): @bp.route("/") def index(): - photos = Photo.query.order_by(Photo.created_at.desc()).all() - total_size = sum(p.file_size for p in photos) + photos = Photo.query.order_by(Photo.created_at.desc()).limit(24).all() + total_photos = Photo.query.count() + total_size = db.session.query(db.func.coalesce(db.func.sum(Photo.file_size), 0)).scalar() or 0 return render_template( "index.html", photos=photos, - total_photos=len(photos), - total_size=total_size, + total_photos=total_photos, + total_size=int(total_size), max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024), ) @bp.route("/upload", methods=["POST"]) +@login_required def upload(): if "photo" not in request.files: flash("Файл не выбран", "error") - return redirect(url_for("main.index")) + return redirect(request.referrer or url_for("main.index")) file = request.files["photo"] if file.filename == "": flash("Файл не выбран", "error") - return redirect(url_for("main.index")) + return redirect(request.referrer or url_for("main.index")) if not allowed_file(file.filename): flash("Недопустимый формат. Разрешены: PNG, JPG, GIF, WEBP, BMP", "error") - return redirect(url_for("main.index")) + return redirect(request.referrer or url_for("main.index")) ext = file.filename.rsplit(".", 1)[1].lower() stored_name = f"{uuid.uuid4().hex}.{ext}" @@ -69,13 +74,14 @@ def upload(): original_name=safe_original, file_size=file_size, mime_type=file.content_type or f"image/{ext}", + user_id=current_user.id, created_at=datetime.now(timezone.utc), ) db.session.add(photo) db.session.commit() flash("Фото успешно загружено", "success") - return redirect(url_for("main.index")) + return redirect(url_for("cabinet.index")) @bp.route("/api/photos") @@ -89,6 +95,7 @@ def api_photos(): "original_name": p.original_name, "file_size": p.file_size, "size_human": p.size_human, + "user_id": p.user_id, "created_at": p.created_at.isoformat(), } for p in photos @@ -102,12 +109,67 @@ def uploaded_file(filename): @bp.route("/delete/", methods=["POST"]) +@login_required def delete_photo(photo_id): photo = Photo.query.get_or_404(photo_id) + photo_owner_or_admin(photo) + filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], photo.filename) if os.path.exists(filepath): os.remove(filepath) db.session.delete(photo) db.session.commit() flash("Фото удалено", "success") - return redirect(url_for("main.index")) + return redirect(request.referrer or url_for("main.index")) + + +cabinet_bp = Blueprint("cabinet", __name__, url_prefix="/cabinet") + + +@cabinet_bp.route("/") +@login_required +def index(): + photos = ( + Photo.query.filter_by(user_id=current_user.id) + .order_by(Photo.created_at.desc()) + .all() + ) + total_size = sum(p.file_size for p in photos) + return render_template( + "cabinet/index.html", + photos=photos, + total_photos=len(photos), + total_size=total_size, + max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024), + ) + + +@cabinet_bp.route("/profile", methods=["GET", "POST"]) +@login_required +def profile(): + from app.models import User + + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + current_password = request.form.get("current_password", "") + new_password = request.form.get("new_password", "") + new_password2 = request.form.get("new_password2", "") + + other = User.query.filter(User.email == email, User.id != current_user.id).first() + if other: + flash("Этот email уже используется", "error") + elif not current_user.check_password(current_password): + flash("Неверный текущий пароль", "error") + elif new_password and len(new_password) < 6: + flash("Новый пароль — минимум 6 символов", "error") + elif new_password and new_password != new_password2: + flash("Новые пароли не совпадают", "error") + else: + current_user.email = email + if new_password: + current_user.set_password(new_password) + db.session.commit() + flash("Профиль обновлён", "success") + return redirect(url_for("cabinet.profile")) + + return render_template("cabinet/profile.html") diff --git a/app/static/css/style.css b/app/static/css/style.css index 8f4b992..7fcef93 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -561,4 +561,323 @@ body { align-items: flex-end; padding-bottom: 12px; } + + .nav { + flex-wrap: wrap; + justify-content: flex-end; + } + + .admin-table-wrap { + overflow-x: auto; + } +} + +/* Auth */ +.auth-section { + position: relative; + z-index: 1; + padding: 48px 0 80px; +} + +.auth-container { + max-width: 440px; +} + +.auth-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 32px; + backdrop-filter: blur(10px); +} + +.auth-card--wide { + max-width: 520px; + margin: 0 auto; +} + +.auth-card__title { + font-size: 1.75rem; + font-weight: 700; + margin-bottom: 8px; +} + +.auth-card__subtitle { + color: var(--text-muted); + margin-bottom: 24px; +} + +.auth-card__footer { + margin-top: 20px; + text-align: center; + color: var(--text-muted); + font-size: 0.9rem; +} + +.auth-card__footer a { + color: var(--accent-light); + text-decoration: none; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.form-group label { + font-size: 0.85rem; + font-weight: 500; + color: var(--text-muted); +} + +.form-group input { + padding: 12px 14px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.3); + color: var(--text); + font-family: var(--font); + font-size: 0.95rem; + transition: border-color 0.2s; +} + +.form-group input:focus { + outline: none; + border-color: var(--accent); +} + +.form-checkbox { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + color: var(--text-muted); + cursor: pointer; +} + +.btn--full { + width: 100%; +} + +.hero__actions { + display: flex; + gap: 12px; + justify-content: center; + margin-top: 24px; +} + +.nav__user { + color: var(--accent-light); + font-size: 0.85rem; + font-weight: 600; + padding: 8px 12px; +} + +.nav__link--accent { + color: var(--accent-light); + border: 1px solid rgba(99, 102, 241, 0.4); +} + +.nav__link--admin { + color: #fbbf24; +} + +.photo-card__owner { + font-size: 0.75rem; + color: var(--accent-light); + margin-bottom: 8px; +} + +/* Page header */ +.page-header { + position: relative; + z-index: 1; + padding: 48px 0 24px; +} + +.page-header--admin .page-header__title { + color: #fbbf24; +} + +.page-header__title { + font-size: 2rem; + font-weight: 700; + margin-bottom: 8px; +} + +.page-header__subtitle { + color: var(--text-muted); +} + +.page-header__actions { + margin-top: 16px; +} + +.stats-bar { + position: relative; + z-index: 1; + padding-bottom: 24px; +} + +.profile-info { + margin-bottom: 24px; + padding-bottom: 24px; + border-bottom: 1px solid var(--border); +} + +.profile-info__row { + display: flex; + justify-content: space-between; + padding: 8px 0; + font-size: 0.9rem; +} + +.profile-info__row span { + color: var(--text-muted); +} + +/* Admin */ +.admin-section { + position: relative; + z-index: 1; + padding-bottom: 80px; +} + +.admin-nav { + display: flex; + gap: 8px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.admin-nav__link { + padding: 10px 18px; + border-radius: var(--radius-sm); + text-decoration: none; + color: var(--text-muted); + font-size: 0.9rem; + font-weight: 500; + border: 1px solid var(--border); + background: var(--bg-card); + transition: all 0.2s; +} + +.admin-nav__link:hover, +.admin-nav__link--active { + color: #fbbf24; + border-color: rgba(251, 191, 36, 0.4); + background: rgba(251, 191, 36, 0.08); +} + +.admin-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 16px; + margin-bottom: 32px; +} + +.stat-card--admin { + border-color: rgba(251, 191, 36, 0.2); +} + +.admin-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 24px; +} + +.admin-panel { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; +} + +.admin-panel__title { + font-size: 1.1rem; + margin-bottom: 16px; +} + +.admin-table-wrap { + overflow-x: auto; +} + +.admin-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.admin-table th, +.admin-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.admin-table th { + color: var(--text-muted); + font-weight: 500; +} + +.admin-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.admin-mini-gallery { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} + +.admin-mini-gallery__item { + aspect-ratio: 1; + border-radius: var(--radius-sm); + overflow: hidden; +} + +.admin-mini-gallery__item img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.admin-empty, +.text-muted { + color: var(--text-muted); + font-size: 0.9rem; +} + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + background: var(--bg-card); + border: 1px solid var(--border); +} + +.badge--admin { + color: #fbbf24; + border-color: rgba(251, 191, 36, 0.4); +} + +.badge--success { + color: #86efac; + border-color: rgba(34, 197, 94, 0.3); +} + +.badge--danger { + color: #fca5a5; + border-color: rgba(239, 68, 68, 0.3); } diff --git a/app/templates/admin/_nav.html b/app/templates/admin/_nav.html new file mode 100644 index 0000000..b324a48 --- /dev/null +++ b/app/templates/admin/_nav.html @@ -0,0 +1,5 @@ + diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html new file mode 100644 index 0000000..3c429df --- /dev/null +++ b/app/templates/admin/dashboard.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} +{% from "macros.html" import format_size %} + +{% block title %}Админка — PhotoHost{% endblock %} + +{% block content %} + + +
+
+ {% include "admin/_nav.html" %} + {% include "partials/alerts.html" %} + +
+
+ {{ stats.users }} + пользователей +
+
+ {{ stats.photos }} + фотографий +
+
+ {{ stats.admins }} + администраторов +
+
+ {{ format_size(stats.storage) }} + хранилище +
+
+ +
+
+

Новые пользователи

+
+ + + + + + + + + + {% for user in recent_users %} + + + + + + {% else %} + + {% endfor %} + +
ЛогинEmailДата
{{ user.username }}{% if user.is_admin %} admin{% endif %}{{ user.email }}{{ user.created_at.strftime('%d.%m.%Y') }}
Нет пользователей
+
+
+ +
+

Последние фото

+ +
+
+
+
+{% endblock %} diff --git a/app/templates/admin/photos.html b/app/templates/admin/photos.html new file mode 100644 index 0000000..9b41a14 --- /dev/null +++ b/app/templates/admin/photos.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block title %}Фото — Админка{% endblock %} + +{% block content %} + + +
+
+ {% include "admin/_nav.html" %} + {% include "partials/alerts.html" %} + + {% with photos=photos, show_owner=true, delete_mode='admin', empty_title='Нет фотографий', empty_text='Пользователи ещё не загружали фото' %} + {% include "partials/photo_gallery.html" %} + {% endwith %} +
+
+{% endblock %} diff --git a/app/templates/admin/users.html b/app/templates/admin/users.html new file mode 100644 index 0000000..541d177 --- /dev/null +++ b/app/templates/admin/users.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} + +{% block title %}Пользователи — Админка{% endblock %} + +{% block content %} + + +
+
+ {% include "admin/_nav.html" %} + {% include "partials/alerts.html" %} + +
+ + + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + + {% endfor %} + +
IDЛогинEmailФотоРольСтатусДатаДействия
{{ user.id }}{{ user.username }}{{ user.email }}{{ user.photo_count }} + {% if user.is_admin %} + Админ + {% else %} + User + {% endif %} + + {% if user.is_active %} + Активен + {% else %} + Заблокирован + {% endif %} + {{ user.created_at.strftime('%d.%m.%Y') }} + {% if user.id != current_user.id %} +
+ +
+
+ +
+
+ +
+ {% else %} + Вы + {% endif %} +
+
+
+
+{% endblock %} diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..17e661b --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %}Вход — PhotoHost{% endblock %} + +{% block content %} +
+
+
+

Вход

+

Войдите в аккаунт для загрузки фото

+ + {% include "partials/alerts.html" %} + +
+
+ + +
+
+ + +
+ + +
+ + +
+
+
+{% endblock %} diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 0000000..119fa67 --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} + +{% block title %}Регистрация — PhotoHost{% endblock %} + +{% block content %} +
+
+
+

Регистрация

+

Создайте аккаунт для загрузки и управления фото

+ + {% include "partials/alerts.html" %} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index d4dab6b..a0cf9ca 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -21,8 +21,18 @@ PhotoHost diff --git a/app/templates/cabinet/index.html b/app/templates/cabinet/index.html new file mode 100644 index 0000000..8793896 --- /dev/null +++ b/app/templates/cabinet/index.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% from "macros.html" import format_size %} + +{% block title %}Личный кабинет — PhotoHost{% endblock %} + +{% block content %} + + +{% include "partials/alerts.html" %} + +
+
+
+ {{ total_photos }} + ваших фото +
+
+ {{ format_size(total_size) }} + занято места +
+
+ до {{ max_upload_mb }} МБ + на файл +
+
+
+ +
+
+

Загрузить фото

+
+
+ +
+ + + + +
+

Перетащите фото сюда

+

или нажмите для выбора файла

+ +
+ +
+
+
+ + +{% endblock %} diff --git a/app/templates/cabinet/profile.html b/app/templates/cabinet/profile.html new file mode 100644 index 0000000..19dbb12 --- /dev/null +++ b/app/templates/cabinet/profile.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} + +{% block title %}Профиль — PhotoHost{% endblock %} + +{% block content %} + + +
+
+
+ {% include "partials/alerts.html" %} + +
+
+ Имя пользователя + {{ current_user.username }} +
+
+ Роль + {% if current_user.is_admin %}Администратор{% else %}Пользователь{% endif %} +
+
+ Дата регистрации + {{ current_user.created_at.strftime('%d.%m.%Y') }} +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
+{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html index 2b07130..d0b5379 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,29 +1,21 @@ {% extends "base.html" %} - -{% macro format_size(bytes) %} -{% set size = bytes|float %} -{% if size < 1024 %} -{{ size|int }} Б -{% elif size < 1048576 %} -{{ "%.1f"|format(size / 1024) }} КБ -{% elif size < 1073741824 %} -{{ "%.1f"|format(size / 1048576) }} МБ -{% else %} -{{ "%.1f"|format(size / 1073741824) }} ГБ -{% endif %} -{% endmacro %} +{% from "macros.html" import format_size %} {% block content %}
-
Бесплатно · Без регистрации
+
Регистрация · Личный кабинет · Админка

Загружайте фото
мгновенно

Современный фото-хостинг на Python и PostgreSQL. - Перетащите изображение — получите прямую ссылку за секунды. + {% if current_user.is_authenticated %} + Загружайте изображения в личном кабинете и делитесь ссылками. + {% else %} + Зарегистрируйтесь, чтобы загружать фото и управлять галереей. + {% endif %}

@@ -39,19 +31,18 @@ на файл
+ {% if not current_user.is_authenticated %} + + {% endif %}
-{% with messages = get_flashed_messages(with_categories=true) %} -{% if messages %} -
- {% for category, message in messages %} -
{{ message }}
- {% endfor %} -
-{% endif %} -{% endwith %} +{% include "partials/alerts.html" %} +{% if current_user.is_authenticated %}

Загрузить фото

@@ -81,53 +72,17 @@
+{% endif %} {% endblock %} diff --git a/app/templates/macros.html b/app/templates/macros.html new file mode 100644 index 0000000..5cb02f2 --- /dev/null +++ b/app/templates/macros.html @@ -0,0 +1,12 @@ +{% macro format_size(bytes) %} +{% set size = bytes|float %} +{% if size < 1024 %} +{{ size|int }} Б +{% elif size < 1048576 %} +{{ "%.1f"|format(size / 1024) }} КБ +{% elif size < 1073741824 %} +{{ "%.1f"|format(size / 1048576) }} МБ +{% else %} +{{ "%.1f"|format(size / 1073741824) }} ГБ +{% endif %} +{% endmacro %} diff --git a/app/templates/partials/alerts.html b/app/templates/partials/alerts.html new file mode 100644 index 0000000..0f56623 --- /dev/null +++ b/app/templates/partials/alerts.html @@ -0,0 +1,9 @@ +{% with messages = get_flashed_messages(with_categories=true) %} +{% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+{% endif %} +{% endwith %} diff --git a/app/templates/partials/photo_gallery.html b/app/templates/partials/photo_gallery.html new file mode 100644 index 0000000..7d23a0d --- /dev/null +++ b/app/templates/partials/photo_gallery.html @@ -0,0 +1,50 @@ +{% if photos %} + +{% else %} +
+
🖼️
+

{{ empty_title or 'Пока нет фотографий' }}

+

{{ empty_text or 'Загрузите первое изображение — оно появится здесь' }}

+ {% if empty_link %} + {{ empty_link_text or 'Загрузить фото' }} + {% endif %} +
+{% endif %} diff --git a/docker-compose.yml b/docker-compose.yml index fe7637f..888ca7e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,9 @@ services: SECRET_KEY: ${SECRET_KEY:-dev-secret-change-in-production} MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10} UPLOAD_FOLDER: /app/uploads + ADMIN_USERNAME: ${ADMIN_USERNAME:-} + ADMIN_EMAIL: ${ADMIN_EMAIL:-} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} volumes: - uploads_data:/app/uploads depends_on: diff --git a/requirements.txt b/requirements.txt index 38705b9..9ace476 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +Flask-Login==0.6.3 Flask==3.1.0 Flask-SQLAlchemy==3.1.1 psycopg2-binary==2.9.10