From c1aac7ecaca5f75a97c842ffb5fc5f228e9c73fc Mon Sep 17 00:00:00 2001 From: test2 Date: Sat, 6 Jun 2026 22:38:37 +0300 Subject: [PATCH] Release 1.2: bulk upload, S3/SFTP/FTP, SMTP, password reset, user groups, git deploy Co-authored-by: Cursor --- .env.example | 7 + Dockerfile | 2 + app/__init__.py | 29 +++- app/admin.py | 170 ++++++++++++++++++- app/auth.py | 61 ++++++- app/bootstrap.py | 70 +++++++- app/deploy_utils.py | 151 +++++++++++++++++ app/email_service.py | 71 ++++++++ app/folders.py | 12 +- app/models.py | 114 ++++++++++++- app/quota_utils.py | 65 +++++++ app/routes.py | 98 ++++++----- app/settings_service.py | 56 ++++++ app/static/css/style.css | 79 +++++++++ app/static/js/main.js | 35 ++-- app/storage_service.py | 216 ++++++++++++++++++++++++ app/templates/admin/_nav.html | 3 + app/templates/admin/dashboard.html | 12 ++ app/templates/admin/deploy.html | 92 ++++++++++ app/templates/admin/groups.html | 75 ++++++++ app/templates/admin/settings.html | 93 ++++++++++ app/templates/admin/users.html | 10 ++ app/templates/auth/forgot_password.html | 23 +++ app/templates/auth/login.html | 3 +- app/templates/auth/reset_password.html | 25 +++ app/templates/cabinet/folders/view.html | 16 +- app/templates/cabinet/index.html | 43 +++-- app/templates/index.html | 27 +-- app/templates/partials/upload_form.html | 22 +++ app/templates/share/folder.html | 15 +- app/upload_service.py | 96 +++++++++++ docker-compose.yml | 6 + requirements.txt | 2 + 33 files changed, 1649 insertions(+), 150 deletions(-) create mode 100644 app/deploy_utils.py create mode 100644 app/email_service.py create mode 100644 app/quota_utils.py create mode 100644 app/settings_service.py create mode 100644 app/storage_service.py create mode 100644 app/templates/admin/deploy.html create mode 100644 app/templates/admin/groups.html create mode 100644 app/templates/admin/settings.html create mode 100644 app/templates/auth/forgot_password.html create mode 100644 app/templates/auth/reset_password.html create mode 100644 app/templates/partials/upload_form.html create mode 100644 app/upload_service.py diff --git a/.env.example b/.env.example index 5d742d9..bfeedaa 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,10 @@ APP_PORT=8080 ADMIN_USERNAME=admin ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=change_me_admin_password + +# Default user group quota in MB (0 = unlimited) +DEFAULT_GROUP_QUOTA_MB=100 + +# Git deploy from admin panel (requires repo mount and docker socket) +ALLOW_GIT_DEPLOY=false +GIT_REMOTE_URL=https://git.evilfox.cc/test2/fotohost.git diff --git a/Dockerfile b/Dockerfile index 46e8de6..159693a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,8 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ libpq-dev \ gcc \ + git \ + docker.io \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . diff --git a/app/__init__.py b/app/__init__.py index 71ec558..e232215 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -33,6 +33,13 @@ def create_app(): app.config["UPLOAD_FOLDER"] = os.getenv("UPLOAD_FOLDER", "uploads") app.config["MAX_CONTENT_LENGTH"] = int(os.getenv("MAX_UPLOAD_MB", "10")) * 1024 * 1024 app.config["ALLOWED_EXTENSIONS"] = {"png", "jpg", "jpeg", "gif", "webp", "bmp"} + app.config["GIT_REPO_PATH"] = os.getenv("GIT_REPO_PATH", "/repo") + app.config["ALLOW_GIT_DEPLOY"] = os.getenv("ALLOW_GIT_DEPLOY", "false").lower() in ( + "1", + "true", + "yes", + ) + app.config["DEFAULT_GROUP_QUOTA_MB"] = int(os.getenv("DEFAULT_GROUP_QUOTA_MB", "100")) os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True) @@ -53,14 +60,32 @@ def create_app(): register_cli(app) with app.app_context(): - from app.models import Folder, FolderInvite, FolderMember, Photo, User # noqa: F401 + from app.models import ( # noqa: F401 + Folder, + FolderInvite, + FolderMember, + PasswordResetToken, + Photo, + SiteSettings, + User, + UserGroup, + ) db.create_all() - from app.bootstrap import create_first_admin, ensure_schema + from app.bootstrap import ( + create_first_admin, + ensure_default_group, + ensure_photo_storage_column, + ensure_schema, + ensure_site_settings, + ) from app.folders import ensure_folder_schema ensure_schema() + ensure_default_group(app) ensure_folder_schema() + ensure_site_settings(app) + ensure_photo_storage_column() create_first_admin(app) return app diff --git a/app/admin.py b/app/admin.py index fabbda8..68d18db 100644 --- a/app/admin.py +++ b/app/admin.py @@ -6,7 +6,19 @@ from sqlalchemy import func from app import db from app.auth_utils import admin_required -from app.models import Photo, User +from app.bootstrap import slugify +from app.deploy_utils import ( + checkout_version, + deploy_rebuild, + fetch_remote, + get_current_version, + get_deploy_status, + is_deploy_enabled, +) +from app.models import Photo, User, UserGroup +from app.quota_utils import get_user_storage_used +from app.settings_service import get_settings, update_settings_from_form +from app.storage_service import delete_photo_file bp = Blueprint("admin", __name__, url_prefix="/admin") @@ -18,17 +30,21 @@ def dashboard(): "users": User.query.count(), "photos": Photo.query.count(), "admins": User.query.filter_by(is_admin=True).count(), + "groups": UserGroup.query.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() + current_version, _ = get_current_version() return render_template( "admin/dashboard.html", stats=stats, recent_users=recent_users, recent_photos=recent_photos, + current_version=current_version, + deploy_enabled=is_deploy_enabled(), ) @@ -36,7 +52,20 @@ def dashboard(): @admin_required def users(): all_users = User.query.order_by(User.created_at.desc()).all() - return render_template("admin/users.html", users=all_users) + groups = UserGroup.query.order_by(UserGroup.name).all() + return render_template("admin/users.html", users=all_users, groups=groups) + + +@bp.route("/users//set-group", methods=["POST"]) +@admin_required +def set_user_group(user_id): + user = User.query.get_or_404(user_id) + group_id = request.form.get("group_id", type=int) + group = UserGroup.query.get_or_404(group_id) + user.group_id = group.id + db.session.commit() + flash(f"Пользователь {user.username} перемещён в группу «{group.name}»", "success") + return redirect(url_for("admin.users")) @bp.route("/users//toggle-admin", methods=["POST"]) @@ -87,9 +116,7 @@ def delete_user(user_id): 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) + delete_photo_file(photo.filename, photo.storage_backend) db.session.delete(photo) db.session.delete(user) @@ -98,6 +125,83 @@ def delete_user(user_id): return redirect(url_for("admin.users")) +@bp.route("/groups", methods=["GET", "POST"]) +@admin_required +def groups(): + if request.method == "POST": + name = request.form.get("name", "").strip() + quota_mb = request.form.get("disk_quota_mb", type=int) or 100 + + if len(name) < 2: + flash("Название группы — минимум 2 символа", "error") + elif UserGroup.query.filter_by(name=name).first(): + flash("Группа с таким названием уже существует", "error") + else: + slug = slugify(name) + base_slug = slug + counter = 1 + while UserGroup.query.filter_by(slug=slug).first(): + slug = f"{base_slug}-{counter}" + counter += 1 + + group = UserGroup(name=name, slug=slug, disk_quota_mb=max(0, quota_mb)) + db.session.add(group) + db.session.commit() + flash(f"Группа «{name}» создана", "success") + return redirect(url_for("admin.groups")) + + all_groups = UserGroup.query.order_by(UserGroup.is_default.desc(), UserGroup.name).all() + group_stats = [] + for group in all_groups: + used = sum(get_user_storage_used(u.id) for u in group.users) + group_stats.append({"group": group, "storage_used": used}) + return render_template("admin/groups.html", group_stats=group_stats) + + +@bp.route("/groups//edit", methods=["POST"]) +@admin_required +def edit_group(group_id): + group = UserGroup.query.get_or_404(group_id) + name = request.form.get("name", "").strip() + quota_mb = request.form.get("disk_quota_mb", type=int) + + if len(name) < 2: + flash("Название группы — минимум 2 символа", "error") + return redirect(url_for("admin.groups")) + + other = UserGroup.query.filter(UserGroup.name == name, UserGroup.id != group.id).first() + if other: + flash("Группа с таким названием уже существует", "error") + return redirect(url_for("admin.groups")) + + group.name = name + if quota_mb is not None: + group.disk_quota_mb = max(0, quota_mb) + db.session.commit() + flash(f"Группа «{group.name}» обновлена", "success") + return redirect(url_for("admin.groups")) + + +@bp.route("/groups//delete", methods=["POST"]) +@admin_required +def delete_group(group_id): + group = UserGroup.query.get_or_404(group_id) + if group.is_default: + flash("Нельзя удалить группу по умолчанию", "error") + return redirect(url_for("admin.groups")) + + default_group = UserGroup.query.filter_by(is_default=True).first() + if not default_group: + flash("Не найдена группа по умолчанию", "error") + return redirect(url_for("admin.groups")) + + User.query.filter_by(group_id=group.id).update({"group_id": default_group.id}) + db.session.delete(group) + db.session.commit() + flash(f"Группа удалена, пользователи перенесены в «{default_group.name}»", "success") + return redirect(url_for("admin.groups")) + + @bp.route("/photos") @admin_required def photos(): @@ -109,10 +213,60 @@ def photos(): @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) + delete_photo_file(photo.filename, photo.storage_backend) db.session.delete(photo) db.session.commit() flash("Фото удалено", "success") return redirect(url_for("admin.photos")) + + +@bp.route("/deploy", methods=["GET", "POST"]) +@admin_required +def deploy(): + status = get_deploy_status() + + if request.method == "POST": + action = request.form.get("action") + + if action == "fetch": + ok, msg = fetch_remote() + flash(msg if ok else msg, "success" if ok else "error") + elif action == "checkout": + ref = request.form.get("ref", "").strip() + ok, msg = checkout_version(ref) + flash(msg, "success" if ok else "error") + elif action == "rebuild": + ok, msg = deploy_rebuild() + flash(msg, "success" if ok else "error") + else: + flash("Неизвестное действие", "error") + + return redirect(url_for("admin.deploy")) + + return render_template("admin/deploy.html", status=status) + + +@bp.route("/settings", methods=["GET", "POST"]) +@admin_required +def settings(): + site_settings = get_settings() + + if request.method == "POST": + action = request.form.get("action", "save") + + if action == "test_smtp": + from app.email_service import send_email + + ok, msg = send_email( + current_user.email, + "PhotoHost — тест SMTP", + "SMTP настроен корректно.", + ) + flash(msg, "success" if ok else "error") + return redirect(url_for("admin.settings")) + + update_settings_from_form(request.form) + flash("Настройки сохранены", "success") + return redirect(url_for("admin.settings")) + + return render_template("admin/settings.html", settings=site_settings) diff --git a/app/auth.py b/app/auth.py index 7d3d618..1f26c4e 100644 --- a/app/auth.py +++ b/app/auth.py @@ -2,8 +2,9 @@ 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.email_service import send_password_reset_email, send_welcome_email from app.folder_utils import process_pending_invites -from app.models import User +from app.models import PasswordResetToken, User, UserGroup bp = Blueprint("auth", __name__, url_prefix="/auth") @@ -30,12 +31,18 @@ def register(): elif User.query.filter_by(email=email).first(): flash("Этот email уже зарегистрирован", "error") else: - user = User(username=username, email=email) + default_group = UserGroup.query.filter_by(is_default=True).first() + user = User( + username=username, + email=email, + group_id=default_group.id if default_group else None, + ) user.set_password(password) db.session.add(user) db.session.commit() login_user(user) accepted = process_pending_invites(user) + send_welcome_email(user) flash("Регистрация успешна. Добро пожаловать!", "success") if accepted: flash(f"Вам открыт доступ к {accepted} общим папкам", "success") @@ -78,6 +85,56 @@ def login(): return render_template("auth/login.html") +@bp.route("/forgot-password", methods=["GET", "POST"]) +def forgot_password(): + if current_user.is_authenticated: + return redirect(url_for("cabinet.index")) + + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + user = User.query.filter_by(email=email).first() + if user: + token = PasswordResetToken.create_for_user(user) + db.session.add(token) + db.session.commit() + ok, msg = send_password_reset_email(user, token.token) + if not ok: + flash(f"Не удалось отправить email: {msg}", "error") + return redirect(url_for("auth.forgot_password")) + + flash("Если email зарегистрирован, на него отправлена ссылка для сброса пароля", "success") + return redirect(url_for("auth.login")) + + return render_template("auth/forgot_password.html") + + +@bp.route("/reset-password/", methods=["GET", "POST"]) +def reset_password(token): + if current_user.is_authenticated: + return redirect(url_for("cabinet.index")) + + reset_token = PasswordResetToken.query.filter_by(token=token).first() + if reset_token is None or not reset_token.is_valid(): + flash("Ссылка для сброса пароля недействительна или истекла", "error") + return redirect(url_for("auth.forgot_password")) + + if request.method == "POST": + password = request.form.get("password", "") + password2 = request.form.get("password2", "") + if len(password) < 6: + flash("Пароль — минимум 6 символов", "error") + elif password != password2: + flash("Пароли не совпадают", "error") + else: + reset_token.user.set_password(password) + reset_token.used = True + db.session.commit() + flash("Пароль успешно изменён. Войдите в аккаунт.", "success") + return redirect(url_for("auth.login")) + + return render_template("auth/reset_password.html", token=token) + + @bp.route("/logout") def logout(): logout_user() diff --git a/app/bootstrap.py b/app/bootstrap.py index 8e0c6ff..b198734 100644 --- a/app/bootstrap.py +++ b/app/bootstrap.py @@ -1,9 +1,10 @@ import os +import re from sqlalchemy import inspect, text from app import db -from app.models import User +from app.models import User, UserGroup def ensure_schema(): @@ -18,6 +19,62 @@ def ensure_schema(): ) db.session.commit() + if "users" in tables and "user_groups" in tables: + columns = {col["name"] for col in inspector.get_columns("users")} + if "group_id" not in columns: + db.session.execute( + text("ALTER TABLE users ADD COLUMN group_id INTEGER REFERENCES user_groups(id)") + ) + db.session.commit() + + +def ensure_default_group(app): + default_quota = int(os.getenv("DEFAULT_GROUP_QUOTA_MB", "100")) + default_group = UserGroup.query.filter_by(is_default=True).first() + + if not default_group: + default_group = UserGroup.query.filter_by(slug="users").first() + if default_group: + default_group.is_default = True + else: + default_group = UserGroup( + name="Пользователи", + slug="users", + disk_quota_mb=default_quota, + is_default=True, + ) + db.session.add(default_group) + db.session.commit() + app.logger.info("Default user group 'users' created with %s MB quota", default_quota) + + User.query.filter(User.group_id.is_(None)).update({"group_id": default_group.id}) + db.session.commit() + + +def ensure_site_settings(app): + from app.models import SiteSettings + + if SiteSettings.query.get(1) is None: + db.session.add(SiteSettings(id=1)) + db.session.commit() + app.logger.info("Site settings initialized") + + +def ensure_photo_storage_column(): + inspector = inspect(db.engine) + if "photos" not in inspector.get_table_names(): + return + columns = {col["name"] for col in inspector.get_columns("photos")} + if "storage_backend" not in columns: + db.session.execute(text("ALTER TABLE photos ADD COLUMN storage_backend VARCHAR(20) DEFAULT 'local'")) + db.session.commit() + + +def slugify(name): + slug = re.sub(r"[^a-z0-9]+", "-", name.lower().strip()) + slug = slug.strip("-") or "group" + return slug[:80] + def create_first_admin(app): username = os.getenv("ADMIN_USERNAME", "").strip() @@ -31,15 +88,24 @@ def create_first_admin(app): if User.query.filter_by(is_admin=True).first(): return None + default_group = UserGroup.query.filter_by(is_default=True).first() + if User.query.filter_by(username=username).first(): user = User.query.filter_by(username=username).first() user.is_admin = True user.set_password(password) + if default_group and not user.group_id: + user.group_id = default_group.id 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 = User( + username=username, + email=email, + is_admin=True, + group_id=default_group.id if default_group else None, + ) user.set_password(password) db.session.add(user) db.session.commit() diff --git a/app/deploy_utils.py b/app/deploy_utils.py new file mode 100644 index 0000000..d3443ad --- /dev/null +++ b/app/deploy_utils.py @@ -0,0 +1,151 @@ +import os +import re +import subprocess + +REF_PATTERN = re.compile(r"^[a-zA-Z0-9._/-]+$") + + +def is_deploy_enabled(): + return os.getenv("ALLOW_GIT_DEPLOY", "false").lower() in ("1", "true", "yes") + + +def get_repo_path(): + return os.getenv("GIT_REPO_PATH", "/repo") + + +def get_git_remote(): + return os.getenv("GIT_REMOTE_URL", "").strip() + + +def _repo_ready(): + repo = get_repo_path() + return os.path.isdir(repo) and os.path.isdir(os.path.join(repo, ".git")) + + +def run_git(args, timeout=120): + if not _repo_ready(): + return False, f"Git-репозиторий не найден: {get_repo_path()}" + + result = subprocess.run( + ["git", "-C", get_repo_path()] + args, + capture_output=True, + text=True, + timeout=timeout, + ) + if result.returncode != 0: + return False, (result.stderr or result.stdout or "Git error").strip() + return True, result.stdout.strip() + + +def fetch_remote(): + remote = get_git_remote() + if remote: + ok, msg = run_git(["remote", "set-url", "origin", remote]) + if not ok: + return False, msg + return run_git(["fetch", "--all", "--tags", "--prune"], timeout=180) + + +def list_tags(): + ok, msg = fetch_remote() + if not ok: + return [], msg + ok, out = run_git(["tag", "--sort=-version:refname"]) + if not ok: + return [], out + return [line for line in out.splitlines() if line.strip()], None + + +def list_branches(): + ok, msg = fetch_remote() + if not ok: + return [], msg + ok, out = run_git(["branch", "-a", "--format=%(refname:short)"]) + if not ok: + return [], out + branches = [] + for line in out.splitlines(): + name = line.strip().replace("origin/", "") + if name and name not in branches and "HEAD" not in name: + branches.append(name) + return branches, None + + +def get_current_version(): + if not _repo_ready(): + return None, "Репозиторий недоступен" + + ok, tag = run_git(["describe", "--tags", "--always"]) + if ok and tag: + return tag, None + + ok, branch = run_git(["rev-parse", "--abbrev-ref", "HEAD"]) + if ok: + return branch, None + return "unknown", None + + +def checkout_version(ref): + if not ref or not REF_PATTERN.match(ref): + return False, "Недопустимое имя версии" + + ok, msg = fetch_remote() + if not ok: + return False, msg + + ok, msg = run_git(["checkout", ref]) + if not ok: + return False, msg + return True, f"Переключено на {ref}" + + +def deploy_rebuild(): + if not is_deploy_enabled(): + return False, "Обновление через админку отключено (ALLOW_GIT_DEPLOY=false)" + + repo = get_repo_path() + compose_file = os.path.join(repo, "docker-compose.yml") + if not os.path.isfile(compose_file): + return False, f"Не найден {compose_file}" + + commands = [ + ["docker", "compose", "-f", compose_file, "up", "-d", "--build"], + ["docker-compose", "-f", compose_file, "up", "-d", "--build"], + ] + + last_error = "" + for cmd in commands: + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=600, + cwd=repo, + ) + if result.returncode == 0: + return True, result.stdout or "Контейнеры пересобраны и запущены" + last_error = result.stderr or result.stdout + except FileNotFoundError: + last_error = f"Команда не найдена: {cmd[0]}" + except subprocess.TimeoutExpired: + return False, "Превышено время ожидания пересборки (10 мин)" + + return False, last_error or "Не удалось выполнить docker compose" + + +def get_deploy_status(): + current, _ = get_current_version() + tags, tags_err = list_tags() + branches, branches_err = list_branches() + return { + "enabled": is_deploy_enabled(), + "repo_path": get_repo_path(), + "repo_ready": _repo_ready(), + "remote_url": get_git_remote(), + "current": current, + "tags": tags[:30], + "branches": branches[:30], + "tags_error": tags_err, + "branches_error": branches_err, + } diff --git a/app/email_service.py b/app/email_service.py new file mode 100644 index 0000000..3617fb4 --- /dev/null +++ b/app/email_service.py @@ -0,0 +1,71 @@ +import io +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from flask import current_app, url_for + +from app.settings_service import get_settings + + +def send_email(to_email, subject, body_text, body_html=None): + settings = get_settings() + if not settings.smtp_enabled: + current_app.logger.warning("SMTP disabled, email not sent to %s", to_email) + return False, "SMTP не включён в настройках админки" + + if not settings.smtp_host or not settings.smtp_from_email: + return False, "SMTP host или from email не настроены" + + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = f"{settings.smtp_from_name} <{settings.smtp_from_email}>" + msg["To"] = to_email + msg.attach(MIMEText(body_text, "plain", "utf-8")) + if body_html: + msg.attach(MIMEText(body_html, "html", "utf-8")) + + try: + if settings.smtp_use_tls: + server = smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) + server.starttls() + else: + server = smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) + + if settings.smtp_username and settings.smtp_password: + server.login(settings.smtp_username, settings.smtp_password) + server.sendmail(settings.smtp_from_email, [to_email], msg.as_string()) + server.quit() + return True, "Email отправлен" + except Exception as exc: + current_app.logger.exception("SMTP error") + return False, str(exc) + + +def send_password_reset_email(user, token): + reset_url = url_for("auth.reset_password", token=token, _external=True) + subject = "PhotoHost — сброс пароля" + body = ( + f"Здравствуйте, {user.username}!\n\n" + f"Для сброса пароля перейдите по ссылке:\n{reset_url}\n\n" + "Ссылка действует 24 часа. Если вы не запрашивали сброс — проигнорируйте письмо." + ) + html = ( + f"

