12 Commits

Author SHA1 Message Date
test2 49abcc20b4 Fix migration order: add group limit columns before ORM queries
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 23:03:54 +03:00
test2 b014e64c5d Fix worker crash: remove broken gunicorn post_fork hook
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 23:02:32 +03:00
test2 e334a7b32c Fix empty reply: remove gunicorn preload, isolate DB init from workers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 23:00:43 +03:00
test2 5353c82066 Fix 502 after update: safe startup, single DB init, healthcheck
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:58:09 +03:00
test2 82fdb60f5e Document v1.4 git deploy fix in README
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:53:07 +03:00
test2 28eb9e19f5 Fix git deploy .git/config permission denied via docker exec fallback
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:51:45 +03:00
test2 69715ecd06 v1.4: group folder/photo limits and ad banners
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:50:10 +03:00
test2 6a6704bc4b Fix git deploy permissions: entrypoint chown and no remote set-url
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:45:55 +03:00
test2 d10f25eb06 Fix git safe.directory for mounted /repo in Docker
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:43:09 +03:00
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
48 changed files with 3613 additions and 132 deletions
+9
View File
@@ -13,3 +13,12 @@ APP_PORT=8080
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=change_me_admin_password
# Default user group quota in MB (0 = unlimited)
DEFAULT_GROUP_QUOTA_MB=100
DEFAULT_GROUP_MAX_FOLDERS=10
DEFAULT_GROUP_MAX_PHOTOS=500
# 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
+12 -5
View File
@@ -5,6 +5,9 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
gcc \
git \
gosu \
docker.io \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
@@ -12,13 +15,17 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /app/uploads && adduser --disabled-password --gecos "" appuser \
&& chown -R appuser:appuser /app
USER appuser
RUN mkdir -p /app/uploads \
&& adduser --disabled-password --gecos "" appuser \
&& chown -R appuser:appuser /app \
&& chmod +x /app/entrypoint.sh
ENV FLASK_APP=wsgi:app
ENV GIT_CONFIG_COUNT=1
ENV GIT_CONFIG_KEY_0=safe.directory
ENV GIT_CONFIG_VALUE_0=/repo
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120", "wsgi:app"]
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["gunicorn", "-c", "gunicorn.conf.py", "wsgi:app"]
+146
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 | Описание |
@@ -232,6 +312,8 @@ sudo systemctl start docker
| `/cabinet/profile` | Настройки профиля, смена пароля |
| `/admin/` | Панель администратора (только admin) |
| `/admin/users` | Управление пользователями |
| `/admin/groups` | Группы: квота диска, лимиты папок и фото |
| `/admin/banners` | Рекламные баннеры на сайте |
| `/admin/photos` | Все фото на сервере |
**Права доступа:**
@@ -241,6 +323,47 @@ sudo systemctl start docker
---
## Релиз v1.4
**Лимиты групп пользователей**
- В `/admin/groups` администратор задаёт для каждой группы:
- **Квота диска** (МБ, `0` = без лимита)
- **Максимум папок** на пользователя (`0` = без лимита)
- **Максимум фото** на пользователя (`0` = без лимита)
- Лимиты проверяются при создании папки и загрузке фото
- В личном кабинете отображается использование квот
**Переменные `.env` для группы по умолчанию:**
```env
DEFAULT_GROUP_QUOTA_MB=100
DEFAULT_GROUP_MAX_FOLDERS=10
DEFAULT_GROUP_MAX_PHOTOS=500
```
**Рекламные баннеры**
- Управление в `/admin/banners`
- Позиции: главная (под hero), личный кабинет, подвал
- URL изображения, опциональная ссылка при клике, порядок сортировки, вкл/выкл
**Git deploy из админки**
- Исправлена ошибка `could not lock config file .git/config: Permission denied`
- Fetch/checkout без записи в `.git/config`, автоматическое восстановление прав
**Обновление до v1.4 на сервере:**
```bash
cd ~/fotohost
git fetch --tags
git checkout v1.4
docker compose up -d --build
```
---
## Полезные команды
| Действие | Команда |
@@ -369,6 +492,7 @@ python wsgi.py
| POST | `/auth/login` | Вход |
| GET | `/cabinet/` | Личный кабинет |
| GET | `/admin/` | Админ-панель |
| GET | `/admin/banners` | Управление рекламными баннерами |
| POST | `/upload` | Загрузка фото (auth) |
| GET | `/uploads/<filename>` | Прямая ссылка на файл |
| GET | `/api/photos` | JSON-список всех фото |
@@ -421,6 +545,28 @@ docker compose restart web
docker compose down && docker compose up -d
```
**502 Bad Gateway (Nginx)**
Nginx не может достучаться до контейнера `web`. Проверьте:
```bash
cd ~/fotohost
docker compose ps
docker compose logs --tail=100 web
curl -I http://127.0.0.1:8080/health
```
Частые причины после обновления:
1. Контейнер `photohost-web` не запущен или перезапускается — смотрите логи `docker compose logs web`
2. В Nginx указан неверный порт — должен совпадать с `APP_PORT` из `.env` (по умолчанию `8080`):
```nginx
proxy_pass http://127.0.0.1:8080;
```
3. База данных ещё не готова — подождите 30–60 секунд и выполните `docker compose restart web`
---
## Технологии
+47 -6
View File
@@ -21,7 +21,7 @@ def load_user(user_id):
return db.session.get(User, int(user_id))
def create_app():
def create_app(setup_database=True):
app = Flask(__name__)
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-change-me")
@@ -30,9 +30,17 @@ def create_app():
"postgresql://photohost:photohost_secret@localhost:5432/photohost",
)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {"pool_pre_ping": True}
app.config["UPLOAD_FOLDER"] = os.getenv("UPLOAD_FOLDER", "uploads")
app.config["MAX_CONTENT_LENGTH"] = int(os.getenv("MAX_UPLOAD_MB", "10")) * 1024 * 1024
app.config["ALLOWED_EXTENSIONS"] = {"png", "jpg", "jpeg", "gif", "webp", "bmp"}
app.config["GIT_REPO_PATH"] = os.getenv("GIT_REPO_PATH", "/repo")
app.config["ALLOW_GIT_DEPLOY"] = os.getenv("ALLOW_GIT_DEPLOY", "false").lower() in (
"1",
"true",
"yes",
)
app.config["DEFAULT_GROUP_QUOTA_MB"] = int(os.getenv("DEFAULT_GROUP_QUOTA_MB", "100"))
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
@@ -42,22 +50,55 @@ def create_app():
from .routes import bp as main_bp, cabinet_bp
from .auth import bp as auth_bp
from .admin import bp as admin_bp
from .folders import bp as folders_bp
app.register_blueprint(main_bp)
app.register_blueprint(cabinet_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(folders_bp)
register_cli(app)
# Ensure models are registered even when DB setup runs in init_db.py.
with app.app_context():
from app.models import Photo, User # noqa: F401
from app.models import ( # noqa: F401
AdBanner,
Folder,
FolderInvite,
FolderMember,
PasswordResetToken,
Photo,
SiteSettings,
User,
UserGroup,
)
db.create_all()
from app.bootstrap import create_first_admin, ensure_schema
@app.context_processor
def inject_banners():
from app.banner_service import get_banners_by_position
ensure_schema()
create_first_admin(app)
try:
return {"site_banners": get_banners_by_position()}
except Exception:
return {"site_banners": {}}
if setup_database:
with app.app_context():
from app.bootstrap import (
create_first_admin,
ensure_default_group,
ensure_site_settings,
run_schema_migrations,
)
db.create_all()
run_schema_migrations()
if os.getenv("SKIP_DB_INIT") != "1":
ensure_default_group(app)
ensure_site_settings(app)
create_first_admin(app)
return app
+278 -8
View File
@@ -6,7 +6,19 @@ from sqlalchemy import func
from app import db
from app.auth_utils import admin_required
from app.models import Photo, User
from app.bootstrap import slugify
from app.deploy_utils import (
checkout_version,
deploy_rebuild,
fetch_remote,
get_current_version,
get_deploy_status,
is_deploy_enabled,
)
from app.models import AdBanner, Photo, User, UserGroup
from app.quota_utils import get_user_folder_count, get_user_photo_count, get_user_storage_used
from app.settings_service import get_settings, update_settings_from_form
from app.storage_service import delete_photo_file
bp = Blueprint("admin", __name__, url_prefix="/admin")
@@ -18,17 +30,21 @@ def dashboard():
"users": User.query.count(),
"photos": Photo.query.count(),
"admins": User.query.filter_by(is_admin=True).count(),
"groups": UserGroup.query.count(),
"storage": int(
db.session.query(func.coalesce(func.sum(Photo.file_size), 0)).scalar() or 0
),
}
recent_users = User.query.order_by(User.created_at.desc()).limit(5).all()
recent_photos = Photo.query.order_by(Photo.created_at.desc()).limit(8).all()
current_version, _ = get_current_version()
return render_template(
"admin/dashboard.html",
stats=stats,
recent_users=recent_users,
recent_photos=recent_photos,
current_version=current_version,
deploy_enabled=is_deploy_enabled(),
)
@@ -36,7 +52,20 @@ def dashboard():
@admin_required
def users():
all_users = User.query.order_by(User.created_at.desc()).all()
return render_template("admin/users.html", users=all_users)
groups = UserGroup.query.order_by(UserGroup.name).all()
return render_template("admin/users.html", users=all_users, groups=groups)
@bp.route("/users/<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"])
@@ -87,9 +116,7 @@ def delete_user(user_id):
return redirect(url_for("admin.users"))
for photo in user.photos.all():
filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], photo.filename)
if os.path.exists(filepath):
os.remove(filepath)
delete_photo_file(photo.filename, photo.storage_backend)
db.session.delete(photo)
db.session.delete(user)
@@ -98,6 +125,199 @@ def delete_user(user_id):
return redirect(url_for("admin.users"))
@bp.route("/groups", methods=["GET", "POST"])
@admin_required
def groups():
if request.method == "POST":
name = request.form.get("name", "").strip()
quota_mb = request.form.get("disk_quota_mb", type=int) or 100
max_folders = request.form.get("max_folders", type=int)
max_photos = request.form.get("max_photos", type=int)
if max_folders is None:
max_folders = 10
if max_photos is None:
max_photos = 500
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),
max_folders=max(0, max_folders),
max_photos=max(0, max_photos),
)
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)
photos = sum(get_user_photo_count(u.id) for u in group.users)
folders = sum(get_user_folder_count(u.id) for u in group.users)
group_stats.append({
"group": group,
"storage_used": used,
"photo_count": photos,
"folder_count": folders,
})
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)
max_folders = request.form.get("max_folders", type=int)
max_photos = request.form.get("max_photos", 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)
if max_folders is not None:
group.max_folders = max(0, max_folders)
if max_photos is not None:
group.max_photos = max(0, max_photos)
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("/banners", methods=["GET", "POST"])
@admin_required
def banners():
if request.method == "POST":
title = request.form.get("title", "").strip()
image_url = request.form.get("image_url", "").strip()
link_url = request.form.get("link_url", "").strip() or None
alt_text = request.form.get("alt_text", "").strip() or None
position = request.form.get("position", "main").strip()
sort_order = request.form.get("sort_order", type=int) or 0
is_active = request.form.get("is_active") == "on"
if len(title) < 2:
flash("Название баннера — минимум 2 символа", "error")
elif not image_url:
flash("Укажите URL изображения", "error")
elif position not in AdBanner.POSITIONS:
flash("Неверная позиция баннера", "error")
else:
banner = AdBanner(
title=title,
image_url=image_url,
link_url=link_url,
alt_text=alt_text or title,
position=position,
sort_order=sort_order,
is_active=is_active,
)
db.session.add(banner)
db.session.commit()
flash(f"Баннер «{title}» добавлен", "success")
return redirect(url_for("admin.banners"))
all_banners = AdBanner.query.order_by(AdBanner.position, AdBanner.sort_order, AdBanner.id).all()
return render_template("admin/banners.html", banners=all_banners, positions=AdBanner.POSITIONS)
@bp.route("/banners/<int:banner_id>/edit", methods=["POST"])
@admin_required
def edit_banner(banner_id):
banner = AdBanner.query.get_or_404(banner_id)
title = request.form.get("title", "").strip()
image_url = request.form.get("image_url", "").strip()
link_url = request.form.get("link_url", "").strip() or None
alt_text = request.form.get("alt_text", "").strip() or None
position = request.form.get("position", banner.position).strip()
sort_order = request.form.get("sort_order", type=int)
is_active = request.form.get("is_active") == "on"
if len(title) < 2:
flash("Название баннера — минимум 2 символа", "error")
elif not image_url:
flash("Укажите URL изображения", "error")
elif position not in AdBanner.POSITIONS:
flash("Неверная позиция баннера", "error")
else:
banner.title = title
banner.image_url = image_url
banner.link_url = link_url
banner.alt_text = alt_text or title
banner.position = position
if sort_order is not None:
banner.sort_order = sort_order
banner.is_active = is_active
db.session.commit()
flash(f"Баннер «{banner.title}» обновлён", "success")
return redirect(url_for("admin.banners"))
@bp.route("/banners/<int:banner_id>/delete", methods=["POST"])
@admin_required
def delete_banner(banner_id):
banner = AdBanner.query.get_or_404(banner_id)
db.session.delete(banner)
db.session.commit()
flash("Баннер удалён", "success")
return redirect(url_for("admin.banners"))
@bp.route("/banners/<int:banner_id>/toggle", methods=["POST"])
@admin_required
def toggle_banner(banner_id):
banner = AdBanner.query.get_or_404(banner_id)
banner.is_active = not banner.is_active
db.session.commit()
state = "включён" if banner.is_active else "выключен"
flash(f"Баннер «{banner.title}» {state}", "success")
return redirect(url_for("admin.banners"))
@bp.route("/photos")
@admin_required
def photos():
@@ -109,10 +329,60 @@ def photos():
@admin_required
def delete_photo(photo_id):
photo = Photo.query.get_or_404(photo_id)
filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], photo.filename)
if os.path.exists(filepath):
os.remove(filepath)
delete_photo_file(photo.filename, photo.storage_backend)
db.session.delete(photo)
db.session.commit()
flash("Фото удалено", "success")
return redirect(url_for("admin.photos"))
@bp.route("/deploy", methods=["GET", "POST"])
@admin_required
def deploy():
status = get_deploy_status()
if request.method == "POST":
action = request.form.get("action")
if action == "fetch":
ok, msg = fetch_remote()
flash(msg if ok else msg, "success" if ok else "error")
elif action == "checkout":
ref = request.form.get("ref", "").strip()
ok, msg = checkout_version(ref)
flash(msg, "success" if ok else "error")
elif action == "rebuild":
ok, msg = deploy_rebuild()
flash(msg, "success" if ok else "error")
else:
flash("Неизвестное действие", "error")
return redirect(url_for("admin.deploy"))
return render_template("admin/deploy.html", status=status)
@bp.route("/settings", methods=["GET", "POST"])
@admin_required
def settings():
site_settings = get_settings()
if request.method == "POST":
action = request.form.get("action", "save")
if action == "test_smtp":
from app.email_service import send_email
ok, msg = send_email(
current_user.email,
"PhotoHost — тест SMTP",
"SMTP настроен корректно.",
)
flash(msg, "success" if ok else "error")
return redirect(url_for("admin.settings"))
update_settings_from_form(request.form)
flash("Настройки сохранены", "success")
return redirect(url_for("admin.settings"))
return render_template("admin/settings.html", settings=site_settings)
+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 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")
@@ -29,12 +31,21 @@ def register():
elif User.query.filter_by(email=email).first():
flash("Этот email уже зарегистрирован", "error")
else:
user = User(username=username, email=email)
default_group = UserGroup.query.filter_by(is_default=True).first()
user = User(
username=username,
email=email,
group_id=default_group.id if default_group else None,
)
user.set_password(password)
db.session.add(user)
db.session.commit()
login_user(user)
accepted = process_pending_invites(user)
send_welcome_email(user)
flash("Регистрация успешна. Добро пожаловать!", "success")
if accepted:
flash(f"Вам открыт доступ к {accepted} общим папкам", "success")
return redirect(url_for("cabinet.index"))
return render_template("auth/register.html")
@@ -60,7 +71,10 @@ def login():
flash("Аккаунт заблокирован", "error")
else:
login_user(user, remember=remember)
accepted = process_pending_invites(user)
flash(f"Добро пожаловать, {user.username}!", "success")
if accepted:
flash(f"Вам открыт доступ к {accepted} общим папкам", "success")
next_page = request.args.get("next")
if next_page:
return redirect(next_page)
@@ -71,6 +85,56 @@ def login():
return render_template("auth/login.html")
@bp.route("/forgot-password", methods=["GET", "POST"])
def forgot_password():
if current_user.is_authenticated:
return redirect(url_for("cabinet.index"))
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
user = User.query.filter_by(email=email).first()
if user:
token = PasswordResetToken.create_for_user(user)
db.session.add(token)
db.session.commit()
ok, msg = send_password_reset_email(user, token.token)
if not ok:
flash(f"Не удалось отправить email: {msg}", "error")
return redirect(url_for("auth.forgot_password"))
flash("Если email зарегистрирован, на него отправлена ссылка для сброса пароля", "success")
return redirect(url_for("auth.login"))
return render_template("auth/forgot_password.html")
@bp.route("/reset-password/<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")
def logout():
logout_user()
+11 -1
View File
@@ -3,6 +3,9 @@ from functools import wraps
from flask import abort, flash, redirect, url_for
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):
@wraps(f)
@@ -20,7 +23,14 @@ def can_manage_photo(photo):
return False
if current_user.is_admin:
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):
+16
View File
@@ -0,0 +1,16 @@
from app.models import AdBanner
def get_banners(position=None):
query = AdBanner.query.filter_by(is_active=True).order_by(AdBanner.sort_order, AdBanner.id)
if position:
query = query.filter_by(position=position)
return query.all()
def get_banners_by_position():
banners = get_banners()
grouped = {}
for banner in banners:
grouped.setdefault(banner.position, []).append(banner)
return grouped
+123 -7
View File
@@ -1,9 +1,10 @@
import os
import re
from sqlalchemy import inspect, text
from app import db
from app.models import User
from app.models import User, UserGroup
def ensure_schema():
@@ -11,12 +12,118 @@ def ensure_schema():
tables = inspector.get_table_names()
if "photos" in tables:
columns = {col["name"] for col in inspector.get_columns("photos")}
if "user_id" not in columns:
db.session.execute(
text("ALTER TABLE photos ADD COLUMN user_id INTEGER REFERENCES users(id)")
db.session.execute(
text(
"ALTER TABLE photos ADD COLUMN IF NOT EXISTS "
"user_id INTEGER REFERENCES users(id)"
)
db.session.commit()
)
db.session.commit()
if "users" in tables and "user_groups" in tables:
db.session.execute(
text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS "
"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_max_folders = int(os.getenv("DEFAULT_GROUP_MAX_FOLDERS", "10"))
default_max_photos = int(os.getenv("DEFAULT_GROUP_MAX_PHOTOS", "500"))
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,
max_folders=default_max_folders,
max_photos=default_max_photos,
is_default=True,
)
db.session.add(default_group)
db.session.commit()
app.logger.info(
"Default user group 'users' created (quota=%s MB, folders=%s, photos=%s)",
default_quota,
default_max_folders,
default_max_photos,
)
User.query.filter(User.group_id.is_(None)).update({"group_id": default_group.id})
db.session.commit()
def ensure_group_limit_columns():
inspector = inspect(db.engine)
if "user_groups" not in inspector.get_table_names():
return
db.session.execute(
text(
"ALTER TABLE user_groups ADD COLUMN IF NOT EXISTS "
"max_folders INTEGER NOT NULL DEFAULT 10"
)
)
db.session.execute(
text(
"ALTER TABLE user_groups ADD COLUMN IF NOT EXISTS "
"max_photos INTEGER NOT NULL DEFAULT 500"
)
)
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
db.session.execute(
text(
"ALTER TABLE photos ADD COLUMN IF NOT EXISTS "
"storage_backend VARCHAR(20) DEFAULT 'local'"
)
)
db.session.commit()
def run_schema_migrations():
ensure_schema()
ensure_group_limit_columns()
from app.folders import ensure_folder_schema
ensure_folder_schema()
ensure_photo_storage_column()
def run_database_setup(app):
run_schema_migrations()
ensure_default_group(app)
ensure_site_settings(app)
create_first_admin(app)
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):
@@ -31,15 +138,24 @@ def create_first_admin(app):
if User.query.filter_by(is_admin=True).first():
return None
default_group = UserGroup.query.filter_by(is_default=True).first()
if User.query.filter_by(username=username).first():
user = User.query.filter_by(username=username).first()
user.is_admin = True
user.set_password(password)
if default_group and not user.group_id:
user.group_id = default_group.id
db.session.commit()
app.logger.info("Existing user '%s' promoted to admin", username)
return user
user = User(username=username, email=email, is_admin=True)
user = User(
username=username,
email=email,
is_admin=True,
group_id=default_group.id if default_group else None,
)
user.set_password(password)
db.session.add(user)
db.session.commit()
+258
View File
@@ -0,0 +1,258 @@
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 get_container_name():
return os.getenv("CONTAINER_NAME", os.getenv("HOSTNAME", "photohost-web"))
def _repo_ready():
repo = get_repo_path()
return os.path.isdir(repo) and os.path.isdir(os.path.join(repo, ".git"))
def _docker_available():
try:
result = subprocess.run(
["docker", "info"],
capture_output=True,
text=True,
timeout=10,
)
return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
def _git_base_cmd(repo):
return ["git", "-c", f"safe.directory={repo}", "-C", repo]
def _run_subprocess(cmd, timeout=120, cwd=None):
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
cwd=cwd,
)
output = (result.stderr or result.stdout or "").strip()
return result.returncode == 0, output
def _run_git_as_root(args, timeout=120):
repo = get_repo_path()
container = get_container_name()
cmd = ["docker", "exec", "-u", "0", container] + _git_base_cmd(repo) + args
return _run_subprocess(cmd, timeout=timeout)
def _fix_repo_permissions():
if not _docker_available() or not _repo_ready():
return False
repo = get_repo_path()
container = get_container_name()
fix_cmd = [
"docker",
"exec",
"-u",
"0",
container,
"sh",
"-c",
f"chown -R appuser:appuser {repo} && chmod -R u+rwX {repo}/.git",
]
ok, _ = _run_subprocess(fix_cmd, timeout=60)
return ok
def run_git(args, timeout=120):
if not _repo_ready():
return False, f"Git-репозиторий не найден: {get_repo_path()}"
repo = get_repo_path()
cmd = _git_base_cmd(repo) + args
ok, output = _run_subprocess(cmd, timeout=timeout)
if ok:
return True, output
permission_error = "permission denied" in output.lower() and "config" in output.lower()
if permission_error and _docker_available():
_fix_repo_permissions()
ok, output = _run_subprocess(cmd, timeout=timeout)
if ok:
return True, output
ok, output = _run_git_as_root(args, timeout=timeout)
if ok:
return True, output
return False, output or "Git error"
def run_ls_remote(extra_args=None, timeout=60):
remote = get_git_remote()
if not remote:
return False, "GIT_REMOTE_URL не задан", []
cmd = ["git", "ls-remote", remote]
if extra_args:
cmd.extend(extra_args)
ok, output = _run_subprocess(cmd, timeout=timeout)
if not ok:
return False, output or "ls-remote error", []
return True, "", output.splitlines()
def fetch_remote():
remote = get_git_remote()
if not remote:
return run_git(["fetch", "--all", "--tags", "--prune"], timeout=180)
# Fetch by URL only — never run `git remote set-url` or other config writes.
return run_git(
[
"fetch",
"--tags",
"--prune",
remote,
"+refs/heads/*:refs/heads/*",
"+refs/tags/*:refs/tags/*",
],
timeout=180,
)
def list_tags():
ok, err, lines = run_ls_remote(["--tags"])
if not ok:
return [], err
tags = []
for line in lines:
parts = line.split()
if len(parts) < 2:
continue
ref = parts[1]
if not ref.startswith("refs/tags/"):
continue
tag = ref.removeprefix("refs/tags/")
if tag.endswith("^{}"):
continue
tags.append(tag)
tags = sorted(set(tags), reverse=True)
return tags, None
def list_branches():
ok, err, lines = run_ls_remote(["--heads"])
if not ok:
return [], err
branches = []
for line in lines:
parts = line.split()
if len(parts) < 2:
continue
ref = parts[1]
if ref.startswith("refs/heads/"):
branches.append(ref.removeprefix("refs/heads/"))
return sorted(set(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
+328
View File
@@ -0,0 +1,328 @@
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.quota_utils import check_folder_limit
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():
from app.quota_utils import quota_status
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,
quota=quota_status(current_user),
)
@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"))
ok, limit_msg = check_folder_limit(current_user)
if not ok:
flash(limit_msg, "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:
db.session.execute(
text(
"ALTER TABLE photos ADD COLUMN IF NOT EXISTS "
"folder_id INTEGER REFERENCES folders(id)"
)
)
db.session.commit()
+245 -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 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
class SiteSettings(db.Model):
__tablename__ = "site_settings"
id = db.Column(db.Integer, primary_key=True, default=1)
max_bulk_upload = db.Column(db.Integer, nullable=False, default=100)
s3_enabled = db.Column(db.Boolean, nullable=False, default=False)
s3_endpoint = db.Column(db.String(255), nullable=True)
s3_bucket = db.Column(db.String(120), nullable=True)
s3_access_key = db.Column(db.String(120), nullable=True)
s3_secret_key = db.Column(db.String(255), nullable=True)
s3_region = db.Column(db.String(80), nullable=True, default="us-east-1")
s3_public_url = db.Column(db.String(255), nullable=True)
sftp_enabled = db.Column(db.Boolean, nullable=False, default=False)
sftp_host = db.Column(db.String(255), nullable=True)
sftp_port = db.Column(db.Integer, nullable=False, default=22)
sftp_username = db.Column(db.String(120), nullable=True)
sftp_password = db.Column(db.String(255), nullable=True)
sftp_remote_path = db.Column(db.String(255), nullable=True, default="/uploads")
ftp_enabled = db.Column(db.Boolean, nullable=False, default=False)
ftp_host = db.Column(db.String(255), nullable=True)
ftp_port = db.Column(db.Integer, nullable=False, default=21)
ftp_username = db.Column(db.String(120), nullable=True)
ftp_password = db.Column(db.String(255), nullable=True)
ftp_remote_path = db.Column(db.String(255), nullable=True, default="/uploads")
ftp_use_tls = db.Column(db.Boolean, nullable=False, default=False)
smtp_enabled = db.Column(db.Boolean, nullable=False, default=False)
smtp_host = db.Column(db.String(255), nullable=True)
smtp_port = db.Column(db.Integer, nullable=False, default=587)
smtp_username = db.Column(db.String(120), nullable=True)
smtp_password = db.Column(db.String(255), nullable=True)
smtp_from_email = db.Column(db.String(120), nullable=True)
smtp_from_name = db.Column(db.String(120), nullable=True, default="PhotoHost")
smtp_use_tls = db.Column(db.Boolean, nullable=False, default=True)
updated_at = db.Column(
db.DateTime,
nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
class PasswordResetToken(db.Model):
__tablename__ = "password_reset_tokens"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
token = db.Column(db.String(64), unique=True, nullable=False, index=True)
expires_at = db.Column(db.DateTime, nullable=False)
used = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(
db.DateTime,
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
user = db.relationship("User", backref="reset_tokens")
@staticmethod
def create_for_user(user, hours=24):
token = PasswordResetToken(
user_id=user.id,
token=uuid.uuid4().hex,
expires_at=datetime.now(timezone.utc) + timedelta(hours=hours),
)
return token
def is_valid(self):
now = datetime.now(timezone.utc)
expires = self.expires_at
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
return not self.used and expires > now
class User(UserMixin, db.Model):
__tablename__ = "users"
@@ -15,6 +95,7 @@ class User(UserMixin, db.Model):
password_hash = db.Column(db.String(256), nullable=False)
is_admin = db.Column(db.Boolean, nullable=False, default=False)
is_active = db.Column(db.Boolean, nullable=False, default=True)
group_id = db.Column(db.Integer, db.ForeignKey("user_groups.id"), nullable=True, index=True)
created_at = db.Column(
db.DateTime,
nullable=False,
@@ -22,6 +103,8 @@ class User(UserMixin, db.Model):
)
photos = db.relationship("Photo", backref="owner", lazy="dynamic")
folders = db.relationship("Folder", backref="owner", lazy="dynamic")
group = db.relationship("UserGroup", backref="users")
def set_password(self, password):
self.password_hash = generate_password_hash(password)
@@ -43,6 +126,160 @@ class User(UserMixin, db.Model):
return int(result or 0)
class UserGroup(db.Model):
__tablename__ = "user_groups"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(80), unique=True, nullable=False)
slug = db.Column(db.String(80), unique=True, nullable=False, index=True)
disk_quota_mb = db.Column(db.Integer, nullable=False, default=100)
max_folders = db.Column(db.Integer, nullable=False, default=10)
max_photos = db.Column(db.Integer, nullable=False, default=500)
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} МБ"
@property
def folders_limit_label(self):
if self.max_folders == 0:
return "Без лимита"
return str(self.max_folders)
@property
def photos_limit_label(self):
if self.max_photos == 0:
return "Без лимита"
return str(self.max_photos)
class AdBanner(db.Model):
__tablename__ = "ad_banners"
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(120), nullable=False)
image_url = db.Column(db.String(500), nullable=False)
link_url = db.Column(db.String(500), nullable=True)
alt_text = db.Column(db.String(200), nullable=True)
position = db.Column(db.String(30), nullable=False, default="main", index=True)
is_active = db.Column(db.Boolean, nullable=False, default=True)
sort_order = db.Column(db.Integer, nullable=False, default=0)
created_at = db.Column(
db.DateTime,
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
POSITIONS = {
"main": "Главная (под hero)",
"cabinet": "Личный кабинет",
"sidebar": "Боковая колонка",
"footer": "Подвал",
}
@property
def position_label(self):
return self.POSITIONS.get(self.position, self.position)
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):
__tablename__ = "photos"
@@ -52,6 +289,8 @@ class Photo(db.Model):
file_size = db.Column(db.Integer, nullable=False, default=0)
mime_type = db.Column(db.String(100), nullable=False, default="image/jpeg")
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True)
folder_id = db.Column(db.Integer, db.ForeignKey("folders.id"), nullable=True, index=True)
storage_backend = db.Column(db.String(20), nullable=False, default="local")
created_at = db.Column(
db.DateTime,
nullable=False,
@@ -60,6 +299,11 @@ class Photo(db.Model):
@property
def url(self):
from app.settings_service import get_settings
settings = get_settings()
if self.storage_backend == "s3" and settings.s3_public_url:
return f"{settings.s3_public_url.rstrip('/')}/{self.filename}"
return f"/uploads/{self.filename}"
@property
+137
View File
@@ -0,0 +1,137 @@
from sqlalchemy import func
from app import db
from app.models import Folder, 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_user_photo_count(user_id):
return Photo.query.filter_by(user_id=user_id).count()
def get_user_folder_count(user_id):
return Folder.query.filter_by(owner_id=user_id).count()
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 _limit_reached(current, limit):
return limit > 0 and current >= limit
def check_folder_limit(user):
group = get_user_group(user)
if not group or group.max_folders == 0:
return True, ""
count = get_user_folder_count(user.id)
if _limit_reached(count, group.max_folders):
return False, (
f"Достигнут лимит папок группы «{group.name}»: "
f"{count} / {group.max_folders}"
)
return True, ""
def check_photo_count_limit(user, additional_count=1):
group = get_user_group(user)
if not group or group.max_photos == 0:
return True, ""
count = get_user_photo_count(user.id)
if count + additional_count > group.max_photos:
return False, (
f"Достигнут лимит фото группы «{group.name}»: "
f"{count} / {group.max_photos}"
)
return True, ""
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:
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)
photo_count = get_user_photo_count(user.id)
folder_count = get_user_folder_count(user.id)
quota_bytes = get_group_quota_bytes(group)
photos_unlimited = not group or group.max_photos == 0
folders_unlimited = not group or group.max_folders == 0
if quota_bytes is None:
return {
"group": group,
"used": used,
"quota_bytes": None,
"percent": 0,
"unlimited": True,
"photo_count": photo_count,
"photo_limit": group.max_photos if group else 0,
"photos_unlimited": photos_unlimited,
"photos_percent": 0,
"folder_count": folder_count,
"folder_limit": group.max_folders if group else 0,
"folders_unlimited": folders_unlimited,
"folders_percent": 0,
}
percent = min(100, int(used / quota_bytes * 100)) if quota_bytes else 0
photos_percent = (
min(100, int(photo_count / group.max_photos * 100))
if group and group.max_photos
else 0
)
folders_percent = (
min(100, int(folder_count / group.max_folders * 100))
if group and group.max_folders
else 0
)
return {
"group": group,
"used": used,
"quota_bytes": quota_bytes,
"percent": percent,
"unlimited": False,
"photo_count": photo_count,
"photo_limit": group.max_photos if group else 0,
"photos_unlimited": photos_unlimited,
"photos_percent": photos_percent,
"folder_count": folder_count,
"folder_limit": group.max_folders if group else 0,
"folders_unlimited": folders_unlimited,
"folders_percent": folders_percent,
}
+75 -47
View File
@@ -1,33 +1,41 @@
import os
import uuid
from datetime import datetime, timezone
from flask import (
Blueprint,
Response,
abort,
current_app,
flash,
jsonify,
redirect,
render_template,
request,
send_from_directory,
send_file,
url_for,
)
from flask_login import current_user, login_required
from werkzeug.utils import secure_filename
from app import db
from app.auth_utils import photo_owner_or_admin
from app.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
from sqlalchemy import text
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("/health")
def health():
try:
db.session.execute(text("SELECT 1"))
db.session.remove()
return Response("ok\n", mimetype="text/plain")
except Exception as exc:
db.session.remove()
return Response(f"error: {exc}\n", status=503, mimetype="text/plain")
@bp.route("/")
@@ -35,53 +43,57 @@ def index():
photos = Photo.query.order_by(Photo.created_at.desc()).limit(24).all()
total_photos = Photo.query.count()
total_size = db.session.query(db.func.coalesce(db.func.sum(Photo.file_size), 0)).scalar() or 0
settings = get_settings()
return render_template(
"index.html",
photos=photos,
total_photos=total_photos,
total_size=int(total_size),
max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024),
max_bulk_upload=settings.max_bulk_upload,
)
@bp.route("/upload", methods=["POST"])
@login_required
def upload():
if "photo" not in request.files:
flash("Файл не выбран", "error")
return redirect(request.referrer or url_for("main.index"))
folder_id = request.form.get("folder_id", type=int)
folder = None
if folder_id:
folder = Folder.query.get_or_404(folder_id)
if not can_edit_folder(folder):
abort(403)
file = request.files["photo"]
if file.filename == "":
flash("Файл не выбран", "error")
return redirect(request.referrer or url_for("main.index"))
if not allowed_file(file.filename):
flash("Недопустимый формат. Разрешены: PNG, JPG, GIF, WEBP, BMP", "error")
return redirect(request.referrer or url_for("main.index"))
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),
result = process_uploads(
request.files,
current_user,
folder,
current_app.config["ALLOWED_EXTENSIONS"],
)
db.session.add(photo)
db.session.commit()
flash("Фото успешно загружено", "success")
return redirect(url_for("cabinet.index"))
if result["uploaded"] == 0 and result["errors"]:
flash(result["errors"][0], "error")
elif result["uploaded"] == 1:
flash("Фото успешно загружено", "success")
elif result["uploaded"] > 1:
flash(f"Загружено {result['uploaded']} фото", "success")
for err in result["errors"]:
if result["uploaded"] > 0:
flash(err, "error")
if result["uploaded"] > 0:
from app.email_service import send_upload_notification
send_upload_notification(
current_user,
result["uploaded"],
folder.name if folder else None,
)
if folder:
return redirect(url_for("folders.view_folder", folder_id=folder.id))
return redirect(request.referrer or url_for("cabinet.index"))
@bp.route("/api/photos")
@@ -105,7 +117,15 @@ def api_photos():
@bp.route("/uploads/<path: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"])
@@ -114,9 +134,7 @@ def delete_photo(photo_id):
photo = Photo.query.get_or_404(photo_id)
photo_owner_or_admin(photo)
filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], photo.filename)
if os.path.exists(filepath):
os.remove(filepath)
delete_photo_file(photo.filename, photo.storage_backend)
db.session.delete(photo)
db.session.commit()
flash("Фото удалено", "success")
@@ -129,18 +147,28 @@ cabinet_bp = Blueprint("cabinet", __name__, url_prefix="/cabinet")
@cabinet_bp.route("/")
@login_required
def index():
from app.folder_utils import process_pending_invites
from app.quota_utils import quota_status
process_pending_invites(current_user)
photos = (
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())
.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)
quota = quota_status(current_user)
settings = get_settings()
return render_template(
"cabinet/index.html",
photos=photos,
folders=folders,
total_photos=len(photos),
total_size=total_size,
quota=quota,
max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024),
max_bulk_upload=settings.max_bulk_upload,
)
+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
+279
View File
@@ -881,3 +881,282 @@ body {
color: #fca5a5;
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;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
}
.quota-bar__limits {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
font-size: 0.8rem;
color: var(--text-muted);
}
.quota-bar__track--sm {
height: 6px;
margin-top: 8px;
}
.form-hint {
margin-top: 12px;
font-size: 0.85rem;
color: var(--text-muted);
}
.form-hint--warn {
color: #f97316;
}
.ad-banners {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 24px 0;
}
.ad-banners--main,
.ad-banners--cabinet {
padding-top: 0;
}
.ad-banners--footer {
padding-bottom: 0;
}
.ad-banner {
width: min(100%, 728px);
}
.ad-banner__link {
display: block;
border-radius: var(--radius-sm);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.ad-banner__link:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
.ad-banner__img {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.banner-preview {
display: flex;
align-items: center;
gap: 12px;
}
.banner-preview__img {
width: 120px;
height: 48px;
object-fit: cover;
border-radius: 6px;
border: 1px solid var(--border);
}
.banner-edit-form {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.banner-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.form-checkbox--inline {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
}
.badge--muted {
background: rgba(255, 255, 255, 0.08);
color: var(--text-muted);
}
.admin-table--groups td {
vertical-align: top;
}
+25 -10
View File
@@ -8,6 +8,8 @@ document.addEventListener("DOMContentLoaded", () => {
if (!dropzone || !photoInput) return;
const maxFiles = parseInt(photoInput.dataset.max || "100", 10);
dropzone.addEventListener("click", (e) => {
if (e.target.closest("button")) return;
photoInput.click();
@@ -28,30 +30,43 @@ document.addEventListener("DOMContentLoaded", () => {
});
dropzone.addEventListener("drop", (e) => {
const files = e.dataTransfer.files;
if (files.length > 0) {
photoInput.files = files;
showPreview(files[0]);
}
e.preventDefault();
assignFiles(e.dataTransfer.files);
});
photoInput.addEventListener("change", () => {
if (photoInput.files.length > 0) {
showPreview(photoInput.files[0]);
showPreview(photoInput.files);
}
});
function showPreview(file) {
if (!file.type.startsWith("image/")) return;
function assignFiles(fileList) {
const dt = new DataTransfer();
const limit = Math.min(fileList.length, maxFiles);
for (let i = 0; i < limit; i++) {
if (fileList[i].type.startsWith("image/")) {
dt.items.add(fileList[i]);
}
}
photoInput.files = dt.files;
showPreview(photoInput.files);
}
function showPreview(files) {
if (!files || files.length === 0) return;
const first = files[0];
const reader = new FileReader();
reader.onload = (e) => {
previewImg.src = e.target.result;
previewName.textContent = file.name;
previewName.textContent =
files.length === 1
? first.name
: `${files.length} файлов (первый: ${first.name})`;
preview.hidden = false;
submitBtn.disabled = false;
};
reader.readAsDataURL(file);
reader.readAsDataURL(first);
}
document.querySelectorAll(".copy-btn").forEach((btn) => {
+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)
+4
View File
@@ -1,5 +1,9 @@
<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.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.banners') }}" class="admin-nav__link {% if request.endpoint in ['admin.banners', 'admin.edit_banner', 'admin.delete_banner', 'admin.toggle_banner'] %}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>
+126
View File
@@ -0,0 +1,126 @@
{% 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">Баннеры на главной, в кабинете и в подвале сайта</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="title">Название (для админки)</label>
<input type="text" id="title" name="title" required minlength="2" placeholder="Промо лето">
</div>
<div class="form-group">
<label for="image_url">URL изображения</label>
<input type="url" id="image_url" name="image_url" required placeholder="https://example.com/banner.jpg">
</div>
<div class="form-group">
<label for="link_url">Ссылка при клике (необязательно)</label>
<input type="url" id="link_url" name="link_url" placeholder="https://example.com">
</div>
<div class="form-group">
<label for="alt_text">Alt-текст</label>
<input type="text" id="alt_text" name="alt_text" placeholder="Описание баннера">
</div>
<div class="form-group">
<label for="position">Позиция</label>
<select id="position" name="position" class="form-select">
{% for key, label in positions.items() %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="sort_order">Порядок (меньше — выше)</label>
<input type="number" id="sort_order" name="sort_order" value="0">
</div>
<label class="form-checkbox">
<input type="checkbox" name="is_active" checked>
<span>Активен</span>
</label>
<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>
</tr>
</thead>
<tbody>
{% for banner in banners %}
<tr>
<td>
<div class="banner-preview">
<img src="{{ banner.image_url }}" alt="" class="banner-preview__img">
<div>
<strong>{{ banner.title }}</strong>
{% if banner.link_url %}<br><small>{{ banner.link_url }}</small>{% endif %}
</div>
</div>
</td>
<td>{{ banner.position_label }}</td>
<td>
{% if banner.is_active %}
<span class="badge badge--success">активен</span>
{% else %}
<span class="badge badge--muted">выключен</span>
{% endif %}
</td>
<td>
<form method="post" action="{{ url_for('admin.edit_banner', banner_id=banner.id) }}" class="banner-edit-form">
<input type="text" name="title" value="{{ banner.title }}" required minlength="2" class="form-inline-input">
<input type="url" name="image_url" value="{{ banner.image_url }}" required class="form-inline-input">
<input type="url" name="link_url" value="{{ banner.link_url or '' }}" placeholder="Ссылка" class="form-inline-input">
<select name="position" class="form-select form-select--sm">
{% for key, label in positions.items() %}
<option value="{{ key }}" {% if banner.position == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<input type="number" name="sort_order" value="{{ banner.sort_order }}" class="form-inline-input form-inline-input--sm">
<label class="form-checkbox form-checkbox--inline">
<input type="checkbox" name="is_active" {% if banner.is_active %}checked{% endif %}>
<span>Активен</span>
</label>
<button type="submit" class="btn btn--ghost btn--sm">Сохранить</button>
</form>
<div class="banner-actions">
<form method="post" action="{{ url_for('admin.toggle_banner', banner_id=banner.id) }}">
<button type="submit" class="btn btn--ghost btn--sm">
{% if banner.is_active %}Выключить{% else %}Включить{% endif %}
</button>
</form>
<form method="post" action="{{ url_for('admin.delete_banner', banner_id=banner.id) }}" onsubmit="return confirm('Удалить баннер?');">
<button type="submit" class="btn btn--danger btn--sm">Удалить</button>
</form>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="4">Баннеров пока нет</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
+12
View File
@@ -29,12 +29,24 @@
<span class="stat-card__value">{{ stats.admins }}</span>
<span class="stat-card__label">администраторов</span>
</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">
<span class="stat-card__value">{{ format_size(stats.storage) }}</span>
<span class="stat-card__label">хранилище</span>
</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-panel">
<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 %}
+91
View File
@@ -0,0 +1,91 @@
{% 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-row">
<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>
<div class="form-group">
<label for="max_folders">Макс. папок (0 = без лимита)</label>
<input type="number" id="max_folders" name="max_folders" min="0" value="20">
</div>
<div class="form-group">
<label for="max_photos">Макс. фото (0 = без лимита)</label>
<input type="number" id="max_photos" name="max_photos" min="0" value="1000">
</div>
</div>
<button type="submit" class="btn btn--primary">Создать группу</button>
</form>
</div>
<div class="admin-table-wrap">
<table class="admin-table admin-table--groups">
<thead>
<tr>
<th>Группа</th>
<th>Квота</th>
<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>{{ item.folder_count }}{% if group.max_folders %} / {{ group.max_folders }}{% endif %}</td>
<td>{{ item.photo_count }}{% if group.max_photos %} / {{ group.max_photos }}{% endif %}</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" title="Название">
<input type="number" name="disk_quota_mb" value="{{ group.disk_quota_mb }}" min="0" class="form-inline-input form-inline-input--sm" title="Квота МБ">
<input type="number" name="max_folders" value="{{ group.max_folders }}" min="0" class="form-inline-input form-inline-input--sm" title="Макс. папок">
<input type="number" name="max_photos" value="{{ group.max_photos }}" min="0" class="form-inline-input form-inline-input--sm" title="Макс. фото">
<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>Логин</th>
<th>Email</th>
<th>Группа</th>
<th>Фото</th>
<th>Роль</th>
<th>Статус</th>
@@ -34,6 +35,15 @@
<td>{{ user.id }}</td>
<td>{{ user.username }}</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>
{% 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>
<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>
</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 %}
+5
View File
@@ -24,6 +24,7 @@
<a href="{{ url_for('main.index') }}" class="nav__link">Главная</a>
{% if current_user.is_authenticated %}
<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 %}
<a href="{{ url_for('admin.dashboard') }}" class="nav__link nav__link--admin">Админка</a>
{% endif %}
@@ -41,6 +42,10 @@
{% block content %}{% endblock %}
</main>
{% with banners=site_banners.get('footer', []), position='footer' %}
{% include "partials/banners.html" %}
{% endwith %}
<footer class="footer">
<div class="container footer__inner">
<p>PhotoHost — Python + PostgreSQL + Docker</p>
+91
View File
@@ -0,0 +1,91 @@
{% 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>
{% if quota and not quota.folders_unlimited and quota.folder_count >= quota.folder_limit %}
<p class="form-hint form-hint--warn">Достигнут лимит папок: {{ quota.folder_count }} / {{ quota.folder_limit }}</p>
{% else %}
<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>
{% endif %}
{% if quota %}
<p class="form-hint">
Лимит группы «{{ quota.group.name if quota.group else 'Пользователи' }}»:
папки {{ quota.folder_count }}{% if not quota.folders_unlimited %} / {{ quota.folder_limit }}{% else %} / без лимита{% endif %},
фото {{ quota.photo_count }}{% if not quota.photos_unlimited %} / {{ quota.photo_limit }}{% else %} / без лимита{% endif %}
</p>
{% endif %}
</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 %}
+81 -18
View File
@@ -9,6 +9,7 @@
<h1 class="page-header__title">Личный кабинет</h1>
<p class="page-header__subtitle">Привет, {{ current_user.username }}! Управляйте своими фотографиями.</p>
<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>
</div>
</div>
@@ -16,6 +17,10 @@
{% include "partials/alerts.html" %}
{% with banners=site_banners.get('cabinet', []), position='cabinet' %}
{% include "partials/banners.html" %}
{% endwith %}
<section class="stats-bar">
<div class="container stats">
<div class="stat-card">
@@ -31,32 +36,90 @@
<span class="stat-card__label">на файл</span>
</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 class="quota-bar__limits">
<span>
Папки:
{{ quota.folder_count }}
{% if not quota.folders_unlimited %}
/ {{ quota.folder_limit }}
{% else %}
/ без лимита
{% endif %}
</span>
<span>
Фото:
{{ quota.photo_count }}
{% if not quota.photos_unlimited %}
/ {{ quota.photo_limit }}
{% else %}
/ без лимита
{% endif %}
</span>
</div>
{% if not quota.folders_unlimited %}
<div class="quota-bar__track quota-bar__track--sm">
<div class="quota-bar__fill {% if quota.folders_percent > 90 %}quota-bar__fill--warn{% endif %}" style="width: {{ quota.folders_percent }}%"></div>
</div>
{% endif %}
{% if not quota.photos_unlimited %}
<div class="quota-bar__track quota-bar__track--sm">
<div class="quota-bar__fill {% if quota.photos_percent > 90 %}quota-bar__fill--warn{% endif %}" style="width: {{ quota.photos_percent }}%"></div>
</div>
{% endif %}
</div>
</div>
{% endif %}
</section>
<section id="upload" class="upload-section">
<div class="container">
<h2 class="section-title">Загрузить фото</h2>
<form action="{{ url_for('main.upload') }}" method="post" enctype="multipart/form-data" class="upload-form" id="uploadForm">
<div class="dropzone" id="dropzone">
<input type="file" name="photo" id="photoInput" accept="image/png,image/jpeg,image/gif,image/webp,image/bmp" 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">или нажмите для выбора файла</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>
{% with folder_id=None, max_bulk_upload=max_bulk_upload %}
{% include "partials/upload_form.html" %}
{% endwith %}
</div>
</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">
<div class="container">
<div class="gallery-header">
+8 -25
View File
@@ -38,7 +38,11 @@
</div>
{% endif %}
</div>
</section>
</section>
{% with banners=site_banners.get('main', []), position='main' %}
{% include "partials/banners.html" %}
{% endwith %}
{% include "partials/alerts.html" %}
@@ -46,30 +50,9 @@
<section id="upload" class="upload-section">
<div class="container">
<h2 class="section-title">Загрузить фото</h2>
<form action="{{ url_for('main.upload') }}" method="post" enctype="multipart/form-data" class="upload-form" id="uploadForm">
<div class="dropzone" id="dropzone">
<input type="file" name="photo" id="photoInput" accept="image/png,image/jpeg,image/gif,image/webp,image/bmp" 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">или нажмите для выбора файла</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>
{% with folder_id=None, max_bulk_upload=max_bulk_upload %}
{% include "partials/upload_form.html" %}
{% endwith %}
</div>
</section>
{% endif %}
+15
View File
@@ -0,0 +1,15 @@
{% if banners %}
<div class="ad-banners ad-banners--{{ position|default('default') }}">
{% for banner in banners %}
<div class="ad-banner">
{% if banner.link_url %}
<a href="{{ banner.link_url }}" class="ad-banner__link" target="_blank" rel="noopener sponsored">
<img src="{{ banner.image_url }}" alt="{{ banner.alt_text or banner.title }}" class="ad-banner__img" loading="lazy">
</a>
{% else %}
<img src="{{ banner.image_url }}" alt="{{ banner.alt_text or banner.title }}" class="ad-banner__img" loading="lazy">
{% endif %}
</div>
{% endfor %}
</div>
{% 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 %}
+100
View File
@@ -0,0 +1,100 @@
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_photo_count_limit, 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, photo_limit_msg = check_photo_count_limit(user, len(valid_files))
if not ok:
return {"uploaded": 0, "errors": [photo_limit_msg], "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,
}
+16
View File
@@ -29,11 +29,27 @@ services:
ADMIN_USERNAME: ${ADMIN_USERNAME:-}
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
DEFAULT_GROUP_QUOTA_MB: ${DEFAULT_GROUP_QUOTA_MB:-100}
GIT_REPO_PATH: /repo
GIT_REMOTE_URL: ${GIT_REMOTE_URL:-https://git.evilfox.cc/test2/fotohost.git}
ALLOW_GIT_DEPLOY: ${ALLOW_GIT_DEPLOY:-false}
CONTAINER_NAME: photohost-web
GIT_CONFIG_COUNT: "1"
GIT_CONFIG_KEY_0: safe.directory
GIT_CONFIG_VALUE_0: /repo
volumes:
- uploads_data:/app/uploads
- .:/repo
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5)"]
interval: 15s
timeout: 10s
retries: 8
start_period: 40s
volumes:
postgres_data:
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
set -e
# Git deploy needs write access to mounted /repo; never fail container start on chown errors.
if [ "$ALLOW_GIT_DEPLOY" = "true" ] || [ "$ALLOW_GIT_DEPLOY" = "1" ] || [ "$ALLOW_GIT_DEPLOY" = "yes" ]; then
if [ -d /repo/.git ]; then
chown -R appuser:appuser /repo 2>/dev/null || true
chmod -R u+rwX /repo/.git 2>/dev/null || true
elif [ -d /repo ]; then
chown -R appuser:appuser /repo 2>/dev/null || true
fi
fi
# Run DB migrations once before gunicorn workers start.
gosu appuser python /app/init_db.py || exit 1
export SKIP_DB_INIT=1
exec gosu appuser "$@"
+6
View File
@@ -0,0 +1,6 @@
bind = "0.0.0.0:8000"
workers = 2
timeout = 120
accesslog = "-"
errorlog = "-"
loglevel = "info"
+10
View File
@@ -0,0 +1,10 @@
import sys
try:
from app import create_app
create_app(setup_database=True)
print("Database init OK", flush=True)
except Exception as exc:
print(f"Database init FAILED: {exc}", file=sys.stderr, flush=True)
raise
+2
View File
@@ -6,3 +6,5 @@ gunicorn==23.0.0
Pillow==11.1.0
python-dotenv==1.0.1
Werkzeug==3.1.3
boto3==1.35.99
paramiko==3.5.1
+1 -1
View File
@@ -1,3 +1,3 @@
from app import create_app
app = create_app()
app = create_app(setup_database=False)