Release 1.2: bulk upload, S3/SFTP/FTP, SMTP, password reset, user groups, git deploy
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,3 +13,10 @@ APP_PORT=8080
|
|||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_EMAIL=admin@example.com
|
ADMIN_EMAIL=admin@example.com
|
||||||
ADMIN_PASSWORD=change_me_admin_password
|
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
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ WORKDIR /app
|
|||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
gcc \
|
gcc \
|
||||||
|
git \
|
||||||
|
docker.io \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|||||||
+27
-2
@@ -33,6 +33,13 @@ def create_app():
|
|||||||
app.config["UPLOAD_FOLDER"] = os.getenv("UPLOAD_FOLDER", "uploads")
|
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["MAX_CONTENT_LENGTH"] = int(os.getenv("MAX_UPLOAD_MB", "10")) * 1024 * 1024
|
||||||
app.config["ALLOWED_EXTENSIONS"] = {"png", "jpg", "jpeg", "gif", "webp", "bmp"}
|
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)
|
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
|
||||||
|
|
||||||
@@ -53,14 +60,32 @@ def create_app():
|
|||||||
register_cli(app)
|
register_cli(app)
|
||||||
|
|
||||||
with app.app_context():
|
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()
|
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
|
from app.folders import ensure_folder_schema
|
||||||
|
|
||||||
ensure_schema()
|
ensure_schema()
|
||||||
|
ensure_default_group(app)
|
||||||
ensure_folder_schema()
|
ensure_folder_schema()
|
||||||
|
ensure_site_settings(app)
|
||||||
|
ensure_photo_storage_column()
|
||||||
create_first_admin(app)
|
create_first_admin(app)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
+162
-8
@@ -6,7 +6,19 @@ from sqlalchemy import func
|
|||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from app.auth_utils import admin_required
|
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")
|
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||||
|
|
||||||
@@ -18,17 +30,21 @@ def dashboard():
|
|||||||
"users": User.query.count(),
|
"users": User.query.count(),
|
||||||
"photos": Photo.query.count(),
|
"photos": Photo.query.count(),
|
||||||
"admins": User.query.filter_by(is_admin=True).count(),
|
"admins": User.query.filter_by(is_admin=True).count(),
|
||||||
|
"groups": UserGroup.query.count(),
|
||||||
"storage": int(
|
"storage": int(
|
||||||
db.session.query(func.coalesce(func.sum(Photo.file_size), 0)).scalar() or 0
|
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_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()
|
recent_photos = Photo.query.order_by(Photo.created_at.desc()).limit(8).all()
|
||||||
|
current_version, _ = get_current_version()
|
||||||
return render_template(
|
return render_template(
|
||||||
"admin/dashboard.html",
|
"admin/dashboard.html",
|
||||||
stats=stats,
|
stats=stats,
|
||||||
recent_users=recent_users,
|
recent_users=recent_users,
|
||||||
recent_photos=recent_photos,
|
recent_photos=recent_photos,
|
||||||
|
current_version=current_version,
|
||||||
|
deploy_enabled=is_deploy_enabled(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -36,7 +52,20 @@ def dashboard():
|
|||||||
@admin_required
|
@admin_required
|
||||||
def users():
|
def users():
|
||||||
all_users = User.query.order_by(User.created_at.desc()).all()
|
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/<int:user_id>/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/<int:user_id>/toggle-admin", methods=["POST"])
|
@bp.route("/users/<int:user_id>/toggle-admin", methods=["POST"])
|
||||||
@@ -87,9 +116,7 @@ def delete_user(user_id):
|
|||||||
return redirect(url_for("admin.users"))
|
return redirect(url_for("admin.users"))
|
||||||
|
|
||||||
for photo in user.photos.all():
|
for photo in user.photos.all():
|
||||||
filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], photo.filename)
|
delete_photo_file(photo.filename, photo.storage_backend)
|
||||||
if os.path.exists(filepath):
|
|
||||||
os.remove(filepath)
|
|
||||||
db.session.delete(photo)
|
db.session.delete(photo)
|
||||||
|
|
||||||
db.session.delete(user)
|
db.session.delete(user)
|
||||||
@@ -98,6 +125,83 @@ def delete_user(user_id):
|
|||||||
return redirect(url_for("admin.users"))
|
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/<int:group_id>/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/<int:group_id>/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")
|
@bp.route("/photos")
|
||||||
@admin_required
|
@admin_required
|
||||||
def photos():
|
def photos():
|
||||||
@@ -109,10 +213,60 @@ def photos():
|
|||||||
@admin_required
|
@admin_required
|
||||||
def delete_photo(photo_id):
|
def delete_photo(photo_id):
|
||||||
photo = Photo.query.get_or_404(photo_id)
|
photo = Photo.query.get_or_404(photo_id)
|
||||||
filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], photo.filename)
|
delete_photo_file(photo.filename, photo.storage_backend)
|
||||||
if os.path.exists(filepath):
|
|
||||||
os.remove(filepath)
|
|
||||||
db.session.delete(photo)
|
db.session.delete(photo)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("Фото удалено", "success")
|
flash("Фото удалено", "success")
|
||||||
return redirect(url_for("admin.photos"))
|
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)
|
||||||
|
|||||||
+59
-2
@@ -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 flask_login import current_user, login_user, logout_user
|
||||||
|
|
||||||
from app import db
|
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.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")
|
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||||
|
|
||||||
@@ -30,12 +31,18 @@ def register():
|
|||||||
elif User.query.filter_by(email=email).first():
|
elif User.query.filter_by(email=email).first():
|
||||||
flash("Этот email уже зарегистрирован", "error")
|
flash("Этот email уже зарегистрирован", "error")
|
||||||
else:
|
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)
|
user.set_password(password)
|
||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
login_user(user)
|
login_user(user)
|
||||||
accepted = process_pending_invites(user)
|
accepted = process_pending_invites(user)
|
||||||
|
send_welcome_email(user)
|
||||||
flash("Регистрация успешна. Добро пожаловать!", "success")
|
flash("Регистрация успешна. Добро пожаловать!", "success")
|
||||||
if accepted:
|
if accepted:
|
||||||
flash(f"Вам открыт доступ к {accepted} общим папкам", "success")
|
flash(f"Вам открыт доступ к {accepted} общим папкам", "success")
|
||||||
@@ -78,6 +85,56 @@ def login():
|
|||||||
return render_template("auth/login.html")
|
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/<token>", 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")
|
@bp.route("/logout")
|
||||||
def logout():
|
def logout():
|
||||||
logout_user()
|
logout_user()
|
||||||
|
|||||||
+68
-2
@@ -1,9 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
from sqlalchemy import inspect, text
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from app.models import User
|
from app.models import User, UserGroup
|
||||||
|
|
||||||
|
|
||||||
def ensure_schema():
|
def ensure_schema():
|
||||||
@@ -18,6 +19,62 @@ def ensure_schema():
|
|||||||
)
|
)
|
||||||
db.session.commit()
|
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):
|
def create_first_admin(app):
|
||||||
username = os.getenv("ADMIN_USERNAME", "").strip()
|
username = os.getenv("ADMIN_USERNAME", "").strip()
|
||||||
@@ -31,15 +88,24 @@ def create_first_admin(app):
|
|||||||
if User.query.filter_by(is_admin=True).first():
|
if User.query.filter_by(is_admin=True).first():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
default_group = UserGroup.query.filter_by(is_default=True).first()
|
||||||
|
|
||||||
if User.query.filter_by(username=username).first():
|
if User.query.filter_by(username=username).first():
|
||||||
user = User.query.filter_by(username=username).first()
|
user = User.query.filter_by(username=username).first()
|
||||||
user.is_admin = True
|
user.is_admin = True
|
||||||
user.set_password(password)
|
user.set_password(password)
|
||||||
|
if default_group and not user.group_id:
|
||||||
|
user.group_id = default_group.id
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
app.logger.info("Existing user '%s' promoted to admin", username)
|
app.logger.info("Existing user '%s' promoted to admin", username)
|
||||||
return user
|
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)
|
user.set_password(password)
|
||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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"<p>Здравствуйте, <strong>{user.username}</strong>!</p>"
|
||||||
|
f'<p><a href="{reset_url}">Сбросить пароль</a></p>'
|
||||||
|
"<p>Ссылка действует 24 часа.</p>"
|
||||||
|
)
|
||||||
|
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)
|
||||||
+5
-7
@@ -22,6 +22,8 @@ from app.folder_utils import (
|
|||||||
unlock_folder,
|
unlock_folder,
|
||||||
)
|
)
|
||||||
from app.models import Folder, FolderInvite, FolderMember, Photo, User
|
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__)
|
bp = Blueprint("folders", __name__)
|
||||||
|
|
||||||
@@ -84,6 +86,7 @@ def view_folder(folder_id):
|
|||||||
photos=photos,
|
photos=photos,
|
||||||
can_edit=can_edit,
|
can_edit=can_edit,
|
||||||
share_url=_share_url(folder),
|
share_url=_share_url(folder),
|
||||||
|
max_bulk_upload=get_settings().max_bulk_upload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -274,6 +277,7 @@ def _render_share_folder(folder):
|
|||||||
photos=photos,
|
photos=photos,
|
||||||
can_edit=can_edit_folder(folder),
|
can_edit=can_edit_folder(folder),
|
||||||
share_url=_share_url(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):
|
def _delete_folder(folder):
|
||||||
upload_dir = None
|
|
||||||
for photo in folder.photos.all():
|
for photo in folder.photos.all():
|
||||||
if upload_dir is None:
|
delete_photo_file(photo.filename, photo.storage_backend)
|
||||||
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)
|
|
||||||
db.session.delete(photo)
|
db.session.delete(photo)
|
||||||
db.session.delete(folder)
|
db.session.delete(folder)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|||||||
+113
-1
@@ -1,5 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from flask_login import UserMixin
|
from flask_login import UserMixin
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
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
|
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):
|
class User(UserMixin, db.Model):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
@@ -16,6 +95,7 @@ class User(UserMixin, db.Model):
|
|||||||
password_hash = db.Column(db.String(256), nullable=False)
|
password_hash = db.Column(db.String(256), nullable=False)
|
||||||
is_admin = db.Column(db.Boolean, nullable=False, default=False)
|
is_admin = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
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(
|
created_at = db.Column(
|
||||||
db.DateTime,
|
db.DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
@@ -24,6 +104,7 @@ class User(UserMixin, db.Model):
|
|||||||
|
|
||||||
photos = db.relationship("Photo", backref="owner", lazy="dynamic")
|
photos = db.relationship("Photo", backref="owner", lazy="dynamic")
|
||||||
folders = db.relationship("Folder", backref="owner", lazy="dynamic")
|
folders = db.relationship("Folder", backref="owner", lazy="dynamic")
|
||||||
|
group = db.relationship("UserGroup", backref="users")
|
||||||
|
|
||||||
def set_password(self, password):
|
def set_password(self, password):
|
||||||
self.password_hash = generate_password_hash(password)
|
self.password_hash = generate_password_hash(password)
|
||||||
@@ -45,6 +126,31 @@ class User(UserMixin, db.Model):
|
|||||||
return int(result or 0)
|
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):
|
class Folder(db.Model):
|
||||||
__tablename__ = "folders"
|
__tablename__ = "folders"
|
||||||
|
|
||||||
@@ -141,6 +247,7 @@ class Photo(db.Model):
|
|||||||
mime_type = db.Column(db.String(100), nullable=False, default="image/jpeg")
|
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)
|
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)
|
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(
|
created_at = db.Column(
|
||||||
db.DateTime,
|
db.DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
@@ -149,6 +256,11 @@ class Photo(db.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def url(self):
|
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}"
|
return f"/uploads/{self.filename}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
+47
-49
@@ -1,67 +1,49 @@
|
|||||||
import os
|
import os
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
current_app,
|
|
||||||
abort,
|
abort,
|
||||||
|
current_app,
|
||||||
flash,
|
flash,
|
||||||
jsonify,
|
jsonify,
|
||||||
redirect,
|
redirect,
|
||||||
render_template,
|
render_template,
|
||||||
request,
|
request,
|
||||||
send_from_directory,
|
send_file,
|
||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from werkzeug.utils import secure_filename
|
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from app.auth_utils import photo_owner_or_admin
|
from app.auth_utils import photo_owner_or_admin
|
||||||
from app.folder_utils import can_edit_folder
|
from app.folder_utils import can_edit_folder
|
||||||
from app.models import Folder, Photo
|
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__)
|
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("/")
|
@bp.route("/")
|
||||||
def index():
|
def index():
|
||||||
photos = Photo.query.order_by(Photo.created_at.desc()).limit(24).all()
|
photos = Photo.query.order_by(Photo.created_at.desc()).limit(24).all()
|
||||||
total_photos = Photo.query.count()
|
total_photos = Photo.query.count()
|
||||||
total_size = db.session.query(db.func.coalesce(db.func.sum(Photo.file_size), 0)).scalar() or 0
|
total_size = db.session.query(db.func.coalesce(db.func.sum(Photo.file_size), 0)).scalar() or 0
|
||||||
|
settings = get_settings()
|
||||||
return render_template(
|
return render_template(
|
||||||
"index.html",
|
"index.html",
|
||||||
photos=photos,
|
photos=photos,
|
||||||
total_photos=total_photos,
|
total_photos=total_photos,
|
||||||
total_size=int(total_size),
|
total_size=int(total_size),
|
||||||
max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024),
|
max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024),
|
||||||
|
max_bulk_upload=settings.max_bulk_upload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/upload", methods=["POST"])
|
@bp.route("/upload", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def upload():
|
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_id = request.form.get("folder_id", type=int)
|
||||||
folder = None
|
folder = None
|
||||||
if folder_id:
|
if folder_id:
|
||||||
@@ -69,31 +51,36 @@ def upload():
|
|||||||
if not can_edit_folder(folder):
|
if not can_edit_folder(folder):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
ext = file.filename.rsplit(".", 1)[1].lower()
|
result = process_uploads(
|
||||||
stored_name = f"{uuid.uuid4().hex}.{ext}"
|
request.files,
|
||||||
safe_original = secure_filename(file.filename) or f"photo.{ext}"
|
current_user,
|
||||||
|
folder,
|
||||||
upload_dir = current_app.config["UPLOAD_FOLDER"]
|
current_app.config["ALLOWED_EXTENSIONS"],
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
db.session.add(photo)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
|
if result["uploaded"] == 0 and result["errors"]:
|
||||||
|
flash(result["errors"][0], "error")
|
||||||
|
elif result["uploaded"] == 1:
|
||||||
flash("Фото успешно загружено", "success")
|
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:
|
if folder:
|
||||||
return redirect(url_for("folders.view_folder", folder_id=folder.id))
|
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")
|
@bp.route("/api/photos")
|
||||||
@@ -117,7 +104,15 @@ def api_photos():
|
|||||||
|
|
||||||
@bp.route("/uploads/<path:filename>")
|
@bp.route("/uploads/<path:filename>")
|
||||||
def uploaded_file(filename):
|
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/<int:photo_id>", methods=["POST"])
|
@bp.route("/delete/<int:photo_id>", methods=["POST"])
|
||||||
@@ -126,9 +121,7 @@ def delete_photo(photo_id):
|
|||||||
photo = Photo.query.get_or_404(photo_id)
|
photo = Photo.query.get_or_404(photo_id)
|
||||||
photo_owner_or_admin(photo)
|
photo_owner_or_admin(photo)
|
||||||
|
|
||||||
filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], photo.filename)
|
delete_photo_file(photo.filename, photo.storage_backend)
|
||||||
if os.path.exists(filepath):
|
|
||||||
os.remove(filepath)
|
|
||||||
db.session.delete(photo)
|
db.session.delete(photo)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("Фото удалено", "success")
|
flash("Фото удалено", "success")
|
||||||
@@ -142,6 +135,7 @@ cabinet_bp = Blueprint("cabinet", __name__, url_prefix="/cabinet")
|
|||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
from app.folder_utils import process_pending_invites
|
from app.folder_utils import process_pending_invites
|
||||||
|
from app.quota_utils import quota_status
|
||||||
|
|
||||||
process_pending_invites(current_user)
|
process_pending_invites(current_user)
|
||||||
photos = (
|
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()
|
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)
|
total_size = sum(p.file_size for p in photos)
|
||||||
|
quota = quota_status(current_user)
|
||||||
|
settings = get_settings()
|
||||||
return render_template(
|
return render_template(
|
||||||
"cabinet/index.html",
|
"cabinet/index.html",
|
||||||
photos=photos,
|
photos=photos,
|
||||||
folders=folders,
|
folders=folders,
|
||||||
total_photos=len(photos),
|
total_photos=len(photos),
|
||||||
total_size=total_size,
|
total_size=total_size,
|
||||||
|
quota=quota,
|
||||||
max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024),
|
max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024),
|
||||||
|
max_bulk_upload=settings.max_bulk_upload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -966,3 +966,82 @@ body {
|
|||||||
.admin-panel--danger {
|
.admin-panel--danger {
|
||||||
border-color: rgba(239, 68, 68, 0.25);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+25
-10
@@ -8,6 +8,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
if (!dropzone || !photoInput) return;
|
if (!dropzone || !photoInput) return;
|
||||||
|
|
||||||
|
const maxFiles = parseInt(photoInput.dataset.max || "100", 10);
|
||||||
|
|
||||||
dropzone.addEventListener("click", (e) => {
|
dropzone.addEventListener("click", (e) => {
|
||||||
if (e.target.closest("button")) return;
|
if (e.target.closest("button")) return;
|
||||||
photoInput.click();
|
photoInput.click();
|
||||||
@@ -28,30 +30,43 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
dropzone.addEventListener("drop", (e) => {
|
dropzone.addEventListener("drop", (e) => {
|
||||||
const files = e.dataTransfer.files;
|
e.preventDefault();
|
||||||
if (files.length > 0) {
|
assignFiles(e.dataTransfer.files);
|
||||||
photoInput.files = files;
|
|
||||||
showPreview(files[0]);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
photoInput.addEventListener("change", () => {
|
photoInput.addEventListener("change", () => {
|
||||||
if (photoInput.files.length > 0) {
|
if (photoInput.files.length > 0) {
|
||||||
showPreview(photoInput.files[0]);
|
showPreview(photoInput.files);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function showPreview(file) {
|
function assignFiles(fileList) {
|
||||||
if (!file.type.startsWith("image/")) return;
|
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();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
previewImg.src = e.target.result;
|
previewImg.src = e.target.result;
|
||||||
previewName.textContent = file.name;
|
previewName.textContent =
|
||||||
|
files.length === 1
|
||||||
|
? first.name
|
||||||
|
: `${files.length} файлов (первый: ${first.name})`;
|
||||||
preview.hidden = false;
|
preview.hidden = false;
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(first);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll(".copy-btn").forEach((btn) => {
|
document.querySelectorAll(".copy-btn").forEach((btn) => {
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
<nav class="admin-nav">
|
<nav class="admin-nav">
|
||||||
<a href="{{ url_for('admin.dashboard') }}" class="admin-nav__link {% if request.endpoint == 'admin.dashboard' %}admin-nav__link--active{% endif %}">Обзор</a>
|
<a href="{{ url_for('admin.dashboard') }}" class="admin-nav__link {% if request.endpoint == 'admin.dashboard' %}admin-nav__link--active{% endif %}">Обзор</a>
|
||||||
<a href="{{ url_for('admin.users') }}" class="admin-nav__link {% if request.endpoint == 'admin.users' %}admin-nav__link--active{% endif %}">Пользователи</a>
|
<a href="{{ url_for('admin.users') }}" class="admin-nav__link {% if request.endpoint == 'admin.users' %}admin-nav__link--active{% endif %}">Пользователи</a>
|
||||||
|
<a href="{{ url_for('admin.groups') }}" class="admin-nav__link {% if request.endpoint in ['admin.groups', 'admin.edit_group', 'admin.delete_group'] %}admin-nav__link--active{% endif %}">Группы</a>
|
||||||
<a href="{{ url_for('admin.photos') }}" class="admin-nav__link {% if request.endpoint == 'admin.photos' %}admin-nav__link--active{% endif %}">Фото</a>
|
<a href="{{ url_for('admin.photos') }}" class="admin-nav__link {% if request.endpoint == 'admin.photos' %}admin-nav__link--active{% endif %}">Фото</a>
|
||||||
|
<a href="{{ url_for('admin.deploy') }}" class="admin-nav__link {% if request.endpoint == 'admin.deploy' %}admin-nav__link--active{% endif %}">Версии Git</a>
|
||||||
|
<a href="{{ url_for('admin.settings') }}" class="admin-nav__link {% if request.endpoint == 'admin.settings' %}admin-nav__link--active{% endif %}">Настройки</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -29,12 +29,24 @@
|
|||||||
<span class="stat-card__value">{{ stats.admins }}</span>
|
<span class="stat-card__value">{{ stats.admins }}</span>
|
||||||
<span class="stat-card__label">администраторов</span>
|
<span class="stat-card__label">администраторов</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-card stat-card--admin">
|
||||||
|
<span class="stat-card__value">{{ stats.groups }}</span>
|
||||||
|
<span class="stat-card__label">групп</span>
|
||||||
|
</div>
|
||||||
<div class="stat-card stat-card--admin">
|
<div class="stat-card stat-card--admin">
|
||||||
<span class="stat-card__value">{{ format_size(stats.storage) }}</span>
|
<span class="stat-card__value">{{ format_size(stats.storage) }}</span>
|
||||||
<span class="stat-card__label">хранилище</span>
|
<span class="stat-card__label">хранилище</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if current_version %}
|
||||||
|
<p class="folder-hint" style="margin-bottom: 24px;">
|
||||||
|
Версия Git: <strong>{{ current_version }}</strong>
|
||||||
|
· <a href="{{ url_for('admin.deploy') }}">Управление версиями</a>
|
||||||
|
{% if not deploy_enabled %}(deploy выключен){% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="admin-grid">
|
<div class="admin-grid">
|
||||||
<div class="admin-panel">
|
<div class="admin-panel">
|
||||||
<h2 class="admin-panel__title">Новые пользователи</h2>
|
<h2 class="admin-panel__title">Новые пользователи</h2>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Версии Git — Админка{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-header page-header--admin">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="page-header__title">Обновление и версии Git</h1>
|
||||||
|
<p class="page-header__subtitle">Переключение между релизами и пересборка Docker</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-section">
|
||||||
|
<div class="container">
|
||||||
|
{% include "admin/_nav.html" %}
|
||||||
|
{% include "partials/alerts.html" %}
|
||||||
|
|
||||||
|
<div class="admin-stats">
|
||||||
|
<div class="stat-card stat-card--admin">
|
||||||
|
<span class="stat-card__value">{{ status.current or '—' }}</span>
|
||||||
|
<span class="stat-card__label">текущая версия</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card--admin">
|
||||||
|
<span class="stat-card__value">{% if status.repo_ready %}OK{% else %}—{% endif %}</span>
|
||||||
|
<span class="stat-card__label">репозиторий</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card--admin">
|
||||||
|
<span class="stat-card__value">{% if status.enabled %}ON{% else %}OFF{% endif %}</span>
|
||||||
|
<span class="stat-card__label">deploy из админки</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not status.repo_ready %}
|
||||||
|
<div class="alert alert--error">
|
||||||
|
Git-репозиторий недоступен по пути <code>{{ status.repo_path }}</code>.
|
||||||
|
Смонтируйте проект в контейнер: <code>./:/repo</code> в docker-compose.yml
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not status.enabled %}
|
||||||
|
<div class="alert alert--error">
|
||||||
|
Обновление через админку отключено. Установите <code>ALLOW_GIT_DEPLOY=true</code> в .env
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="admin-grid">
|
||||||
|
<div class="admin-panel">
|
||||||
|
<h2 class="admin-panel__title">1. Обновить список версий</h2>
|
||||||
|
<p class="folder-hint">Путь: {{ status.repo_path }}{% if status.remote_url %} · {{ status.remote_url }}{% endif %}</p>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="action" value="fetch">
|
||||||
|
<button type="submit" class="btn btn--primary" {% if not status.repo_ready %}disabled{% endif %}>git fetch</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-panel">
|
||||||
|
<h2 class="admin-panel__title">2. Переключить версию</h2>
|
||||||
|
<form method="post" class="auth-form">
|
||||||
|
<input type="hidden" name="action" value="checkout">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ref">Тег или ветка</label>
|
||||||
|
<input type="text" id="ref" name="ref" list="git-refs" required placeholder="v1.1">
|
||||||
|
<datalist id="git-refs">
|
||||||
|
{% for tag in status.tags %}
|
||||||
|
<option value="{{ tag }}">
|
||||||
|
{% endfor %}
|
||||||
|
{% for branch in status.branches %}
|
||||||
|
<option value="{{ branch }}">
|
||||||
|
{% endfor %}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn--primary" {% if not status.repo_ready %}disabled{% endif %}>git checkout</button>
|
||||||
|
</form>
|
||||||
|
{% if status.tags %}
|
||||||
|
<p class="folder-hint">Теги: {{ status.tags[:8]|join(', ') }}{% if status.tags|length > 8 %}…{% endif %}</p>
|
||||||
|
{% elif status.tags_error %}
|
||||||
|
<p class="folder-hint">{{ status.tags_error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-panel">
|
||||||
|
<h2 class="admin-panel__title">3. Пересобрать Docker</h2>
|
||||||
|
<p class="folder-hint">Требуется доступ к <code>/var/run/docker.sock</code> в контейнере web</p>
|
||||||
|
<form method="post" onsubmit="return confirm('Пересобрать и перезапустить контейнеры?');">
|
||||||
|
<input type="hidden" name="action" value="rebuild">
|
||||||
|
<button type="submit" class="btn btn--primary" {% if not status.enabled or not status.repo_ready %}disabled{% endif %}>docker compose up -d --build</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "macros.html" import format_size %}
|
||||||
|
|
||||||
|
{% block title %}Группы — Админка{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-header page-header--admin">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="page-header__title">Группы пользователей</h1>
|
||||||
|
<p class="page-header__subtitle">Квоты дискового пространства и назначение пользователей</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-section">
|
||||||
|
<div class="container">
|
||||||
|
{% include "admin/_nav.html" %}
|
||||||
|
{% include "partials/alerts.html" %}
|
||||||
|
|
||||||
|
<div class="admin-panel folder-create">
|
||||||
|
<h2 class="admin-panel__title">Создать группу</h2>
|
||||||
|
<form method="post" class="auth-form folder-create__form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Название</label>
|
||||||
|
<input type="text" id="name" name="name" required minlength="2" placeholder="VIP">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="disk_quota_mb">Квота (МБ, 0 = без лимита)</label>
|
||||||
|
<input type="number" id="disk_quota_mb" name="disk_quota_mb" min="0" value="500">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn--primary">Создать группу</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Группа</th>
|
||||||
|
<th>Квота</th>
|
||||||
|
<th>Пользователей</th>
|
||||||
|
<th>Занято</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in group_stats %}
|
||||||
|
{% set group = item.group %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ group.name }}
|
||||||
|
{% if group.is_default %}<span class="badge badge--success">по умолчанию</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ group.quota_label }}</td>
|
||||||
|
<td>{{ group.user_count }}</td>
|
||||||
|
<td>{{ format_size(item.storage_used) }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="{{ url_for('admin.edit_group', group_id=group.id) }}" class="group-edit-form">
|
||||||
|
<input type="text" name="name" value="{{ group.name }}" required minlength="2" class="form-inline-input">
|
||||||
|
<input type="number" name="disk_quota_mb" value="{{ group.disk_quota_mb }}" min="0" class="form-inline-input form-inline-input--sm">
|
||||||
|
<button type="submit" class="btn btn--ghost btn--sm">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
{% if not group.is_default %}
|
||||||
|
<form method="post" action="{{ url_for('admin.delete_group', group_id=group.id) }}" style="margin-top:6px" onsubmit="return confirm('Удалить группу? Пользователи будут перенесены в группу по умолчанию.');">
|
||||||
|
<button type="submit" class="btn btn--danger btn--sm">Удалить</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Настройки — Админка{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-header page-header--admin">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="page-header__title">Настройки системы</h1>
|
||||||
|
<p class="page-header__subtitle">S3, SFTP, FTP, SMTP и лимиты загрузки</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-section">
|
||||||
|
<div class="container">
|
||||||
|
{% include "admin/_nav.html" %}
|
||||||
|
{% include "partials/alerts.html" %}
|
||||||
|
|
||||||
|
<form method="post" class="settings-form">
|
||||||
|
<input type="hidden" name="action" value="save">
|
||||||
|
|
||||||
|
<div class="admin-panel">
|
||||||
|
<h2 class="admin-panel__title">Загрузка фото</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="max_bulk_upload">Максимум файлов за раз (до 100)</label>
|
||||||
|
<input type="number" id="max_bulk_upload" name="max_bulk_upload" min="1" max="100" value="{{ settings.max_bulk_upload }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-panel" style="margin-top:24px">
|
||||||
|
<h2 class="admin-panel__title">Amazon S3 / совместимое хранилище</h2>
|
||||||
|
<label class="form-checkbox"><input type="checkbox" name="s3_enabled" {% if settings.s3_enabled %}checked{% endif %}><span>Включить S3</span></label>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="form-group"><label>Endpoint</label><input type="text" name="s3_endpoint" value="{{ settings.s3_endpoint or '' }}" placeholder="https://s3.amazonaws.com"></div>
|
||||||
|
<div class="form-group"><label>Bucket</label><input type="text" name="s3_bucket" value="{{ settings.s3_bucket or '' }}"></div>
|
||||||
|
<div class="form-group"><label>Access Key</label><input type="text" name="s3_access_key" value="{{ settings.s3_access_key or '' }}"></div>
|
||||||
|
<div class="form-group"><label>Secret Key</label><input type="password" name="s3_secret_key" placeholder="оставьте пустым, если не меняете"></div>
|
||||||
|
<div class="form-group"><label>Region</label><input type="text" name="s3_region" value="{{ settings.s3_region or 'us-east-1' }}"></div>
|
||||||
|
<div class="form-group"><label>Public URL (CDN)</label><input type="text" name="s3_public_url" value="{{ settings.s3_public_url or '' }}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-panel" style="margin-top:24px">
|
||||||
|
<h2 class="admin-panel__title">SFTP (резервная копия)</h2>
|
||||||
|
<label class="form-checkbox"><input type="checkbox" name="sftp_enabled" {% if settings.sftp_enabled %}checked{% endif %}><span>Включить SFTP</span></label>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="form-group"><label>Host</label><input type="text" name="sftp_host" value="{{ settings.sftp_host or '' }}"></div>
|
||||||
|
<div class="form-group"><label>Port</label><input type="number" name="sftp_port" value="{{ settings.sftp_port }}"></div>
|
||||||
|
<div class="form-group"><label>Username</label><input type="text" name="sftp_username" value="{{ settings.sftp_username or '' }}"></div>
|
||||||
|
<div class="form-group"><label>Password</label><input type="password" name="sftp_password" placeholder="оставьте пустым, если не меняете"></div>
|
||||||
|
<div class="form-group"><label>Remote path</label><input type="text" name="sftp_remote_path" value="{{ settings.sftp_remote_path or '/uploads' }}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-panel" style="margin-top:24px">
|
||||||
|
<h2 class="admin-panel__title">FTP</h2>
|
||||||
|
<label class="form-checkbox"><input type="checkbox" name="ftp_enabled" {% if settings.ftp_enabled %}checked{% endif %}><span>Включить FTP</span></label>
|
||||||
|
<label class="form-checkbox"><input type="checkbox" name="ftp_use_tls" {% if settings.ftp_use_tls %}checked{% endif %}><span>FTPS (TLS)</span></label>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="form-group"><label>Host</label><input type="text" name="ftp_host" value="{{ settings.ftp_host or '' }}"></div>
|
||||||
|
<div class="form-group"><label>Port</label><input type="number" name="ftp_port" value="{{ settings.ftp_port }}"></div>
|
||||||
|
<div class="form-group"><label>Username</label><input type="text" name="ftp_username" value="{{ settings.ftp_username or '' }}"></div>
|
||||||
|
<div class="form-group"><label>Password</label><input type="password" name="ftp_password" placeholder="оставьте пустым, если не меняете"></div>
|
||||||
|
<div class="form-group"><label>Remote path</label><input type="text" name="ftp_remote_path" value="{{ settings.ftp_remote_path or '/uploads' }}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-panel" style="margin-top:24px">
|
||||||
|
<h2 class="admin-panel__title">SMTP (email)</h2>
|
||||||
|
<label class="form-checkbox"><input type="checkbox" name="smtp_enabled" {% if settings.smtp_enabled %}checked{% endif %}><span>Включить SMTP</span></label>
|
||||||
|
<label class="form-checkbox"><input type="checkbox" name="smtp_use_tls" {% if settings.smtp_use_tls %}checked{% endif %}><span>TLS</span></label>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="form-group"><label>Host</label><input type="text" name="smtp_host" value="{{ settings.smtp_host or '' }}" placeholder="smtp.gmail.com"></div>
|
||||||
|
<div class="form-group"><label>Port</label><input type="number" name="smtp_port" value="{{ settings.smtp_port }}"></div>
|
||||||
|
<div class="form-group"><label>Username</label><input type="text" name="smtp_username" value="{{ settings.smtp_username or '' }}"></div>
|
||||||
|
<div class="form-group"><label>Password</label><input type="password" name="smtp_password" placeholder="оставьте пустым, если не меняете"></div>
|
||||||
|
<div class="form-group"><label>From email</label><input type="email" name="smtp_from_email" value="{{ settings.smtp_from_email or '' }}"></div>
|
||||||
|
<div class="form-group"><label>From name</label><input type="text" name="smtp_from_name" value="{{ settings.smtp_from_name or 'PhotoHost' }}"></div>
|
||||||
|
</div>
|
||||||
|
<p class="folder-hint">SMTP используется для сброса пароля, регистрации и уведомлений о загрузке.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-header__actions" style="margin-top:24px">
|
||||||
|
<button type="submit" class="btn btn--primary">Сохранить настройки</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" style="margin-top:16px">
|
||||||
|
<input type="hidden" name="action" value="test_smtp">
|
||||||
|
<button type="submit" class="btn btn--ghost">Отправить тестовое письмо на {{ current_user.email }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Логин</th>
|
<th>Логин</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
|
<th>Группа</th>
|
||||||
<th>Фото</th>
|
<th>Фото</th>
|
||||||
<th>Роль</th>
|
<th>Роль</th>
|
||||||
<th>Статус</th>
|
<th>Статус</th>
|
||||||
@@ -34,6 +35,15 @@
|
|||||||
<td>{{ user.id }}</td>
|
<td>{{ user.id }}</td>
|
||||||
<td>{{ user.username }}</td>
|
<td>{{ user.username }}</td>
|
||||||
<td>{{ user.email }}</td>
|
<td>{{ user.email }}</td>
|
||||||
|
<td>
|
||||||
|
<form action="{{ url_for('admin.set_user_group', user_id=user.id) }}" method="post" class="group-assign-form">
|
||||||
|
<select name="group_id" class="form-select form-select--sm" onchange="this.form.submit()">
|
||||||
|
{% for group in groups %}
|
||||||
|
<option value="{{ group.id }}" {% if user.group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
<td>{{ user.photo_count }}</td>
|
<td>{{ user.photo_count }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if user.is_admin %}
|
{% if user.is_admin %}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Сброс пароля — PhotoHost{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="auth-section">
|
||||||
|
<div class="container auth-container">
|
||||||
|
<div class="auth-card">
|
||||||
|
<h1 class="auth-card__title">Сброс пароля</h1>
|
||||||
|
<p class="auth-card__subtitle">Введите email — отправим ссылку для восстановления</p>
|
||||||
|
{% include "partials/alerts.html" %}
|
||||||
|
<form method="post" class="auth-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" name="email" required autocomplete="email">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn--primary btn--full">Отправить ссылку</button>
|
||||||
|
</form>
|
||||||
|
<p class="auth-card__footer"><a href="{{ url_for('auth.login') }}">← Вернуться ко входу</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -28,7 +28,8 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="auth-card__footer">
|
<p class="auth-card__footer">
|
||||||
Нет аккаунта? <a href="{{ url_for('auth.register') }}">Зарегистрироваться</a>
|
<a href="{{ url_for('auth.forgot_password') }}">Забыли пароль?</a> ·
|
||||||
|
<a href="{{ url_for('auth.register') }}">Зарегистрироваться</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Новый пароль — PhotoHost{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="auth-section">
|
||||||
|
<div class="container auth-container">
|
||||||
|
<div class="auth-card">
|
||||||
|
<h1 class="auth-card__title">Новый пароль</h1>
|
||||||
|
{% include "partials/alerts.html" %}
|
||||||
|
<form method="post" class="auth-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Новый пароль</label>
|
||||||
|
<input type="password" id="password" name="password" required minlength="6">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password2">Подтверждение</label>
|
||||||
|
<input type="password" id="password2" name="password2" required minlength="6">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn--primary btn--full">Сохранить пароль</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -27,19 +27,9 @@
|
|||||||
<section id="upload" class="upload-section">
|
<section id="upload" class="upload-section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">Загрузить в папку</h2>
|
<h2 class="section-title">Загрузить в папку</h2>
|
||||||
<form action="{{ url_for('main.upload') }}" method="post" enctype="multipart/form-data" class="upload-form" id="uploadForm">
|
{% with folder_id=folder.id, max_bulk_upload=max_bulk_upload %}
|
||||||
<input type="hidden" name="folder_id" value="{{ folder.id }}">
|
{% include "partials/upload_form.html" %}
|
||||||
<div class="dropzone" id="dropzone">
|
{% endwith %}
|
||||||
<input type="file" name="photo" id="photoInput" accept="image/png,image/jpeg,image/gif,image/webp,image/bmp" hidden>
|
|
||||||
<p class="dropzone__title">Перетащите фото сюда</p>
|
|
||||||
<p class="dropzone__hint">или нажмите для выбора файла</p>
|
|
||||||
<div class="dropzone__preview" id="preview" hidden>
|
|
||||||
<img id="previewImg" alt="Предпросмотр">
|
|
||||||
<span id="previewName"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn--primary" id="submitBtn" disabled>Загрузить</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -32,29 +32,36 @@
|
|||||||
<span class="stat-card__label">на файл</span>
|
<span class="stat-card__label">на файл</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if quota %}
|
||||||
|
<div class="container quota-bar-wrap">
|
||||||
|
<div class="quota-bar">
|
||||||
|
<div class="quota-bar__header">
|
||||||
|
<span>Квота: {{ quota.group.name if quota.group else 'Пользователи' }}</span>
|
||||||
|
<span>
|
||||||
|
{{ format_size(quota.used) }}
|
||||||
|
{% if not quota.unlimited %}
|
||||||
|
/ {{ quota.group.disk_quota_mb }} МБ
|
||||||
|
{% else %}
|
||||||
|
/ без лимита
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if not quota.unlimited %}
|
||||||
|
<div class="quota-bar__track">
|
||||||
|
<div class="quota-bar__fill {% if quota.percent > 90 %}quota-bar__fill--warn{% endif %}" style="width: {{ quota.percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="upload" class="upload-section">
|
<section id="upload" class="upload-section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">Загрузить фото</h2>
|
<h2 class="section-title">Загрузить фото</h2>
|
||||||
<form action="{{ url_for('main.upload') }}" method="post" enctype="multipart/form-data" class="upload-form" id="uploadForm">
|
{% with folder_id=None, max_bulk_upload=max_bulk_upload %}
|
||||||
<div class="dropzone" id="dropzone">
|
{% include "partials/upload_form.html" %}
|
||||||
<input type="file" name="photo" id="photoInput" accept="image/png,image/jpeg,image/gif,image/webp,image/bmp" hidden>
|
{% endwith %}
|
||||||
<div class="dropzone__icon">
|
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
||||||
<path d="M12 16V4m0 0L8 8m4-4l4 4"/>
|
|
||||||
<path d="M20 16.5v1a2.5 2.5 0 01-2.5 2.5h-11A2.5 2.5 0 014 17.5v-1"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p class="dropzone__title">Перетащите фото сюда</p>
|
|
||||||
<p class="dropzone__hint">или нажмите для выбора файла</p>
|
|
||||||
<div class="dropzone__preview" id="preview" hidden>
|
|
||||||
<img id="previewImg" alt="Предпросмотр">
|
|
||||||
<span id="previewName"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn--primary" id="submitBtn" disabled>Загрузить</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -46,30 +46,9 @@
|
|||||||
<section id="upload" class="upload-section">
|
<section id="upload" class="upload-section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">Загрузить фото</h2>
|
<h2 class="section-title">Загрузить фото</h2>
|
||||||
<form action="{{ url_for('main.upload') }}" method="post" enctype="multipart/form-data" class="upload-form" id="uploadForm">
|
{% with folder_id=None, max_bulk_upload=max_bulk_upload %}
|
||||||
<div class="dropzone" id="dropzone">
|
{% include "partials/upload_form.html" %}
|
||||||
<input type="file" name="photo" id="photoInput" accept="image/png,image/jpeg,image/gif,image/webp,image/bmp" hidden>
|
{% endwith %}
|
||||||
<div class="dropzone__icon">
|
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
||||||
<path d="M12 16V4m0 0L8 8m4-4l4 4"/>
|
|
||||||
<path d="M20 16.5v1a2.5 2.5 0 01-2.5 2.5h-11A2.5 2.5 0 014 17.5v-1"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p class="dropzone__title">Перетащите фото сюда</p>
|
|
||||||
<p class="dropzone__hint">или нажмите для выбора файла</p>
|
|
||||||
<p class="dropzone__formats">PNG · JPG · GIF · WEBP · BMP</p>
|
|
||||||
<div class="dropzone__preview" id="preview" hidden>
|
|
||||||
<img id="previewImg" alt="Предпросмотр">
|
|
||||||
<span id="previewName"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn--primary" id="submitBtn" disabled>
|
|
||||||
<span>Загрузить на сервер</span>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<form action="{{ url_for('main.upload') }}" method="post" enctype="multipart/form-data" class="upload-form" id="uploadForm">
|
||||||
|
{% if folder_id %}<input type="hidden" name="folder_id" value="{{ folder_id }}">{% endif %}
|
||||||
|
<div class="dropzone" id="dropzone">
|
||||||
|
<input type="file" name="photos" id="photoInput" accept="image/png,image/jpeg,image/gif,image/webp,image/bmp" multiple data-max="{{ max_bulk_upload|default(100) }}" hidden>
|
||||||
|
<div class="dropzone__icon">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M12 16V4m0 0L8 8m4-4l4 4"/>
|
||||||
|
<path d="M20 16.5v1a2.5 2.5 0 01-2.5 2.5h-11A2.5 2.5 0 014 17.5v-1"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="dropzone__title">Перетащите фото сюда</p>
|
||||||
|
<p class="dropzone__hint">или выберите до {{ max_bulk_upload|default(100) }} файлов</p>
|
||||||
|
<p class="dropzone__formats">PNG · JPG · GIF · WEBP · BMP</p>
|
||||||
|
<div class="dropzone__preview" id="preview" hidden>
|
||||||
|
<img id="previewImg" alt="Предпросмотр">
|
||||||
|
<span id="previewName"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn--primary" id="submitBtn" disabled>
|
||||||
|
<span>Загрузить</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
@@ -21,18 +21,9 @@
|
|||||||
<section id="upload" class="upload-section">
|
<section id="upload" class="upload-section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">Загрузить в папку</h2>
|
<h2 class="section-title">Загрузить в папку</h2>
|
||||||
<form action="{{ url_for('main.upload') }}" method="post" enctype="multipart/form-data" class="upload-form" id="uploadForm">
|
{% with folder_id=folder.id, max_bulk_upload=max_bulk_upload %}
|
||||||
<input type="hidden" name="folder_id" value="{{ folder.id }}">
|
{% include "partials/upload_form.html" %}
|
||||||
<div class="dropzone" id="dropzone">
|
{% endwith %}
|
||||||
<input type="file" name="photo" id="photoInput" accept="image/png,image/jpeg,image/gif,image/webp,image/bmp" hidden>
|
|
||||||
<p class="dropzone__title">Перетащите фото сюда</p>
|
|
||||||
<div class="dropzone__preview" id="preview" hidden>
|
|
||||||
<img id="previewImg" alt="Предпросмотр">
|
|
||||||
<span id="previewName"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn--primary" id="submitBtn" disabled>Загрузить</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -29,8 +29,14 @@ services:
|
|||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-}
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
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:
|
volumes:
|
||||||
- uploads_data:/app/uploads
|
- uploads_data:/app/uploads
|
||||||
|
- .:/repo
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ gunicorn==23.0.0
|
|||||||
Pillow==11.1.0
|
Pillow==11.1.0
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
Werkzeug==3.1.3
|
Werkzeug==3.1.3
|
||||||
|
boto3==1.35.99
|
||||||
|
paramiko==3.5.1
|
||||||
|
|||||||
Reference in New Issue
Block a user