Здравствуйте, {user.username}!

" + f'

Сбросить пароль

' + "

Ссылка действует 24 часа.

" + ) + return send_email(user.email, subject, body, html) + + +def send_welcome_email(user): + subject = "PhotoHost — регистрация успешна" + body = f"Добро пожаловать, {user.username}! Ваш аккаунт на PhotoHost создан." + return send_email(user.email, subject, body) + + +def send_upload_notification(user, count, folder_name=None): + location = f" в папку «{folder_name}»" if folder_name else "" + subject = f"PhotoHost — загружено {count} фото" + body = f"Загружено {count} фото{location}." + return send_email(user.email, subject, body) diff --git a/app/folders.py b/app/folders.py index 3bef44e..09d53f7 100644 --- a/app/folders.py +++ b/app/folders.py @@ -22,6 +22,8 @@ from app.folder_utils import ( unlock_folder, ) from app.models import Folder, FolderInvite, FolderMember, Photo, User +from app.settings_service import get_settings +from app.storage_service import delete_photo_file bp = Blueprint("folders", __name__) @@ -84,6 +86,7 @@ def view_folder(folder_id): photos=photos, can_edit=can_edit, share_url=_share_url(folder), + max_bulk_upload=get_settings().max_bulk_upload, ) @@ -274,6 +277,7 @@ def _render_share_folder(folder): photos=photos, can_edit=can_edit_folder(folder), share_url=_share_url(folder), + max_bulk_upload=get_settings().max_bulk_upload, ) @@ -290,14 +294,8 @@ def is_folder_owner_or_member(folder): def _delete_folder(folder): - upload_dir = None for photo in folder.photos.all(): - if upload_dir is None: - from flask import current_app - upload_dir = current_app.config["UPLOAD_FOLDER"] - filepath = os.path.join(upload_dir, photo.filename) - if os.path.exists(filepath): - os.remove(filepath) + delete_photo_file(photo.filename, photo.storage_backend) db.session.delete(photo) db.session.delete(folder) db.session.commit() diff --git a/app/models.py b/app/models.py index 700adca..0a03105 100644 --- a/app/models.py +++ b/app/models.py @@ -1,5 +1,5 @@ import uuid -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from flask_login import UserMixin from werkzeug.security import check_password_hash, generate_password_hash @@ -7,6 +7,85 @@ from werkzeug.security import check_password_hash, generate_password_hash from app import db +class SiteSettings(db.Model): + __tablename__ = "site_settings" + + id = db.Column(db.Integer, primary_key=True, default=1) + max_bulk_upload = db.Column(db.Integer, nullable=False, default=100) + + s3_enabled = db.Column(db.Boolean, nullable=False, default=False) + s3_endpoint = db.Column(db.String(255), nullable=True) + s3_bucket = db.Column(db.String(120), nullable=True) + s3_access_key = db.Column(db.String(120), nullable=True) + s3_secret_key = db.Column(db.String(255), nullable=True) + s3_region = db.Column(db.String(80), nullable=True, default="us-east-1") + s3_public_url = db.Column(db.String(255), nullable=True) + + sftp_enabled = db.Column(db.Boolean, nullable=False, default=False) + sftp_host = db.Column(db.String(255), nullable=True) + sftp_port = db.Column(db.Integer, nullable=False, default=22) + sftp_username = db.Column(db.String(120), nullable=True) + sftp_password = db.Column(db.String(255), nullable=True) + sftp_remote_path = db.Column(db.String(255), nullable=True, default="/uploads") + + ftp_enabled = db.Column(db.Boolean, nullable=False, default=False) + ftp_host = db.Column(db.String(255), nullable=True) + ftp_port = db.Column(db.Integer, nullable=False, default=21) + ftp_username = db.Column(db.String(120), nullable=True) + ftp_password = db.Column(db.String(255), nullable=True) + ftp_remote_path = db.Column(db.String(255), nullable=True, default="/uploads") + ftp_use_tls = db.Column(db.Boolean, nullable=False, default=False) + + smtp_enabled = db.Column(db.Boolean, nullable=False, default=False) + smtp_host = db.Column(db.String(255), nullable=True) + smtp_port = db.Column(db.Integer, nullable=False, default=587) + smtp_username = db.Column(db.String(120), nullable=True) + smtp_password = db.Column(db.String(255), nullable=True) + smtp_from_email = db.Column(db.String(120), nullable=True) + smtp_from_name = db.Column(db.String(120), nullable=True, default="PhotoHost") + smtp_use_tls = db.Column(db.Boolean, nullable=False, default=True) + + updated_at = db.Column( + db.DateTime, + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + +class PasswordResetToken(db.Model): + __tablename__ = "password_reset_tokens" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + token = db.Column(db.String(64), unique=True, nullable=False, index=True) + expires_at = db.Column(db.DateTime, nullable=False) + used = db.Column(db.Boolean, nullable=False, default=False) + created_at = db.Column( + db.DateTime, + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + + user = db.relationship("User", backref="reset_tokens") + + @staticmethod + def create_for_user(user, hours=24): + token = PasswordResetToken( + user_id=user.id, + token=uuid.uuid4().hex, + expires_at=datetime.now(timezone.utc) + timedelta(hours=hours), + ) + return token + + def is_valid(self): + now = datetime.now(timezone.utc) + expires = self.expires_at + if expires.tzinfo is None: + expires = expires.replace(tzinfo=timezone.utc) + return not self.used and expires > now + + class User(UserMixin, db.Model): __tablename__ = "users" @@ -16,6 +95,7 @@ class User(UserMixin, db.Model): 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) + group_id = db.Column(db.Integer, db.ForeignKey("user_groups.id"), nullable=True, index=True) created_at = db.Column( db.DateTime, nullable=False, @@ -24,6 +104,7 @@ class User(UserMixin, db.Model): photos = db.relationship("Photo", backref="owner", lazy="dynamic") folders = db.relationship("Folder", backref="owner", lazy="dynamic") + group = db.relationship("UserGroup", backref="users") def set_password(self, password): self.password_hash = generate_password_hash(password) @@ -45,6 +126,31 @@ class User(UserMixin, db.Model): return int(result or 0) +class UserGroup(db.Model): + __tablename__ = "user_groups" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + slug = db.Column(db.String(80), unique=True, nullable=False, index=True) + disk_quota_mb = db.Column(db.Integer, nullable=False, default=100) + is_default = db.Column(db.Boolean, nullable=False, default=False) + created_at = db.Column( + db.DateTime, + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + + @property + def user_count(self): + return len(self.users) + + @property + def quota_label(self): + if self.disk_quota_mb == 0: + return "Без лимита" + return f"{self.disk_quota_mb} МБ" + + class Folder(db.Model): __tablename__ = "folders" @@ -141,6 +247,7 @@ class Photo(db.Model): 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) folder_id = db.Column(db.Integer, db.ForeignKey("folders.id"), nullable=True, index=True) + storage_backend = db.Column(db.String(20), nullable=False, default="local") created_at = db.Column( db.DateTime, nullable=False, @@ -149,6 +256,11 @@ class Photo(db.Model): @property def url(self): + from app.settings_service import get_settings + + settings = get_settings() + if self.storage_backend == "s3" and settings.s3_public_url: + return f"{settings.s3_public_url.rstrip('/')}/{self.filename}" return f"/uploads/{self.filename}" @property diff --git a/app/quota_utils.py b/app/quota_utils.py new file mode 100644 index 0000000..fdda8b4 --- /dev/null +++ b/app/quota_utils.py @@ -0,0 +1,65 @@ +from sqlalchemy import func + +from app import db +from app.models import Photo, User, UserGroup + + +def get_default_group(): + return UserGroup.query.filter_by(is_default=True).first() + + +def get_user_group(user): + if user.group_id and user.group: + return user.group + return get_default_group() + + +def get_user_storage_used(user_id): + result = db.session.query(func.coalesce(func.sum(Photo.file_size), 0)).filter( + Photo.user_id == user_id + ).scalar() + return int(result or 0) + + +def get_group_quota_bytes(group): + if not group or group.disk_quota_mb == 0: + return None + return group.disk_quota_mb * 1024 * 1024 + + +def check_upload_quota(user, new_file_size): + group = get_user_group(user) + quota_bytes = get_group_quota_bytes(group) + if quota_bytes is None: + return True, "" + + used = get_user_storage_used(user.id) + if used + new_file_size > quota_bytes: + from app.models import Photo as _Photo + + used_human = _Photo(file_size=used).size_human if used else "0 Б" + quota_human = f"{group.disk_quota_mb} МБ" + return False, f"Превышена квота группы «{group.name}»: {used_human} / {quota_human}" + return True, "" + + +def quota_status(user): + group = get_user_group(user) + used = get_user_storage_used(user.id) + quota_bytes = get_group_quota_bytes(group) + if quota_bytes is None: + return { + "group": group, + "used": used, + "quota_bytes": None, + "percent": 0, + "unlimited": True, + } + percent = min(100, int(used / quota_bytes * 100)) if quota_bytes else 0 + return { + "group": group, + "used": used, + "quota_bytes": quota_bytes, + "percent": percent, + "unlimited": False, + } diff --git a/app/routes.py b/app/routes.py index 2a7bd25..d36f260 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,67 +1,49 @@ import os -import uuid -from datetime import datetime, timezone from flask import ( Blueprint, - current_app, abort, + current_app, flash, jsonify, redirect, render_template, request, - send_from_directory, + send_file, url_for, ) from flask_login import current_user, login_required -from werkzeug.utils import secure_filename from app import db from app.auth_utils import photo_owner_or_admin from app.folder_utils import can_edit_folder from app.models import Folder, Photo +from app.settings_service import get_settings +from app.storage_service import delete_photo_file, get_photo_stream +from app.upload_service import process_uploads bp = Blueprint("main", __name__) -def allowed_file(filename): - return ( - "." in filename - and filename.rsplit(".", 1)[1].lower() in current_app.config["ALLOWED_EXTENSIONS"] - ) - - @bp.route("/") def index(): 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 + settings = get_settings() return render_template( "index.html", photos=photos, total_photos=total_photos, total_size=int(total_size), max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024), + max_bulk_upload=settings.max_bulk_upload, ) @bp.route("/upload", methods=["POST"]) @login_required def upload(): - if "photo" not in request.files: - flash("Файл не выбран", "error") - return redirect(request.referrer or url_for("main.index")) - - file = request.files["photo"] - if file.filename == "": - flash("Файл не выбран", "error") - return redirect(request.referrer or url_for("main.index")) - - if not allowed_file(file.filename): - flash("Недопустимый формат. Разрешены: PNG, JPG, GIF, WEBP, BMP", "error") - return redirect(request.referrer or url_for("main.index")) - folder_id = request.form.get("folder_id", type=int) folder = None if folder_id: @@ -69,31 +51,36 @@ def upload(): if not can_edit_folder(folder): abort(403) - ext = file.filename.rsplit(".", 1)[1].lower() - stored_name = f"{uuid.uuid4().hex}.{ext}" - safe_original = secure_filename(file.filename) or f"photo.{ext}" - - upload_dir = current_app.config["UPLOAD_FOLDER"] - filepath = os.path.join(upload_dir, stored_name) - file.save(filepath) - file_size = os.path.getsize(filepath) - - photo = Photo( - filename=stored_name, - original_name=safe_original, - file_size=file_size, - mime_type=file.content_type or f"image/{ext}", - user_id=current_user.id, - folder_id=folder.id if folder else None, - created_at=datetime.now(timezone.utc), + result = process_uploads( + request.files, + current_user, + folder, + current_app.config["ALLOWED_EXTENSIONS"], ) - db.session.add(photo) - db.session.commit() - flash("Фото успешно загружено", "success") + if result["uploaded"] == 0 and result["errors"]: + flash(result["errors"][0], "error") + elif result["uploaded"] == 1: + flash("Фото успешно загружено", "success") + elif result["uploaded"] > 1: + flash(f"Загружено {result['uploaded']} фото", "success") + + for err in result["errors"]: + if result["uploaded"] > 0: + flash(err, "error") + + if result["uploaded"] > 0: + from app.email_service import send_upload_notification + + send_upload_notification( + current_user, + result["uploaded"], + folder.name if folder else None, + ) + if folder: return redirect(url_for("folders.view_folder", folder_id=folder.id)) - return redirect(url_for("cabinet.index")) + return redirect(request.referrer or url_for("cabinet.index")) @bp.route("/api/photos") @@ -117,7 +104,15 @@ def api_photos(): @bp.route("/uploads/") def uploaded_file(filename): - return send_from_directory(current_app.config["UPLOAD_FOLDER"], filename) + photo = Photo.query.filter_by(filename=filename).first() + storage_backend = photo.storage_backend if photo else "local" + + stream = get_photo_stream(filename, storage_backend) + if stream is None: + abort(404) + + mimetype = photo.mime_type if photo else "application/octet-stream" + return send_file(stream, mimetype=mimetype) @bp.route("/delete/", methods=["POST"]) @@ -126,9 +121,7 @@ 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) + delete_photo_file(photo.filename, photo.storage_backend) db.session.delete(photo) db.session.commit() flash("Фото удалено", "success") @@ -142,6 +135,7 @@ cabinet_bp = Blueprint("cabinet", __name__, url_prefix="/cabinet") @login_required def index(): from app.folder_utils import process_pending_invites + from app.quota_utils import quota_status process_pending_invites(current_user) photos = ( @@ -151,13 +145,17 @@ def index(): ) folders = Folder.query.filter_by(owner_id=current_user.id).order_by(Folder.created_at.desc()).limit(6).all() total_size = sum(p.file_size for p in photos) + quota = quota_status(current_user) + settings = get_settings() return render_template( "cabinet/index.html", photos=photos, folders=folders, total_photos=len(photos), total_size=total_size, + quota=quota, max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024), + max_bulk_upload=settings.max_bulk_upload, ) diff --git a/app/settings_service.py b/app/settings_service.py new file mode 100644 index 0000000..f341554 --- /dev/null +++ b/app/settings_service.py @@ -0,0 +1,56 @@ +from app import db +from app.models import SiteSettings + + +def get_settings(): + settings = db.session.get(SiteSettings, 1) + if settings is None: + settings = SiteSettings(id=1) + db.session.add(settings) + db.session.commit() + return settings + + +def update_settings_from_form(form): + settings = get_settings() + + settings.max_bulk_upload = max(1, min(100, int(form.get("max_bulk_upload") or 100))) + + settings.s3_enabled = form.get("s3_enabled") == "on" + settings.s3_endpoint = form.get("s3_endpoint", "").strip() or None + settings.s3_bucket = form.get("s3_bucket", "").strip() or None + settings.s3_access_key = form.get("s3_access_key", "").strip() or None + if form.get("s3_secret_key", "").strip(): + settings.s3_secret_key = form.get("s3_secret_key", "").strip() + settings.s3_region = form.get("s3_region", "").strip() or "us-east-1" + settings.s3_public_url = form.get("s3_public_url", "").strip() or None + + settings.sftp_enabled = form.get("sftp_enabled") == "on" + settings.sftp_host = form.get("sftp_host", "").strip() or None + settings.sftp_port = int(form.get("sftp_port") or 22) + settings.sftp_username = form.get("sftp_username", "").strip() or None + if form.get("sftp_password", "").strip(): + settings.sftp_password = form.get("sftp_password", "").strip() + settings.sftp_remote_path = form.get("sftp_remote_path", "").strip() or "/uploads" + + settings.ftp_enabled = form.get("ftp_enabled") == "on" + settings.ftp_host = form.get("ftp_host", "").strip() or None + settings.ftp_port = int(form.get("ftp_port") or 21) + settings.ftp_username = form.get("ftp_username", "").strip() or None + if form.get("ftp_password", "").strip(): + settings.ftp_password = form.get("ftp_password", "").strip() + settings.ftp_remote_path = form.get("ftp_remote_path", "").strip() or "/uploads" + settings.ftp_use_tls = form.get("ftp_use_tls") == "on" + + settings.smtp_enabled = form.get("smtp_enabled") == "on" + settings.smtp_host = form.get("smtp_host", "").strip() or None + settings.smtp_port = int(form.get("smtp_port") or 587) + settings.smtp_username = form.get("smtp_username", "").strip() or None + if form.get("smtp_password", "").strip(): + settings.smtp_password = form.get("smtp_password", "").strip() + settings.smtp_from_email = form.get("smtp_from_email", "").strip() or None + settings.smtp_from_name = form.get("smtp_from_name", "").strip() or "PhotoHost" + settings.smtp_use_tls = form.get("smtp_use_tls") == "on" + + db.session.commit() + return settings diff --git a/app/static/css/style.css b/app/static/css/style.css index 0c111a4..26351bd 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -966,3 +966,82 @@ body { .admin-panel--danger { border-color: rgba(239, 68, 68, 0.25); } + +.form-inline-input { + padding: 6px 10px; + 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.85rem; + margin-right: 6px; + margin-bottom: 6px; +} + +.form-inline-input--sm { + width: 90px; +} + +.form-select--sm { + padding: 6px 10px; + font-size: 0.8rem; + min-width: 120px; +} + +.group-edit-form, +.group-assign-form { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; +} + +.quota-bar-wrap { + margin-top: 20px; + max-width: 520px; +} + +.quota-bar { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 14px 16px; +} + +.quota-bar__header { + display: flex; + justify-content: space-between; + font-size: 0.85rem; + color: var(--text-muted); + margin-bottom: 10px; +} + +.quota-bar__track { + height: 8px; + background: rgba(255, 255, 255, 0.08); + border-radius: 999px; + overflow: hidden; +} + +.quota-bar__fill { + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--accent-light)); + border-radius: 999px; + transition: width 0.3s; +} + +.quota-bar__fill--warn { + background: linear-gradient(90deg, #ef4444, #f97316); +} + +.settings-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-top: 16px; +} + +.settings-form .admin-panel { + margin-bottom: 0; +} diff --git a/app/static/js/main.js b/app/static/js/main.js index 8dce600..d33e706 100644 --- a/app/static/js/main.js +++ b/app/static/js/main.js @@ -8,6 +8,8 @@ document.addEventListener("DOMContentLoaded", () => { if (!dropzone || !photoInput) return; + const maxFiles = parseInt(photoInput.dataset.max || "100", 10); + dropzone.addEventListener("click", (e) => { if (e.target.closest("button")) return; photoInput.click(); @@ -28,30 +30,43 @@ document.addEventListener("DOMContentLoaded", () => { }); dropzone.addEventListener("drop", (e) => { - const files = e.dataTransfer.files; - if (files.length > 0) { - photoInput.files = files; - showPreview(files[0]); - } + e.preventDefault(); + assignFiles(e.dataTransfer.files); }); photoInput.addEventListener("change", () => { if (photoInput.files.length > 0) { - showPreview(photoInput.files[0]); + showPreview(photoInput.files); } }); - function showPreview(file) { - if (!file.type.startsWith("image/")) return; + function assignFiles(fileList) { + const dt = new DataTransfer(); + const limit = Math.min(fileList.length, maxFiles); + for (let i = 0; i < limit; i++) { + if (fileList[i].type.startsWith("image/")) { + dt.items.add(fileList[i]); + } + } + photoInput.files = dt.files; + showPreview(photoInput.files); + } + function showPreview(files) { + if (!files || files.length === 0) return; + + const first = files[0]; const reader = new FileReader(); reader.onload = (e) => { previewImg.src = e.target.result; - previewName.textContent = file.name; + previewName.textContent = + files.length === 1 + ? first.name + : `${files.length} файлов (первый: ${first.name})`; preview.hidden = false; submitBtn.disabled = false; }; - reader.readAsDataURL(file); + reader.readAsDataURL(first); } document.querySelectorAll(".copy-btn").forEach((btn) => { diff --git a/app/storage_service.py b/app/storage_service.py new file mode 100644 index 0000000..edc230e --- /dev/null +++ b/app/storage_service.py @@ -0,0 +1,216 @@ +import io +import os +from ftplib import FTP, FTP_TLS + +from flask import current_app + +from app.settings_service import get_settings + + +def save_photo_file(file_storage, stored_name): + settings = get_settings() + upload_dir = current_app.config["UPLOAD_FOLDER"] + os.makedirs(upload_dir, exist_ok=True) + local_path = os.path.join(upload_dir, stored_name) + + file_storage.save(local_path) + file_size = os.path.getsize(local_path) + storage_backend = "local" + + errors = [] + + if settings.s3_enabled: + ok, err = _upload_s3(local_path, stored_name, settings) + if ok: + storage_backend = "s3" + elif err: + errors.append(f"S3: {err}") + + if settings.sftp_enabled: + ok, err = _upload_sftp(local_path, stored_name, settings) + if err: + errors.append(f"SFTP: {err}") + + if settings.ftp_enabled: + ok, err = _upload_ftp(local_path, stored_name, settings) + if err: + errors.append(f"FTP: {err}") + + return local_path, file_size, storage_backend, errors + + +def delete_photo_file(stored_name, storage_backend="local"): + settings = get_settings() + upload_dir = current_app.config["UPLOAD_FOLDER"] + local_path = os.path.join(upload_dir, stored_name) + + if os.path.exists(local_path): + os.remove(local_path) + + if storage_backend == "s3" and settings.s3_enabled: + _delete_s3(stored_name, settings) + + if settings.sftp_enabled: + _delete_sftp(stored_name, settings) + + if settings.ftp_enabled: + _delete_ftp(stored_name, settings) + + +def get_photo_stream(stored_name, storage_backend="local"): + settings = get_settings() + upload_dir = current_app.config["UPLOAD_FOLDER"] + local_path = os.path.join(upload_dir, stored_name) + + if os.path.exists(local_path): + return open(local_path, "rb") + + if storage_backend == "s3" and settings.s3_enabled: + data = _download_s3(stored_name, settings) + if data: + return io.BytesIO(data) + return None + + +def _upload_s3(local_path, key, settings): + try: + import boto3 + from botocore.config import Config + + kwargs = { + "aws_access_key_id": settings.s3_access_key, + "aws_secret_access_key": settings.s3_secret_key, + "region_name": settings.s3_region or "us-east-1", + } + if settings.s3_endpoint: + kwargs["endpoint_url"] = settings.s3_endpoint + + client = boto3.client("s3", config=Config(signature_version="s3v4"), **kwargs) + client.upload_file(local_path, settings.s3_bucket, key) + return True, None + except Exception as exc: + return False, str(exc) + + +def _delete_s3(key, settings): + try: + import boto3 + + kwargs = { + "aws_access_key_id": settings.s3_access_key, + "aws_secret_access_key": settings.s3_secret_key, + "region_name": settings.s3_region or "us-east-1", + } + if settings.s3_endpoint: + kwargs["endpoint_url"] = settings.s3_endpoint + client = boto3.client("s3", **kwargs) + client.delete_object(Bucket=settings.s3_bucket, Key=key) + except Exception: + current_app.logger.exception("S3 delete failed") + + +def _download_s3(key, settings): + try: + import boto3 + + kwargs = { + "aws_access_key_id": settings.s3_access_key, + "aws_secret_access_key": settings.s3_secret_key, + "region_name": settings.s3_region or "us-east-1", + } + if settings.s3_endpoint: + kwargs["endpoint_url"] = settings.s3_endpoint + client = boto3.client("s3", **kwargs) + obj = client.get_object(Bucket=settings.s3_bucket, Key=key) + return obj["Body"].read() + except Exception: + current_app.logger.exception("S3 download failed") + return None + + +def _upload_sftp(local_path, remote_name, settings): + try: + import paramiko + + transport = paramiko.Transport((settings.sftp_host, settings.sftp_port)) + transport.connect(username=settings.sftp_username, password=settings.sftp_password) + sftp = paramiko.SFTPClient.from_transport(transport) + remote_dir = settings.sftp_remote_path or "/uploads" + _sftp_makedirs(sftp, remote_dir) + sftp.put(local_path, f"{remote_dir.rstrip('/')}/{remote_name}") + sftp.close() + transport.close() + return True, None + except Exception as exc: + return False, str(exc) + + +def _delete_sftp(remote_name, settings): + try: + import paramiko + + transport = paramiko.Transport((settings.sftp_host, settings.sftp_port)) + transport.connect(username=settings.sftp_username, password=settings.sftp_password) + sftp = paramiko.SFTPClient.from_transport(transport) + remote_path = f"{settings.sftp_remote_path.rstrip('/')}/{remote_name}" + sftp.remove(remote_path) + sftp.close() + transport.close() + except Exception: + current_app.logger.exception("SFTP delete failed") + + +def _sftp_makedirs(sftp, remote_dir): + parts = remote_dir.strip("/").split("/") + path = "" + for part in parts: + path += f"/{part}" + try: + sftp.stat(path) + except IOError: + sftp.mkdir(path) + + +def _upload_ftp(local_path, remote_name, settings): + try: + ftp_cls = FTP_TLS if settings.ftp_use_tls else FTP + ftp = ftp_cls() + ftp.connect(settings.ftp_host, settings.ftp_port, timeout=30) + ftp.login(settings.ftp_username, settings.ftp_password) + if settings.ftp_use_tls: + ftp.prot_p() + remote_dir = settings.ftp_remote_path or "/uploads" + _ftp_makedirs(ftp, remote_dir) + ftp.cwd(remote_dir) + with open(local_path, "rb") as f: + ftp.storbinary(f"STOR {remote_name}", f) + ftp.quit() + return True, None + except Exception as exc: + return False, str(exc) + + +def _delete_ftp(remote_name, settings): + try: + ftp_cls = FTP_TLS if settings.ftp_use_tls else FTP + ftp = ftp_cls() + ftp.connect(settings.ftp_host, settings.ftp_port, timeout=30) + ftp.login(settings.ftp_username, settings.ftp_password) + if settings.ftp_use_tls: + ftp.prot_p() + ftp.cwd(settings.ftp_remote_path or "/uploads") + ftp.delete(remote_name) + ftp.quit() + except Exception: + current_app.logger.exception("FTP delete failed") + + +def _ftp_makedirs(ftp, remote_dir): + parts = remote_dir.strip("/").split("/") + path = "" + for part in parts: + path += f"/{part}" + try: + ftp.cwd(path) + except Exception: + ftp.mkd(path) diff --git a/app/templates/admin/_nav.html b/app/templates/admin/_nav.html index b324a48..d5c2ae6 100644 --- a/app/templates/admin/_nav.html +++ b/app/templates/admin/_nav.html @@ -1,5 +1,8 @@ diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html index 3c429df..9be509d 100644 --- a/app/templates/admin/dashboard.html +++ b/app/templates/admin/dashboard.html @@ -29,12 +29,24 @@ {{ stats.admins }} администраторов +
+ {{ stats.groups }} + групп +
{{ format_size(stats.storage) }} хранилище
+ {% if current_version %} +

