Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0584ebdc74 | |||
| 0a51001791 | |||
| d4f0eaa7d9 | |||
| 49abcc20b4 | |||
| b014e64c5d | |||
| e334a7b32c | |||
| 5353c82066 | |||
| 82fdb60f5e | |||
| 28eb9e19f5 | |||
| 69715ecd06 | |||
| 6a6704bc4b | |||
| d10f25eb06 | |||
| c1aac7ecac | |||
| db2cef41bb | |||
| a375ad330a |
@@ -13,3 +13,18 @@ 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
|
||||
|
||||
# WebAuthn / Passkey — можно задать в админке (Настройки → Passkey)
|
||||
# или через переменные окружения (админка имеет приоритет)
|
||||
WEBAUTHN_RP_ID=localhost
|
||||
WEBAUTHN_RP_NAME=PhotoHost
|
||||
WEBAUTHN_ORIGIN=http://localhost:8080
|
||||
|
||||
+12
-5
@@ -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"]
|
||||
|
||||
@@ -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,137 @@ sudo systemctl start docker
|
||||
|
||||
---
|
||||
|
||||
## Релиз v2.2
|
||||
|
||||
**Настройки авторизации в админке**
|
||||
|
||||
- Включение/отключение регистрации, входа по паролю и Passkey
|
||||
- Passkey RP ID, RP Name и Origin — без правки `.env`
|
||||
- Captcha: Cloudflare Turnstile, Google reCAPTCHA v2 и v3
|
||||
- Выбор страниц с captcha: вход, регистрация, сброс пароля
|
||||
|
||||
**Captcha**
|
||||
|
||||
- Один активный провайдер на сайт (настраивается в админке)
|
||||
- reCAPTCHA v3 — проверка score на сервере (порог 0–1)
|
||||
|
||||
**Обновление до v2.2 на сервере:**
|
||||
|
||||
```bash
|
||||
cd ~/fotohost
|
||||
git fetch --tags
|
||||
git checkout v2.2
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
После деплоя откройте **Админка → Настройки** и задайте Passkey (RP ID / Origin) и captcha.
|
||||
|
||||
---
|
||||
|
||||
## Релиз v2.1
|
||||
|
||||
**GDPR и cookies**
|
||||
|
||||
- Политика конфиденциальности: `/legal/privacy`
|
||||
- Политика cookies: `/legal/cookies`
|
||||
- GDPR-права: `/legal/gdpr`
|
||||
- Баннер согласия на cookies
|
||||
- Экспорт данных и удаление аккаунта в профиле
|
||||
|
||||
**Passkey (WebAuthn)**
|
||||
|
||||
- Регистрация passkey в профиле
|
||||
- Вход кнопкой «Войти с Passkey» на странице входа
|
||||
- Переменные `WEBAUTHN_RP_ID`, `WEBAUTHN_ORIGIN` в `.env`
|
||||
|
||||
**Управление сессиями**
|
||||
|
||||
- Список активных сессий в профиле (устройство, IP, время)
|
||||
- Завершение отдельной сессии или всех кроме текущей
|
||||
|
||||
**Обновлённая админка**
|
||||
|
||||
- Боковое меню, карточки статистики, улучшенная вёрстка
|
||||
|
||||
**Обновление до v2.1 на сервере:**
|
||||
|
||||
```bash
|
||||
cd ~/fotohost
|
||||
git fetch --tags
|
||||
git checkout v2.1
|
||||
# добавьте WEBAUTHN_RP_ID и WEBAUTHN_ORIGIN в .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Релиз v2.0
|
||||
|
||||
**Загрузка по прямым ссылкам**
|
||||
|
||||
- В форме загрузки вкладка «Ссылки»
|
||||
- Вставьте одну или несколько прямых URL на изображения (HTTP/HTTPS)
|
||||
- Поддерживаются те же форматы и лимиты, что и при обычной загрузке
|
||||
|
||||
**Поделиться: BBCode, HTML, QR**
|
||||
|
||||
- На каждой фотографии кнопки **Ссылка**, **BBCode** и **QR**
|
||||
- BBCode для форумов: `[img]https://...[/img]`
|
||||
- HTML для сайтов: `<img src="...">`
|
||||
- QR-код открывается в модальном окне с быстрым копированием всех форматов
|
||||
|
||||
**Обновление до v2.0 на сервере:**
|
||||
|
||||
```bash
|
||||
cd ~/fotohost
|
||||
git fetch --tags
|
||||
git checkout v2.0
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Релиз 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,7 +582,9 @@ python wsgi.py
|
||||
| POST | `/auth/login` | Вход |
|
||||
| GET | `/cabinet/` | Личный кабинет |
|
||||
| GET | `/admin/` | Админ-панель |
|
||||
| POST | `/upload` | Загрузка фото (auth) |
|
||||
| GET | `/admin/banners` | Управление рекламными баннерами |
|
||||
| GET | `/photo/<id>/qr` | QR-код для прямой ссылки на фото |
|
||||
| POST | `/upload` | Загрузка фото или по URL (auth) |
|
||||
| GET | `/uploads/<filename>` | Прямая ссылка на файл |
|
||||
| GET | `/api/photos` | JSON-список всех фото |
|
||||
| POST | `/delete/<id>` | Удаление фото |
|
||||
@@ -421,6 +636,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`
|
||||
|
||||
---
|
||||
|
||||
## Технологии
|
||||
|
||||
+106
-4
@@ -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,26 +50,120 @@ 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
|
||||
from .legal import bp as legal_bp
|
||||
from .passkey import bp as passkey_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)
|
||||
app.register_blueprint(legal_bp)
|
||||
app.register_blueprint(passkey_bp)
|
||||
|
||||
register_request_hooks(app)
|
||||
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,
|
||||
UserPasskey,
|
||||
UserSession,
|
||||
)
|
||||
|
||||
@app.context_processor
|
||||
def inject_banners():
|
||||
from app.banner_service import get_banners_by_position
|
||||
|
||||
try:
|
||||
return {"site_banners": get_banners_by_position()}
|
||||
except Exception:
|
||||
return {"site_banners": {}}
|
||||
|
||||
@app.context_processor
|
||||
def inject_auth_settings():
|
||||
from app.settings_service import get_auth_public_settings
|
||||
|
||||
try:
|
||||
return {"auth_settings": get_auth_public_settings()}
|
||||
except Exception:
|
||||
return {
|
||||
"auth_settings": {
|
||||
"registration_enabled": True,
|
||||
"password_login_enabled": True,
|
||||
"passkey_enabled": True,
|
||||
}
|
||||
}
|
||||
|
||||
@app.context_processor
|
||||
def inject_captcha():
|
||||
from flask import request
|
||||
|
||||
from app.captcha_service import get_captcha_config_for_request
|
||||
|
||||
try:
|
||||
return {"captcha_config": get_captcha_config_for_request(request.endpoint)}
|
||||
except Exception:
|
||||
return {"captcha_config": None}
|
||||
|
||||
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()
|
||||
from app.bootstrap import create_first_admin, ensure_schema
|
||||
run_schema_migrations()
|
||||
|
||||
ensure_schema()
|
||||
if os.getenv("SKIP_DB_INIT") != "1":
|
||||
ensure_default_group(app)
|
||||
ensure_site_settings(app)
|
||||
create_first_admin(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def register_request_hooks(app):
|
||||
@app.before_request
|
||||
def validate_tracked_session():
|
||||
from flask import flash, redirect, request, session, url_for
|
||||
from flask_login import current_user, logout_user
|
||||
|
||||
from app.session_service import ensure_user_session, touch_user_session, validate_user_session
|
||||
|
||||
if not current_user.is_authenticated:
|
||||
return None
|
||||
|
||||
endpoint = request.endpoint or ""
|
||||
if endpoint.startswith("static") or endpoint.startswith("passkey.") or endpoint.startswith("legal."):
|
||||
return None
|
||||
if endpoint in ("main.health",):
|
||||
return None
|
||||
|
||||
if not validate_user_session(current_user.id):
|
||||
if session.get("sid"):
|
||||
logout_user()
|
||||
flash("Сессия завершена. Войдите снова.", "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
ensure_user_session(current_user)
|
||||
else:
|
||||
touch_user_session()
|
||||
return None
|
||||
|
||||
|
||||
def register_cli(app):
|
||||
@app.cli.command("create-admin")
|
||||
def create_admin_command():
|
||||
|
||||
+278
-8
@@ -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)
|
||||
|
||||
+104
-3
@@ -2,17 +2,35 @@ 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.captcha_service import verify_captcha
|
||||
from app.session_service import create_user_session, revoke_current_session
|
||||
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
|
||||
from app.settings_service import get_auth_public_settings, get_settings
|
||||
|
||||
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||
|
||||
|
||||
def _registration_allowed():
|
||||
return get_settings().registration_enabled
|
||||
|
||||
|
||||
@bp.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("cabinet.index"))
|
||||
|
||||
if not _registration_allowed():
|
||||
flash("Регистрация новых пользователей отключена", "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
if request.method == "POST":
|
||||
ok, captcha_msg = verify_captcha(request, "register")
|
||||
if not ok:
|
||||
flash(captcha_msg, "error")
|
||||
return render_template("auth/register.html")
|
||||
|
||||
username = request.form.get("username", "").strip()
|
||||
email = request.form.get("email", "").strip().lower()
|
||||
password = request.form.get("password", "")
|
||||
@@ -29,12 +47,22 @@ 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)
|
||||
create_user_session(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")
|
||||
@@ -45,7 +73,18 @@ def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("cabinet.index"))
|
||||
|
||||
auth = get_auth_public_settings()
|
||||
if not auth["password_login_enabled"] and not auth["passkey_enabled"]:
|
||||
flash("Вход временно недоступен", "error")
|
||||
return render_template("auth/login.html")
|
||||
|
||||
if request.method == "POST":
|
||||
if auth["password_login_enabled"]:
|
||||
ok, captcha_msg = verify_captcha(request, "login")
|
||||
if not ok:
|
||||
flash(captcha_msg, "error")
|
||||
return render_template("auth/login.html")
|
||||
|
||||
login = request.form.get("login", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
remember = request.form.get("remember") == "on"
|
||||
@@ -54,13 +93,19 @@ def login():
|
||||
(User.username == login) | (User.email == login.lower())
|
||||
).first()
|
||||
|
||||
if user is None or not user.check_password(password):
|
||||
if not auth["password_login_enabled"]:
|
||||
flash("Вход по паролю отключён. Используйте Passkey.", "error")
|
||||
elif user is None or not user.check_password(password):
|
||||
flash("Неверный логин или пароль", "error")
|
||||
elif not user.is_active:
|
||||
flash("Аккаунт заблокирован", "error")
|
||||
else:
|
||||
login_user(user, remember=remember)
|
||||
create_user_session(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,8 +116,64 @@ 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":
|
||||
ok, captcha_msg = verify_captcha(request, "forgot_password")
|
||||
if not ok:
|
||||
flash(captcha_msg, "error")
|
||||
return render_template("auth/forgot_password.html")
|
||||
|
||||
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():
|
||||
revoke_current_session()
|
||||
logout_user()
|
||||
flash("Вы вышли из аккаунта", "success")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
+11
-1
@@ -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):
|
||||
|
||||
@@ -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
|
||||
+173
-5
@@ -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,13 +12,171 @@ 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)")
|
||||
text(
|
||||
"ALTER TABLE photos ADD COLUMN IF NOT EXISTS "
|
||||
"user_id INTEGER REFERENCES users(id)"
|
||||
)
|
||||
)
|
||||
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 ensure_site_settings_auth_columns():
|
||||
inspector = inspect(db.engine)
|
||||
if "site_settings" not in inspector.get_table_names():
|
||||
return
|
||||
columns = [
|
||||
"registration_enabled BOOLEAN NOT NULL DEFAULT TRUE",
|
||||
"password_login_enabled BOOLEAN NOT NULL DEFAULT TRUE",
|
||||
"passkey_enabled BOOLEAN NOT NULL DEFAULT TRUE",
|
||||
"webauthn_rp_id VARCHAR(255)",
|
||||
"webauthn_rp_name VARCHAR(120) DEFAULT 'PhotoHost'",
|
||||
"webauthn_origin VARCHAR(255)",
|
||||
"captcha_provider VARCHAR(20) NOT NULL DEFAULT 'none'",
|
||||
"turnstile_site_key VARCHAR(255)",
|
||||
"turnstile_secret_key VARCHAR(255)",
|
||||
"recaptcha_v2_site_key VARCHAR(255)",
|
||||
"recaptcha_v2_secret_key VARCHAR(255)",
|
||||
"recaptcha_v3_site_key VARCHAR(255)",
|
||||
"recaptcha_v3_secret_key VARCHAR(255)",
|
||||
"recaptcha_v3_min_score DOUBLE PRECISION NOT NULL DEFAULT 0.5",
|
||||
"captcha_on_login BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
"captcha_on_register BOOLEAN NOT NULL DEFAULT TRUE",
|
||||
"captcha_on_forgot_password BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
]
|
||||
for column in columns:
|
||||
name = column.split()[0]
|
||||
db.session.execute(
|
||||
text(f"ALTER TABLE site_settings ADD COLUMN IF NOT EXISTS {name} {column[len(name) + 1:]}")
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def ensure_user_privacy_columns():
|
||||
inspector = inspect(db.engine)
|
||||
if "users" not in inspector.get_table_names():
|
||||
return
|
||||
db.session.execute(
|
||||
text(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS "
|
||||
"gdpr_accepted_at TIMESTAMP WITH TIME ZONE"
|
||||
)
|
||||
)
|
||||
db.session.execute(
|
||||
text(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS "
|
||||
"cookie_analytics BOOLEAN NOT NULL DEFAULT FALSE"
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def run_schema_migrations():
|
||||
ensure_schema()
|
||||
ensure_group_limit_columns()
|
||||
ensure_site_settings_auth_columns()
|
||||
ensure_user_privacy_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):
|
||||
username = os.getenv("ADMIN_USERNAME", "").strip()
|
||||
@@ -31,15 +190,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()
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import requests
|
||||
|
||||
from app.settings_service import get_settings
|
||||
|
||||
CAPTCHA_PROVIDERS = ("none", "turnstile", "recaptcha_v2", "recaptcha_v3")
|
||||
|
||||
ENDPOINT_PAGE_MAP = {
|
||||
"auth.login": "login",
|
||||
"auth.register": "register",
|
||||
"auth.forgot_password": "forgot_password",
|
||||
}
|
||||
|
||||
|
||||
def get_captcha_config(page):
|
||||
settings = get_settings()
|
||||
provider = (settings.captcha_provider or "none").strip()
|
||||
if provider not in CAPTCHA_PROVIDERS or provider == "none":
|
||||
return None
|
||||
|
||||
page_flags = {
|
||||
"login": settings.captcha_on_login,
|
||||
"register": settings.captcha_on_register,
|
||||
"forgot_password": settings.captcha_on_forgot_password,
|
||||
}
|
||||
if not page_flags.get(page):
|
||||
return None
|
||||
|
||||
config = {"provider": provider, "page": page}
|
||||
if provider == "turnstile":
|
||||
config["site_key"] = (settings.turnstile_site_key or "").strip()
|
||||
elif provider == "recaptcha_v2":
|
||||
config["site_key"] = (settings.recaptcha_v2_site_key or "").strip()
|
||||
elif provider == "recaptcha_v3":
|
||||
config["site_key"] = (settings.recaptcha_v3_site_key or "").strip()
|
||||
config["action"] = page
|
||||
|
||||
if not config.get("site_key"):
|
||||
return None
|
||||
return config
|
||||
|
||||
|
||||
def get_captcha_config_for_request(endpoint):
|
||||
page = ENDPOINT_PAGE_MAP.get(endpoint)
|
||||
if not page:
|
||||
return None
|
||||
return get_captcha_config(page)
|
||||
|
||||
|
||||
def verify_captcha(request, page):
|
||||
config = get_captcha_config(page)
|
||||
if not config:
|
||||
return True, None
|
||||
|
||||
settings = get_settings()
|
||||
provider = config["provider"]
|
||||
|
||||
if provider == "turnstile":
|
||||
token = (request.form.get("cf-turnstile-response") or "").strip()
|
||||
secret = (settings.turnstile_secret_key or "").strip()
|
||||
if not token:
|
||||
return False, "Подтвердите captcha"
|
||||
if not secret:
|
||||
return False, "Captcha не настроена (нет secret key)"
|
||||
return _verify_turnstile(token, secret)
|
||||
|
||||
token = (request.form.get("g-recaptcha-response") or "").strip()
|
||||
if provider == "recaptcha_v2":
|
||||
secret = (settings.recaptcha_v2_secret_key or "").strip()
|
||||
if not token:
|
||||
return False, "Подтвердите captcha"
|
||||
if not secret:
|
||||
return False, "Captcha не настроена (нет secret key)"
|
||||
return _verify_recaptcha(token, secret)
|
||||
|
||||
secret = (settings.recaptcha_v3_secret_key or "").strip()
|
||||
if not token:
|
||||
return False, "Подтвердите captcha"
|
||||
if not secret:
|
||||
return False, "Captcha не настроена (нет secret key)"
|
||||
ok, msg, score = _verify_recaptcha_with_score(token, secret)
|
||||
if not ok:
|
||||
return False, msg
|
||||
min_score = settings.recaptcha_v3_min_score if settings.recaptcha_v3_min_score is not None else 0.5
|
||||
if score < min_score:
|
||||
return False, "Подозрительная активность, попробуйте снова"
|
||||
return True, None
|
||||
|
||||
|
||||
def _verify_turnstile(token, secret):
|
||||
try:
|
||||
response = requests.post(
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
data={"secret": secret, "response": token},
|
||||
timeout=10,
|
||||
)
|
||||
data = response.json()
|
||||
except requests.RequestException:
|
||||
return False, "Не удалось проверить captcha"
|
||||
|
||||
if data.get("success"):
|
||||
return True, None
|
||||
return False, "Captcha не пройдена"
|
||||
|
||||
|
||||
def _verify_recaptcha(token, secret):
|
||||
ok, msg, _score = _verify_recaptcha_with_score(token, secret)
|
||||
return ok, msg
|
||||
|
||||
|
||||
def _verify_recaptcha_with_score(token, secret):
|
||||
try:
|
||||
response = requests.post(
|
||||
"https://www.google.com/recaptcha/api/siteverify",
|
||||
data={"secret": secret, "response": token},
|
||||
timeout=10,
|
||||
)
|
||||
data = response.json()
|
||||
except requests.RequestException:
|
||||
return False, "Не удалось проверить captcha", 0.0
|
||||
|
||||
if data.get("success"):
|
||||
return True, None, float(data.get("score") or 1.0)
|
||||
return False, "Captcha не пройдена", 0.0
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
@@ -0,0 +1,85 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import Blueprint, jsonify, make_response, render_template, request, session
|
||||
|
||||
from app import db
|
||||
from app.models import Folder, Photo
|
||||
bp = Blueprint("legal", __name__, url_prefix="/legal")
|
||||
|
||||
|
||||
@bp.route("/privacy")
|
||||
def privacy():
|
||||
return render_template("legal/privacy.html")
|
||||
|
||||
|
||||
@bp.route("/cookies")
|
||||
def cookies():
|
||||
return render_template("legal/cookies.html")
|
||||
|
||||
|
||||
@bp.route("/gdpr")
|
||||
def gdpr():
|
||||
return render_template("legal/gdpr.html")
|
||||
|
||||
|
||||
@bp.route("/cookie-consent", methods=["POST"])
|
||||
def cookie_consent():
|
||||
data = request.get_json(silent=True) or {}
|
||||
essential = bool(data.get("essential", True))
|
||||
analytics = bool(data.get("analytics", False))
|
||||
|
||||
session["cookie_consent"] = {
|
||||
"essential": essential,
|
||||
"analytics": analytics,
|
||||
"accepted_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
from flask_login import current_user
|
||||
|
||||
if current_user.is_authenticated:
|
||||
current_user.cookie_analytics = analytics
|
||||
if not current_user.gdpr_accepted_at:
|
||||
current_user.gdpr_accepted_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
|
||||
response = make_response(jsonify({"ok": True}))
|
||||
response.set_cookie(
|
||||
"photohost_consent",
|
||||
f"1:{int(analytics)}",
|
||||
max_age=60 * 60 * 24 * 365,
|
||||
samesite="Lax",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def export_user_data(user):
|
||||
photos = Photo.query.filter_by(user_id=user.id).all()
|
||||
folders = Folder.query.filter_by(owner_id=user.id).all()
|
||||
return {
|
||||
"user": {
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
"gdpr_accepted_at": user.gdpr_accepted_at.isoformat()
|
||||
if user.gdpr_accepted_at
|
||||
else None,
|
||||
},
|
||||
"photos": [
|
||||
{
|
||||
"original_name": p.original_name,
|
||||
"url": p.url,
|
||||
"file_size": p.file_size,
|
||||
"created_at": p.created_at.isoformat(),
|
||||
}
|
||||
for p in photos
|
||||
],
|
||||
"folders": [
|
||||
{
|
||||
"name": f.name,
|
||||
"photo_count": f.photo_count,
|
||||
"created_at": f.created_at.isoformat(),
|
||||
}
|
||||
for f in folders
|
||||
],
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
+323
-1
@@ -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,104 @@ 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)
|
||||
|
||||
registration_enabled = db.Column(db.Boolean, nullable=False, default=True)
|
||||
password_login_enabled = db.Column(db.Boolean, nullable=False, default=True)
|
||||
passkey_enabled = db.Column(db.Boolean, nullable=False, default=True)
|
||||
webauthn_rp_id = db.Column(db.String(255), nullable=True)
|
||||
webauthn_rp_name = db.Column(db.String(120), nullable=True, default="PhotoHost")
|
||||
webauthn_origin = db.Column(db.String(255), nullable=True)
|
||||
|
||||
captcha_provider = db.Column(db.String(20), nullable=False, default="none")
|
||||
turnstile_site_key = db.Column(db.String(255), nullable=True)
|
||||
turnstile_secret_key = db.Column(db.String(255), nullable=True)
|
||||
recaptcha_v2_site_key = db.Column(db.String(255), nullable=True)
|
||||
recaptcha_v2_secret_key = db.Column(db.String(255), nullable=True)
|
||||
recaptcha_v3_site_key = db.Column(db.String(255), nullable=True)
|
||||
recaptcha_v3_secret_key = db.Column(db.String(255), nullable=True)
|
||||
recaptcha_v3_min_score = db.Column(db.Float, nullable=False, default=0.5)
|
||||
captcha_on_login = db.Column(db.Boolean, nullable=False, default=False)
|
||||
captcha_on_register = db.Column(db.Boolean, nullable=False, default=True)
|
||||
captcha_on_forgot_password = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
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 +114,9 @@ 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)
|
||||
gdpr_accepted_at = db.Column(db.DateTime, nullable=True)
|
||||
cookie_analytics = db.Column(db.Boolean, nullable=False, default=False)
|
||||
created_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
@@ -22,6 +124,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 +147,217 @@ class User(UserMixin, db.Model):
|
||||
return int(result or 0)
|
||||
|
||||
|
||||
class UserSession(db.Model):
|
||||
__tablename__ = "user_sessions"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
||||
session_key = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||
ip_address = db.Column(db.String(45), nullable=True)
|
||||
user_agent = db.Column(db.String(512), nullable=True)
|
||||
created_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
last_seen_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
revoked = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
user = db.relationship("User", backref=db.backref("sessions", lazy="dynamic"))
|
||||
|
||||
@property
|
||||
def device_label(self):
|
||||
if not self.user_agent:
|
||||
return "Неизвестное устройство"
|
||||
ua = self.user_agent.lower()
|
||||
if "mobile" in ua or "android" in ua or "iphone" in ua:
|
||||
return "Мобильное устройство"
|
||||
if "windows" in ua:
|
||||
return "Windows"
|
||||
if "mac" in ua:
|
||||
return "macOS"
|
||||
if "linux" in ua:
|
||||
return "Linux"
|
||||
return "Браузер"
|
||||
|
||||
|
||||
class UserPasskey(db.Model):
|
||||
__tablename__ = "user_passkeys"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
||||
credential_id = db.Column(db.String(512), unique=True, nullable=False, index=True)
|
||||
public_key = db.Column(db.Text, nullable=False)
|
||||
sign_count = db.Column(db.Integer, nullable=False, default=0)
|
||||
name = db.Column(db.String(120), nullable=False, default="Passkey")
|
||||
created_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
last_used_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
user = db.relationship("User", backref=db.backref("passkeys", lazy="dynamic"))
|
||||
|
||||
|
||||
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 +367,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 +377,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
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import json
|
||||
|
||||
from flask import Blueprint, flash, jsonify, redirect, request, url_for
|
||||
from flask_login import current_user, login_required, login_user
|
||||
|
||||
from app import db
|
||||
from app.passkey_service import (
|
||||
authentication_options,
|
||||
delete_passkey,
|
||||
is_passkey_enabled,
|
||||
registration_options,
|
||||
verify_authentication,
|
||||
verify_registration,
|
||||
)
|
||||
from webauthn.helpers.options_to_json import options_to_json
|
||||
|
||||
bp = Blueprint("passkey", __name__, url_prefix="/auth/passkey")
|
||||
|
||||
|
||||
@bp.route("/register/options", methods=["POST"])
|
||||
@login_required
|
||||
def register_options():
|
||||
if not is_passkey_enabled():
|
||||
return jsonify({"error": "Passkey отключён администратором"}), 403
|
||||
options = registration_options(current_user)
|
||||
return jsonify(json.loads(options_to_json(options)))
|
||||
|
||||
|
||||
@bp.route("/register/verify", methods=["POST"])
|
||||
@login_required
|
||||
def register_verify():
|
||||
if not is_passkey_enabled():
|
||||
return jsonify({"error": "Passkey отключён администратором"}), 403
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = data.get("name", "Passkey")
|
||||
credential = data.get("credential")
|
||||
if not credential:
|
||||
return jsonify({"error": "Нет данных passkey"}), 400
|
||||
try:
|
||||
passkey = verify_registration(current_user, credential, name)
|
||||
return jsonify({"ok": True, "id": passkey.id, "name": passkey.name})
|
||||
except Exception as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.route("/login/options", methods=["POST"])
|
||||
def login_options():
|
||||
if not is_passkey_enabled():
|
||||
return jsonify({"error": "Passkey отключён администратором"}), 403
|
||||
from app.models import User
|
||||
|
||||
username = (request.get_json(silent=True) or {}).get("username", "").strip()
|
||||
if not username:
|
||||
return jsonify({"error": "Укажите логин или email"}), 400
|
||||
|
||||
user = User.query.filter(
|
||||
(User.username == username) | (User.email == username.lower())
|
||||
).first()
|
||||
if not user or not user.is_active:
|
||||
return jsonify({"error": "Passkey не найден для этого аккаунта"}), 404
|
||||
|
||||
try:
|
||||
options = authentication_options(user)
|
||||
return jsonify(json.loads(options_to_json(options)))
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.route("/login/verify", methods=["POST"])
|
||||
def login_verify():
|
||||
if not is_passkey_enabled():
|
||||
return jsonify({"error": "Passkey отключён администратором"}), 403
|
||||
from app.folder_utils import process_pending_invites
|
||||
from app.session_service import create_user_session
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
credential = data.get("credential")
|
||||
remember = bool(data.get("remember"))
|
||||
if not credential:
|
||||
return jsonify({"error": "Нет данных passkey"}), 400
|
||||
|
||||
try:
|
||||
user = verify_authentication(credential)
|
||||
if not user.is_active:
|
||||
return jsonify({"error": "Аккаунт заблокирован"}), 403
|
||||
login_user(user, remember=remember)
|
||||
create_user_session(user, remember=remember)
|
||||
process_pending_invites(user)
|
||||
redirect_url = url_for("admin.dashboard") if user.is_admin else url_for("cabinet.index")
|
||||
return jsonify({"ok": True, "redirect": redirect_url})
|
||||
except Exception as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
@@ -0,0 +1,174 @@
|
||||
import base64
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import json
|
||||
|
||||
from flask import current_app, session
|
||||
from webauthn import (
|
||||
generate_authentication_options,
|
||||
generate_registration_options,
|
||||
verify_authentication_response,
|
||||
verify_registration_response,
|
||||
)
|
||||
from webauthn.helpers.parse_authentication_credential_json import (
|
||||
parse_authentication_credential_json,
|
||||
)
|
||||
from webauthn.helpers.parse_registration_credential_json import (
|
||||
parse_registration_credential_json,
|
||||
)
|
||||
from webauthn.helpers.structs import (
|
||||
AuthenticatorSelectionCriteria,
|
||||
PublicKeyCredentialDescriptor,
|
||||
ResidentKeyRequirement,
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
|
||||
from app import db
|
||||
from app.models import UserPasskey
|
||||
from app.settings_service import get_settings
|
||||
|
||||
|
||||
def is_passkey_enabled():
|
||||
settings = get_settings()
|
||||
return settings.passkey_enabled
|
||||
|
||||
|
||||
def _rp_id():
|
||||
settings = get_settings()
|
||||
if settings.webauthn_rp_id:
|
||||
return settings.webauthn_rp_id.strip()
|
||||
return os.getenv("WEBAUTHN_RP_ID", "localhost")
|
||||
|
||||
|
||||
def _rp_name():
|
||||
settings = get_settings()
|
||||
if settings.webauthn_rp_name:
|
||||
return settings.webauthn_rp_name.strip()
|
||||
return os.getenv("WEBAUTHN_RP_NAME", "PhotoHost")
|
||||
|
||||
|
||||
def _origin():
|
||||
settings = get_settings()
|
||||
if settings.webauthn_origin:
|
||||
return settings.webauthn_origin.strip()
|
||||
return os.getenv("WEBAUTHN_ORIGIN", "http://localhost:8080")
|
||||
|
||||
|
||||
def _b64url_encode(data):
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8")
|
||||
|
||||
|
||||
def _b64url_decode(data):
|
||||
padding = "=" * (-len(data) % 4)
|
||||
return base64.urlsafe_b64decode(data + padding)
|
||||
|
||||
|
||||
def registration_options(user):
|
||||
existing = UserPasskey.query.filter_by(user_id=user.id).all()
|
||||
exclude = [
|
||||
PublicKeyCredentialDescriptor(id=_b64url_decode(item.credential_id))
|
||||
for item in existing
|
||||
]
|
||||
|
||||
options = generate_registration_options(
|
||||
rp_id=_rp_id(),
|
||||
rp_name=_rp_name(),
|
||||
user_id=str(user.id).encode("utf-8"),
|
||||
user_name=user.username,
|
||||
user_display_name=user.username,
|
||||
exclude_credentials=exclude,
|
||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||
resident_key=ResidentKeyRequirement.PREFERRED,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
),
|
||||
)
|
||||
session["passkey_reg_challenge"] = _b64url_encode(options.challenge)
|
||||
return options
|
||||
|
||||
|
||||
def verify_registration(user, credential_json, name):
|
||||
challenge = session.pop("passkey_reg_challenge", None)
|
||||
if not challenge:
|
||||
raise ValueError("Сессия регистрации passkey истекла")
|
||||
|
||||
verification = verify_registration_response(
|
||||
credential=parse_registration_credential_json(json.dumps(credential_json)),
|
||||
expected_challenge=_b64url_decode(challenge),
|
||||
expected_rp_id=_rp_id(),
|
||||
expected_origin=_origin(),
|
||||
)
|
||||
|
||||
passkey = UserPasskey(
|
||||
user_id=user.id,
|
||||
credential_id=credential_json.get("id") or _b64url_encode(verification.credential_id),
|
||||
public_key=_b64url_encode(verification.credential_public_key),
|
||||
sign_count=verification.sign_count,
|
||||
name=(name or "Passkey").strip()[:120] or "Passkey",
|
||||
)
|
||||
db.session.add(passkey)
|
||||
db.session.commit()
|
||||
return passkey
|
||||
|
||||
|
||||
def authentication_options(user):
|
||||
passkeys = UserPasskey.query.filter_by(user_id=user.id).all()
|
||||
if not passkeys:
|
||||
raise ValueError("Passkey не настроен")
|
||||
|
||||
allow = [
|
||||
PublicKeyCredentialDescriptor(id=_b64url_decode(item.credential_id))
|
||||
for item in passkeys
|
||||
]
|
||||
options = generate_authentication_options(
|
||||
rp_id=_rp_id(),
|
||||
allow_credentials=allow,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
)
|
||||
session["passkey_auth_challenge"] = _b64url_encode(options.challenge)
|
||||
session["passkey_auth_user_id"] = user.id
|
||||
return options
|
||||
|
||||
|
||||
def verify_authentication(credential_json):
|
||||
challenge = session.pop("passkey_auth_challenge", None)
|
||||
user_id = session.pop("passkey_auth_user_id", None)
|
||||
if not challenge or not user_id:
|
||||
raise ValueError("Сессия входа passkey истекла")
|
||||
|
||||
credential_id = credential_json.get("id") or credential_json.get("rawId")
|
||||
if isinstance(credential_id, dict):
|
||||
credential_id = credential_id.get("id")
|
||||
if not credential_id:
|
||||
raise ValueError("Некорректные данные passkey")
|
||||
|
||||
passkey = UserPasskey.query.filter_by(
|
||||
user_id=user_id, credential_id=credential_id
|
||||
).first()
|
||||
if not passkey:
|
||||
passkey = UserPasskey.query.filter_by(credential_id=credential_id).first()
|
||||
if not passkey:
|
||||
raise ValueError("Passkey не найден")
|
||||
|
||||
verification = verify_authentication_response(
|
||||
credential=parse_authentication_credential_json(json.dumps(credential_json)),
|
||||
expected_challenge=_b64url_decode(challenge),
|
||||
expected_rp_id=_rp_id(),
|
||||
expected_origin=_origin(),
|
||||
credential_public_key=_b64url_decode(passkey.public_key),
|
||||
credential_current_sign_count=passkey.sign_count,
|
||||
)
|
||||
|
||||
passkey.sign_count = verification.new_sign_count
|
||||
passkey.last_used_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
return passkey.user
|
||||
|
||||
|
||||
def delete_passkey(user, passkey_id):
|
||||
passkey = UserPasskey.query.filter_by(id=passkey_id, user_id=user.id).first()
|
||||
if not passkey:
|
||||
return False
|
||||
db.session.delete(passkey)
|
||||
db.session.commit()
|
||||
return True
|
||||
@@ -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,
|
||||
}
|
||||
+194
-47
@@ -1,33 +1,42 @@
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
Response,
|
||||
abort,
|
||||
current_app,
|
||||
flash,
|
||||
jsonify,
|
||||
make_response,
|
||||
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, process_url_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 +44,69 @@ 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"))
|
||||
image_urls = request.form.get("image_urls", "").strip()
|
||||
max_upload_mb = current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024)
|
||||
|
||||
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),
|
||||
if image_urls:
|
||||
result = process_url_uploads(
|
||||
image_urls,
|
||||
current_user,
|
||||
folder,
|
||||
current_app.config["ALLOWED_EXTENSIONS"],
|
||||
max_upload_mb,
|
||||
)
|
||||
else:
|
||||
result = process_uploads(
|
||||
request.files,
|
||||
current_user,
|
||||
folder,
|
||||
current_app.config["ALLOWED_EXTENSIONS"],
|
||||
)
|
||||
db.session.add(photo)
|
||||
db.session.commit()
|
||||
|
||||
if result["uploaded"] == 0 and result["errors"]:
|
||||
flash(result["errors"][0], "error")
|
||||
elif result["uploaded"] == 1:
|
||||
flash("Фото успешно загружено", "success")
|
||||
return redirect(url_for("cabinet.index"))
|
||||
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")
|
||||
@@ -103,9 +128,39 @@ def api_photos():
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/photo/<int:photo_id>/qr")
|
||||
def photo_qr(photo_id):
|
||||
import io
|
||||
|
||||
import qrcode
|
||||
|
||||
from app.share_utils import photo_absolute_url
|
||||
|
||||
photo = Photo.query.get_or_404(photo_id)
|
||||
target = photo_absolute_url(photo, request.url_root)
|
||||
|
||||
qr = qrcode.QRCode(box_size=8, border=2)
|
||||
qr.add_data(target)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
buf.seek(0)
|
||||
return send_file(buf, mimetype="image/png", download_name=f"photo-{photo.id}-qr.png")
|
||||
|
||||
|
||||
@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 +169,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,27 +182,72 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@cabinet_bp.route("/profile", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def profile():
|
||||
from app.models import User
|
||||
from app.models import User, UserPasskey
|
||||
from app.passkey_service import delete_passkey
|
||||
from app.session_service import (
|
||||
get_current_session_key,
|
||||
list_user_sessions,
|
||||
revoke_all_sessions,
|
||||
revoke_session,
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
action = request.form.get("action", "save")
|
||||
|
||||
if action == "revoke_session":
|
||||
session_id = request.form.get("session_id", type=int)
|
||||
if session_id and revoke_session(session_id, current_user.id):
|
||||
flash("Сессия завершена", "success")
|
||||
return redirect(url_for("cabinet.profile"))
|
||||
|
||||
if action == "revoke_all_sessions":
|
||||
count = revoke_all_sessions(current_user.id, except_current=True)
|
||||
flash(f"Завершено сессий: {count}", "success")
|
||||
return redirect(url_for("cabinet.profile"))
|
||||
|
||||
if action == "delete_passkey":
|
||||
passkey_id = request.form.get("passkey_id", type=int)
|
||||
if passkey_id and delete_passkey(current_user, passkey_id):
|
||||
flash("Passkey удалён", "success")
|
||||
return redirect(url_for("cabinet.profile"))
|
||||
|
||||
if action == "delete_account":
|
||||
password = request.form.get("delete_password", "")
|
||||
if not current_user.check_password(password):
|
||||
flash("Неверный пароль", "error")
|
||||
else:
|
||||
_delete_user_account(current_user)
|
||||
flash("Аккаунт удалён", "success")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
email = request.form.get("email", "").strip().lower()
|
||||
current_password = request.form.get("current_password", "")
|
||||
new_password = request.form.get("new_password", "")
|
||||
@@ -172,4 +270,53 @@ def profile():
|
||||
flash("Профиль обновлён", "success")
|
||||
return redirect(url_for("cabinet.profile"))
|
||||
|
||||
return render_template("cabinet/profile.html")
|
||||
sessions = list_user_sessions(current_user.id)
|
||||
passkeys = current_user.passkeys.order_by(UserPasskey.created_at.desc()).all()
|
||||
current_sid = get_current_session_key()
|
||||
return render_template(
|
||||
"cabinet/profile.html",
|
||||
sessions=sessions,
|
||||
passkeys=passkeys,
|
||||
current_sid=current_sid,
|
||||
)
|
||||
|
||||
|
||||
@cabinet_bp.route("/profile/export")
|
||||
@login_required
|
||||
def export_profile():
|
||||
from app.legal import export_user_data
|
||||
import json
|
||||
|
||||
data = export_user_data(current_user)
|
||||
response = make_response(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
response.headers["Content-Disposition"] = (
|
||||
f'attachment; filename="photohost-{current_user.username}.json"'
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def _delete_user_account(user):
|
||||
from app.models import UserPasskey, UserSession
|
||||
from app.session_service import revoke_all_sessions, revoke_current_session
|
||||
from app.storage_service import delete_photo_file
|
||||
from flask_login import logout_user
|
||||
|
||||
revoke_all_sessions(user.id, except_current=False)
|
||||
UserSession.query.filter_by(user_id=user.id).delete()
|
||||
UserPasskey.query.filter_by(user_id=user.id).delete()
|
||||
|
||||
for photo in user.photos.all():
|
||||
delete_photo_file(photo.filename, photo.storage_backend)
|
||||
db.session.delete(photo)
|
||||
|
||||
for folder in user.folders.all():
|
||||
for photo in folder.photos.all():
|
||||
delete_photo_file(photo.filename, photo.storage_backend)
|
||||
db.session.delete(photo)
|
||||
db.session.delete(folder)
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
revoke_current_session()
|
||||
logout_user()
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import request, session
|
||||
|
||||
from app import db
|
||||
from app.models import UserSession
|
||||
|
||||
|
||||
def get_current_session_key():
|
||||
return session.get("sid")
|
||||
|
||||
|
||||
def create_user_session(user, remember=False):
|
||||
session_key = secrets.token_hex(32)
|
||||
record = UserSession(
|
||||
user_id=user.id,
|
||||
session_key=session_key,
|
||||
ip_address=_client_ip(),
|
||||
user_agent=_client_user_agent(),
|
||||
)
|
||||
db.session.add(record)
|
||||
db.session.commit()
|
||||
session["sid"] = session_key
|
||||
session.permanent = bool(remember)
|
||||
return record
|
||||
|
||||
|
||||
def ensure_user_session(user):
|
||||
key = get_current_session_key()
|
||||
if not key:
|
||||
return create_user_session(user)
|
||||
|
||||
record = UserSession.query.filter_by(
|
||||
session_key=key, user_id=user.id, revoked=False
|
||||
).first()
|
||||
if record:
|
||||
return record
|
||||
return create_user_session(user)
|
||||
|
||||
|
||||
def validate_user_session(user_id):
|
||||
key = get_current_session_key()
|
||||
if not key:
|
||||
return False
|
||||
return (
|
||||
UserSession.query.filter_by(
|
||||
session_key=key, user_id=user_id, revoked=False
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def touch_user_session():
|
||||
key = get_current_session_key()
|
||||
if not key:
|
||||
return
|
||||
record = UserSession.query.filter_by(session_key=key, revoked=False).first()
|
||||
if record:
|
||||
record.last_seen_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def list_user_sessions(user_id):
|
||||
return (
|
||||
UserSession.query.filter_by(user_id=user_id, revoked=False)
|
||||
.order_by(UserSession.last_seen_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def revoke_session(session_id, user_id):
|
||||
record = UserSession.query.filter_by(id=session_id, user_id=user_id).first()
|
||||
if not record:
|
||||
return False
|
||||
record.revoked = True
|
||||
db.session.commit()
|
||||
return True
|
||||
|
||||
|
||||
def revoke_all_sessions(user_id, except_current=True):
|
||||
current_key = get_current_session_key() if except_current else None
|
||||
query = UserSession.query.filter_by(user_id=user_id, revoked=False)
|
||||
if current_key:
|
||||
query = query.filter(UserSession.session_key != current_key)
|
||||
count = query.update({"revoked": True})
|
||||
db.session.commit()
|
||||
return count
|
||||
|
||||
|
||||
def revoke_current_session():
|
||||
key = get_current_session_key()
|
||||
if not key:
|
||||
return
|
||||
UserSession.query.filter_by(session_key=key).update({"revoked": True})
|
||||
db.session.commit()
|
||||
session.pop("sid", None)
|
||||
|
||||
|
||||
def _client_ip():
|
||||
forwarded = request.headers.get("X-Forwarded-For", "")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.remote_addr
|
||||
|
||||
|
||||
def _client_user_agent():
|
||||
if request.user_agent and request.user_agent.string:
|
||||
return request.user_agent.string[:512]
|
||||
return None
|
||||
@@ -0,0 +1,93 @@
|
||||
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"
|
||||
|
||||
settings.registration_enabled = form.get("registration_enabled") == "on"
|
||||
settings.password_login_enabled = form.get("password_login_enabled") == "on"
|
||||
settings.passkey_enabled = form.get("passkey_enabled") == "on"
|
||||
settings.webauthn_rp_id = form.get("webauthn_rp_id", "").strip() or None
|
||||
settings.webauthn_rp_name = form.get("webauthn_rp_name", "").strip() or "PhotoHost"
|
||||
settings.webauthn_origin = form.get("webauthn_origin", "").strip() or None
|
||||
|
||||
provider = form.get("captcha_provider", "none").strip()
|
||||
if provider not in ("none", "turnstile", "recaptcha_v2", "recaptcha_v3"):
|
||||
provider = "none"
|
||||
settings.captcha_provider = provider
|
||||
settings.turnstile_site_key = form.get("turnstile_site_key", "").strip() or None
|
||||
if form.get("turnstile_secret_key", "").strip():
|
||||
settings.turnstile_secret_key = form.get("turnstile_secret_key", "").strip()
|
||||
settings.recaptcha_v2_site_key = form.get("recaptcha_v2_site_key", "").strip() or None
|
||||
if form.get("recaptcha_v2_secret_key", "").strip():
|
||||
settings.recaptcha_v2_secret_key = form.get("recaptcha_v2_secret_key", "").strip()
|
||||
settings.recaptcha_v3_site_key = form.get("recaptcha_v3_site_key", "").strip() or None
|
||||
if form.get("recaptcha_v3_secret_key", "").strip():
|
||||
settings.recaptcha_v3_secret_key = form.get("recaptcha_v3_secret_key", "").strip()
|
||||
try:
|
||||
settings.recaptcha_v3_min_score = max(0.0, min(1.0, float(form.get("recaptcha_v3_min_score") or 0.5)))
|
||||
except ValueError:
|
||||
settings.recaptcha_v3_min_score = 0.5
|
||||
settings.captcha_on_login = form.get("captcha_on_login") == "on"
|
||||
settings.captcha_on_register = form.get("captcha_on_register") == "on"
|
||||
settings.captcha_on_forgot_password = form.get("captcha_on_forgot_password") == "on"
|
||||
|
||||
db.session.commit()
|
||||
return settings
|
||||
|
||||
|
||||
def get_auth_public_settings():
|
||||
settings = get_settings()
|
||||
return {
|
||||
"registration_enabled": settings.registration_enabled,
|
||||
"password_login_enabled": settings.password_login_enabled,
|
||||
"passkey_enabled": settings.passkey_enabled,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
def photo_absolute_url(photo, base_url):
|
||||
return f"{base_url.rstrip('/')}{photo.url}"
|
||||
|
||||
|
||||
def photo_bbcode(photo, base_url):
|
||||
return f"[img]{photo_absolute_url(photo, base_url)}[/img]"
|
||||
|
||||
|
||||
def photo_html(photo, base_url):
|
||||
url = photo_absolute_url(photo, base_url)
|
||||
name = photo.original_name.replace('"', """)
|
||||
return f'<img src="{url}" alt="{name}">'
|
||||
+736
-1
@@ -420,7 +420,9 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
@@ -881,3 +883,736 @@ 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;
|
||||
}
|
||||
|
||||
.upload-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.upload-tabs__btn {
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-radius: 999px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.upload-tabs__btn--active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.upload-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-panel--active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.url-upload {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.url-upload__label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.url-upload__input {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-card);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.url-upload__hint {
|
||||
margin-top: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.share-modal[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.share-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.share-modal__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.share-modal__dialog {
|
||||
position: relative;
|
||||
width: min(100%, 520px);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.share-modal__close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.share-modal__title {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.share-modal__name {
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.share-modal__qr-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.share-modal__qr {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.share-field {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.share-field label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.share-field__row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.share-field__row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: inherit;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
body.modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
min-height: calc(100vh - 72px);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
background: rgba(10, 12, 20, 0.95);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 24px 16px;
|
||||
position: sticky;
|
||||
top: 72px;
|
||||
height: calc(100vh - 72px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-sidebar__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.admin-sidebar__head strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-sidebar__head span {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.admin-sidebar__logo {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-sidebar__back {
|
||||
margin-top: auto;
|
||||
padding: 12px 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
padding: 28px 32px 48px;
|
||||
}
|
||||
|
||||
.admin-main__header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-main__title {
|
||||
font-size: 1.75rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.admin-main__subtitle {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-nav__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.admin-nav__link:hover,
|
||||
.admin-nav__link--active {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-nav__icon {
|
||||
width: 22px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-stats--cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-stat-card {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.12), rgba(15, 23, 42, 0.8));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.admin-stat-card--accent {
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(15, 23, 42, 0.8));
|
||||
}
|
||||
|
||||
.admin-stat-card__value {
|
||||
display: block;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-stat-card__label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.admin-panel--elevated {
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.admin-version-bar {
|
||||
margin-bottom: 24px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.profile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.profile-card__title {
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.profile-card__hint,
|
||||
.profile-card__empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.session-item--current {
|
||||
background: rgba(34, 197, 94, 0.06);
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.session-item__meta {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.profile-delete-form {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.profile-footer {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.legal-section {
|
||||
padding: 48px 0 80px;
|
||||
}
|
||||
|
||||
.legal-container {
|
||||
max-width: 820px;
|
||||
}
|
||||
|
||||
.legal-container h1 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.legal-updated {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.legal-container h2 {
|
||||
margin: 28px 0 12px;
|
||||
}
|
||||
|
||||
.legal-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.legal-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.cookie-banner {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1100;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.cookie-banner[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cookie-banner__inner {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 20px;
|
||||
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.cookie-banner__text p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.cookie-banner__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footer__links {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.footer__links a {
|
||||
color: var(--text-muted);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.admin-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
position: static;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cookie-banner__inner {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const banner = document.getElementById("cookieBanner");
|
||||
const acceptBtn = document.getElementById("cookieAcceptBtn");
|
||||
const rejectBtn = document.getElementById("cookieRejectBtn");
|
||||
if (!banner) return;
|
||||
|
||||
const consent = localStorage.getItem("photohost_cookie_consent");
|
||||
if (!consent) {
|
||||
banner.hidden = false;
|
||||
}
|
||||
|
||||
async function saveConsent(analytics) {
|
||||
await fetch("/legal/cookie-consent", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ essential: true, analytics }),
|
||||
});
|
||||
localStorage.setItem("photohost_cookie_consent", analytics ? "all" : "essential");
|
||||
banner.hidden = true;
|
||||
}
|
||||
|
||||
acceptBtn?.addEventListener("click", () => saveConsent(true));
|
||||
rejectBtn?.addEventListener("click", () => saveConsent(false));
|
||||
});
|
||||
+132
-22
@@ -1,13 +1,36 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initUploadForm();
|
||||
initCopyButtons();
|
||||
initShareModal();
|
||||
});
|
||||
|
||||
function initUploadForm() {
|
||||
const dropzone = document.getElementById("dropzone");
|
||||
const photoInput = document.getElementById("photoInput");
|
||||
const preview = document.getElementById("preview");
|
||||
const previewImg = document.getElementById("previewImg");
|
||||
const previewName = document.getElementById("previewName");
|
||||
const submitBtn = document.getElementById("submitBtn");
|
||||
const uploadForm = document.getElementById("uploadForm");
|
||||
const tabButtons = document.querySelectorAll(".upload-tabs__btn");
|
||||
const panels = document.querySelectorAll(".upload-panel");
|
||||
|
||||
if (!uploadForm) return;
|
||||
|
||||
tabButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const tab = btn.dataset.tab;
|
||||
tabButtons.forEach((item) => item.classList.toggle("upload-tabs__btn--active", item === btn));
|
||||
panels.forEach((panel) => {
|
||||
panel.classList.toggle("upload-panel--active", panel.dataset.panel === tab);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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,51 +51,138 @@ 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;
|
||||
uploadForm.addEventListener("submit", (e) => {
|
||||
const activePanel = document.querySelector(".upload-panel--active");
|
||||
if (!activePanel) return;
|
||||
|
||||
if (activePanel.dataset.panel === "urls") {
|
||||
const urls = document.getElementById("imageUrls");
|
||||
if (!urls || !urls.value.trim()) {
|
||||
e.preventDefault();
|
||||
showToast("Укажите хотя бы одну ссылку");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activePanel.dataset.panel === "files" && photoInput.files.length === 0) {
|
||||
e.preventDefault();
|
||||
showToast("Выберите файлы для загрузки");
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function initCopyButtons() {
|
||||
document.querySelectorAll(".copy-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const url = btn.dataset.url;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showToast("Ссылка скопирована!");
|
||||
} catch {
|
||||
const input = document.createElement("input");
|
||||
input.value = url;
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(input);
|
||||
showToast("Ссылка скопирована!");
|
||||
const targetId = btn.dataset.target;
|
||||
const url = targetId
|
||||
? document.getElementById(targetId)?.value
|
||||
: btn.dataset.url;
|
||||
|
||||
if (!url) return;
|
||||
|
||||
const copied = await copyText(url);
|
||||
if (copied) {
|
||||
const label = btn.textContent.trim();
|
||||
showToast(label === "BBCode" ? "BBCode скопирован!" : "Скопировано!");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initShareModal() {
|
||||
const modal = document.getElementById("shareModal");
|
||||
if (!modal) return;
|
||||
|
||||
const urlInput = document.getElementById("shareModalUrl");
|
||||
const bbcodeInput = document.getElementById("shareModalBbcode");
|
||||
const htmlInput = document.getElementById("shareModalHtml");
|
||||
const qrImg = document.getElementById("shareModalQr");
|
||||
const nameEl = document.getElementById("shareModalName");
|
||||
|
||||
document.querySelectorAll(".share-qr-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
urlInput.value = btn.dataset.url || "";
|
||||
bbcodeInput.value = btn.dataset.bbcode || "";
|
||||
htmlInput.value = btn.dataset.html || "";
|
||||
qrImg.src = btn.dataset.qr || "";
|
||||
nameEl.textContent = btn.dataset.name || "";
|
||||
modal.hidden = false;
|
||||
document.body.classList.add("modal-open");
|
||||
});
|
||||
});
|
||||
|
||||
modal.querySelectorAll("[data-close-share]").forEach((el) => {
|
||||
el.addEventListener("click", closeShareModal);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && !modal.hidden) {
|
||||
closeShareModal();
|
||||
}
|
||||
});
|
||||
|
||||
function closeShareModal() {
|
||||
modal.hidden = true;
|
||||
document.body.classList.remove("modal-open");
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
const input = document.createElement("input");
|
||||
input.value = text;
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(input);
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
const existing = document.querySelector(".toast");
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
function bufferDecode(value) {
|
||||
const padding = "=".repeat((4 - (value.length % 4)) % 4);
|
||||
const base64 = (value + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = window.atob(base64);
|
||||
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
function bufferEncode(value) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(value)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
async function registerPasskey() {
|
||||
const nameInput = document.getElementById("passkeyName");
|
||||
const name = nameInput ? nameInput.value.trim() : "Passkey";
|
||||
|
||||
const optionsResp = await fetch("/auth/passkey/register/options", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const options = await optionsResp.json();
|
||||
if (!optionsResp.ok) throw new Error(options.error || "Ошибка passkey");
|
||||
|
||||
options.challenge = bufferDecode(options.challenge);
|
||||
options.user.id = bufferDecode(options.user.id);
|
||||
if (options.excludeCredentials) {
|
||||
options.excludeCredentials = options.excludeCredentials.map((item) => ({
|
||||
...item,
|
||||
id: bufferDecode(item.id),
|
||||
}));
|
||||
}
|
||||
|
||||
const credential = await navigator.credentials.create({ publicKey: options });
|
||||
const verifyResp = await fetch("/auth/passkey/register/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
rawId: bufferEncode(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
attestationObject: bufferEncode(credential.response.attestationObject),
|
||||
clientDataJSON: bufferEncode(credential.response.clientDataJSON),
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const result = await verifyResp.json();
|
||||
if (!verifyResp.ok) throw new Error(result.error || "Не удалось сохранить passkey");
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function loginWithPasskey(username, remember) {
|
||||
const optionsResp = await fetch("/auth/passkey/login/options", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
const options = await optionsResp.json();
|
||||
if (!optionsResp.ok) throw new Error(options.error || "Passkey недоступен");
|
||||
|
||||
options.challenge = bufferDecode(options.challenge);
|
||||
if (options.allowCredentials) {
|
||||
options.allowCredentials = options.allowCredentials.map((item) => ({
|
||||
...item,
|
||||
id: bufferDecode(item.id),
|
||||
}));
|
||||
}
|
||||
|
||||
const credential = await navigator.credentials.get({ publicKey: options });
|
||||
const verifyResp = await fetch("/auth/passkey/login/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
remember,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
rawId: bufferEncode(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
authenticatorData: bufferEncode(credential.response.authenticatorData),
|
||||
clientDataJSON: bufferEncode(credential.response.clientDataJSON),
|
||||
signature: bufferEncode(credential.response.signature),
|
||||
userHandle: credential.response.userHandle
|
||||
? bufferEncode(credential.response.userHandle)
|
||||
: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const result = await verifyResp.json();
|
||||
if (!verifyResp.ok) throw new Error(result.error || "Ошибка входа");
|
||||
window.location.href = result.redirect || "/cabinet/";
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const addBtn = document.getElementById("addPasskeyBtn");
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
if (!window.PublicKeyCredential) {
|
||||
alert("Passkey не поддерживается в этом браузере");
|
||||
return;
|
||||
}
|
||||
await registerPasskey();
|
||||
} catch (err) {
|
||||
alert(err.message || "Ошибка passkey");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const loginBtn = document.getElementById("passkeyLoginBtn");
|
||||
if (loginBtn) {
|
||||
loginBtn.addEventListener("click", async () => {
|
||||
const loginInput = document.getElementById("login");
|
||||
const remember = document.querySelector('input[name="remember"]')?.checked;
|
||||
const username = loginInput ? loginInput.value.trim() : "";
|
||||
if (!username) {
|
||||
alert("Введите логин или email");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await loginWithPasskey(username, remember);
|
||||
} catch (err) {
|
||||
alert(err.message || "Ошибка passkey");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import io
|
||||
import os
|
||||
from ftplib import FTP, FTP_TLS
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from app.settings_service import get_settings
|
||||
|
||||
|
||||
def save_photo_file(file_storage, stored_name):
|
||||
settings = get_settings()
|
||||
upload_dir = current_app.config["UPLOAD_FOLDER"]
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
local_path = os.path.join(upload_dir, stored_name)
|
||||
|
||||
file_storage.save(local_path)
|
||||
file_size = os.path.getsize(local_path)
|
||||
storage_backend = "local"
|
||||
|
||||
errors = []
|
||||
|
||||
if settings.s3_enabled:
|
||||
ok, err = _upload_s3(local_path, stored_name, settings)
|
||||
if ok:
|
||||
storage_backend = "s3"
|
||||
elif err:
|
||||
errors.append(f"S3: {err}")
|
||||
|
||||
if settings.sftp_enabled:
|
||||
ok, err = _upload_sftp(local_path, stored_name, settings)
|
||||
if err:
|
||||
errors.append(f"SFTP: {err}")
|
||||
|
||||
if settings.ftp_enabled:
|
||||
ok, err = _upload_ftp(local_path, stored_name, settings)
|
||||
if err:
|
||||
errors.append(f"FTP: {err}")
|
||||
|
||||
return local_path, file_size, storage_backend, errors
|
||||
|
||||
|
||||
def delete_photo_file(stored_name, storage_backend="local"):
|
||||
settings = get_settings()
|
||||
upload_dir = current_app.config["UPLOAD_FOLDER"]
|
||||
local_path = os.path.join(upload_dir, stored_name)
|
||||
|
||||
if os.path.exists(local_path):
|
||||
os.remove(local_path)
|
||||
|
||||
if storage_backend == "s3" and settings.s3_enabled:
|
||||
_delete_s3(stored_name, settings)
|
||||
|
||||
if settings.sftp_enabled:
|
||||
_delete_sftp(stored_name, settings)
|
||||
|
||||
if settings.ftp_enabled:
|
||||
_delete_ftp(stored_name, settings)
|
||||
|
||||
|
||||
def get_photo_stream(stored_name, storage_backend="local"):
|
||||
settings = get_settings()
|
||||
upload_dir = current_app.config["UPLOAD_FOLDER"]
|
||||
local_path = os.path.join(upload_dir, stored_name)
|
||||
|
||||
if os.path.exists(local_path):
|
||||
return open(local_path, "rb")
|
||||
|
||||
if storage_backend == "s3" and settings.s3_enabled:
|
||||
data = _download_s3(stored_name, settings)
|
||||
if data:
|
||||
return io.BytesIO(data)
|
||||
return None
|
||||
|
||||
|
||||
def _upload_s3(local_path, key, settings):
|
||||
try:
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
kwargs = {
|
||||
"aws_access_key_id": settings.s3_access_key,
|
||||
"aws_secret_access_key": settings.s3_secret_key,
|
||||
"region_name": settings.s3_region or "us-east-1",
|
||||
}
|
||||
if settings.s3_endpoint:
|
||||
kwargs["endpoint_url"] = settings.s3_endpoint
|
||||
|
||||
client = boto3.client("s3", config=Config(signature_version="s3v4"), **kwargs)
|
||||
client.upload_file(local_path, settings.s3_bucket, key)
|
||||
return True, None
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def _delete_s3(key, settings):
|
||||
try:
|
||||
import boto3
|
||||
|
||||
kwargs = {
|
||||
"aws_access_key_id": settings.s3_access_key,
|
||||
"aws_secret_access_key": settings.s3_secret_key,
|
||||
"region_name": settings.s3_region or "us-east-1",
|
||||
}
|
||||
if settings.s3_endpoint:
|
||||
kwargs["endpoint_url"] = settings.s3_endpoint
|
||||
client = boto3.client("s3", **kwargs)
|
||||
client.delete_object(Bucket=settings.s3_bucket, Key=key)
|
||||
except Exception:
|
||||
current_app.logger.exception("S3 delete failed")
|
||||
|
||||
|
||||
def _download_s3(key, settings):
|
||||
try:
|
||||
import boto3
|
||||
|
||||
kwargs = {
|
||||
"aws_access_key_id": settings.s3_access_key,
|
||||
"aws_secret_access_key": settings.s3_secret_key,
|
||||
"region_name": settings.s3_region or "us-east-1",
|
||||
}
|
||||
if settings.s3_endpoint:
|
||||
kwargs["endpoint_url"] = settings.s3_endpoint
|
||||
client = boto3.client("s3", **kwargs)
|
||||
obj = client.get_object(Bucket=settings.s3_bucket, Key=key)
|
||||
return obj["Body"].read()
|
||||
except Exception:
|
||||
current_app.logger.exception("S3 download failed")
|
||||
return None
|
||||
|
||||
|
||||
def _upload_sftp(local_path, remote_name, settings):
|
||||
try:
|
||||
import paramiko
|
||||
|
||||
transport = paramiko.Transport((settings.sftp_host, settings.sftp_port))
|
||||
transport.connect(username=settings.sftp_username, password=settings.sftp_password)
|
||||
sftp = paramiko.SFTPClient.from_transport(transport)
|
||||
remote_dir = settings.sftp_remote_path or "/uploads"
|
||||
_sftp_makedirs(sftp, remote_dir)
|
||||
sftp.put(local_path, f"{remote_dir.rstrip('/')}/{remote_name}")
|
||||
sftp.close()
|
||||
transport.close()
|
||||
return True, None
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def _delete_sftp(remote_name, settings):
|
||||
try:
|
||||
import paramiko
|
||||
|
||||
transport = paramiko.Transport((settings.sftp_host, settings.sftp_port))
|
||||
transport.connect(username=settings.sftp_username, password=settings.sftp_password)
|
||||
sftp = paramiko.SFTPClient.from_transport(transport)
|
||||
remote_path = f"{settings.sftp_remote_path.rstrip('/')}/{remote_name}"
|
||||
sftp.remove(remote_path)
|
||||
sftp.close()
|
||||
transport.close()
|
||||
except Exception:
|
||||
current_app.logger.exception("SFTP delete failed")
|
||||
|
||||
|
||||
def _sftp_makedirs(sftp, remote_dir):
|
||||
parts = remote_dir.strip("/").split("/")
|
||||
path = ""
|
||||
for part in parts:
|
||||
path += f"/{part}"
|
||||
try:
|
||||
sftp.stat(path)
|
||||
except IOError:
|
||||
sftp.mkdir(path)
|
||||
|
||||
|
||||
def _upload_ftp(local_path, remote_name, settings):
|
||||
try:
|
||||
ftp_cls = FTP_TLS if settings.ftp_use_tls else FTP
|
||||
ftp = ftp_cls()
|
||||
ftp.connect(settings.ftp_host, settings.ftp_port, timeout=30)
|
||||
ftp.login(settings.ftp_username, settings.ftp_password)
|
||||
if settings.ftp_use_tls:
|
||||
ftp.prot_p()
|
||||
remote_dir = settings.ftp_remote_path or "/uploads"
|
||||
_ftp_makedirs(ftp, remote_dir)
|
||||
ftp.cwd(remote_dir)
|
||||
with open(local_path, "rb") as f:
|
||||
ftp.storbinary(f"STOR {remote_name}", f)
|
||||
ftp.quit()
|
||||
return True, None
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def _delete_ftp(remote_name, settings):
|
||||
try:
|
||||
ftp_cls = FTP_TLS if settings.ftp_use_tls else FTP
|
||||
ftp = ftp_cls()
|
||||
ftp.connect(settings.ftp_host, settings.ftp_port, timeout=30)
|
||||
ftp.login(settings.ftp_username, settings.ftp_password)
|
||||
if settings.ftp_use_tls:
|
||||
ftp.prot_p()
|
||||
ftp.cwd(settings.ftp_remote_path or "/uploads")
|
||||
ftp.delete(remote_name)
|
||||
ftp.quit()
|
||||
except Exception:
|
||||
current_app.logger.exception("FTP delete failed")
|
||||
|
||||
|
||||
def _ftp_makedirs(ftp, remote_dir):
|
||||
parts = remote_dir.strip("/").split("/")
|
||||
path = ""
|
||||
for part in parts:
|
||||
path += f"/{part}"
|
||||
try:
|
||||
ftp.cwd(path)
|
||||
except Exception:
|
||||
ftp.mkd(path)
|
||||
@@ -1,5 +1,23 @@
|
||||
<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.photos') }}" class="admin-nav__link {% if request.endpoint == 'admin.photos' %}admin-nav__link--active{% endif %}">Фото</a>
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="admin-nav__link {% if request.endpoint == 'admin.dashboard' %}admin-nav__link--active{% endif %}">
|
||||
<span class="admin-nav__icon">📊</span> Обзор
|
||||
</a>
|
||||
<a href="{{ url_for('admin.users') }}" class="admin-nav__link {% if request.endpoint == 'admin.users' %}admin-nav__link--active{% endif %}">
|
||||
<span class="admin-nav__icon">👥</span> Пользователи
|
||||
</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 %}">
|
||||
<span class="admin-nav__icon">🏷️</span> Группы
|
||||
</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 %}">
|
||||
<span class="admin-nav__icon">📢</span> Баннеры
|
||||
</a>
|
||||
<a href="{{ url_for('admin.photos') }}" class="admin-nav__link {% if request.endpoint == 'admin.photos' %}admin-nav__link--active{% endif %}">
|
||||
<span class="admin-nav__icon">🖼️</span> Фото
|
||||
</a>
|
||||
<a href="{{ url_for('admin.deploy') }}" class="admin-nav__link {% if request.endpoint == 'admin.deploy' %}admin-nav__link--active{% endif %}">
|
||||
<span class="admin-nav__icon">🚀</span> Версии Git
|
||||
</a>
|
||||
<a href="{{ url_for('admin.settings') }}" class="admin-nav__link {% if request.endpoint == 'admin.settings' %}admin-nav__link--active{% endif %}">
|
||||
<span class="admin-nav__icon">🔧</span> Настройки
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
|
||||
{% block title %}Рекламные баннеры — Админка{% endblock %}
|
||||
{% block admin_title %}Рекламные баннеры{% endblock %}
|
||||
{% block admin_subtitle %}<p class="admin-main__subtitle">Баннеры на главной, в кабинете и в подвале</p>{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<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>
|
||||
{% endblock %}
|
||||
@@ -1,42 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "admin/layout.html" %}
|
||||
{% from "macros.html" import format_size %}
|
||||
|
||||
{% block title %}Админка — PhotoHost{% endblock %}
|
||||
{% block admin_title %}Обзор{% endblock %}
|
||||
{% block admin_subtitle %}<p class="admin-main__subtitle">Статистика и последние действия</p>{% 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>
|
||||
{% block admin_content %}
|
||||
<div class="admin-stats admin-stats--cards">
|
||||
<div class="admin-stat-card">
|
||||
<span class="admin-stat-card__value">{{ stats.users }}</span>
|
||||
<span class="admin-stat-card__label">Пользователей</span>
|
||||
</div>
|
||||
<div class="admin-stat-card">
|
||||
<span class="admin-stat-card__value">{{ stats.photos }}</span>
|
||||
<span class="admin-stat-card__label">Фотографий</span>
|
||||
</div>
|
||||
<div class="admin-stat-card">
|
||||
<span class="admin-stat-card__value">{{ stats.admins }}</span>
|
||||
<span class="admin-stat-card__label">Администраторов</span>
|
||||
</div>
|
||||
<div class="admin-stat-card">
|
||||
<span class="admin-stat-card__value">{{ stats.groups }}</span>
|
||||
<span class="admin-stat-card__label">Групп</span>
|
||||
</div>
|
||||
<div class="admin-stat-card admin-stat-card--accent">
|
||||
<span class="admin-stat-card__value">{{ format_size(stats.storage) }}</span>
|
||||
<span class="admin-stat-card__label">Хранилище</span>
|
||||
</div>
|
||||
</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">{{ stats.users }}</span>
|
||||
<span class="stat-card__label">пользователей</span>
|
||||
</div>
|
||||
<div class="stat-card stat-card--admin">
|
||||
<span class="stat-card__value">{{ stats.photos }}</span>
|
||||
<span class="stat-card__label">фотографий</span>
|
||||
</div>
|
||||
<div class="stat-card stat-card--admin">
|
||||
<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">{{ format_size(stats.storage) }}</span>
|
||||
<span class="stat-card__label">хранилище</span>
|
||||
</div>
|
||||
</div>
|
||||
{% if current_version %}
|
||||
<p class="admin-version-bar">
|
||||
Версия Git: <strong>{{ current_version }}</strong>
|
||||
· <a href="{{ url_for('admin.deploy') }}">Управление версиями</a>
|
||||
{% if not deploy_enabled %}<span class="badge badge--muted">deploy off</span>{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="admin-grid">
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel admin-panel--elevated">
|
||||
<h2 class="admin-panel__title">Новые пользователи</h2>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
@@ -62,7 +64,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel admin-panel--elevated">
|
||||
<h2 class="admin-panel__title">Последние фото</h2>
|
||||
<div class="admin-mini-gallery">
|
||||
{% for photo in recent_photos %}
|
||||
@@ -75,6 +77,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
|
||||
{% block title %}Версии Git — Админка{% endblock %}
|
||||
{% block admin_title %}Обновление Git{% endblock %}
|
||||
{% block admin_subtitle %}<p class="admin-main__subtitle">Переключение релизов и пересборка Docker</p>{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<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>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,79 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% from "macros.html" import format_size %}
|
||||
|
||||
{% block title %}Группы — Админка{% endblock %}
|
||||
{% block admin_title %}Группы пользователей{% endblock %}
|
||||
{% block admin_subtitle %}<p class="admin-main__subtitle">Квоты диска, лимиты папок и фото</p>{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<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>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-shell">
|
||||
<aside class="admin-sidebar">
|
||||
<div class="admin-sidebar__head">
|
||||
<span class="admin-sidebar__logo">⚙️</span>
|
||||
<div>
|
||||
<strong>PhotoHost</strong>
|
||||
<span>Админ-панель</span>
|
||||
</div>
|
||||
</div>
|
||||
{% include "admin/_nav.html" %}
|
||||
<a href="{{ url_for('main.index') }}" class="admin-sidebar__back">← На сайт</a>
|
||||
</aside>
|
||||
<div class="admin-main">
|
||||
<div class="admin-main__header">
|
||||
<div>
|
||||
<h1 class="admin-main__title">{% block admin_title %}Админка{% endblock %}</h1>
|
||||
{% block admin_subtitle %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% include "partials/alerts.html" %}
|
||||
{% block admin_content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,22 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "admin/layout.html" %}
|
||||
|
||||
{% block title %}Фото — Админка{% endblock %}
|
||||
{% block admin_title %}Все фотографии{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-header page-header--admin">
|
||||
<div class="container">
|
||||
<h1 class="page-header__title">Все фотографии</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-section">
|
||||
<div class="container">
|
||||
{% include "admin/_nav.html" %}
|
||||
{% include "partials/alerts.html" %}
|
||||
|
||||
{% block admin_content %}
|
||||
{% with photos=photos, show_owner=true, delete_mode='admin', empty_title='Нет фотографий', empty_text='Пользователи ещё не загружали фото' %}
|
||||
{% include "partials/photo_gallery.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
|
||||
{% block title %}Настройки — Админка{% endblock %}
|
||||
{% block admin_title %}Настройки системы{% endblock %}
|
||||
{% block admin_subtitle %}<p class="admin-main__subtitle">Авторизация, captcha, S3, SFTP, FTP, SMTP и лимиты загрузки</p>{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<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">Регистрация и авторизация</h2>
|
||||
<label class="form-checkbox"><input type="checkbox" name="registration_enabled" {% if settings.registration_enabled %}checked{% endif %}><span>Разрешить регистрацию</span></label>
|
||||
<label class="form-checkbox"><input type="checkbox" name="password_login_enabled" {% if settings.password_login_enabled %}checked{% endif %}><span>Вход по паролю</span></label>
|
||||
<label class="form-checkbox"><input type="checkbox" name="passkey_enabled" {% if settings.passkey_enabled %}checked{% endif %}><span>Passkey (WebAuthn)</span></label>
|
||||
</div>
|
||||
|
||||
<div class="admin-panel" style="margin-top:24px">
|
||||
<h2 class="admin-panel__title">Passkey — RP ID и Origin</h2>
|
||||
<p class="folder-hint">Для production укажите домен сайта. Значения из админки имеют приоритет над <code>.env</code>.</p>
|
||||
<div class="settings-grid">
|
||||
<div class="form-group"><label>RP ID (домен)</label><input type="text" name="webauthn_rp_id" value="{{ settings.webauthn_rp_id or '' }}" placeholder="example.com"></div>
|
||||
<div class="form-group"><label>RP Name</label><input type="text" name="webauthn_rp_name" value="{{ settings.webauthn_rp_name or 'PhotoHost' }}"></div>
|
||||
<div class="form-group"><label>Origin (полный URL)</label><input type="text" name="webauthn_origin" value="{{ settings.webauthn_origin or '' }}" placeholder="https://example.com"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-panel" style="margin-top:24px">
|
||||
<h2 class="admin-panel__title">Captcha</h2>
|
||||
<div class="form-group">
|
||||
<label for="captcha_provider">Провайдер</label>
|
||||
<select id="captcha_provider" name="captcha_provider">
|
||||
<option value="none" {% if settings.captcha_provider == 'none' %}selected{% endif %}>Отключена</option>
|
||||
<option value="turnstile" {% if settings.captcha_provider == 'turnstile' %}selected{% endif %}>Cloudflare Turnstile</option>
|
||||
<option value="recaptcha_v2" {% if settings.captcha_provider == 'recaptcha_v2' %}selected{% endif %}>Google reCAPTCHA v2</option>
|
||||
<option value="recaptcha_v3" {% if settings.captcha_provider == 'recaptcha_v3' %}selected{% endif %}>Google reCAPTCHA v3</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="form-checkbox"><input type="checkbox" name="captcha_on_login" {% if settings.captcha_on_login %}checked{% endif %}><span>На странице входа</span></label>
|
||||
<label class="form-checkbox"><input type="checkbox" name="captcha_on_register" {% if settings.captcha_on_register %}checked{% endif %}><span>На странице регистрации</span></label>
|
||||
<label class="form-checkbox"><input type="checkbox" name="captcha_on_forgot_password" {% if settings.captcha_on_forgot_password %}checked{% endif %}><span>На сбросе пароля</span></label>
|
||||
|
||||
<h3 class="admin-panel__subtitle" style="margin-top:16px">Cloudflare Turnstile</h3>
|
||||
<div class="settings-grid">
|
||||
<div class="form-group"><label>Site Key</label><input type="text" name="turnstile_site_key" value="{{ settings.turnstile_site_key or '' }}"></div>
|
||||
<div class="form-group"><label>Secret Key</label><input type="password" name="turnstile_secret_key" placeholder="оставьте пустым, если не меняете"></div>
|
||||
</div>
|
||||
|
||||
<h3 class="admin-panel__subtitle" style="margin-top:16px">Google reCAPTCHA v2</h3>
|
||||
<div class="settings-grid">
|
||||
<div class="form-group"><label>Site Key</label><input type="text" name="recaptcha_v2_site_key" value="{{ settings.recaptcha_v2_site_key or '' }}"></div>
|
||||
<div class="form-group"><label>Secret Key</label><input type="password" name="recaptcha_v2_secret_key" placeholder="оставьте пустым, если не меняете"></div>
|
||||
</div>
|
||||
|
||||
<h3 class="admin-panel__subtitle" style="margin-top:16px">Google reCAPTCHA v3</h3>
|
||||
<div class="settings-grid">
|
||||
<div class="form-group"><label>Site Key</label><input type="text" name="recaptcha_v3_site_key" value="{{ settings.recaptcha_v3_site_key or '' }}"></div>
|
||||
<div class="form-group"><label>Secret Key</label><input type="password" name="recaptcha_v3_secret_key" placeholder="оставьте пустым, если не меняете"></div>
|
||||
<div class="form-group"><label>Мин. score (0–1)</label><input type="number" step="0.1" min="0" max="1" name="recaptcha_v3_min_score" value="{{ settings.recaptcha_v3_min_score or 0.5 }}"></div>
|
||||
</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>
|
||||
{% endblock %}
|
||||
@@ -1,19 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "admin/layout.html" %}
|
||||
|
||||
{% block title %}Пользователи — Админка{% endblock %}
|
||||
{% block admin_title %}Пользователи{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-header page-header--admin">
|
||||
<div class="container">
|
||||
<h1 class="page-header__title">Пользователи</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-section">
|
||||
<div class="container">
|
||||
{% include "admin/_nav.html" %}
|
||||
{% include "partials/alerts.html" %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
@@ -21,6 +11,7 @@
|
||||
<th>ID</th>
|
||||
<th>Логин</th>
|
||||
<th>Email</th>
|
||||
<th>Группа</th>
|
||||
<th>Фото</th>
|
||||
<th>Роль</th>
|
||||
<th>Статус</th>
|
||||
@@ -34,6 +25,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 %}
|
||||
@@ -74,6 +74,4 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{% 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>
|
||||
{% include "partials/captcha.html" %}
|
||||
<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 %}
|
||||
|
||||
{% block scripts %}
|
||||
{% include "partials/captcha_scripts.html" %}
|
||||
{% endblock %}
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
{% include "partials/alerts.html" %}
|
||||
|
||||
{% if auth_settings.password_login_enabled %}
|
||||
<form method="post" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="login">Логин или email</label>
|
||||
@@ -24,13 +25,34 @@
|
||||
<input type="checkbox" name="remember">
|
||||
<span>Запомнить меня</span>
|
||||
</label>
|
||||
{% include "partials/captcha.html" %}
|
||||
<button type="submit" class="btn btn--primary btn--full">Войти</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if auth_settings.passkey_enabled %}
|
||||
<button type="button" class="btn btn--ghost btn--full" id="passkeyLoginBtn" style="margin-top:12px">
|
||||
Войти с Passkey
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<p class="auth-card__footer">
|
||||
Нет аккаунта? <a href="{{ url_for('auth.register') }}">Зарегистрироваться</a>
|
||||
{% if auth_settings.password_login_enabled %}
|
||||
<a href="{{ url_for('auth.forgot_password') }}">Забыли пароль?</a>
|
||||
{% if auth_settings.registration_enabled %} · {% endif %}
|
||||
{% endif %}
|
||||
{% if auth_settings.registration_enabled %}
|
||||
<a href="{{ url_for('auth.register') }}">Зарегистрироваться</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% include "partials/captcha_scripts.html" %}
|
||||
{% if auth_settings.passkey_enabled %}
|
||||
<script src="{{ url_for('static', filename='js/passkey.js') }}"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<label for="password2">Подтверждение пароля</label>
|
||||
<input type="password" id="password2" name="password2" required minlength="6" autocomplete="new-password" placeholder="повторите пароль">
|
||||
</div>
|
||||
{% include "partials/captcha.html" %}
|
||||
<button type="submit" class="btn btn--primary btn--full">Создать аккаунт</button>
|
||||
</form>
|
||||
|
||||
@@ -38,3 +39,7 @@
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% include "partials/captcha_scripts.html" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -31,8 +32,10 @@
|
||||
<a href="{{ url_for('auth.logout') }}" class="nav__link">Выйти</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.login') }}" class="nav__link">Вход</a>
|
||||
{% if auth_settings.registration_enabled %}
|
||||
<a href="{{ url_for('auth.register') }}" class="nav__link nav__link--accent">Регистрация</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
@@ -41,14 +44,26 @@
|
||||
{% 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>
|
||||
<p class="footer__links">
|
||||
<a href="{{ url_for('legal.privacy') }}">Конфиденциальность</a> ·
|
||||
<a href="{{ url_for('legal.cookies') }}">Cookies</a> ·
|
||||
<a href="{{ url_for('legal.gdpr') }}">GDPR</a>
|
||||
</p>
|
||||
<p class="footer__muted">Храните и делитесь фотографиями просто</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{% include "partials/cookie_banner.html" %}
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/cookie-consent.js') }}"></script>
|
||||
{% include "partials/share_modal.html" %}
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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">
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
<section class="page-header">
|
||||
<div class="container">
|
||||
<h1 class="page-header__title">Настройки профиля</h1>
|
||||
<p class="page-header__subtitle">Измените email или пароль</p>
|
||||
<p class="page-header__subtitle">Безопасность, passkey, сессии и GDPR</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="auth-section">
|
||||
<div class="container auth-container">
|
||||
<div class="auth-card auth-card--wide">
|
||||
<section class="auth-section profile-section">
|
||||
<div class="container profile-grid">
|
||||
<div class="auth-card auth-card--wide profile-card">
|
||||
{% include "partials/alerts.html" %}
|
||||
|
||||
<div class="profile-info">
|
||||
@@ -30,7 +30,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="profile-card__title">Email и пароль</h2>
|
||||
<form method="post" class="auth-form">
|
||||
<input type="hidden" name="action" value="save">
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" required value="{{ current_user.email }}">
|
||||
@@ -41,19 +43,118 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new_password">Новый пароль (необязательно)</label>
|
||||
<input type="password" id="new_password" name="new_password" minlength="6" placeholder="оставьте пустым, если не меняете">
|
||||
<input type="password" id="new_password" name="new_password" minlength="6">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new_password2">Подтверждение нового пароля</label>
|
||||
<input type="password" id="new_password2" name="new_password2" minlength="6">
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary btn--full">Сохранить</button>
|
||||
<button type="submit" class="btn btn--primary">Сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p class="auth-card__footer">
|
||||
<div class="auth-card auth-card--wide profile-card">
|
||||
{% if auth_settings.passkey_enabled %}
|
||||
<h2 class="profile-card__title">Passkey</h2>
|
||||
<p class="profile-card__hint">Вход без пароля через Face ID, Touch ID, Windows Hello или ключ безопасности.</p>
|
||||
|
||||
{% if passkeys %}
|
||||
<ul class="session-list">
|
||||
{% for passkey in passkeys %}
|
||||
<li class="session-item">
|
||||
<div>
|
||||
<strong>{{ passkey.name }}</strong>
|
||||
<span class="session-item__meta">Добавлен {{ passkey.created_at.strftime('%d.%m.%Y') }}</span>
|
||||
</div>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="delete_passkey">
|
||||
<input type="hidden" name="passkey_id" value="{{ passkey.id }}">
|
||||
<button type="submit" class="btn btn--danger btn--sm">Удалить</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="profile-card__empty">Passkey не настроен</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group" style="margin-top:16px">
|
||||
<label for="passkeyName">Название устройства</label>
|
||||
<input type="text" id="passkeyName" value="Моё устройство" maxlength="120">
|
||||
</div>
|
||||
<button type="button" class="btn btn--ghost" id="addPasskeyBtn">Добавить passkey</button>
|
||||
{% else %}
|
||||
<h2 class="profile-card__title">Passkey</h2>
|
||||
<p class="profile-card__hint">Passkey отключён администратором сайта.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="auth-card auth-card--wide profile-card">
|
||||
<h2 class="profile-card__title">Активные сессии</h2>
|
||||
<p class="profile-card__hint">Все устройства, где выполнен вход в ваш аккаунт.</p>
|
||||
|
||||
{% if sessions %}
|
||||
<ul class="session-list">
|
||||
{% for item in sessions %}
|
||||
<li class="session-item {% if item.session_key == current_sid %}session-item--current{% endif %}">
|
||||
<div>
|
||||
<strong>{{ item.device_label }}</strong>
|
||||
{% if item.session_key == current_sid %}<span class="badge badge--success">текущая</span>{% endif %}
|
||||
<span class="session-item__meta">
|
||||
{{ item.ip_address or 'IP неизвестен' }} ·
|
||||
{{ item.last_seen_at.strftime('%d.%m.%Y %H:%M') }}
|
||||
</span>
|
||||
</div>
|
||||
{% if item.session_key != current_sid %}
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="revoke_session">
|
||||
<input type="hidden" name="session_id" value="{{ item.id }}">
|
||||
<button type="submit" class="btn btn--ghost btn--sm">Завершить</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<form method="post" style="margin-top:16px">
|
||||
<input type="hidden" name="action" value="revoke_all_sessions">
|
||||
<button type="submit" class="btn btn--danger btn--sm" onclick="return confirm('Завершить все сессии кроме текущей?');">
|
||||
Выйти на всех устройствах
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="profile-card__empty">Нет активных сессий</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="auth-card auth-card--wide profile-card">
|
||||
<h2 class="profile-card__title">GDPR и данные</h2>
|
||||
<p class="profile-card__hint">
|
||||
Вы можете скачать копию своих данных или удалить аккаунт.
|
||||
<a href="{{ url_for('legal.gdpr') }}">Подробнее о правах</a>
|
||||
</p>
|
||||
<div class="profile-actions">
|
||||
<a href="{{ url_for('cabinet.export_profile') }}" class="btn btn--ghost">Скачать мои данные (JSON)</a>
|
||||
</div>
|
||||
|
||||
<form method="post" class="profile-delete-form" onsubmit="return confirm('Удалить аккаунт без возможности восстановления?');">
|
||||
<input type="hidden" name="action" value="delete_account">
|
||||
<div class="form-group">
|
||||
<label for="delete_password">Пароль для удаления аккаунта</label>
|
||||
<input type="password" id="delete_password" name="delete_password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--danger">Удалить аккаунт</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p class="auth-card__footer profile-footer">
|
||||
<a href="{{ url_for('cabinet.index') }}">← Вернуться в кабинет</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if auth_settings.passkey_enabled %}
|
||||
<script src="{{ url_for('static', filename='js/passkey.js') }}"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -40,36 +40,19 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% with banners=site_banners.get('main', []), position='main' %}
|
||||
{% include "partials/banners.html" %}
|
||||
{% endwith %}
|
||||
|
||||
{% include "partials/alerts.html" %}
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<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 %}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Политика cookies — PhotoHost{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="legal-section">
|
||||
<div class="container legal-container">
|
||||
<h1>Политика cookies</h1>
|
||||
|
||||
<h2>Обязательные cookies</h2>
|
||||
<p>Нужны для входа, CSRF-защиты и работы личного кабинета. Отключить их нельзя.</p>
|
||||
<ul>
|
||||
<li><code>session</code> — идентификатор сессии Flask</li>
|
||||
<li><code>photohost_consent</code> — ваш выбор по cookies</li>
|
||||
</ul>
|
||||
|
||||
<h2>Аналитические cookies</h2>
|
||||
<p>Используются только при вашем согласии. Помогают понять, как улучшать сервис.</p>
|
||||
|
||||
<h2>Управление согласием</h2>
|
||||
<p>Вы можете изменить выбор через баннер cookies или в профиле после входа.</p>
|
||||
|
||||
<p><a href="{{ url_for('legal.privacy') }}">Политика конфиденциальности</a></p>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}GDPR — PhotoHost{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="legal-section">
|
||||
<div class="container legal-container">
|
||||
<h1>GDPR — ваши права</h1>
|
||||
<p>В соответствии с Общим регламентом защиты данных (EU GDPR) вы имеете следующие права:</p>
|
||||
|
||||
<div class="legal-cards">
|
||||
<article class="legal-card">
|
||||
<h3>Право на доступ</h3>
|
||||
<p>Узнать, какие данные мы храним о вас.</p>
|
||||
</article>
|
||||
<article class="legal-card">
|
||||
<h3>Право на исправление</h3>
|
||||
<p>Обновить email и пароль в профиле.</p>
|
||||
</article>
|
||||
<article class="legal-card">
|
||||
<h3>Право на переносимость</h3>
|
||||
<p>Скачать данные в JSON из <a href="{{ url_for('cabinet.profile') }}">профиля</a>.</p>
|
||||
</article>
|
||||
<article class="legal-card">
|
||||
<h3>Право на удаление</h3>
|
||||
<p>Удалить аккаунт и все связанные фото в профиле.</p>
|
||||
</article>
|
||||
<article class="legal-card">
|
||||
<h3>Право на ограничение</h3>
|
||||
<p>Отключить аналитические cookies в баннере согласия.</p>
|
||||
</article>
|
||||
<article class="legal-card">
|
||||
<h3>Право отозвать согласие</h3>
|
||||
<p>Изменить настройки cookies в любой момент.</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<h2>Как воспользоваться</h2>
|
||||
<ol>
|
||||
<li>Войдите в аккаунт</li>
|
||||
<li>Откройте <a href="{{ url_for('cabinet.profile') }}">Профиль</a></li>
|
||||
<li>Экспортируйте данные или удалите аккаунт</li>
|
||||
</ol>
|
||||
|
||||
<p>Для запросов к администратору используйте email, указанный при регистрации.</p>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Политика конфиденциальности — PhotoHost{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="legal-section">
|
||||
<div class="container legal-container">
|
||||
<h1>Политика конфиденциальности</h1>
|
||||
<p class="legal-updated">Последнее обновление: {{ "2026-06-06" }}</p>
|
||||
|
||||
<h2>1. Какие данные мы обрабатываем</h2>
|
||||
<ul>
|
||||
<li>Учётные данные: имя пользователя, email, хеш пароля</li>
|
||||
<li>Загруженные фото и метаданные (имя файла, размер, дата)</li>
|
||||
<li>Технические данные: IP-адрес, user-agent, cookies сессии</li>
|
||||
<li>Passkey (публичный ключ WebAuthn, без хранения биометрии)</li>
|
||||
</ul>
|
||||
|
||||
<h2>2. Цели обработки</h2>
|
||||
<ul>
|
||||
<li>Регистрация, авторизация и предоставление сервиса</li>
|
||||
<li>Хранение и публикация загруженных изображений</li>
|
||||
<li>Безопасность аккаунта и управление сессиями</li>
|
||||
<li>Выполнение требований GDPR</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Правовые основания (GDPR)</h2>
|
||||
<p>Обработка осуществляется на основании исполнения договора (п. 6(1)(b) GDPR) и законного интереса по обеспечению безопасности (п. 6(1)(f) GDPR).</p>
|
||||
|
||||
<h2>4. Срок хранения</h2>
|
||||
<p>Данные хранятся до удаления аккаунта пользователем или до получения запроса на удаление.</p>
|
||||
|
||||
<h2>5. Ваши права</h2>
|
||||
<p>Вы можете запросить доступ, исправление, экспорт или удаление данных в <a href="{{ url_for('cabinet.profile') }}">профиле</a> или связавшись с администратором сайта.</p>
|
||||
|
||||
<p><a href="{{ url_for('legal.gdpr') }}">GDPR — подробнее о правах</a> · <a href="{{ url_for('legal.cookies') }}">Политика cookies</a></p>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% if captcha_config %}
|
||||
<div class="form-group captcha-widget">
|
||||
{% if captcha_config.provider == 'turnstile' %}
|
||||
<div class="cf-turnstile" data-sitekey="{{ captcha_config.site_key }}"></div>
|
||||
{% elif captcha_config.provider == 'recaptcha_v2' %}
|
||||
<div class="g-recaptcha" data-sitekey="{{ captcha_config.site_key }}"></div>
|
||||
{% elif captcha_config.provider == 'recaptcha_v3' %}
|
||||
<input type="hidden" name="g-recaptcha-response" id="recaptchaV3Token" value="">
|
||||
<p class="folder-hint">Защита reCAPTCHA v3 активна</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% if captcha_config %}
|
||||
{% if captcha_config.provider == 'turnstile' %}
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
{% elif captcha_config.provider == 'recaptcha_v2' %}
|
||||
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
|
||||
{% elif captcha_config.provider == 'recaptcha_v3' %}
|
||||
<script src="https://www.google.com/recaptcha/api.js?render={{ captcha_config.site_key }}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
function bindRecaptchaV3() {
|
||||
const form = document.querySelector(".auth-form");
|
||||
const tokenInput = document.getElementById("recaptchaV3Token");
|
||||
if (!form || !tokenInput || !window.grecaptcha) return;
|
||||
|
||||
form.addEventListener("submit", function (event) {
|
||||
if (tokenInput.value) return;
|
||||
event.preventDefault();
|
||||
grecaptcha.ready(function () {
|
||||
grecaptcha.execute("{{ captcha_config.site_key }}", { action: "{{ captcha_config.action }}" })
|
||||
.then(function (token) {
|
||||
tokenInput.value = token;
|
||||
form.submit();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (window.grecaptcha) {
|
||||
bindRecaptchaV3();
|
||||
} else {
|
||||
window.addEventListener("load", bindRecaptchaV3);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,17 @@
|
||||
<div class="cookie-banner" id="cookieBanner" hidden>
|
||||
<div class="cookie-banner__inner">
|
||||
<div class="cookie-banner__text">
|
||||
<strong>Мы используем cookies</strong>
|
||||
<p>
|
||||
Обязательные cookies нужны для входа и работы сайта.
|
||||
Аналитические cookies помогают улучшать сервис.
|
||||
<a href="{{ url_for('legal.cookies') }}">Политика cookies</a> ·
|
||||
<a href="{{ url_for('legal.privacy') }}">Конфиденциальность</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="cookie-banner__actions">
|
||||
<button type="button" class="btn btn--ghost btn--sm" id="cookieRejectBtn">Только необходимые</button>
|
||||
<button type="button" class="btn btn--primary btn--sm" id="cookieAcceptBtn">Принять все</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,6 +1,9 @@
|
||||
{% if photos %}
|
||||
<div class="gallery">
|
||||
{% for photo in photos %}
|
||||
{% set share_url = request.url_root.rstrip('/') ~ photo.url %}
|
||||
{% set share_bbcode = '[img]' ~ share_url ~ '[/img]' %}
|
||||
{% set share_html = '<img src="' ~ share_url ~ '" alt="' ~ photo.original_name ~ '">' %}
|
||||
<article class="photo-card" data-id="{{ photo.id }}">
|
||||
<div class="photo-card__image-wrap">
|
||||
<img
|
||||
@@ -10,8 +13,22 @@
|
||||
loading="lazy"
|
||||
>
|
||||
<div class="photo-card__overlay">
|
||||
<button type="button" class="btn btn--ghost btn--sm copy-btn" data-url="{{ request.url_root.rstrip('/') }}{{ photo.url }}">
|
||||
Копировать ссылку
|
||||
<button type="button" class="btn btn--ghost btn--sm copy-btn" data-url="{{ share_url }}">
|
||||
Ссылка
|
||||
</button>
|
||||
<button type="button" class="btn btn--ghost btn--sm copy-btn" data-url="{{ share_bbcode }}">
|
||||
BBCode
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--ghost btn--sm share-qr-btn"
|
||||
data-url="{{ share_url }}"
|
||||
data-bbcode="{{ share_bbcode }}"
|
||||
data-html="{{ share_html }}"
|
||||
data-qr="{{ url_for('main.photo_qr', photo_id=photo.id) }}"
|
||||
data-name="{{ photo.original_name }}"
|
||||
>
|
||||
QR
|
||||
</button>
|
||||
<a href="{{ photo.url }}" target="_blank" class="btn btn--ghost btn--sm">Открыть</a>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<div class="share-modal" id="shareModal" hidden>
|
||||
<div class="share-modal__backdrop" data-close-share></div>
|
||||
<div class="share-modal__dialog" role="dialog" aria-labelledby="shareModalTitle">
|
||||
<button type="button" class="share-modal__close" data-close-share aria-label="Закрыть">×</button>
|
||||
<h3 class="share-modal__title" id="shareModalTitle">Поделиться</h3>
|
||||
<p class="share-modal__name" id="shareModalName"></p>
|
||||
|
||||
<div class="share-modal__qr-wrap">
|
||||
<img id="shareModalQr" class="share-modal__qr" alt="QR-код">
|
||||
</div>
|
||||
|
||||
<div class="share-field">
|
||||
<label for="shareModalUrl">Прямая ссылка</label>
|
||||
<div class="share-field__row">
|
||||
<input id="shareModalUrl" type="text" readonly>
|
||||
<button type="button" class="btn btn--ghost btn--sm copy-btn" data-target="shareModalUrl">Копировать</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="share-field">
|
||||
<label for="shareModalBbcode">BBCode для форумов</label>
|
||||
<div class="share-field__row">
|
||||
<input id="shareModalBbcode" type="text" readonly>
|
||||
<button type="button" class="btn btn--ghost btn--sm copy-btn" data-target="shareModalBbcode">Копировать</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="share-field">
|
||||
<label for="shareModalHtml">HTML для сайтов</label>
|
||||
<div class="share-field__row">
|
||||
<input id="shareModalHtml" type="text" readonly>
|
||||
<button type="button" class="btn btn--ghost btn--sm copy-btn" data-target="shareModalHtml">Копировать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,47 @@
|
||||
<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="upload-tabs">
|
||||
<button type="button" class="upload-tabs__btn upload-tabs__btn--active" data-tab="files">Файлы</button>
|
||||
<button type="button" class="upload-tabs__btn" data-tab="urls">Ссылки</button>
|
||||
</div>
|
||||
|
||||
<div class="upload-panel upload-panel--active" data-panel="files">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="upload-panel" data-panel="urls">
|
||||
<div class="url-upload">
|
||||
<label for="imageUrls" class="url-upload__label">Прямые ссылки на изображения</label>
|
||||
<textarea
|
||||
id="imageUrls"
|
||||
name="image_urls"
|
||||
class="url-upload__input"
|
||||
rows="5"
|
||||
placeholder="https://example.com/photo.jpg https://example.com/image.png"
|
||||
></textarea>
|
||||
<p class="url-upload__hint">По одной ссылке в строке. Поддерживаются HTTP и HTTPS.</p>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary" id="submitUrlBtn">
|
||||
<span>Загрузить по ссылкам</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,296 @@
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from werkzeug.datastructures import FileStorage
|
||||
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
|
||||
|
||||
URL_SPLIT_RE = re.compile(r"[\r\n,;\s]+")
|
||||
|
||||
MIME_TO_EXT = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"image/bmp": "bmp",
|
||||
"image/x-png": "png",
|
||||
}
|
||||
|
||||
|
||||
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 parse_image_urls(raw_text):
|
||||
if not raw_text:
|
||||
return []
|
||||
urls = []
|
||||
for part in URL_SPLIT_RE.split(raw_text.strip()):
|
||||
url = part.strip()
|
||||
if url and url not in urls:
|
||||
urls.append(url)
|
||||
return urls
|
||||
|
||||
|
||||
def is_safe_image_url(url):
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
if not parsed.hostname:
|
||||
return False
|
||||
|
||||
host = parsed.hostname.lower()
|
||||
if host in ("localhost", "0.0.0.0") or host.endswith(".local"):
|
||||
return False
|
||||
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||
return False
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def guess_extension(url, content_type):
|
||||
ext = MIME_TO_EXT.get((content_type or "").split(";")[0].strip().lower())
|
||||
if ext:
|
||||
return ext
|
||||
|
||||
path = urlparse(url).path
|
||||
if "." in path:
|
||||
candidate = path.rsplit(".", 1)[1].lower()
|
||||
if candidate in {"png", "jpg", "jpeg", "gif", "webp", "bmp"}:
|
||||
return "jpg" if candidate == "jpeg" else candidate
|
||||
return None
|
||||
|
||||
|
||||
def download_image(url, max_bytes, timeout=30):
|
||||
response = requests.get(
|
||||
url,
|
||||
stream=True,
|
||||
timeout=timeout,
|
||||
headers={"User-Agent": "PhotoHost/2.0"},
|
||||
allow_redirects=True,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
final_url = response.url
|
||||
if not is_safe_image_url(final_url):
|
||||
raise ValueError("Недопустимый URL после редиректа")
|
||||
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
ext = guess_extension(final_url, content_type)
|
||||
if not ext:
|
||||
raise ValueError("URL не содержит изображение")
|
||||
|
||||
chunks = []
|
||||
total = 0
|
||||
for chunk in response.iter_content(chunk_size=65536):
|
||||
if not chunk:
|
||||
continue
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise ValueError("Файл превышает лимит размера")
|
||||
chunks.append(chunk)
|
||||
|
||||
if total == 0:
|
||||
raise ValueError("Пустой файл")
|
||||
|
||||
data = b"".join(chunks)
|
||||
filename = os.path.basename(urlparse(final_url).path) or f"image.{ext}"
|
||||
return data, ext, content_type, filename
|
||||
|
||||
|
||||
def save_downloaded_image(data, ext, content_type, original_name, user, folder):
|
||||
stored_name = f"{uuid.uuid4().hex}.{ext}"
|
||||
safe_original = secure_filename(original_name) or f"photo.{ext}"
|
||||
if not safe_original.lower().endswith(f".{ext}"):
|
||||
safe_original = f"{safe_original.rsplit('.', 1)[0]}.{ext}"
|
||||
|
||||
stream = BytesIO(data)
|
||||
file_storage = FileStorage(
|
||||
stream=stream,
|
||||
filename=safe_original,
|
||||
content_type=content_type or f"image/{ext}",
|
||||
)
|
||||
|
||||
_path, file_size, storage_backend, sync_errors = save_photo_file(file_storage, stored_name)
|
||||
photo = Photo(
|
||||
filename=stored_name,
|
||||
original_name=safe_original,
|
||||
file_size=file_size,
|
||||
mime_type=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)
|
||||
return photo, sync_errors
|
||||
|
||||
|
||||
def process_url_uploads(raw_urls, user, folder, allowed_extensions, max_upload_mb):
|
||||
settings = get_settings()
|
||||
max_bulk = settings.max_bulk_upload or 100
|
||||
urls = parse_image_urls(raw_urls)
|
||||
|
||||
if not urls:
|
||||
return {"uploaded": 0, "errors": ["Ссылки не указаны"], "photos": []}
|
||||
|
||||
if len(urls) > max_bulk:
|
||||
return {
|
||||
"uploaded": 0,
|
||||
"errors": [f"Максимум {max_bulk} ссылок за раз"],
|
||||
"photos": [],
|
||||
}
|
||||
|
||||
ok, photo_limit_msg = check_photo_count_limit(user, len(urls))
|
||||
if not ok:
|
||||
return {"uploaded": 0, "errors": [photo_limit_msg], "photos": []}
|
||||
|
||||
max_bytes = max_upload_mb * 1024 * 1024
|
||||
errors = []
|
||||
uploaded_photos = []
|
||||
pending_sizes = []
|
||||
|
||||
for url in urls:
|
||||
if not is_safe_image_url(url):
|
||||
errors.append(f"{url}: недопустимый URL")
|
||||
continue
|
||||
try:
|
||||
data, ext, content_type, filename = download_image(url, max_bytes)
|
||||
if ext not in allowed_extensions:
|
||||
errors.append(f"{url}: недопустимый формат")
|
||||
continue
|
||||
pending_sizes.append((url, data, ext, content_type, filename, len(data)))
|
||||
except requests.RequestException:
|
||||
errors.append(f"{url}: не удалось скачать")
|
||||
except ValueError as exc:
|
||||
errors.append(f"{url}: {exc}")
|
||||
|
||||
if not pending_sizes:
|
||||
return {"uploaded": 0, "errors": errors, "photos": []}
|
||||
|
||||
total_size = sum(item[5] for item in pending_sizes)
|
||||
ok, quota_msg = check_upload_quota(user, total_size)
|
||||
if not ok:
|
||||
return {"uploaded": 0, "errors": [quota_msg], "photos": []}
|
||||
|
||||
for url, data, ext, content_type, filename, _size in pending_sizes:
|
||||
try:
|
||||
photo, sync_errors = save_downloaded_image(
|
||||
data, ext, content_type, filename, user, folder
|
||||
)
|
||||
for sync_err in sync_errors:
|
||||
errors.append(f"{filename}: {sync_err}")
|
||||
uploaded_photos.append(photo)
|
||||
except Exception as exc:
|
||||
errors.append(f"{url}: {exc}")
|
||||
|
||||
if uploaded_photos:
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
"uploaded": len(uploaded_photos),
|
||||
"errors": errors,
|
||||
"photos": uploaded_photos,
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -29,11 +29,30 @@ 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
|
||||
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost}
|
||||
WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-PhotoHost}
|
||||
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-http://localhost:8080}
|
||||
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:
|
||||
|
||||
@@ -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 "$@"
|
||||
@@ -0,0 +1,6 @@
|
||||
bind = "0.0.0.0:8000"
|
||||
workers = 2
|
||||
timeout = 120
|
||||
accesslog = "-"
|
||||
errorlog = "-"
|
||||
loglevel = "info"
|
||||
+10
@@ -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
|
||||
@@ -6,3 +6,8 @@ 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
|
||||
requests==2.32.3
|
||||
qrcode[pil]==8.0
|
||||
webauthn==2.2.0
|
||||
|
||||
Reference in New Issue
Block a user