3 Commits

Author SHA1 Message Date
test2 c1aac7ecac Release 1.2: bulk upload, S3/SFTP/FTP, SMTP, password reset, user groups, git deploy
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:38:37 +03:00
test2 db2cef41bb Add folders with password sharing and email invites
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:30:00 +03:00
test2 a375ad330a Add server update instructions for new Git releases
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:24:51 +03:00
41 changed files with 2709 additions and 118 deletions
+7
View File
@@ -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
+2
View File
@@ -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 .
+80
View File
@@ -221,6 +221,86 @@ sudo systemctl start docker
--- ---
## Обновление до новой версии на сервере
Когда выходит новая версия в Git, обновите проект на сервере без потери данных (БД и фото хранятся в Docker volumes, файл `.env` не перезаписывается).
### Быстрое обновление (последняя версия из `main`)
```bash
cd ~/fotohost
git pull origin main
docker compose up -d --build
docker compose ps
docker compose logs --tail=50 web
```
### Обновление до конкретного релиза (рекомендуется)
Список доступных версий:
```bash
cd ~/fotohost
git fetch --tags
git tag -l
```
Пример — установить релиз **v1.0-beta**:
```bash
cd ~/fotohost
git fetch --tags
git checkout v1.0-beta
docker compose up -d --build
docker compose ps
```
Вернуться на последнюю dev-версию из `main`:
```bash
cd ~/fotohost
git checkout main
git pull origin main
docker compose up -d --build
```
### Перед обновлением (рекомендуется)
```bash
cd ~/fotohost
# Бэкап базы данных
docker compose exec db pg_dump -U photohost photohost > backup_$(date +%Y%m%d_%H%M).sql
# Проверить, не появились ли новые переменные в .env.example
diff .env .env.example || true
nano .env
```
Если в `.env.example` появились новые строки — добавьте их в свой `.env` вручную.
### После обновления — проверка
```bash
docker compose ps
curl -I http://127.0.0.1:8080
docker compose logs --tail=100 web
```
Откройте сайт в браузере и проверьте вход, загрузку фото и админку.
### Если что-то пошло не так — откат на предыдущий тег
```bash
cd ~/fotohost
git checkout v1.0-beta
docker compose up -d --build
```
> **Важно:** команда `docker compose up -d --build` пересобирает контейнер `web`, но **не удаляет** volumes с PostgreSQL и загруженными фото.
---
## Регистрация, авторизация и роли ## Регистрация, авторизация и роли
| URL | Описание | | URL | Описание |
+31 -2
View File
@@ -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)
@@ -42,21 +49,43 @@ def create_app():
from .routes import bp as main_bp, cabinet_bp from .routes import bp as main_bp, cabinet_bp
from .auth import bp as auth_bp from .auth import bp as auth_bp
from .admin import bp as admin_bp from .admin import bp as admin_bp
from .folders import bp as folders_bp
app.register_blueprint(main_bp) app.register_blueprint(main_bp)
app.register_blueprint(cabinet_bp) app.register_blueprint(cabinet_bp)
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(admin_bp) app.register_blueprint(admin_bp)
app.register_blueprint(folders_bp)
register_cli(app) register_cli(app)
with app.app_context(): with app.app_context():
from app.models import 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
ensure_schema() ensure_schema()
ensure_default_group(app)
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
View File
@@ -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)
+66 -2
View File
@@ -2,7 +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.models import User from app.email_service import send_password_reset_email, send_welcome_email
from app.folder_utils import process_pending_invites
from app.models import PasswordResetToken, User, UserGroup
bp = Blueprint("auth", __name__, url_prefix="/auth") bp = Blueprint("auth", __name__, url_prefix="/auth")
@@ -29,12 +31,21 @@ 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)
send_welcome_email(user)
flash("Регистрация успешна. Добро пожаловать!", "success") flash("Регистрация успешна. Добро пожаловать!", "success")
if accepted:
flash(f"Вам открыт доступ к {accepted} общим папкам", "success")
return redirect(url_for("cabinet.index")) return redirect(url_for("cabinet.index"))
return render_template("auth/register.html") return render_template("auth/register.html")
@@ -60,7 +71,10 @@ def login():
flash("Аккаунт заблокирован", "error") flash("Аккаунт заблокирован", "error")
else: else:
login_user(user, remember=remember) login_user(user, remember=remember)
accepted = process_pending_invites(user)
flash(f"Добро пожаловать, {user.username}!", "success") flash(f"Добро пожаловать, {user.username}!", "success")
if accepted:
flash(f"Вам открыт доступ к {accepted} общим папкам", "success")
next_page = request.args.get("next") next_page = request.args.get("next")
if next_page: if next_page:
return redirect(next_page) return redirect(next_page)
@@ -71,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()
+11 -1
View File
@@ -3,6 +3,9 @@ from functools import wraps
from flask import abort, flash, redirect, url_for from flask import abort, flash, redirect, url_for
from flask_login import current_user from flask_login import current_user
from app.folder_utils import can_edit_folder, is_folder_owner
from app.models import FolderMember
def admin_required(f): def admin_required(f):
@wraps(f) @wraps(f)
@@ -20,7 +23,14 @@ def can_manage_photo(photo):
return False return False
if current_user.is_admin: if current_user.is_admin:
return True return True
return photo.user_id == current_user.id if photo.user_id == current_user.id:
return True
if photo.folder_id and photo.folder:
if is_folder_owner(photo.folder, current_user):
return True
if can_edit_folder(photo.folder, current_user):
return True
return False
def photo_owner_or_admin(photo): def photo_owner_or_admin(photo):
+68 -2
View File
@@ -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()
+151
View File
@@ -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,
}
+71
View File
@@ -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)
+94
View File
@@ -0,0 +1,94 @@
from flask import session
from app.models import Folder, FolderMember
def get_unlocked_folder_ids():
return session.get("unlocked_folders", [])
def unlock_folder(folder_id):
unlocked = session.get("unlocked_folders", [])
if folder_id not in unlocked:
unlocked.append(folder_id)
session["unlocked_folders"] = unlocked
session.modified = True
def is_folder_unlocked(folder):
if not folder.has_password:
return True
return folder.id in get_unlocked_folder_ids()
def get_folder_member(folder, user):
if not user or not user.is_authenticated:
return None
return FolderMember.query.filter_by(folder_id=folder.id, user_id=user.id).first()
def is_folder_owner(folder, user):
return user and user.is_authenticated and folder.owner_id == user.id
def can_view_folder(folder, user=None):
from flask_login import current_user
user = user or current_user
if is_folder_owner(folder, user):
return True
if user and user.is_authenticated and user.is_admin:
return True
member = get_folder_member(folder, user)
if member:
return True
if not folder.is_private and is_folder_unlocked(folder):
return True
if is_folder_unlocked(folder):
return True
return False
def can_edit_folder(folder, user=None):
from flask_login import current_user
user = user or current_user
if is_folder_owner(folder, user):
return True
if user and user.is_authenticated and user.is_admin:
return True
member = get_folder_member(folder, user)
return member is not None and member.role == "editor"
def can_manage_folder_settings(folder, user=None):
from flask_login import current_user
user = user or current_user
return is_folder_owner(folder, user) or (user and user.is_authenticated and user.is_admin)
def process_pending_invites(user):
from app import db
from app.models import FolderInvite
invites = FolderInvite.query.filter_by(email=user.email.lower()).all()
accepted = 0
for invite in invites:
existing = FolderMember.query.filter_by(
folder_id=invite.folder_id, user_id=user.id
).first()
if not existing:
db.session.add(
FolderMember(
folder_id=invite.folder_id,
user_id=user.id,
role=invite.role,
added_by_id=invite.invited_by_id,
)
)
accepted += 1
db.session.delete(invite)
if invites:
db.session.commit()
return accepted
+314
View File
@@ -0,0 +1,314 @@
import os
from flask import (
Blueprint,
abort,
flash,
redirect,
render_template,
request,
url_for,
)
from flask_login import current_user, login_required
from sqlalchemy import inspect, text
from app import db
from app.folder_utils import (
can_edit_folder,
can_manage_folder_settings,
can_view_folder,
is_folder_unlocked,
process_pending_invites,
unlock_folder,
)
from app.models import Folder, FolderInvite, FolderMember, Photo, User
from app.settings_service import get_settings
from app.storage_service import delete_photo_file
bp = Blueprint("folders", __name__)
@bp.route("/cabinet/folders")
@login_required
def list_folders():
process_pending_invites(current_user)
owned = Folder.query.filter_by(owner_id=current_user.id).order_by(Folder.created_at.desc()).all()
shared = (
Folder.query.join(FolderMember)
.filter(
FolderMember.user_id == current_user.id,
Folder.owner_id != current_user.id,
)
.order_by(Folder.created_at.desc())
.all()
)
return render_template("cabinet/folders/list.html", owned_folders=owned, shared_folders=shared)
@bp.route("/cabinet/folders/create", methods=["POST"])
@login_required
def create_folder():
name = request.form.get("name", "").strip()
is_private = request.form.get("is_private") == "on"
access_password = request.form.get("access_password", "").strip()
if len(name) < 2:
flash("Название папки — минимум 2 символа", "error")
return redirect(url_for("folders.list_folders"))
folder = Folder(name=name, owner_id=current_user.id, is_private=is_private)
if access_password:
if len(access_password) < 4:
flash("Пароль папки — минимум 4 символа", "error")
return redirect(url_for("folders.list_folders"))
folder.set_access_password(access_password)
db.session.add(folder)
db.session.commit()
flash(f"Папка «{folder.name}» создана", "success")
return redirect(url_for("folders.view_folder", folder_id=folder.id))
@bp.route("/cabinet/folders/<int:folder_id>")
@login_required
def view_folder(folder_id):
folder = Folder.query.get_or_404(folder_id)
if not can_view_folder(folder):
if folder.has_password:
return redirect(url_for("folders.folder_password", folder_id=folder.id))
abort(403)
photos = folder.photos.order_by(Photo.created_at.desc()).all()
can_edit = can_edit_folder(folder)
return render_template(
"cabinet/folders/view.html",
folder=folder,
photos=photos,
can_edit=can_edit,
share_url=_share_url(folder),
max_bulk_upload=get_settings().max_bulk_upload,
)
@bp.route("/cabinet/folders/<int:folder_id>/password", methods=["GET", "POST"])
@login_required
def folder_password(folder_id):
folder = Folder.query.get_or_404(folder_id)
if is_folder_owner_or_member(folder):
return redirect(url_for("folders.view_folder", folder_id=folder.id))
if request.method == "POST":
password = request.form.get("password", "")
if folder.check_access_password(password):
unlock_folder(folder.id)
flash("Доступ к папке открыт", "success")
return redirect(url_for("folders.view_folder", folder_id=folder.id))
flash("Неверный пароль", "error")
return render_template("cabinet/folders/password.html", folder=folder)
@bp.route("/cabinet/folders/<int:folder_id>/settings", methods=["GET", "POST"])
@login_required
def folder_settings(folder_id):
folder = Folder.query.get_or_404(folder_id)
if not can_manage_folder_settings(folder):
abort(403)
if request.method == "POST":
action = request.form.get("action", "save")
if action == "regenerate_link":
folder.regenerate_share_token()
db.session.commit()
flash("Ссылка для sharing обновлена", "success")
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
if action == "delete":
_delete_folder(folder)
flash(f"Папка «{folder.name}» удалена", "success")
return redirect(url_for("folders.list_folders"))
name = request.form.get("name", "").strip()
is_private = request.form.get("is_private") == "on"
access_password = request.form.get("access_password", "").strip()
remove_password = request.form.get("remove_password") == "on"
if len(name) < 2:
flash("Название папки — минимум 2 символа", "error")
else:
folder.name = name
folder.is_private = is_private
if remove_password:
folder.set_access_password(None)
elif access_password:
if len(access_password) < 4:
flash("Пароль папки — минимум 4 символа", "error")
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
folder.set_access_password(access_password)
db.session.commit()
flash("Настройки папки сохранены", "success")
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
members = FolderMember.query.filter_by(folder_id=folder.id).all()
invites = FolderInvite.query.filter_by(folder_id=folder.id).all()
return render_template(
"cabinet/folders/settings.html",
folder=folder,
members=members,
invites=invites,
share_url=_share_url(folder),
)
@bp.route("/cabinet/folders/<int:folder_id>/invite", methods=["POST"])
@login_required
def invite_member(folder_id):
folder = Folder.query.get_or_404(folder_id)
if not can_manage_folder_settings(folder):
abort(403)
email = request.form.get("email", "").strip().lower()
role = request.form.get("role", "viewer")
if role not in ("viewer", "editor"):
role = "viewer"
if not email or "@" not in email:
flash("Укажите корректный email", "error")
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
user = User.query.filter_by(email=email).first()
if user:
if user.id == folder.owner_id:
flash("Владелец папки уже имеет доступ", "error")
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
existing = FolderMember.query.filter_by(folder_id=folder.id, user_id=user.id).first()
if existing:
existing.role = role
flash(f"Пользователь {user.username} уже в папке — роль обновлена", "success")
else:
db.session.add(
FolderMember(
folder_id=folder.id,
user_id=user.id,
role=role,
added_by_id=current_user.id,
)
)
FolderInvite.query.filter_by(folder_id=folder.id, email=email).delete()
flash(f"Пользователь {user.username} добавлен в папку", "success")
else:
invite = FolderInvite.query.filter_by(folder_id=folder.id, email=email).first()
if invite:
invite.role = role
flash("Приглашение обновлено", "success")
else:
db.session.add(
FolderInvite(
folder_id=folder.id,
email=email,
role=role,
invited_by_id=current_user.id,
)
)
flash(f"Приглашение отправлено на {email}. Доступ откроется после регистрации.", "success")
db.session.commit()
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
@bp.route("/cabinet/folders/<int:folder_id>/members/<int:user_id>/remove", methods=["POST"])
@login_required
def remove_member(folder_id, user_id):
folder = Folder.query.get_or_404(folder_id)
if not can_manage_folder_settings(folder):
abort(403)
member = FolderMember.query.filter_by(folder_id=folder.id, user_id=user_id).first_or_404()
db.session.delete(member)
db.session.commit()
flash("Пользователь удалён из папки", "success")
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
@bp.route("/cabinet/folders/<int:folder_id>/invites/<int:invite_id>/remove", methods=["POST"])
@login_required
def remove_invite(folder_id, invite_id):
folder = Folder.query.get_or_404(folder_id)
if not can_manage_folder_settings(folder):
abort(403)
invite = FolderInvite.query.filter_by(folder_id=folder.id, id=invite_id).first_or_404()
db.session.delete(invite)
db.session.commit()
flash("Приглашение отменено", "success")
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
@bp.route("/share/f/<share_token>", methods=["GET", "POST"])
def share_folder(share_token):
folder = Folder.query.filter_by(share_token=share_token).first_or_404()
if current_user.is_authenticated:
process_pending_invites(current_user)
if is_folder_owner_or_member(folder) or (can_view_folder(folder) and not folder.has_password):
return _render_share_folder(folder)
if folder.has_password and not is_folder_unlocked(folder):
if request.method == "POST":
password = request.form.get("password", "")
if folder.check_access_password(password):
unlock_folder(folder.id)
flash("Доступ к папке открыт", "success")
return redirect(url_for("folders.share_folder", share_token=share_token))
flash("Неверный пароль", "error")
return render_template("share/password.html", folder=folder, share_token=share_token)
return _render_share_folder(folder)
def _render_share_folder(folder):
photos = folder.photos.order_by(Photo.created_at.desc()).all()
return render_template(
"share/folder.html",
folder=folder,
photos=photos,
can_edit=can_edit_folder(folder),
share_url=_share_url(folder),
max_bulk_upload=get_settings().max_bulk_upload,
)
def _share_url(folder):
return url_for("folders.share_folder", share_token=folder.share_token, _external=True)
def is_folder_owner_or_member(folder):
if not current_user.is_authenticated:
return False
if folder.owner_id == current_user.id:
return True
return FolderMember.query.filter_by(folder_id=folder.id, user_id=current_user.id).first() is not None
def _delete_folder(folder):
for photo in folder.photos.all():
delete_photo_file(photo.filename, photo.storage_backend)
db.session.delete(photo)
db.session.delete(folder)
db.session.commit()
def ensure_folder_schema():
inspector = inspect(db.engine)
tables = inspector.get_table_names()
if "photos" in tables:
columns = {col["name"] for col in inspector.get_columns("photos")}
if "folder_id" not in columns:
db.session.execute(
text("ALTER TABLE photos ADD COLUMN folder_id INTEGER REFERENCES folders(id)")
)
db.session.commit()
+202 -1
View File
@@ -1,4 +1,5 @@
from datetime import datetime, timezone import uuid
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
@@ -6,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"
@@ -15,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,
@@ -22,6 +103,8 @@ 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")
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)
@@ -43,6 +126,117 @@ 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):
__tablename__ = "folders"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
owner_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
share_token = db.Column(db.String(64), unique=True, nullable=False, index=True)
is_private = db.Column(db.Boolean, nullable=False, default=True)
password_hash = db.Column(db.String(256), nullable=True)
created_at = db.Column(
db.DateTime,
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
photos = db.relationship("Photo", backref="folder", lazy="dynamic")
members = db.relationship("FolderMember", backref="folder", lazy="dynamic", cascade="all, delete-orphan")
invites = db.relationship("FolderInvite", backref="folder", lazy="dynamic", cascade="all, delete-orphan")
def __init__(self, **kwargs):
super().__init__(**kwargs)
if not self.share_token:
self.share_token = uuid.uuid4().hex
def set_access_password(self, password):
if password:
self.password_hash = generate_password_hash(password)
else:
self.password_hash = None
def check_access_password(self, password):
if not self.password_hash:
return True
return check_password_hash(self.password_hash, password)
@property
def has_password(self):
return bool(self.password_hash)
@property
def photo_count(self):
return self.photos.count()
def regenerate_share_token(self):
self.share_token = uuid.uuid4().hex
class FolderMember(db.Model):
__tablename__ = "folder_members"
id = db.Column(db.Integer, primary_key=True)
folder_id = db.Column(db.Integer, db.ForeignKey("folders.id"), nullable=False, index=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
role = db.Column(db.String(20), nullable=False, default="viewer")
added_at = db.Column(
db.DateTime,
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
added_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
user = db.relationship("User", foreign_keys=[user_id])
added_by = db.relationship("User", foreign_keys=[added_by_id])
__table_args__ = (db.UniqueConstraint("folder_id", "user_id", name="uq_folder_member"),)
class FolderInvite(db.Model):
__tablename__ = "folder_invites"
id = db.Column(db.Integer, primary_key=True)
folder_id = db.Column(db.Integer, db.ForeignKey("folders.id"), nullable=False, index=True)
email = db.Column(db.String(120), nullable=False, index=True)
role = db.Column(db.String(20), nullable=False, default="viewer")
invited_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
created_at = db.Column(
db.DateTime,
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
invited_by = db.relationship("User", foreign_keys=[invited_by_id])
__table_args__ = (db.UniqueConstraint("folder_id", "email", name="uq_folder_invite"),)
class Photo(db.Model): class Photo(db.Model):
__tablename__ = "photos" __tablename__ = "photos"
@@ -52,6 +246,8 @@ class Photo(db.Model):
file_size = db.Column(db.Integer, nullable=False, default=0) file_size = db.Column(db.Integer, nullable=False, default=0)
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)
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,
@@ -60,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
+65
View File
@@ -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,
}
+64 -49
View File
@@ -1,87 +1,86 @@
import os import os
import uuid
from datetime import datetime, timezone
from flask import ( from flask import (
Blueprint, Blueprint,
abort,
current_app, 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.models import Photo from app.folder_utils import can_edit_folder
from app.models import Folder, Photo
from app.settings_service import get_settings
from app.storage_service import delete_photo_file, get_photo_stream
from app.upload_service import process_uploads
bp = Blueprint("main", __name__) 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: folder_id = request.form.get("folder_id", type=int)
flash("Файл не выбран", "error") folder = None
return redirect(request.referrer or url_for("main.index")) if folder_id:
folder = Folder.query.get_or_404(folder_id)
if not can_edit_folder(folder):
abort(403)
file = request.files["photo"] result = process_uploads(
if file.filename == "": request.files,
flash("Файл не выбран", "error") current_user,
return redirect(request.referrer or url_for("main.index")) folder,
current_app.config["ALLOWED_EXTENSIONS"],
if not allowed_file(file.filename):
flash("Недопустимый формат. Разрешены: PNG, JPG, GIF, WEBP, BMP", "error")
return redirect(request.referrer or url_for("main.index"))
ext = file.filename.rsplit(".", 1)[1].lower()
stored_name = f"{uuid.uuid4().hex}.{ext}"
safe_original = secure_filename(file.filename) or f"photo.{ext}"
upload_dir = current_app.config["UPLOAD_FOLDER"]
filepath = os.path.join(upload_dir, stored_name)
file.save(filepath)
file_size = os.path.getsize(filepath)
photo = Photo(
filename=stored_name,
original_name=safe_original,
file_size=file_size,
mime_type=file.content_type or f"image/{ext}",
user_id=current_user.id,
created_at=datetime.now(timezone.utc),
) )
db.session.add(photo)
db.session.commit()
flash("Фото успешно загружено", "success") if result["uploaded"] == 0 and result["errors"]:
return redirect(url_for("cabinet.index")) flash(result["errors"][0], "error")
elif result["uploaded"] == 1:
flash("Фото успешно загружено", "success")
elif result["uploaded"] > 1:
flash(f"Загружено {result['uploaded']} фото", "success")
for err in result["errors"]:
if result["uploaded"] > 0:
flash(err, "error")
if result["uploaded"] > 0:
from app.email_service import send_upload_notification
send_upload_notification(
current_user,
result["uploaded"],
folder.name if folder else None,
)
if folder:
return redirect(url_for("folders.view_folder", folder_id=folder.id))
return redirect(request.referrer or url_for("cabinet.index"))
@bp.route("/api/photos") @bp.route("/api/photos")
@@ -105,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"])
@@ -114,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")
@@ -129,18 +134,28 @@ cabinet_bp = Blueprint("cabinet", __name__, url_prefix="/cabinet")
@cabinet_bp.route("/") @cabinet_bp.route("/")
@login_required @login_required
def index(): def index():
from app.folder_utils import process_pending_invites
from app.quota_utils import quota_status
process_pending_invites(current_user)
photos = ( photos = (
Photo.query.filter_by(user_id=current_user.id) Photo.query.filter_by(user_id=current_user.id, folder_id=None)
.order_by(Photo.created_at.desc()) .order_by(Photo.created_at.desc())
.all() .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,
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,
) )
+56
View File
@@ -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
+164
View File
@@ -881,3 +881,167 @@ body {
color: #fca5a5; color: #fca5a5;
border-color: rgba(239, 68, 68, 0.3); border-color: rgba(239, 68, 68, 0.3);
} }
/* Folders */
.folder-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.folder-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
transition: transform 0.2s, border-color 0.2s;
}
.folder-card:hover {
transform: translateY(-3px);
border-color: rgba(99, 102, 241, 0.35);
}
.folder-card--shared {
border-color: rgba(34, 197, 94, 0.2);
}
.folder-card__icon {
font-size: 2rem;
margin-bottom: 12px;
}
.folder-card__title {
font-size: 1.1rem;
margin-bottom: 8px;
}
.folder-card__meta {
color: var(--text-muted);
font-size: 0.85rem;
margin-bottom: 16px;
}
.folder-card__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.folder-create {
margin-bottom: 32px;
}
.folder-create__form {
max-width: 520px;
}
.folder-share-url {
word-break: break-all;
font-size: 0.85rem;
color: var(--accent-light);
margin-bottom: 16px;
padding: 12px;
background: rgba(0, 0, 0, 0.25);
border-radius: var(--radius-sm);
}
.folder-hint {
color: var(--text-muted);
font-size: 0.85rem;
margin-top: 12px;
}
.form-select {
padding: 12px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.3);
color: var(--text);
font-family: var(--font);
font-size: 0.95rem;
}
.admin-panel--danger {
border-color: rgba(239, 68, 68, 0.25);
}
.form-inline-input {
padding: 6px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.3);
color: var(--text);
font-family: var(--font);
font-size: 0.85rem;
margin-right: 6px;
margin-bottom: 6px;
}
.form-inline-input--sm {
width: 90px;
}
.form-select--sm {
padding: 6px 10px;
font-size: 0.8rem;
min-width: 120px;
}
.group-edit-form,
.group-assign-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.quota-bar-wrap {
margin-top: 20px;
max-width: 520px;
}
.quota-bar {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 14px 16px;
}
.quota-bar__header {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 10px;
}
.quota-bar__track {
height: 8px;
background: rgba(255, 255, 255, 0.08);
border-radius: 999px;
overflow: hidden;
}
.quota-bar__fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent-light));
border-radius: 999px;
transition: width 0.3s;
}
.quota-bar__fill--warn {
background: linear-gradient(90deg, #ef4444, #f97316);
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-top: 16px;
}
.settings-form .admin-panel {
margin-bottom: 0;
}
+25 -10
View File
@@ -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) => {
+216
View File
@@ -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)
+3
View File
@@ -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>
+12
View File
@@ -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>
+92
View File
@@ -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 %}
+75
View File
@@ -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 %}
+93
View File
@@ -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 %}
+10
View File
@@ -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 %}
+23
View File
@@ -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 %}
+2 -1
View File
@@ -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>
+25
View File
@@ -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 %}
+1
View File
@@ -24,6 +24,7 @@
<a href="{{ url_for('main.index') }}" class="nav__link">Главная</a> <a href="{{ url_for('main.index') }}" class="nav__link">Главная</a>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<a href="{{ url_for('cabinet.index') }}" class="nav__link">Личный кабинет</a> <a href="{{ url_for('cabinet.index') }}" class="nav__link">Личный кабинет</a>
<a href="{{ url_for('folders.list_folders') }}" class="nav__link">Папки</a>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<a href="{{ url_for('admin.dashboard') }}" class="nav__link nav__link--admin">Админка</a> <a href="{{ url_for('admin.dashboard') }}" class="nav__link nav__link--admin">Админка</a>
{% endif %} {% endif %}
+80
View File
@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% block title %}Папки — PhotoHost{% endblock %}
{% block content %}
<section class="page-header">
<div class="container">
<h1 class="page-header__title">Мои папки</h1>
<p class="page-header__subtitle">Создавайте папки, делитесь ссылками, защищайте паролем и приглашайте пользователей по email.</p>
</div>
</section>
{% include "partials/alerts.html" %}
<section class="admin-section">
<div class="container">
<div class="admin-panel folder-create">
<h2 class="admin-panel__title">Создать папку</h2>
<form method="post" action="{{ url_for('folders.create_folder') }}" 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="Отпуск 2025">
</div>
<div class="form-group">
<label for="access_password">Пароль доступа (необязательно)</label>
<input type="password" id="access_password" name="access_password" minlength="4" placeholder="для приватной папки">
</div>
<label class="form-checkbox">
<input type="checkbox" name="is_private" checked>
<span>Приватная папка (доступ по ссылке, паролю или приглашению)</span>
</label>
<button type="submit" class="btn btn--primary">Создать папку</button>
</form>
</div>
<h2 class="section-title">Мои папки</h2>
{% if owned_folders %}
<div class="folder-grid">
{% for folder in owned_folders %}
<article class="folder-card">
<div class="folder-card__icon">📁</div>
<h3 class="folder-card__title">{{ folder.name }}</h3>
<p class="folder-card__meta">
{{ folder.photo_count }} фото
{% if folder.is_private %} · приватная{% endif %}
{% if folder.has_password %} · с паролем{% endif %}
</p>
<div class="folder-card__actions">
<a href="{{ url_for('folders.view_folder', folder_id=folder.id) }}" class="btn btn--ghost btn--sm">Открыть</a>
<a href="{{ url_for('folders.folder_settings', folder_id=folder.id) }}" class="btn btn--ghost btn--sm">Настройки</a>
</div>
</article>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-state__icon">📁</div>
<h3>У вас пока нет папок</h3>
<p>Создайте первую папку для организации фото</p>
</div>
{% endif %}
{% if shared_folders %}
<h2 class="section-title" style="margin-top: 40px;">Общие со мной</h2>
<div class="folder-grid">
{% for folder in shared_folders %}
<article class="folder-card folder-card--shared">
<div class="folder-card__icon">🤝</div>
<h3 class="folder-card__title">{{ folder.name }}</h3>
<p class="folder-card__meta">@{{ folder.owner.username }} · {{ folder.photo_count }} фото</p>
<div class="folder-card__actions">
<a href="{{ url_for('folders.view_folder', folder_id=folder.id) }}" class="btn btn--ghost btn--sm">Открыть</a>
</div>
</article>
{% endfor %}
</div>
{% endif %}
</div>
</section>
{% endblock %}
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Пароль папки — {{ folder.name }}{% endblock %}
{% block content %}
<section class="auth-section">
<div class="container auth-container">
<div class="auth-card">
<h1 class="auth-card__title">Папка «{{ folder.name }}»</h1>
<p class="auth-card__subtitle">Введите пароль для доступа</p>
{% 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>
</div>
<button type="submit" class="btn btn--primary btn--full">Открыть</button>
</form>
</div>
</div>
</section>
{% endblock %}
+142
View File
@@ -0,0 +1,142 @@
{% extends "base.html" %}
{% block title %}Настройки — {{ folder.name }}{% endblock %}
{% block content %}
<section class="page-header">
<div class="container">
<h1 class="page-header__title">Настройки папки</h1>
<p class="page-header__subtitle">{{ folder.name }}</p>
<div class="page-header__actions">
<a href="{{ url_for('folders.view_folder', folder_id=folder.id) }}" class="btn btn--ghost">К папке</a>
</div>
</div>
</section>
<section class="admin-section">
<div class="container">
{% include "partials/alerts.html" %}
<div class="admin-grid">
<div class="admin-panel">
<h2 class="admin-panel__title">Основные настройки</h2>
<form method="post" class="auth-form">
<input type="hidden" name="action" value="save">
<div class="form-group">
<label for="name">Название</label>
<input type="text" id="name" name="name" value="{{ folder.name }}" required minlength="2">
</div>
<label class="form-checkbox">
<input type="checkbox" name="is_private" {% if folder.is_private %}checked{% endif %}>
<span>Приватная папка</span>
</label>
<div class="form-group">
<label for="access_password">Новый пароль доступа</label>
<input type="password" id="access_password" name="access_password" minlength="4" placeholder="оставьте пустым, если не меняете">
</div>
{% if folder.has_password %}
<label class="form-checkbox">
<input type="checkbox" name="remove_password">
<span>Убрать пароль</span>
</label>
{% endif %}
<button type="submit" class="btn btn--primary">Сохранить</button>
</form>
</div>
<div class="admin-panel">
<h2 class="admin-panel__title">Ссылка для sharing</h2>
<p class="folder-share-url">{{ share_url }}</p>
<div class="folder-card__actions">
<button type="button" class="btn btn--ghost btn--sm copy-btn" data-url="{{ share_url }}">Копировать</button>
<form method="post" style="display:inline">
<input type="hidden" name="action" value="regenerate_link">
<button type="submit" class="btn btn--ghost btn--sm">Обновить ссылку</button>
</form>
</div>
</div>
</div>
<div class="admin-panel" style="margin-top: 24px;">
<h2 class="admin-panel__title">Пригласить по email</h2>
<form method="post" action="{{ url_for('folders.invite_member', folder_id=folder.id) }}" class="auth-form folder-create__form">
<div class="form-group">
<label for="email">Email пользователя</label>
<input type="email" id="email" name="email" required placeholder="user@example.com">
</div>
<div class="form-group">
<label for="role">Роль</label>
<select id="role" name="role" class="form-select">
<option value="viewer">Просмотр</option>
<option value="editor">Редактор (загрузка и удаление)</option>
</select>
</div>
<button type="submit" class="btn btn--primary">Добавить / пригласить</button>
</form>
<p class="folder-hint">Если пользователь ещё не зарегистрирован, доступ откроется автоматически после регистрации с этим email.</p>
</div>
{% if members %}
<div class="admin-panel" style="margin-top: 24px;">
<h2 class="admin-panel__title">Участники</h2>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr><th>Пользователь</th><th>Email</th><th>Роль</th><th></th></tr>
</thead>
<tbody>
{% for member in members %}
<tr>
<td>{{ member.user.username }}</td>
<td>{{ member.user.email }}</td>
<td>{{ 'Редактор' if member.role == 'editor' else 'Просмотр' }}</td>
<td>
<form method="post" action="{{ url_for('folders.remove_member', folder_id=folder.id, user_id=member.user_id) }}">
<button type="submit" class="btn btn--danger btn--sm">Удалить</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if invites %}
<div class="admin-panel" style="margin-top: 24px;">
<h2 class="admin-panel__title">Ожидают регистрации</h2>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr><th>Email</th><th>Роль</th><th></th></tr>
</thead>
<tbody>
{% for invite in invites %}
<tr>
<td>{{ invite.email }}</td>
<td>{{ 'Редактор' if invite.role == 'editor' else 'Просмотр' }}</td>
<td>
<form method="post" action="{{ url_for('folders.remove_invite', folder_id=folder.id, invite_id=invite.id) }}">
<button type="submit" class="btn btn--danger btn--sm">Отменить</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<div class="admin-panel admin-panel--danger" style="margin-top: 24px;">
<h2 class="admin-panel__title">Удалить папку</h2>
<p class="folder-hint">Все фото внутри папки будут удалены безвозвратно.</p>
<form method="post" onsubmit="return confirm('Удалить папку и все фото в ней?');">
<input type="hidden" name="action" value="delete">
<button type="submit" class="btn btn--danger">Удалить папку</button>
</form>
</div>
</div>
</section>
{% endblock %}
+44
View File
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}{{ folder.name }} — PhotoHost{% endblock %}
{% block content %}
<section class="page-header">
<div class="container">
<h1 class="page-header__title">📁 {{ folder.name }}</h1>
<p class="page-header__subtitle">
{% if folder.is_private %}Приватная папка{% else %}Публичная папка{% endif %}
{% if folder.has_password %} · защищена паролем{% endif %}
· {{ photos|length }} фото
</p>
<div class="page-header__actions">
<a href="{{ url_for('folders.list_folders') }}" class="btn btn--ghost">← Все папки</a>
{% if folder.owner_id == current_user.id %}
<a href="{{ url_for('folders.folder_settings', folder_id=folder.id) }}" class="btn btn--ghost">Настройки</a>
{% endif %}
<button type="button" class="btn btn--ghost copy-btn" data-url="{{ share_url }}">Копировать ссылку</button>
</div>
</div>
</section>
{% include "partials/alerts.html" %}
{% if can_edit %}
<section id="upload" class="upload-section">
<div class="container">
<h2 class="section-title">Загрузить в папку</h2>
{% with folder_id=folder.id, max_bulk_upload=max_bulk_upload %}
{% include "partials/upload_form.html" %}
{% endwith %}
</div>
</section>
{% endif %}
<section class="gallery-section">
<div class="container">
{% with photos=photos, empty_title='В папке пока нет фото', empty_text='Загрузите первое изображение' %}
{% include "partials/photo_gallery.html" %}
{% endwith %}
</div>
</section>
{% endblock %}
+47 -18
View File
@@ -9,6 +9,7 @@
<h1 class="page-header__title">Личный кабинет</h1> <h1 class="page-header__title">Личный кабинет</h1>
<p class="page-header__subtitle">Привет, {{ current_user.username }}! Управляйте своими фотографиями.</p> <p class="page-header__subtitle">Привет, {{ current_user.username }}! Управляйте своими фотографиями.</p>
<div class="page-header__actions"> <div class="page-header__actions">
<a href="{{ url_for('folders.list_folders') }}" class="btn btn--primary">Мои папки</a>
<a href="{{ url_for('cabinet.profile') }}" class="btn btn--ghost">Настройки профиля</a> <a href="{{ url_for('cabinet.profile') }}" class="btn btn--ghost">Настройки профиля</a>
</div> </div>
</div> </div>
@@ -31,32 +32,60 @@
<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>
{% if folders %}
<section class="gallery-section">
<div class="container">
<div class="gallery-header">
<h2 class="section-title">Недавние папки</h2>
<a href="{{ url_for('folders.list_folders') }}" class="btn btn--ghost btn--sm">Все папки</a>
</div>
<div class="folder-grid">
{% for folder in folders %}
<article class="folder-card">
<div class="folder-card__icon">📁</div>
<h3 class="folder-card__title">{{ folder.name }}</h3>
<p class="folder-card__meta">{{ folder.photo_count }} фото</p>
<a href="{{ url_for('folders.view_folder', folder_id=folder.id) }}" class="btn btn--ghost btn--sm">Открыть</a>
</article>
{% endfor %}
</div>
</div>
</section>
{% endif %}
<section class="gallery-section"> <section class="gallery-section">
<div class="container"> <div class="container">
<div class="gallery-header"> <div class="gallery-header">
+3 -24
View File
@@ -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 %}
+22
View File
@@ -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>
+38
View File
@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}{{ folder.name }} — Shared folder{% endblock %}
{% block content %}
<section class="page-header">
<div class="container">
<h1 class="page-header__title">📁 {{ folder.name }}</h1>
<p class="page-header__subtitle">Общая папка · {{ photos|length }} фото</p>
{% if share_url %}
<div class="page-header__actions">
<button type="button" class="btn btn--ghost copy-btn" data-url="{{ share_url }}">Копировать ссылку</button>
</div>
{% endif %}
</div>
</section>
{% include "partials/alerts.html" %}
{% if can_edit %}
<section id="upload" class="upload-section">
<div class="container">
<h2 class="section-title">Загрузить в папку</h2>
{% with folder_id=folder.id, max_bulk_upload=max_bulk_upload %}
{% include "partials/upload_form.html" %}
{% endwith %}
</div>
</section>
{% endif %}
<section class="gallery-section">
<div class="container">
{% with photos=photos, empty_title='В папке пока нет фото' %}
{% include "partials/photo_gallery.html" %}
{% endwith %}
</div>
</section>
{% endblock %}
+22
View File
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Пароль — {{ folder.name }}{% endblock %}
{% block content %}
<section class="auth-section">
<div class="container auth-container">
<div class="auth-card">
<h1 class="auth-card__title">Папка «{{ folder.name }}»</h1>
<p class="auth-card__subtitle">Эта папка защищена паролем</p>
{% 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 autofocus>
</div>
<button type="submit" class="btn btn--primary btn--full">Открыть папку</button>
</form>
</div>
</div>
</section>
{% endblock %}
+96
View File
@@ -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,
}
+6
View File
@@ -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
+2
View File
@@ -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