+ Версия Git: {{ current_version }} + · Управление версиями + {% if not deploy_enabled %}(deploy выключен){% endif %} +

+ {% endif %} +

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

diff --git a/app/templates/admin/deploy.html b/app/templates/admin/deploy.html new file mode 100644 index 0000000..1269639 --- /dev/null +++ b/app/templates/admin/deploy.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} + +{% block title %}Версии Git — Админка{% endblock %} + +{% block content %} + + +
+
+ {% include "admin/_nav.html" %} + {% include "partials/alerts.html" %} + +
+
+ {{ status.current or '—' }} + текущая версия +
+
+ {% if status.repo_ready %}OK{% else %}—{% endif %} + репозиторий +
+
+ {% if status.enabled %}ON{% else %}OFF{% endif %} + deploy из админки +
+
+ + {% if not status.repo_ready %} +
+ Git-репозиторий недоступен по пути {{ status.repo_path }}. + Смонтируйте проект в контейнер: ./:/repo в docker-compose.yml +
+ {% endif %} + + {% if not status.enabled %} +
+ Обновление через админку отключено. Установите ALLOW_GIT_DEPLOY=true в .env +
+ {% endif %} + +
+
+

1. Обновить список версий

+

Путь: {{ status.repo_path }}{% if status.remote_url %} · {{ status.remote_url }}{% endif %}

+
+ + +
+
+ +
+

2. Переключить версию

+
+ +
+ + + + {% for tag in status.tags %} + +
+ +
+ {% if status.tags %} +

Теги: {{ status.tags[:8]|join(', ') }}{% if status.tags|length > 8 %}…{% endif %}

+ {% elif status.tags_error %} +

{{ status.tags_error }}

+ {% endif %} +
+ +
+

3. Пересобрать Docker

+

Требуется доступ к /var/run/docker.sock в контейнере web

+
+ + +
+
+
+
+
+{% endblock %} diff --git a/app/templates/admin/groups.html b/app/templates/admin/groups.html new file mode 100644 index 0000000..43bc46c --- /dev/null +++ b/app/templates/admin/groups.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} +{% from "macros.html" import format_size %} + +{% block title %}Группы — Админка{% endblock %} + +{% block content %} + + +
+
+ {% include "admin/_nav.html" %} + {% include "partials/alerts.html" %} + +
+

Создать группу

+
+
+ + +
+
+ + +
+ +
+
+ +
+ + + + + + + + + + + + {% for item in group_stats %} + {% set group = item.group %} + + + + + + + + {% endfor %} + +
ГруппаКвотаПользователейЗанятоДействия
+ {{ group.name }} + {% if group.is_default %}по умолчанию{% endif %} + {{ group.quota_label }}{{ group.user_count }}{{ format_size(item.storage_used) }} +
+ + + +
+ {% if not group.is_default %} +
+ +
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html new file mode 100644 index 0000000..82723e9 --- /dev/null +++ b/app/templates/admin/settings.html @@ -0,0 +1,93 @@ +{% extends "base.html" %} + +{% block title %}Настройки — Админка{% endblock %} + +{% block content %} + + +
+
+ {% include "admin/_nav.html" %} + {% include "partials/alerts.html" %} + +
+ + +
+

Загрузка фото

+
+ + +
+
+ +
+

Amazon S3 / совместимое хранилище

+ +
+
+
+
+
+
+
+
+
+ +
+

SFTP (резервная копия)

+ +
+
+
+
+
+
+
+
+ +
+

FTP

+ + +
+
+
+
+
+
+
+
+ +
+

SMTP (email)

+ + +
+
+
+
+
+
+
+
+

SMTP используется для сброса пароля, регистрации и уведомлений о загрузке.

+
+ +
+ +
+
+ +
+ + +
+
+
+{% endblock %} diff --git a/app/templates/admin/users.html b/app/templates/admin/users.html index 541d177..57df06e 100644 --- a/app/templates/admin/users.html +++ b/app/templates/admin/users.html @@ -21,6 +21,7 @@ ID Логин Email + Группа Фото Роль Статус @@ -34,6 +35,15 @@ {{ user.id }} {{ user.username }} {{ user.email }} + +
+ +
+ {{ user.photo_count }} {% if user.is_admin %} diff --git a/app/templates/auth/forgot_password.html b/app/templates/auth/forgot_password.html new file mode 100644 index 0000000..33701fe --- /dev/null +++ b/app/templates/auth/forgot_password.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block title %}Сброс пароля — PhotoHost{% endblock %} + +{% block content %} +
+
+
+

Сброс пароля

+

Введите email — отправим ссылку для восстановления

+ {% include "partials/alerts.html" %} +
+
+ + +
+ +
+ +
+
+
+{% endblock %} diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index 17e661b..9f7e617 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -28,7 +28,8 @@
diff --git a/app/templates/auth/reset_password.html b/app/templates/auth/reset_password.html new file mode 100644 index 0000000..771ae26 --- /dev/null +++ b/app/templates/auth/reset_password.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block title %}Новый пароль — PhotoHost{% endblock %} + +{% block content %} +
+
+
+

Новый пароль

+ {% include "partials/alerts.html" %} +
+
+ + +
+
+ + +
+ +
+
+
+
+{% endblock %} diff --git a/app/templates/cabinet/folders/view.html b/app/templates/cabinet/folders/view.html index 4815a4c..5cd8880 100644 --- a/app/templates/cabinet/folders/view.html +++ b/app/templates/cabinet/folders/view.html @@ -27,19 +27,9 @@

Загрузить в папку

-
- -
- -

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

-

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

- -
- -
+ {% with folder_id=folder.id, max_bulk_upload=max_bulk_upload %} + {% include "partials/upload_form.html" %} + {% endwith %}
{% endif %} diff --git a/app/templates/cabinet/index.html b/app/templates/cabinet/index.html index 5a0d4d7..6254d04 100644 --- a/app/templates/cabinet/index.html +++ b/app/templates/cabinet/index.html @@ -32,29 +32,36 @@ на файл + {% if quota %} +
+
+
+ Квота: {{ quota.group.name if quota.group else 'Пользователи' }} + + {{ format_size(quota.used) }} + {% if not quota.unlimited %} + / {{ quota.group.disk_quota_mb }} МБ + {% else %} + / без лимита + {% endif %} + +
+ {% if not quota.unlimited %} +
+
+
+ {% endif %} +
+
+ {% endif %}

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

-
-
- -
- - - - -
-

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

-

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

- -
- -
+ {% with folder_id=None, max_bulk_upload=max_bulk_upload %} + {% include "partials/upload_form.html" %} + {% endwith %}
diff --git a/app/templates/index.html b/app/templates/index.html index d0b5379..c0df7cd 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -46,30 +46,9 @@

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

-
-
- -
- - - - -
-

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

-

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

-

PNG · JPG · GIF · WEBP · BMP

- -
- -
+ {% with folder_id=None, max_bulk_upload=max_bulk_upload %} + {% include "partials/upload_form.html" %} + {% endwith %}
{% endif %} diff --git a/app/templates/partials/upload_form.html b/app/templates/partials/upload_form.html new file mode 100644 index 0000000..67e048e --- /dev/null +++ b/app/templates/partials/upload_form.html @@ -0,0 +1,22 @@ +
+ {% if folder_id %}{% endif %} +
+ +
+ + + + +
+

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

+

или выберите до {{ max_bulk_upload|default(100) }} файлов

+

PNG · JPG · GIF · WEBP · BMP

+ +
+ +
diff --git a/app/templates/share/folder.html b/app/templates/share/folder.html index 07ae51a..7eae95d 100644 --- a/app/templates/share/folder.html +++ b/app/templates/share/folder.html @@ -21,18 +21,9 @@

Загрузить в папку

-
- -
- -

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

- -
- -
+ {% with folder_id=folder.id, max_bulk_upload=max_bulk_upload %} + {% include "partials/upload_form.html" %} + {% endwith %}
{% endif %} diff --git a/app/upload_service.py b/app/upload_service.py new file mode 100644 index 0000000..9cdd6ee --- /dev/null +++ b/app/upload_service.py @@ -0,0 +1,96 @@ +import os +import uuid +from datetime import datetime, timezone + +from werkzeug.utils import secure_filename + +from app import db +from app.models import Photo +from app.quota_utils import check_upload_quota +from app.settings_service import get_settings +from app.storage_service import save_photo_file + + +def allowed_file(filename, allowed_extensions): + return "." in filename and filename.rsplit(".", 1)[1].lower() in allowed_extensions + + +def collect_upload_files(request_files): + files = request_files.getlist("photos") + if not files or all(f.filename == "" for f in files): + single = request_files.get("photo") + if single and single.filename: + files = [single] + return [f for f in files if f and f.filename] + + +def process_uploads(request_files, user, folder, allowed_extensions): + settings = get_settings() + max_bulk = settings.max_bulk_upload or 100 + files = collect_upload_files(request_files) + + if not files: + return {"uploaded": 0, "errors": ["Файлы не выбраны"], "photos": []} + + if len(files) > max_bulk: + return { + "uploaded": 0, + "errors": [f"Максимум {max_bulk} файлов за раз"], + "photos": [], + } + + total_size = 0 + valid_files = [] + errors = [] + + for file in files: + if not allowed_file(file.filename, allowed_extensions): + errors.append(f"{file.filename}: недопустимый формат") + continue + file.seek(0, os.SEEK_END) + size = file.tell() + file.seek(0) + total_size += size + valid_files.append((file, size)) + + if not valid_files: + return {"uploaded": 0, "errors": errors, "photos": []} + + ok, quota_msg = check_upload_quota(user, total_size) + if not ok: + return {"uploaded": 0, "errors": [quota_msg], "photos": []} + + uploaded_photos = [] + for file, _size in valid_files: + ext = file.filename.rsplit(".", 1)[1].lower() + stored_name = f"{uuid.uuid4().hex}.{ext}" + safe_original = secure_filename(file.filename) or f"photo.{ext}" + + try: + _path, file_size, storage_backend, sync_errors = save_photo_file(file, stored_name) + for sync_err in sync_errors: + errors.append(f"{safe_original}: {sync_err}") + + photo = Photo( + filename=stored_name, + original_name=safe_original, + file_size=file_size, + mime_type=file.content_type or f"image/{ext}", + user_id=user.id, + folder_id=folder.id if folder else None, + storage_backend=storage_backend, + created_at=datetime.now(timezone.utc), + ) + db.session.add(photo) + uploaded_photos.append(photo) + except Exception as exc: + errors.append(f"{safe_original}: {exc}") + + if uploaded_photos: + db.session.commit() + + return { + "uploaded": len(uploaded_photos), + "errors": errors, + "photos": uploaded_photos, + } diff --git a/docker-compose.yml b/docker-compose.yml index 888ca7e..173d9ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,8 +29,14 @@ services: ADMIN_USERNAME: ${ADMIN_USERNAME:-} ADMIN_EMAIL: ${ADMIN_EMAIL:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} + DEFAULT_GROUP_QUOTA_MB: ${DEFAULT_GROUP_QUOTA_MB:-100} + GIT_REPO_PATH: /repo + GIT_REMOTE_URL: ${GIT_REMOTE_URL:-https://git.evilfox.cc/test2/fotohost.git} + ALLOW_GIT_DEPLOY: ${ALLOW_GIT_DEPLOY:-false} volumes: - uploads_data:/app/uploads + - .:/repo + - /var/run/docker.sock:/var/run/docker.sock depends_on: db: condition: service_healthy diff --git a/requirements.txt b/requirements.txt index 9ace476..10c7e45 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,5 @@ gunicorn==23.0.0 Pillow==11.1.0 python-dotenv==1.0.1 Werkzeug==3.1.3 +boto3==1.35.99 +paramiko==3.5.1