15 Commits

Author SHA1 Message Date
test2 0584ebdc74 Release v2.2: admin auth settings, Passkey RP ID, Cloudflare and Google captcha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 02:57:49 +03:00
test2 0a51001791 Release v2.1: GDPR, passkeys, session management, admin redesign
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 02:43:57 +03:00
test2 d4f0eaa7d9 Release v2.0: URL upload, BBCode sharing, QR codes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 02:36:59 +03:00
test2 49abcc20b4 Fix migration order: add group limit columns before ORM queries
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 23:03:54 +03:00
test2 b014e64c5d Fix worker crash: remove broken gunicorn post_fork hook
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 23:02:32 +03:00
test2 e334a7b32c Fix empty reply: remove gunicorn preload, isolate DB init from workers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 23:00:43 +03:00
test2 5353c82066 Fix 502 after update: safe startup, single DB init, healthcheck
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:58:09 +03:00
test2 82fdb60f5e Document v1.4 git deploy fix in README
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:53:07 +03:00
test2 28eb9e19f5 Fix git deploy .git/config permission denied via docker exec fallback
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:51:45 +03:00
test2 69715ecd06 v1.4: group folder/photo limits and ad banners
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:50:10 +03:00
test2 6a6704bc4b Fix git deploy permissions: entrypoint chown and no remote set-url
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:45:55 +03:00
test2 d10f25eb06 Fix git safe.directory for mounted /repo in Docker
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:43:09 +03:00
test2 c1aac7ecac Release 1.2: bulk upload, S3/SFTP/FTP, SMTP, password reset, user groups, git deploy
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:38:37 +03:00
test2 db2cef41bb Add folders with password sharing and email invites
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:30:00 +03:00
test2 a375ad330a Add server update instructions for new Git releases
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:24:51 +03:00
68 changed files with 6135 additions and 260 deletions
+15
View File
@@ -13,3 +13,18 @@ APP_PORT=8080
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.com ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=change_me_admin_password ADMIN_PASSWORD=change_me_admin_password
# Default user group quota in MB (0 = unlimited)
DEFAULT_GROUP_QUOTA_MB=100
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
View File
@@ -5,6 +5,9 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \ libpq-dev \
gcc \ gcc \
git \
gosu \
docker.io \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
@@ -12,13 +15,17 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
RUN mkdir -p /app/uploads && adduser --disabled-password --gecos "" appuser \ RUN mkdir -p /app/uploads \
&& chown -R appuser:appuser /app && adduser --disabled-password --gecos "" appuser \
&& chown -R appuser:appuser /app \
USER appuser && chmod +x /app/entrypoint.sh
ENV FLASK_APP=wsgi:app 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 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"]
+238 -1
View File
@@ -221,6 +221,86 @@ sudo systemctl start docker
--- ---
## Обновление до новой версии на сервере
Когда выходит новая версия в Git, обновите проект на сервере без потери данных (БД и фото хранятся в Docker volumes, файл `.env` не перезаписывается).
### Быстрое обновление (последняя версия из `main`)
```bash
cd ~/fotohost
git pull origin main
docker compose up -d --build
docker compose ps
docker compose logs --tail=50 web
```
### Обновление до конкретного релиза (рекомендуется)
Список доступных версий:
```bash
cd ~/fotohost
git fetch --tags
git tag -l
```
Пример — установить релиз **v1.0-beta**:
```bash
cd ~/fotohost
git fetch --tags
git checkout v1.0-beta
docker compose up -d --build
docker compose ps
```
Вернуться на последнюю dev-версию из `main`:
```bash
cd ~/fotohost
git checkout main
git pull origin main
docker compose up -d --build
```
### Перед обновлением (рекомендуется)
```bash
cd ~/fotohost
# Бэкап базы данных
docker compose exec db pg_dump -U photohost photohost > backup_$(date +%Y%m%d_%H%M).sql
# Проверить, не появились ли новые переменные в .env.example
diff .env .env.example || true
nano .env
```
Если в `.env.example` появились новые строки — добавьте их в свой `.env` вручную.
### После обновления — проверка
```bash
docker compose ps
curl -I http://127.0.0.1:8080
docker compose logs --tail=100 web
```
Откройте сайт в браузере и проверьте вход, загрузку фото и админку.
### Если что-то пошло не так — откат на предыдущий тег
```bash
cd ~/fotohost
git checkout v1.0-beta
docker compose up -d --build
```
> **Важно:** команда `docker compose up -d --build` пересобирает контейнер `web`, но **не удаляет** volumes с PostgreSQL и загруженными фото.
---
## Регистрация, авторизация и роли ## Регистрация, авторизация и роли
| URL | Описание | | URL | Описание |
@@ -232,6 +312,8 @@ sudo systemctl start docker
| `/cabinet/profile` | Настройки профиля, смена пароля | | `/cabinet/profile` | Настройки профиля, смена пароля |
| `/admin/` | Панель администратора (только admin) | | `/admin/` | Панель администратора (только admin) |
| `/admin/users` | Управление пользователями | | `/admin/users` | Управление пользователями |
| `/admin/groups` | Группы: квота диска, лимиты папок и фото |
| `/admin/banners` | Рекламные баннеры на сайте |
| `/admin/photos` | Все фото на сервере | | `/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` | Вход | | POST | `/auth/login` | Вход |
| GET | `/cabinet/` | Личный кабинет | | GET | `/cabinet/` | Личный кабинет |
| GET | `/admin/` | Админ-панель | | GET | `/admin/` | Админ-панель |
| POST | `/upload` | Загрузка фото (auth) | | GET | `/admin/banners` | Управление рекламными баннерами |
| GET | `/photo/<id>/qr` | QR-код для прямой ссылки на фото |
| POST | `/upload` | Загрузка фото или по URL (auth) |
| GET | `/uploads/<filename>` | Прямая ссылка на файл | | GET | `/uploads/<filename>` | Прямая ссылка на файл |
| GET | `/api/photos` | JSON-список всех фото | | GET | `/api/photos` | JSON-список всех фото |
| POST | `/delete/<id>` | Удаление фото | | POST | `/delete/<id>` | Удаление фото |
@@ -421,6 +636,28 @@ docker compose restart web
docker compose down && docker compose up -d 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`
--- ---
## Технологии ## Технологии
+108 -6
View File
@@ -21,7 +21,7 @@ def load_user(user_id):
return db.session.get(User, int(user_id)) return db.session.get(User, int(user_id))
def create_app(): def create_app(setup_database=True):
app = Flask(__name__) app = Flask(__name__)
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-change-me") 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", "postgresql://photohost:photohost_secret@localhost:5432/photohost",
) )
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False 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["UPLOAD_FOLDER"] = os.getenv("UPLOAD_FOLDER", "uploads")
app.config["MAX_CONTENT_LENGTH"] = int(os.getenv("MAX_UPLOAD_MB", "10")) * 1024 * 1024 app.config["MAX_CONTENT_LENGTH"] = int(os.getenv("MAX_UPLOAD_MB", "10")) * 1024 * 1024
app.config["ALLOWED_EXTENSIONS"] = {"png", "jpg", "jpeg", "gif", "webp", "bmp"} app.config["ALLOWED_EXTENSIONS"] = {"png", "jpg", "jpeg", "gif", "webp", "bmp"}
app.config["GIT_REPO_PATH"] = os.getenv("GIT_REPO_PATH", "/repo")
app.config["ALLOW_GIT_DEPLOY"] = os.getenv("ALLOW_GIT_DEPLOY", "false").lower() in (
"1",
"true",
"yes",
)
app.config["DEFAULT_GROUP_QUOTA_MB"] = int(os.getenv("DEFAULT_GROUP_QUOTA_MB", "100"))
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True) os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
@@ -42,26 +50,120 @@ def create_app():
from .routes import bp as main_bp, cabinet_bp from .routes import bp as main_bp, cabinet_bp
from .auth import bp as auth_bp from .auth import bp as auth_bp
from .admin import bp as admin_bp from .admin import bp as admin_bp
from .folders import bp as folders_bp
from .legal import bp as legal_bp
from .passkey import bp as passkey_bp
app.register_blueprint(main_bp) app.register_blueprint(main_bp)
app.register_blueprint(cabinet_bp) app.register_blueprint(cabinet_bp)
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(admin_bp) app.register_blueprint(admin_bp)
app.register_blueprint(folders_bp)
app.register_blueprint(legal_bp)
app.register_blueprint(passkey_bp)
register_request_hooks(app)
register_cli(app) register_cli(app)
# Ensure models are registered even when DB setup runs in init_db.py.
with app.app_context(): 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,
)
db.create_all() @app.context_processor
from app.bootstrap import create_first_admin, ensure_schema def inject_banners():
from app.banner_service import get_banners_by_position
ensure_schema() try:
create_first_admin(app) 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()
run_schema_migrations()
if os.getenv("SKIP_DB_INIT") != "1":
ensure_default_group(app)
ensure_site_settings(app)
create_first_admin(app)
return 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): def register_cli(app):
@app.cli.command("create-admin") @app.cli.command("create-admin")
def create_admin_command(): def create_admin_command():
+278 -8
View File
@@ -6,7 +6,19 @@ from sqlalchemy import func
from app import db from app import db
from app.auth_utils import admin_required from app.auth_utils import admin_required
from app.models import Photo, User from app.bootstrap import slugify
from app.deploy_utils import (
checkout_version,
deploy_rebuild,
fetch_remote,
get_current_version,
get_deploy_status,
is_deploy_enabled,
)
from app.models import 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") bp = Blueprint("admin", __name__, url_prefix="/admin")
@@ -18,17 +30,21 @@ def dashboard():
"users": User.query.count(), "users": User.query.count(),
"photos": Photo.query.count(), "photos": Photo.query.count(),
"admins": User.query.filter_by(is_admin=True).count(), "admins": User.query.filter_by(is_admin=True).count(),
"groups": UserGroup.query.count(),
"storage": int( "storage": int(
db.session.query(func.coalesce(func.sum(Photo.file_size), 0)).scalar() or 0 db.session.query(func.coalesce(func.sum(Photo.file_size), 0)).scalar() or 0
), ),
} }
recent_users = User.query.order_by(User.created_at.desc()).limit(5).all() recent_users = User.query.order_by(User.created_at.desc()).limit(5).all()
recent_photos = Photo.query.order_by(Photo.created_at.desc()).limit(8).all() recent_photos = Photo.query.order_by(Photo.created_at.desc()).limit(8).all()
current_version, _ = get_current_version()
return render_template( return render_template(
"admin/dashboard.html", "admin/dashboard.html",
stats=stats, stats=stats,
recent_users=recent_users, recent_users=recent_users,
recent_photos=recent_photos, recent_photos=recent_photos,
current_version=current_version,
deploy_enabled=is_deploy_enabled(),
) )
@@ -36,7 +52,20 @@ def dashboard():
@admin_required @admin_required
def users(): def users():
all_users = User.query.order_by(User.created_at.desc()).all() all_users = User.query.order_by(User.created_at.desc()).all()
return render_template("admin/users.html", users=all_users) groups = UserGroup.query.order_by(UserGroup.name).all()
return render_template("admin/users.html", users=all_users, groups=groups)
@bp.route("/users/<int:user_id>/set-group", methods=["POST"])
@admin_required
def set_user_group(user_id):
user = User.query.get_or_404(user_id)
group_id = request.form.get("group_id", type=int)
group = UserGroup.query.get_or_404(group_id)
user.group_id = group.id
db.session.commit()
flash(f"Пользователь {user.username} перемещён в группу «{group.name}»", "success")
return redirect(url_for("admin.users"))
@bp.route("/users/<int:user_id>/toggle-admin", methods=["POST"]) @bp.route("/users/<int:user_id>/toggle-admin", methods=["POST"])
@@ -87,9 +116,7 @@ def delete_user(user_id):
return redirect(url_for("admin.users")) return redirect(url_for("admin.users"))
for photo in user.photos.all(): for photo in user.photos.all():
filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], photo.filename) delete_photo_file(photo.filename, photo.storage_backend)
if os.path.exists(filepath):
os.remove(filepath)
db.session.delete(photo) db.session.delete(photo)
db.session.delete(user) db.session.delete(user)
@@ -98,6 +125,199 @@ def delete_user(user_id):
return redirect(url_for("admin.users")) return redirect(url_for("admin.users"))
@bp.route("/groups", methods=["GET", "POST"])
@admin_required
def groups():
if request.method == "POST":
name = request.form.get("name", "").strip()
quota_mb = request.form.get("disk_quota_mb", type=int) or 100
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") @bp.route("/photos")
@admin_required @admin_required
def photos(): def photos():
@@ -109,10 +329,60 @@ def photos():
@admin_required @admin_required
def delete_photo(photo_id): def delete_photo(photo_id):
photo = Photo.query.get_or_404(photo_id) photo = Photo.query.get_or_404(photo_id)
filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], photo.filename) delete_photo_file(photo.filename, photo.storage_backend)
if os.path.exists(filepath):
os.remove(filepath)
db.session.delete(photo) db.session.delete(photo)
db.session.commit() db.session.commit()
flash("Фото удалено", "success") flash("Фото удалено", "success")
return redirect(url_for("admin.photos")) return redirect(url_for("admin.photos"))
@bp.route("/deploy", methods=["GET", "POST"])
@admin_required
def deploy():
status = get_deploy_status()
if request.method == "POST":
action = request.form.get("action")
if action == "fetch":
ok, msg = fetch_remote()
flash(msg if ok else msg, "success" if ok else "error")
elif action == "checkout":
ref = request.form.get("ref", "").strip()
ok, msg = checkout_version(ref)
flash(msg, "success" if ok else "error")
elif action == "rebuild":
ok, msg = deploy_rebuild()
flash(msg, "success" if ok else "error")
else:
flash("Неизвестное действие", "error")
return redirect(url_for("admin.deploy"))
return render_template("admin/deploy.html", status=status)
@bp.route("/settings", methods=["GET", "POST"])
@admin_required
def settings():
site_settings = get_settings()
if request.method == "POST":
action = request.form.get("action", "save")
if action == "test_smtp":
from app.email_service import send_email
ok, msg = send_email(
current_user.email,
"PhotoHost — тест SMTP",
"SMTP настроен корректно.",
)
flash(msg, "success" if ok else "error")
return redirect(url_for("admin.settings"))
update_settings_from_form(request.form)
flash("Настройки сохранены", "success")
return redirect(url_for("admin.settings"))
return render_template("admin/settings.html", settings=site_settings)
+104 -3
View File
@@ -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 flask_login import current_user, login_user, logout_user
from app import db 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") bp = Blueprint("auth", __name__, url_prefix="/auth")
def _registration_allowed():
return get_settings().registration_enabled
@bp.route("/register", methods=["GET", "POST"]) @bp.route("/register", methods=["GET", "POST"])
def register(): def register():
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for("cabinet.index")) return redirect(url_for("cabinet.index"))
if not _registration_allowed():
flash("Регистрация новых пользователей отключена", "error")
return redirect(url_for("auth.login"))
if request.method == "POST": 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() username = request.form.get("username", "").strip()
email = request.form.get("email", "").strip().lower() email = request.form.get("email", "").strip().lower()
password = request.form.get("password", "") password = request.form.get("password", "")
@@ -29,12 +47,22 @@ def register():
elif User.query.filter_by(email=email).first(): elif User.query.filter_by(email=email).first():
flash("Этот email уже зарегистрирован", "error") flash("Этот email уже зарегистрирован", "error")
else: else:
user = User(username=username, email=email) default_group = UserGroup.query.filter_by(is_default=True).first()
user = User(
username=username,
email=email,
group_id=default_group.id if default_group else None,
)
user.set_password(password) user.set_password(password)
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
login_user(user) login_user(user)
create_user_session(user)
accepted = process_pending_invites(user)
send_welcome_email(user)
flash("Регистрация успешна. Добро пожаловать!", "success") flash("Регистрация успешна. Добро пожаловать!", "success")
if accepted:
flash(f"Вам открыт доступ к {accepted} общим папкам", "success")
return redirect(url_for("cabinet.index")) return redirect(url_for("cabinet.index"))
return render_template("auth/register.html") return render_template("auth/register.html")
@@ -45,7 +73,18 @@ def login():
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for("cabinet.index")) 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 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() login = request.form.get("login", "").strip()
password = request.form.get("password", "") password = request.form.get("password", "")
remember = request.form.get("remember") == "on" remember = request.form.get("remember") == "on"
@@ -54,13 +93,19 @@ def login():
(User.username == login) | (User.email == login.lower()) (User.username == login) | (User.email == login.lower())
).first() ).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") flash("Неверный логин или пароль", "error")
elif not user.is_active: elif not user.is_active:
flash("Аккаунт заблокирован", "error") flash("Аккаунт заблокирован", "error")
else: else:
login_user(user, remember=remember) login_user(user, remember=remember)
create_user_session(user, remember=remember)
accepted = process_pending_invites(user)
flash(f"Добро пожаловать, {user.username}!", "success") flash(f"Добро пожаловать, {user.username}!", "success")
if accepted:
flash(f"Вам открыт доступ к {accepted} общим папкам", "success")
next_page = request.args.get("next") next_page = request.args.get("next")
if next_page: if next_page:
return redirect(next_page) return redirect(next_page)
@@ -71,8 +116,64 @@ def login():
return render_template("auth/login.html") return render_template("auth/login.html")
@bp.route("/forgot-password", methods=["GET", "POST"])
def forgot_password():
if current_user.is_authenticated:
return redirect(url_for("cabinet.index"))
if request.method == "POST":
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") @bp.route("/logout")
def logout(): def logout():
revoke_current_session()
logout_user() logout_user()
flash("Вы вышли из аккаунта", "success") flash("Вы вышли из аккаунта", "success")
return redirect(url_for("main.index")) return redirect(url_for("main.index"))
+11 -1
View File
@@ -3,6 +3,9 @@ from functools import wraps
from flask import abort, flash, redirect, url_for from flask import abort, flash, redirect, url_for
from flask_login import current_user from flask_login import current_user
from app.folder_utils import can_edit_folder, is_folder_owner
from app.models import FolderMember
def admin_required(f): def admin_required(f):
@wraps(f) @wraps(f)
@@ -20,7 +23,14 @@ def can_manage_photo(photo):
return False return False
if current_user.is_admin: if current_user.is_admin:
return True return True
return photo.user_id == current_user.id if photo.user_id == current_user.id:
return True
if photo.folder_id and photo.folder:
if is_folder_owner(photo.folder, current_user):
return True
if can_edit_folder(photo.folder, current_user):
return True
return False
def photo_owner_or_admin(photo): def photo_owner_or_admin(photo):
+16
View File
@@ -0,0 +1,16 @@
from app.models import AdBanner
def get_banners(position=None):
query = AdBanner.query.filter_by(is_active=True).order_by(AdBanner.sort_order, AdBanner.id)
if position:
query = query.filter_by(position=position)
return query.all()
def get_banners_by_position():
banners = get_banners()
grouped = {}
for banner in banners:
grouped.setdefault(banner.position, []).append(banner)
return grouped
+175 -7
View File
@@ -1,9 +1,10 @@
import os import os
import re
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
from app import db from app import db
from app.models import User from app.models import User, UserGroup
def ensure_schema(): def ensure_schema():
@@ -11,12 +12,170 @@ def ensure_schema():
tables = inspector.get_table_names() tables = inspector.get_table_names()
if "photos" in tables: if "photos" in tables:
columns = {col["name"] for col in inspector.get_columns("photos")} db.session.execute(
if "user_id" not in columns: text(
db.session.execute( "ALTER TABLE photos ADD COLUMN IF NOT EXISTS "
text("ALTER TABLE photos ADD COLUMN user_id INTEGER REFERENCES users(id)") "user_id INTEGER REFERENCES users(id)"
) )
db.session.commit() )
db.session.commit()
if "users" in tables and "user_groups" in tables:
db.session.execute(
text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS "
"group_id INTEGER REFERENCES user_groups(id)"
)
)
db.session.commit()
def ensure_default_group(app):
default_quota = int(os.getenv("DEFAULT_GROUP_QUOTA_MB", "100"))
default_max_folders = int(os.getenv("DEFAULT_GROUP_MAX_FOLDERS", "10"))
default_max_photos = int(os.getenv("DEFAULT_GROUP_MAX_PHOTOS", "500"))
default_group = UserGroup.query.filter_by(is_default=True).first()
if not default_group:
default_group = UserGroup.query.filter_by(slug="users").first()
if default_group:
default_group.is_default = True
else:
default_group = UserGroup(
name="Пользователи",
slug="users",
disk_quota_mb=default_quota,
max_folders=default_max_folders,
max_photos=default_max_photos,
is_default=True,
)
db.session.add(default_group)
db.session.commit()
app.logger.info(
"Default user group 'users' created (quota=%s MB, folders=%s, photos=%s)",
default_quota,
default_max_folders,
default_max_photos,
)
User.query.filter(User.group_id.is_(None)).update({"group_id": default_group.id})
db.session.commit()
def ensure_group_limit_columns():
inspector = inspect(db.engine)
if "user_groups" not in inspector.get_table_names():
return
db.session.execute(
text(
"ALTER TABLE user_groups ADD COLUMN IF NOT EXISTS "
"max_folders INTEGER NOT NULL DEFAULT 10"
)
)
db.session.execute(
text(
"ALTER TABLE user_groups ADD COLUMN IF NOT EXISTS "
"max_photos INTEGER NOT NULL DEFAULT 500"
)
)
db.session.commit()
def ensure_site_settings(app):
from app.models import SiteSettings
if SiteSettings.query.get(1) is None:
db.session.add(SiteSettings(id=1))
db.session.commit()
app.logger.info("Site settings initialized")
def ensure_photo_storage_column():
inspector = inspect(db.engine)
if "photos" not in inspector.get_table_names():
return
db.session.execute(
text(
"ALTER TABLE photos ADD COLUMN IF NOT EXISTS "
"storage_backend VARCHAR(20) DEFAULT 'local'"
)
)
db.session.commit()
def 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): def create_first_admin(app):
@@ -31,15 +190,24 @@ def create_first_admin(app):
if User.query.filter_by(is_admin=True).first(): if User.query.filter_by(is_admin=True).first():
return None return None
default_group = UserGroup.query.filter_by(is_default=True).first()
if User.query.filter_by(username=username).first(): if User.query.filter_by(username=username).first():
user = User.query.filter_by(username=username).first() user = User.query.filter_by(username=username).first()
user.is_admin = True user.is_admin = True
user.set_password(password) user.set_password(password)
if default_group and not user.group_id:
user.group_id = default_group.id
db.session.commit() db.session.commit()
app.logger.info("Existing user '%s' promoted to admin", username) app.logger.info("Existing user '%s' promoted to admin", username)
return user return user
user = User(username=username, email=email, is_admin=True) user = User(
username=username,
email=email,
is_admin=True,
group_id=default_group.id if default_group else None,
)
user.set_password(password) user.set_password(password)
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
+123
View File
@@ -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
+258
View File
@@ -0,0 +1,258 @@
import os
import re
import subprocess
REF_PATTERN = re.compile(r"^[a-zA-Z0-9._/-]+$")
def is_deploy_enabled():
return os.getenv("ALLOW_GIT_DEPLOY", "false").lower() in ("1", "true", "yes")
def get_repo_path():
return os.getenv("GIT_REPO_PATH", "/repo")
def get_git_remote():
return os.getenv("GIT_REMOTE_URL", "").strip()
def get_container_name():
return os.getenv("CONTAINER_NAME", os.getenv("HOSTNAME", "photohost-web"))
def _repo_ready():
repo = get_repo_path()
return os.path.isdir(repo) and os.path.isdir(os.path.join(repo, ".git"))
def _docker_available():
try:
result = subprocess.run(
["docker", "info"],
capture_output=True,
text=True,
timeout=10,
)
return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
def _git_base_cmd(repo):
return ["git", "-c", f"safe.directory={repo}", "-C", repo]
def _run_subprocess(cmd, timeout=120, cwd=None):
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
cwd=cwd,
)
output = (result.stderr or result.stdout or "").strip()
return result.returncode == 0, output
def _run_git_as_root(args, timeout=120):
repo = get_repo_path()
container = get_container_name()
cmd = ["docker", "exec", "-u", "0", container] + _git_base_cmd(repo) + args
return _run_subprocess(cmd, timeout=timeout)
def _fix_repo_permissions():
if not _docker_available() or not _repo_ready():
return False
repo = get_repo_path()
container = get_container_name()
fix_cmd = [
"docker",
"exec",
"-u",
"0",
container,
"sh",
"-c",
f"chown -R appuser:appuser {repo} && chmod -R u+rwX {repo}/.git",
]
ok, _ = _run_subprocess(fix_cmd, timeout=60)
return ok
def run_git(args, timeout=120):
if not _repo_ready():
return False, f"Git-репозиторий не найден: {get_repo_path()}"
repo = get_repo_path()
cmd = _git_base_cmd(repo) + args
ok, output = _run_subprocess(cmd, timeout=timeout)
if ok:
return True, output
permission_error = "permission denied" in output.lower() and "config" in output.lower()
if permission_error and _docker_available():
_fix_repo_permissions()
ok, output = _run_subprocess(cmd, timeout=timeout)
if ok:
return True, output
ok, output = _run_git_as_root(args, timeout=timeout)
if ok:
return True, output
return False, output or "Git error"
def run_ls_remote(extra_args=None, timeout=60):
remote = get_git_remote()
if not remote:
return False, "GIT_REMOTE_URL не задан", []
cmd = ["git", "ls-remote", remote]
if extra_args:
cmd.extend(extra_args)
ok, output = _run_subprocess(cmd, timeout=timeout)
if not ok:
return False, output or "ls-remote error", []
return True, "", output.splitlines()
def fetch_remote():
remote = get_git_remote()
if not remote:
return run_git(["fetch", "--all", "--tags", "--prune"], timeout=180)
# Fetch by URL only — never run `git remote set-url` or other config writes.
return run_git(
[
"fetch",
"--tags",
"--prune",
remote,
"+refs/heads/*:refs/heads/*",
"+refs/tags/*:refs/tags/*",
],
timeout=180,
)
def list_tags():
ok, err, lines = run_ls_remote(["--tags"])
if not ok:
return [], err
tags = []
for line in lines:
parts = line.split()
if len(parts) < 2:
continue
ref = parts[1]
if not ref.startswith("refs/tags/"):
continue
tag = ref.removeprefix("refs/tags/")
if tag.endswith("^{}"):
continue
tags.append(tag)
tags = sorted(set(tags), reverse=True)
return tags, None
def list_branches():
ok, err, lines = run_ls_remote(["--heads"])
if not ok:
return [], err
branches = []
for line in lines:
parts = line.split()
if len(parts) < 2:
continue
ref = parts[1]
if ref.startswith("refs/heads/"):
branches.append(ref.removeprefix("refs/heads/"))
return sorted(set(branches)), None
def get_current_version():
if not _repo_ready():
return None, "Репозиторий недоступен"
ok, tag = run_git(["describe", "--tags", "--always"])
if ok and tag:
return tag, None
ok, branch = run_git(["rev-parse", "--abbrev-ref", "HEAD"])
if ok:
return branch, None
return "unknown", None
def checkout_version(ref):
if not ref or not REF_PATTERN.match(ref):
return False, "Недопустимое имя версии"
ok, msg = fetch_remote()
if not ok:
return False, msg
ok, msg = run_git(["checkout", ref])
if not ok:
return False, msg
return True, f"Переключено на {ref}"
def deploy_rebuild():
if not is_deploy_enabled():
return False, "Обновление через админку отключено (ALLOW_GIT_DEPLOY=false)"
repo = get_repo_path()
compose_file = os.path.join(repo, "docker-compose.yml")
if not os.path.isfile(compose_file):
return False, f"Не найден {compose_file}"
commands = [
["docker", "compose", "-f", compose_file, "up", "-d", "--build"],
["docker-compose", "-f", compose_file, "up", "-d", "--build"],
]
last_error = ""
for cmd in commands:
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=600,
cwd=repo,
)
if result.returncode == 0:
return True, result.stdout or "Контейнеры пересобраны и запущены"
last_error = result.stderr or result.stdout
except FileNotFoundError:
last_error = f"Команда не найдена: {cmd[0]}"
except subprocess.TimeoutExpired:
return False, "Превышено время ожидания пересборки (10 мин)"
return False, last_error or "Не удалось выполнить docker compose"
def get_deploy_status():
current, _ = get_current_version()
tags, tags_err = list_tags()
branches, branches_err = list_branches()
return {
"enabled": is_deploy_enabled(),
"repo_path": get_repo_path(),
"repo_ready": _repo_ready(),
"remote_url": get_git_remote(),
"current": current,
"tags": tags[:30],
"branches": branches[:30],
"tags_error": tags_err,
"branches_error": branches_err,
}
+71
View File
@@ -0,0 +1,71 @@
import io
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from flask import current_app, url_for
from app.settings_service import get_settings
def send_email(to_email, subject, body_text, body_html=None):
settings = get_settings()
if not settings.smtp_enabled:
current_app.logger.warning("SMTP disabled, email not sent to %s", to_email)
return False, "SMTP не включён в настройках админки"
if not settings.smtp_host or not settings.smtp_from_email:
return False, "SMTP host или from email не настроены"
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{settings.smtp_from_name} <{settings.smtp_from_email}>"
msg["To"] = to_email
msg.attach(MIMEText(body_text, "plain", "utf-8"))
if body_html:
msg.attach(MIMEText(body_html, "html", "utf-8"))
try:
if settings.smtp_use_tls:
server = smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30)
server.starttls()
else:
server = smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30)
if settings.smtp_username and settings.smtp_password:
server.login(settings.smtp_username, settings.smtp_password)
server.sendmail(settings.smtp_from_email, [to_email], msg.as_string())
server.quit()
return True, "Email отправлен"
except Exception as exc:
current_app.logger.exception("SMTP error")
return False, str(exc)
def send_password_reset_email(user, token):
reset_url = url_for("auth.reset_password", token=token, _external=True)
subject = "PhotoHost — сброс пароля"
body = (
f"Здравствуйте, {user.username}!\n\n"
f"Для сброса пароля перейдите по ссылке:\n{reset_url}\n\n"
"Ссылка действует 24 часа. Если вы не запрашивали сброс — проигнорируйте письмо."
)
html = (
f"<p>Здравствуйте, <strong>{user.username}</strong>!</p>"
f'<p><a href="{reset_url}">Сбросить пароль</a></p>'
"<p>Ссылка действует 24 часа.</p>"
)
return send_email(user.email, subject, body, html)
def send_welcome_email(user):
subject = "PhotoHost — регистрация успешна"
body = f"Добро пожаловать, {user.username}! Ваш аккаунт на PhotoHost создан."
return send_email(user.email, subject, body)
def send_upload_notification(user, count, folder_name=None):
location = f" в папку «{folder_name}»" if folder_name else ""
subject = f"PhotoHost — загружено {count} фото"
body = f"Загружено {count} фото{location}."
return send_email(user.email, subject, body)
+94
View File
@@ -0,0 +1,94 @@
from flask import session
from app.models import Folder, FolderMember
def get_unlocked_folder_ids():
return session.get("unlocked_folders", [])
def unlock_folder(folder_id):
unlocked = session.get("unlocked_folders", [])
if folder_id not in unlocked:
unlocked.append(folder_id)
session["unlocked_folders"] = unlocked
session.modified = True
def is_folder_unlocked(folder):
if not folder.has_password:
return True
return folder.id in get_unlocked_folder_ids()
def get_folder_member(folder, user):
if not user or not user.is_authenticated:
return None
return FolderMember.query.filter_by(folder_id=folder.id, user_id=user.id).first()
def is_folder_owner(folder, user):
return user and user.is_authenticated and folder.owner_id == user.id
def can_view_folder(folder, user=None):
from flask_login import current_user
user = user or current_user
if is_folder_owner(folder, user):
return True
if user and user.is_authenticated and user.is_admin:
return True
member = get_folder_member(folder, user)
if member:
return True
if not folder.is_private and is_folder_unlocked(folder):
return True
if is_folder_unlocked(folder):
return True
return False
def can_edit_folder(folder, user=None):
from flask_login import current_user
user = user or current_user
if is_folder_owner(folder, user):
return True
if user and user.is_authenticated and user.is_admin:
return True
member = get_folder_member(folder, user)
return member is not None and member.role == "editor"
def can_manage_folder_settings(folder, user=None):
from flask_login import current_user
user = user or current_user
return is_folder_owner(folder, user) or (user and user.is_authenticated and user.is_admin)
def process_pending_invites(user):
from app import db
from app.models import FolderInvite
invites = FolderInvite.query.filter_by(email=user.email.lower()).all()
accepted = 0
for invite in invites:
existing = FolderMember.query.filter_by(
folder_id=invite.folder_id, user_id=user.id
).first()
if not existing:
db.session.add(
FolderMember(
folder_id=invite.folder_id,
user_id=user.id,
role=invite.role,
added_by_id=invite.invited_by_id,
)
)
accepted += 1
db.session.delete(invite)
if invites:
db.session.commit()
return accepted
+328
View File
@@ -0,0 +1,328 @@
import os
from flask import (
Blueprint,
abort,
flash,
redirect,
render_template,
request,
url_for,
)
from flask_login import current_user, login_required
from sqlalchemy import inspect, text
from app import db
from app.folder_utils import (
can_edit_folder,
can_manage_folder_settings,
can_view_folder,
is_folder_unlocked,
process_pending_invites,
unlock_folder,
)
from app.models import Folder, FolderInvite, FolderMember, Photo, User
from app.quota_utils import check_folder_limit
from app.settings_service import get_settings
from app.storage_service import delete_photo_file
bp = Blueprint("folders", __name__)
@bp.route("/cabinet/folders")
@login_required
def list_folders():
from app.quota_utils import quota_status
process_pending_invites(current_user)
owned = Folder.query.filter_by(owner_id=current_user.id).order_by(Folder.created_at.desc()).all()
shared = (
Folder.query.join(FolderMember)
.filter(
FolderMember.user_id == current_user.id,
Folder.owner_id != current_user.id,
)
.order_by(Folder.created_at.desc())
.all()
)
return render_template(
"cabinet/folders/list.html",
owned_folders=owned,
shared_folders=shared,
quota=quota_status(current_user),
)
@bp.route("/cabinet/folders/create", methods=["POST"])
@login_required
def create_folder():
name = request.form.get("name", "").strip()
is_private = request.form.get("is_private") == "on"
access_password = request.form.get("access_password", "").strip()
if len(name) < 2:
flash("Название папки — минимум 2 символа", "error")
return redirect(url_for("folders.list_folders"))
ok, limit_msg = check_folder_limit(current_user)
if not ok:
flash(limit_msg, "error")
return redirect(url_for("folders.list_folders"))
folder = Folder(name=name, owner_id=current_user.id, is_private=is_private)
if access_password:
if len(access_password) < 4:
flash("Пароль папки — минимум 4 символа", "error")
return redirect(url_for("folders.list_folders"))
folder.set_access_password(access_password)
db.session.add(folder)
db.session.commit()
flash(f"Папка «{folder.name}» создана", "success")
return redirect(url_for("folders.view_folder", folder_id=folder.id))
@bp.route("/cabinet/folders/<int:folder_id>")
@login_required
def view_folder(folder_id):
folder = Folder.query.get_or_404(folder_id)
if not can_view_folder(folder):
if folder.has_password:
return redirect(url_for("folders.folder_password", folder_id=folder.id))
abort(403)
photos = folder.photos.order_by(Photo.created_at.desc()).all()
can_edit = can_edit_folder(folder)
return render_template(
"cabinet/folders/view.html",
folder=folder,
photos=photos,
can_edit=can_edit,
share_url=_share_url(folder),
max_bulk_upload=get_settings().max_bulk_upload,
)
@bp.route("/cabinet/folders/<int:folder_id>/password", methods=["GET", "POST"])
@login_required
def folder_password(folder_id):
folder = Folder.query.get_or_404(folder_id)
if is_folder_owner_or_member(folder):
return redirect(url_for("folders.view_folder", folder_id=folder.id))
if request.method == "POST":
password = request.form.get("password", "")
if folder.check_access_password(password):
unlock_folder(folder.id)
flash("Доступ к папке открыт", "success")
return redirect(url_for("folders.view_folder", folder_id=folder.id))
flash("Неверный пароль", "error")
return render_template("cabinet/folders/password.html", folder=folder)
@bp.route("/cabinet/folders/<int:folder_id>/settings", methods=["GET", "POST"])
@login_required
def folder_settings(folder_id):
folder = Folder.query.get_or_404(folder_id)
if not can_manage_folder_settings(folder):
abort(403)
if request.method == "POST":
action = request.form.get("action", "save")
if action == "regenerate_link":
folder.regenerate_share_token()
db.session.commit()
flash("Ссылка для sharing обновлена", "success")
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
if action == "delete":
_delete_folder(folder)
flash(f"Папка «{folder.name}» удалена", "success")
return redirect(url_for("folders.list_folders"))
name = request.form.get("name", "").strip()
is_private = request.form.get("is_private") == "on"
access_password = request.form.get("access_password", "").strip()
remove_password = request.form.get("remove_password") == "on"
if len(name) < 2:
flash("Название папки — минимум 2 символа", "error")
else:
folder.name = name
folder.is_private = is_private
if remove_password:
folder.set_access_password(None)
elif access_password:
if len(access_password) < 4:
flash("Пароль папки — минимум 4 символа", "error")
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
folder.set_access_password(access_password)
db.session.commit()
flash("Настройки папки сохранены", "success")
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
members = FolderMember.query.filter_by(folder_id=folder.id).all()
invites = FolderInvite.query.filter_by(folder_id=folder.id).all()
return render_template(
"cabinet/folders/settings.html",
folder=folder,
members=members,
invites=invites,
share_url=_share_url(folder),
)
@bp.route("/cabinet/folders/<int:folder_id>/invite", methods=["POST"])
@login_required
def invite_member(folder_id):
folder = Folder.query.get_or_404(folder_id)
if not can_manage_folder_settings(folder):
abort(403)
email = request.form.get("email", "").strip().lower()
role = request.form.get("role", "viewer")
if role not in ("viewer", "editor"):
role = "viewer"
if not email or "@" not in email:
flash("Укажите корректный email", "error")
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
user = User.query.filter_by(email=email).first()
if user:
if user.id == folder.owner_id:
flash("Владелец папки уже имеет доступ", "error")
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
existing = FolderMember.query.filter_by(folder_id=folder.id, user_id=user.id).first()
if existing:
existing.role = role
flash(f"Пользователь {user.username} уже в папке — роль обновлена", "success")
else:
db.session.add(
FolderMember(
folder_id=folder.id,
user_id=user.id,
role=role,
added_by_id=current_user.id,
)
)
FolderInvite.query.filter_by(folder_id=folder.id, email=email).delete()
flash(f"Пользователь {user.username} добавлен в папку", "success")
else:
invite = FolderInvite.query.filter_by(folder_id=folder.id, email=email).first()
if invite:
invite.role = role
flash("Приглашение обновлено", "success")
else:
db.session.add(
FolderInvite(
folder_id=folder.id,
email=email,
role=role,
invited_by_id=current_user.id,
)
)
flash(f"Приглашение отправлено на {email}. Доступ откроется после регистрации.", "success")
db.session.commit()
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
@bp.route("/cabinet/folders/<int:folder_id>/members/<int:user_id>/remove", methods=["POST"])
@login_required
def remove_member(folder_id, user_id):
folder = Folder.query.get_or_404(folder_id)
if not can_manage_folder_settings(folder):
abort(403)
member = FolderMember.query.filter_by(folder_id=folder.id, user_id=user_id).first_or_404()
db.session.delete(member)
db.session.commit()
flash("Пользователь удалён из папки", "success")
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
@bp.route("/cabinet/folders/<int:folder_id>/invites/<int:invite_id>/remove", methods=["POST"])
@login_required
def remove_invite(folder_id, invite_id):
folder = Folder.query.get_or_404(folder_id)
if not can_manage_folder_settings(folder):
abort(403)
invite = FolderInvite.query.filter_by(folder_id=folder.id, id=invite_id).first_or_404()
db.session.delete(invite)
db.session.commit()
flash("Приглашение отменено", "success")
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
@bp.route("/share/f/<share_token>", methods=["GET", "POST"])
def share_folder(share_token):
folder = Folder.query.filter_by(share_token=share_token).first_or_404()
if current_user.is_authenticated:
process_pending_invites(current_user)
if is_folder_owner_or_member(folder) or (can_view_folder(folder) and not folder.has_password):
return _render_share_folder(folder)
if folder.has_password and not is_folder_unlocked(folder):
if request.method == "POST":
password = request.form.get("password", "")
if folder.check_access_password(password):
unlock_folder(folder.id)
flash("Доступ к папке открыт", "success")
return redirect(url_for("folders.share_folder", share_token=share_token))
flash("Неверный пароль", "error")
return render_template("share/password.html", folder=folder, share_token=share_token)
return _render_share_folder(folder)
def _render_share_folder(folder):
photos = folder.photos.order_by(Photo.created_at.desc()).all()
return render_template(
"share/folder.html",
folder=folder,
photos=photos,
can_edit=can_edit_folder(folder),
share_url=_share_url(folder),
max_bulk_upload=get_settings().max_bulk_upload,
)
def _share_url(folder):
return url_for("folders.share_folder", share_token=folder.share_token, _external=True)
def is_folder_owner_or_member(folder):
if not current_user.is_authenticated:
return False
if folder.owner_id == current_user.id:
return True
return FolderMember.query.filter_by(folder_id=folder.id, user_id=current_user.id).first() is not None
def _delete_folder(folder):
for photo in folder.photos.all():
delete_photo_file(photo.filename, photo.storage_backend)
db.session.delete(photo)
db.session.delete(folder)
db.session.commit()
def ensure_folder_schema():
inspector = inspect(db.engine)
tables = inspector.get_table_names()
if "photos" in tables:
db.session.execute(
text(
"ALTER TABLE photos ADD COLUMN IF NOT EXISTS "
"folder_id INTEGER REFERENCES folders(id)"
)
)
db.session.commit()
+85
View File
@@ -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
View File
@@ -1,4 +1,5 @@
from datetime import datetime, timezone import uuid
from datetime import datetime, timedelta, timezone
from flask_login import UserMixin from flask_login import UserMixin
from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.security import check_password_hash, generate_password_hash
@@ -6,6 +7,104 @@ from werkzeug.security import check_password_hash, generate_password_hash
from app import db from app import db
class SiteSettings(db.Model):
__tablename__ = "site_settings"
id = db.Column(db.Integer, primary_key=True, default=1)
max_bulk_upload = db.Column(db.Integer, nullable=False, default=100)
s3_enabled = db.Column(db.Boolean, nullable=False, default=False)
s3_endpoint = db.Column(db.String(255), nullable=True)
s3_bucket = db.Column(db.String(120), nullable=True)
s3_access_key = db.Column(db.String(120), nullable=True)
s3_secret_key = db.Column(db.String(255), nullable=True)
s3_region = db.Column(db.String(80), nullable=True, default="us-east-1")
s3_public_url = db.Column(db.String(255), nullable=True)
sftp_enabled = db.Column(db.Boolean, nullable=False, default=False)
sftp_host = db.Column(db.String(255), nullable=True)
sftp_port = db.Column(db.Integer, nullable=False, default=22)
sftp_username = db.Column(db.String(120), nullable=True)
sftp_password = db.Column(db.String(255), nullable=True)
sftp_remote_path = db.Column(db.String(255), nullable=True, default="/uploads")
ftp_enabled = db.Column(db.Boolean, nullable=False, default=False)
ftp_host = db.Column(db.String(255), nullable=True)
ftp_port = db.Column(db.Integer, nullable=False, default=21)
ftp_username = db.Column(db.String(120), nullable=True)
ftp_password = db.Column(db.String(255), nullable=True)
ftp_remote_path = db.Column(db.String(255), nullable=True, default="/uploads")
ftp_use_tls = db.Column(db.Boolean, nullable=False, default=False)
smtp_enabled = db.Column(db.Boolean, nullable=False, default=False)
smtp_host = db.Column(db.String(255), nullable=True)
smtp_port = db.Column(db.Integer, nullable=False, default=587)
smtp_username = db.Column(db.String(120), nullable=True)
smtp_password = db.Column(db.String(255), nullable=True)
smtp_from_email = db.Column(db.String(120), nullable=True)
smtp_from_name = db.Column(db.String(120), nullable=True, default="PhotoHost")
smtp_use_tls = db.Column(db.Boolean, nullable=False, default=True)
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): class User(UserMixin, db.Model):
__tablename__ = "users" __tablename__ = "users"
@@ -15,6 +114,9 @@ class User(UserMixin, db.Model):
password_hash = db.Column(db.String(256), nullable=False) password_hash = db.Column(db.String(256), nullable=False)
is_admin = db.Column(db.Boolean, nullable=False, default=False) is_admin = db.Column(db.Boolean, nullable=False, default=False)
is_active = db.Column(db.Boolean, nullable=False, default=True) is_active = db.Column(db.Boolean, nullable=False, default=True)
group_id = db.Column(db.Integer, db.ForeignKey("user_groups.id"), nullable=True, index=True)
gdpr_accepted_at = db.Column(db.DateTime, nullable=True)
cookie_analytics = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column( created_at = db.Column(
db.DateTime, db.DateTime,
nullable=False, nullable=False,
@@ -22,6 +124,8 @@ class User(UserMixin, db.Model):
) )
photos = db.relationship("Photo", backref="owner", lazy="dynamic") photos = db.relationship("Photo", backref="owner", lazy="dynamic")
folders = db.relationship("Folder", backref="owner", lazy="dynamic")
group = db.relationship("UserGroup", backref="users")
def set_password(self, password): def set_password(self, password):
self.password_hash = generate_password_hash(password) self.password_hash = generate_password_hash(password)
@@ -43,6 +147,217 @@ class User(UserMixin, db.Model):
return int(result or 0) 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): class Photo(db.Model):
__tablename__ = "photos" __tablename__ = "photos"
@@ -52,6 +367,8 @@ class Photo(db.Model):
file_size = db.Column(db.Integer, nullable=False, default=0) file_size = db.Column(db.Integer, nullable=False, default=0)
mime_type = db.Column(db.String(100), nullable=False, default="image/jpeg") mime_type = db.Column(db.String(100), nullable=False, default="image/jpeg")
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True)
folder_id = db.Column(db.Integer, db.ForeignKey("folders.id"), nullable=True, index=True)
storage_backend = db.Column(db.String(20), nullable=False, default="local")
created_at = db.Column( created_at = db.Column(
db.DateTime, db.DateTime,
nullable=False, nullable=False,
@@ -60,6 +377,11 @@ class Photo(db.Model):
@property @property
def url(self): def url(self):
from app.settings_service import get_settings
settings = get_settings()
if self.storage_backend == "s3" and settings.s3_public_url:
return f"{settings.s3_public_url.rstrip('/')}/{self.filename}"
return f"/uploads/{self.filename}" return f"/uploads/{self.filename}"
@property @property
+92
View File
@@ -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
+174
View File
@@ -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
+137
View File
@@ -0,0 +1,137 @@
from sqlalchemy import func
from app import db
from app.models import Folder, Photo, User, UserGroup
def get_default_group():
return UserGroup.query.filter_by(is_default=True).first()
def get_user_group(user):
if user.group_id and user.group:
return user.group
return get_default_group()
def get_user_storage_used(user_id):
result = db.session.query(func.coalesce(func.sum(Photo.file_size), 0)).filter(
Photo.user_id == user_id
).scalar()
return int(result or 0)
def get_user_photo_count(user_id):
return Photo.query.filter_by(user_id=user_id).count()
def get_user_folder_count(user_id):
return Folder.query.filter_by(owner_id=user_id).count()
def get_group_quota_bytes(group):
if not group or group.disk_quota_mb == 0:
return None
return group.disk_quota_mb * 1024 * 1024
def _limit_reached(current, limit):
return limit > 0 and current >= limit
def check_folder_limit(user):
group = get_user_group(user)
if not group or group.max_folders == 0:
return True, ""
count = get_user_folder_count(user.id)
if _limit_reached(count, group.max_folders):
return False, (
f"Достигнут лимит папок группы «{group.name}»: "
f"{count} / {group.max_folders}"
)
return True, ""
def check_photo_count_limit(user, additional_count=1):
group = get_user_group(user)
if not group or group.max_photos == 0:
return True, ""
count = get_user_photo_count(user.id)
if count + additional_count > group.max_photos:
return False, (
f"Достигнут лимит фото группы «{group.name}»: "
f"{count} / {group.max_photos}"
)
return True, ""
def check_upload_quota(user, new_file_size):
group = get_user_group(user)
quota_bytes = get_group_quota_bytes(group)
if quota_bytes is None:
return True, ""
used = get_user_storage_used(user.id)
if used + new_file_size > quota_bytes:
used_human = Photo(file_size=used).size_human if used else "0 Б"
quota_human = f"{group.disk_quota_mb} МБ"
return False, f"Превышена квота группы «{group.name}»: {used_human} / {quota_human}"
return True, ""
def quota_status(user):
group = get_user_group(user)
used = get_user_storage_used(user.id)
photo_count = get_user_photo_count(user.id)
folder_count = get_user_folder_count(user.id)
quota_bytes = get_group_quota_bytes(group)
photos_unlimited = not group or group.max_photos == 0
folders_unlimited = not group or group.max_folders == 0
if quota_bytes is None:
return {
"group": group,
"used": used,
"quota_bytes": None,
"percent": 0,
"unlimited": True,
"photo_count": photo_count,
"photo_limit": group.max_photos if group else 0,
"photos_unlimited": photos_unlimited,
"photos_percent": 0,
"folder_count": folder_count,
"folder_limit": group.max_folders if group else 0,
"folders_unlimited": folders_unlimited,
"folders_percent": 0,
}
percent = min(100, int(used / quota_bytes * 100)) if quota_bytes else 0
photos_percent = (
min(100, int(photo_count / group.max_photos * 100))
if group and group.max_photos
else 0
)
folders_percent = (
min(100, int(folder_count / group.max_folders * 100))
if group and group.max_folders
else 0
)
return {
"group": group,
"used": used,
"quota_bytes": quota_bytes,
"percent": percent,
"unlimited": False,
"photo_count": photo_count,
"photo_limit": group.max_photos if group else 0,
"photos_unlimited": photos_unlimited,
"photos_percent": photos_percent,
"folder_count": folder_count,
"folder_limit": group.max_folders if group else 0,
"folders_unlimited": folders_unlimited,
"folders_percent": folders_percent,
}
+193 -46
View File
@@ -1,33 +1,42 @@
import os import os
import uuid
from datetime import datetime, timezone
from flask import ( from flask import (
Blueprint, Blueprint,
Response,
abort,
current_app, current_app,
flash, flash,
jsonify, jsonify,
make_response,
redirect, redirect,
render_template, render_template,
request, request,
send_from_directory, send_file,
url_for, url_for,
) )
from flask_login import current_user, login_required from flask_login import current_user, login_required
from werkzeug.utils import secure_filename
from app import db from app import db
from app.auth_utils import photo_owner_or_admin from app.auth_utils import photo_owner_or_admin
from app.models import Photo from app.folder_utils import can_edit_folder
from app.models import Folder, Photo
from app.settings_service import get_settings
from app.storage_service import delete_photo_file, get_photo_stream
from app.upload_service import process_uploads, process_url_uploads
from sqlalchemy import text
bp = Blueprint("main", __name__) bp = Blueprint("main", __name__)
def allowed_file(filename): @bp.route("/health")
return ( def health():
"." in filename try:
and filename.rsplit(".", 1)[1].lower() in current_app.config["ALLOWED_EXTENSIONS"] 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("/") @bp.route("/")
@@ -35,53 +44,69 @@ def index():
photos = Photo.query.order_by(Photo.created_at.desc()).limit(24).all() photos = Photo.query.order_by(Photo.created_at.desc()).limit(24).all()
total_photos = Photo.query.count() total_photos = Photo.query.count()
total_size = db.session.query(db.func.coalesce(db.func.sum(Photo.file_size), 0)).scalar() or 0 total_size = db.session.query(db.func.coalesce(db.func.sum(Photo.file_size), 0)).scalar() or 0
settings = get_settings()
return render_template( return render_template(
"index.html", "index.html",
photos=photos, photos=photos,
total_photos=total_photos, total_photos=total_photos,
total_size=int(total_size), total_size=int(total_size),
max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024), max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024),
max_bulk_upload=settings.max_bulk_upload,
) )
@bp.route("/upload", methods=["POST"]) @bp.route("/upload", methods=["POST"])
@login_required @login_required
def upload(): def upload():
if "photo" not in request.files: folder_id = request.form.get("folder_id", type=int)
flash("Файл не выбран", "error") folder = None
return redirect(request.referrer or url_for("main.index")) if folder_id:
folder = Folder.query.get_or_404(folder_id)
if not can_edit_folder(folder):
abort(403)
file = request.files["photo"] image_urls = request.form.get("image_urls", "").strip()
if file.filename == "": max_upload_mb = current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024)
flash("Файл не выбран", "error")
return redirect(request.referrer or url_for("main.index"))
if not allowed_file(file.filename): if image_urls:
flash("Недопустимый формат. Разрешены: PNG, JPG, GIF, WEBP, BMP", "error") result = process_url_uploads(
return redirect(request.referrer or url_for("main.index")) 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"],
)
ext = file.filename.rsplit(".", 1)[1].lower() if result["uploaded"] == 0 and result["errors"]:
stored_name = f"{uuid.uuid4().hex}.{ext}" flash(result["errors"][0], "error")
safe_original = secure_filename(file.filename) or f"photo.{ext}" elif result["uploaded"] == 1:
flash("Фото успешно загружено", "success")
elif result["uploaded"] > 1:
flash(f"Загружено {result['uploaded']} фото", "success")
upload_dir = current_app.config["UPLOAD_FOLDER"] for err in result["errors"]:
filepath = os.path.join(upload_dir, stored_name) if result["uploaded"] > 0:
file.save(filepath) flash(err, "error")
file_size = os.path.getsize(filepath)
photo = Photo( if result["uploaded"] > 0:
filename=stored_name, from app.email_service import send_upload_notification
original_name=safe_original,
file_size=file_size,
mime_type=file.content_type or f"image/{ext}",
user_id=current_user.id,
created_at=datetime.now(timezone.utc),
)
db.session.add(photo)
db.session.commit()
flash("Фото успешно загружено", "success") send_upload_notification(
return redirect(url_for("cabinet.index")) current_user,
result["uploaded"],
folder.name if folder else None,
)
if folder:
return redirect(url_for("folders.view_folder", folder_id=folder.id))
return redirect(request.referrer or url_for("cabinet.index"))
@bp.route("/api/photos") @bp.route("/api/photos")
@@ -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>") @bp.route("/uploads/<path:filename>")
def uploaded_file(filename): def uploaded_file(filename):
return send_from_directory(current_app.config["UPLOAD_FOLDER"], filename) photo = Photo.query.filter_by(filename=filename).first()
storage_backend = photo.storage_backend if photo else "local"
stream = get_photo_stream(filename, storage_backend)
if stream is None:
abort(404)
mimetype = photo.mime_type if photo else "application/octet-stream"
return send_file(stream, mimetype=mimetype)
@bp.route("/delete/<int:photo_id>", methods=["POST"]) @bp.route("/delete/<int:photo_id>", methods=["POST"])
@@ -114,9 +169,7 @@ def delete_photo(photo_id):
photo = Photo.query.get_or_404(photo_id) photo = Photo.query.get_or_404(photo_id)
photo_owner_or_admin(photo) photo_owner_or_admin(photo)
filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], photo.filename) delete_photo_file(photo.filename, photo.storage_backend)
if os.path.exists(filepath):
os.remove(filepath)
db.session.delete(photo) db.session.delete(photo)
db.session.commit() db.session.commit()
flash("Фото удалено", "success") flash("Фото удалено", "success")
@@ -129,27 +182,72 @@ cabinet_bp = Blueprint("cabinet", __name__, url_prefix="/cabinet")
@cabinet_bp.route("/") @cabinet_bp.route("/")
@login_required @login_required
def index(): def index():
from app.folder_utils import process_pending_invites
from app.quota_utils import quota_status
process_pending_invites(current_user)
photos = ( photos = (
Photo.query.filter_by(user_id=current_user.id) Photo.query.filter_by(user_id=current_user.id, folder_id=None)
.order_by(Photo.created_at.desc()) .order_by(Photo.created_at.desc())
.all() .all()
) )
folders = Folder.query.filter_by(owner_id=current_user.id).order_by(Folder.created_at.desc()).limit(6).all()
total_size = sum(p.file_size for p in photos) total_size = sum(p.file_size for p in photos)
quota = quota_status(current_user)
settings = get_settings()
return render_template( return render_template(
"cabinet/index.html", "cabinet/index.html",
photos=photos, photos=photos,
folders=folders,
total_photos=len(photos), total_photos=len(photos),
total_size=total_size, total_size=total_size,
quota=quota,
max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024), max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024),
max_bulk_upload=settings.max_bulk_upload,
) )
@cabinet_bp.route("/profile", methods=["GET", "POST"]) @cabinet_bp.route("/profile", methods=["GET", "POST"])
@login_required @login_required
def profile(): 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": 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() email = request.form.get("email", "").strip().lower()
current_password = request.form.get("current_password", "") current_password = request.form.get("current_password", "")
new_password = request.form.get("new_password", "") new_password = request.form.get("new_password", "")
@@ -172,4 +270,53 @@ def profile():
flash("Профиль обновлён", "success") flash("Профиль обновлён", "success")
return redirect(url_for("cabinet.profile")) 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()
+110
View File
@@ -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
+93
View File
@@ -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,
}
+12
View File
@@ -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('"', "&quot;")
return f'<img src="{url}" alt="{name}">'
+736 -1
View File
@@ -420,7 +420,9 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; flex-wrap: wrap;
gap: 6px;
padding: 12px;
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.6);
opacity: 0; opacity: 0;
transition: opacity 0.2s; transition: opacity 0.2s;
@@ -881,3 +883,736 @@ body {
color: #fca5a5; color: #fca5a5;
border-color: rgba(239, 68, 68, 0.3); border-color: rgba(239, 68, 68, 0.3);
} }
/* Folders */
.folder-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.folder-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
transition: transform 0.2s, border-color 0.2s;
}
.folder-card:hover {
transform: translateY(-3px);
border-color: rgba(99, 102, 241, 0.35);
}
.folder-card--shared {
border-color: rgba(34, 197, 94, 0.2);
}
.folder-card__icon {
font-size: 2rem;
margin-bottom: 12px;
}
.folder-card__title {
font-size: 1.1rem;
margin-bottom: 8px;
}
.folder-card__meta {
color: var(--text-muted);
font-size: 0.85rem;
margin-bottom: 16px;
}
.folder-card__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.folder-create {
margin-bottom: 32px;
}
.folder-create__form {
max-width: 520px;
}
.folder-share-url {
word-break: break-all;
font-size: 0.85rem;
color: var(--accent-light);
margin-bottom: 16px;
padding: 12px;
background: rgba(0, 0, 0, 0.25);
border-radius: var(--radius-sm);
}
.folder-hint {
color: var(--text-muted);
font-size: 0.85rem;
margin-top: 12px;
}
.form-select {
padding: 12px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.3);
color: var(--text);
font-family: var(--font);
font-size: 0.95rem;
}
.admin-panel--danger {
border-color: rgba(239, 68, 68, 0.25);
}
.form-inline-input {
padding: 6px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.3);
color: var(--text);
font-family: var(--font);
font-size: 0.85rem;
margin-right: 6px;
margin-bottom: 6px;
}
.form-inline-input--sm {
width: 90px;
}
.form-select--sm {
padding: 6px 10px;
font-size: 0.8rem;
min-width: 120px;
}
.group-edit-form,
.group-assign-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.quota-bar-wrap {
margin-top: 20px;
max-width: 520px;
}
.quota-bar {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 14px 16px;
}
.quota-bar__header {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 10px;
}
.quota-bar__track {
height: 8px;
background: rgba(255, 255, 255, 0.08);
border-radius: 999px;
overflow: hidden;
}
.quota-bar__fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent-light));
border-radius: 999px;
transition: width 0.3s;
}
.quota-bar__fill--warn {
background: linear-gradient(90deg, #ef4444, #f97316);
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-top: 16px;
}
.settings-form .admin-panel {
margin-bottom: 0;
}
.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;
}
}
+24
View File
@@ -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));
});
+133 -23
View File
@@ -1,13 +1,36 @@
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
initUploadForm();
initCopyButtons();
initShareModal();
});
function initUploadForm() {
const dropzone = document.getElementById("dropzone"); const dropzone = document.getElementById("dropzone");
const photoInput = document.getElementById("photoInput"); const photoInput = document.getElementById("photoInput");
const preview = document.getElementById("preview"); const preview = document.getElementById("preview");
const previewImg = document.getElementById("previewImg"); const previewImg = document.getElementById("previewImg");
const previewName = document.getElementById("previewName"); const previewName = document.getElementById("previewName");
const submitBtn = document.getElementById("submitBtn"); 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; if (!dropzone || !photoInput) return;
const maxFiles = parseInt(photoInput.dataset.max || "100", 10);
dropzone.addEventListener("click", (e) => { dropzone.addEventListener("click", (e) => {
if (e.target.closest("button")) return; if (e.target.closest("button")) return;
photoInput.click(); photoInput.click();
@@ -28,51 +51,138 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
dropzone.addEventListener("drop", (e) => { dropzone.addEventListener("drop", (e) => {
const files = e.dataTransfer.files; e.preventDefault();
if (files.length > 0) { assignFiles(e.dataTransfer.files);
photoInput.files = files;
showPreview(files[0]);
}
}); });
photoInput.addEventListener("change", () => { photoInput.addEventListener("change", () => {
if (photoInput.files.length > 0) { if (photoInput.files.length > 0) {
showPreview(photoInput.files[0]); showPreview(photoInput.files);
} }
}); });
function showPreview(file) { uploadForm.addEventListener("submit", (e) => {
if (!file.type.startsWith("image/")) return; 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(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
previewImg.src = e.target.result; previewImg.src = e.target.result;
previewName.textContent = file.name; previewName.textContent =
files.length === 1
? first.name
: `${files.length} файлов (первый: ${first.name})`;
preview.hidden = false; preview.hidden = false;
submitBtn.disabled = false; submitBtn.disabled = false;
}; };
reader.readAsDataURL(file); reader.readAsDataURL(first);
} }
}
function initCopyButtons() {
document.querySelectorAll(".copy-btn").forEach((btn) => { document.querySelectorAll(".copy-btn").forEach((btn) => {
btn.addEventListener("click", async (e) => { btn.addEventListener("click", async (e) => {
e.stopPropagation(); e.stopPropagation();
const url = btn.dataset.url; const targetId = btn.dataset.target;
try { const url = targetId
await navigator.clipboard.writeText(url); ? document.getElementById(targetId)?.value
showToast("Ссылка скопирована!"); : btn.dataset.url;
} catch {
const input = document.createElement("input"); if (!url) return;
input.value = url;
document.body.appendChild(input); const copied = await copyText(url);
input.select(); if (copied) {
document.execCommand("copy"); const label = btn.textContent.trim();
document.body.removeChild(input); showToast(label === "BBCode" ? "BBCode скопирован!" : "Скопировано!");
showToast("Ссылка скопирована!");
} }
}); });
}); });
}); }
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) { function showToast(message) {
const existing = document.querySelector(".toast"); const existing = document.querySelector(".toast");
+133
View File
@@ -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");
}
});
}
});
+216
View File
@@ -0,0 +1,216 @@
import io
import os
from ftplib import FTP, FTP_TLS
from flask import current_app
from app.settings_service import get_settings
def save_photo_file(file_storage, stored_name):
settings = get_settings()
upload_dir = current_app.config["UPLOAD_FOLDER"]
os.makedirs(upload_dir, exist_ok=True)
local_path = os.path.join(upload_dir, stored_name)
file_storage.save(local_path)
file_size = os.path.getsize(local_path)
storage_backend = "local"
errors = []
if settings.s3_enabled:
ok, err = _upload_s3(local_path, stored_name, settings)
if ok:
storage_backend = "s3"
elif err:
errors.append(f"S3: {err}")
if settings.sftp_enabled:
ok, err = _upload_sftp(local_path, stored_name, settings)
if err:
errors.append(f"SFTP: {err}")
if settings.ftp_enabled:
ok, err = _upload_ftp(local_path, stored_name, settings)
if err:
errors.append(f"FTP: {err}")
return local_path, file_size, storage_backend, errors
def delete_photo_file(stored_name, storage_backend="local"):
settings = get_settings()
upload_dir = current_app.config["UPLOAD_FOLDER"]
local_path = os.path.join(upload_dir, stored_name)
if os.path.exists(local_path):
os.remove(local_path)
if storage_backend == "s3" and settings.s3_enabled:
_delete_s3(stored_name, settings)
if settings.sftp_enabled:
_delete_sftp(stored_name, settings)
if settings.ftp_enabled:
_delete_ftp(stored_name, settings)
def get_photo_stream(stored_name, storage_backend="local"):
settings = get_settings()
upload_dir = current_app.config["UPLOAD_FOLDER"]
local_path = os.path.join(upload_dir, stored_name)
if os.path.exists(local_path):
return open(local_path, "rb")
if storage_backend == "s3" and settings.s3_enabled:
data = _download_s3(stored_name, settings)
if data:
return io.BytesIO(data)
return None
def _upload_s3(local_path, key, settings):
try:
import boto3
from botocore.config import Config
kwargs = {
"aws_access_key_id": settings.s3_access_key,
"aws_secret_access_key": settings.s3_secret_key,
"region_name": settings.s3_region or "us-east-1",
}
if settings.s3_endpoint:
kwargs["endpoint_url"] = settings.s3_endpoint
client = boto3.client("s3", config=Config(signature_version="s3v4"), **kwargs)
client.upload_file(local_path, settings.s3_bucket, key)
return True, None
except Exception as exc:
return False, str(exc)
def _delete_s3(key, settings):
try:
import boto3
kwargs = {
"aws_access_key_id": settings.s3_access_key,
"aws_secret_access_key": settings.s3_secret_key,
"region_name": settings.s3_region or "us-east-1",
}
if settings.s3_endpoint:
kwargs["endpoint_url"] = settings.s3_endpoint
client = boto3.client("s3", **kwargs)
client.delete_object(Bucket=settings.s3_bucket, Key=key)
except Exception:
current_app.logger.exception("S3 delete failed")
def _download_s3(key, settings):
try:
import boto3
kwargs = {
"aws_access_key_id": settings.s3_access_key,
"aws_secret_access_key": settings.s3_secret_key,
"region_name": settings.s3_region or "us-east-1",
}
if settings.s3_endpoint:
kwargs["endpoint_url"] = settings.s3_endpoint
client = boto3.client("s3", **kwargs)
obj = client.get_object(Bucket=settings.s3_bucket, Key=key)
return obj["Body"].read()
except Exception:
current_app.logger.exception("S3 download failed")
return None
def _upload_sftp(local_path, remote_name, settings):
try:
import paramiko
transport = paramiko.Transport((settings.sftp_host, settings.sftp_port))
transport.connect(username=settings.sftp_username, password=settings.sftp_password)
sftp = paramiko.SFTPClient.from_transport(transport)
remote_dir = settings.sftp_remote_path or "/uploads"
_sftp_makedirs(sftp, remote_dir)
sftp.put(local_path, f"{remote_dir.rstrip('/')}/{remote_name}")
sftp.close()
transport.close()
return True, None
except Exception as exc:
return False, str(exc)
def _delete_sftp(remote_name, settings):
try:
import paramiko
transport = paramiko.Transport((settings.sftp_host, settings.sftp_port))
transport.connect(username=settings.sftp_username, password=settings.sftp_password)
sftp = paramiko.SFTPClient.from_transport(transport)
remote_path = f"{settings.sftp_remote_path.rstrip('/')}/{remote_name}"
sftp.remove(remote_path)
sftp.close()
transport.close()
except Exception:
current_app.logger.exception("SFTP delete failed")
def _sftp_makedirs(sftp, remote_dir):
parts = remote_dir.strip("/").split("/")
path = ""
for part in parts:
path += f"/{part}"
try:
sftp.stat(path)
except IOError:
sftp.mkdir(path)
def _upload_ftp(local_path, remote_name, settings):
try:
ftp_cls = FTP_TLS if settings.ftp_use_tls else FTP
ftp = ftp_cls()
ftp.connect(settings.ftp_host, settings.ftp_port, timeout=30)
ftp.login(settings.ftp_username, settings.ftp_password)
if settings.ftp_use_tls:
ftp.prot_p()
remote_dir = settings.ftp_remote_path or "/uploads"
_ftp_makedirs(ftp, remote_dir)
ftp.cwd(remote_dir)
with open(local_path, "rb") as f:
ftp.storbinary(f"STOR {remote_name}", f)
ftp.quit()
return True, None
except Exception as exc:
return False, str(exc)
def _delete_ftp(remote_name, settings):
try:
ftp_cls = FTP_TLS if settings.ftp_use_tls else FTP
ftp = ftp_cls()
ftp.connect(settings.ftp_host, settings.ftp_port, timeout=30)
ftp.login(settings.ftp_username, settings.ftp_password)
if settings.ftp_use_tls:
ftp.prot_p()
ftp.cwd(settings.ftp_remote_path or "/uploads")
ftp.delete(remote_name)
ftp.quit()
except Exception:
current_app.logger.exception("FTP delete failed")
def _ftp_makedirs(ftp, remote_dir):
parts = remote_dir.strip("/").split("/")
path = ""
for part in parts:
path += f"/{part}"
try:
ftp.cwd(path)
except Exception:
ftp.mkd(path)
+21 -3
View File
@@ -1,5 +1,23 @@
<nav class="admin-nav"> <nav class="admin-nav">
<a href="{{ url_for('admin.dashboard') }}" class="admin-nav__link {% if request.endpoint == 'admin.dashboard' %}admin-nav__link--active{% endif %}">Обзор</a> <a href="{{ url_for('admin.dashboard') }}" class="admin-nav__link {% if request.endpoint == 'admin.dashboard' %}admin-nav__link--active{% endif %}">
<a href="{{ url_for('admin.users') }}" class="admin-nav__link {% if request.endpoint == 'admin.users' %}admin-nav__link--active{% endif %}">Пользователи</a> <span class="admin-nav__icon">📊</span> Обзор
<a href="{{ url_for('admin.photos') }}" class="admin-nav__link {% if request.endpoint == 'admin.photos' %}admin-nav__link--active{% endif %}">Фото</a> </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> </nav>
+114
View File
@@ -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 %}
+68 -68
View File
@@ -1,80 +1,80 @@
{% extends "base.html" %} {% extends "admin/layout.html" %}
{% from "macros.html" import format_size %} {% from "macros.html" import format_size %}
{% block title %}Админка — PhotoHost{% endblock %} {% block title %}Админка — PhotoHost{% endblock %}
{% block admin_title %}Обзор{% endblock %}
{% block admin_subtitle %}<p class="admin-main__subtitle">Статистика и последние действия</p>{% endblock %}
{% block content %} {% block admin_content %}
<section class="page-header page-header--admin"> <div class="admin-stats admin-stats--cards">
<div class="container"> <div class="admin-stat-card">
<h1 class="page-header__title">Панель администратора</h1> <span class="admin-stat-card__value">{{ stats.users }}</span>
<p class="page-header__subtitle">Управление пользователями и контентом</p> <span class="admin-stat-card__label">Пользователей</span>
</div> </div>
</section> <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 class="admin-section"> {% if current_version %}
<div class="container"> <p class="admin-version-bar">
{% include "admin/_nav.html" %} Версия Git: <strong>{{ current_version }}</strong>
{% include "partials/alerts.html" %} · <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-stats"> <div class="admin-grid">
<div class="stat-card stat-card--admin"> <div class="admin-panel admin-panel--elevated">
<span class="stat-card__value">{{ stats.users }}</span> <h2 class="admin-panel__title">Новые пользователи</h2>
<span class="stat-card__label">пользователей</span> <div class="admin-table-wrap">
</div> <table class="admin-table">
<div class="stat-card stat-card--admin"> <thead>
<span class="stat-card__value">{{ stats.photos }}</span> <tr>
<span class="stat-card__label">фотографий</span> <th>Логин</th>
</div> <th>Email</th>
<div class="stat-card stat-card--admin"> <th>Дата</th>
<span class="stat-card__value">{{ stats.admins }}</span> </tr>
<span class="stat-card__label">администраторов</span> </thead>
</div> <tbody>
<div class="stat-card stat-card--admin"> {% for user in recent_users %}
<span class="stat-card__value">{{ format_size(stats.storage) }}</span> <tr>
<span class="stat-card__label">хранилище</span> <td>{{ user.username }}{% if user.is_admin %} <span class="badge badge--admin">admin</span>{% endif %}</td>
</div> <td>{{ user.email }}</td>
</div> <td>{{ user.created_at.strftime('%d.%m.%Y') }}</td>
</tr>
<div class="admin-grid">
<div class="admin-panel">
<h2 class="admin-panel__title">Новые пользователи</h2>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Логин</th>
<th>Email</th>
<th>Дата</th>
</tr>
</thead>
<tbody>
{% for user in recent_users %}
<tr>
<td>{{ user.username }}{% if user.is_admin %} <span class="badge badge--admin">admin</span>{% endif %}</td>
<td>{{ user.email }}</td>
<td>{{ user.created_at.strftime('%d.%m.%Y') }}</td>
</tr>
{% else %}
<tr><td colspan="3">Нет пользователей</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="admin-panel">
<h2 class="admin-panel__title">Последние фото</h2>
<div class="admin-mini-gallery">
{% for photo in recent_photos %}
<a href="{{ photo.url }}" target="_blank" class="admin-mini-gallery__item">
<img src="{{ photo.url }}" alt="{{ photo.original_name }}">
</a>
{% else %} {% else %}
<p class="admin-empty">Нет фотографий</p> <tr><td colspan="3">Нет пользователей</td></tr>
{% endfor %} {% endfor %}
</div> </tbody>
</div> </table>
</div> </div>
</div> </div>
</section>
<div class="admin-panel admin-panel--elevated">
<h2 class="admin-panel__title">Последние фото</h2>
<div class="admin-mini-gallery">
{% for photo in recent_photos %}
<a href="{{ photo.url }}" target="_blank" class="admin-mini-gallery__item">
<img src="{{ photo.url }}" alt="{{ photo.original_name }}">
</a>
{% else %}
<p class="admin-empty">Нет фотографий</p>
{% endfor %}
</div>
</div>
</div>
{% endblock %} {% endblock %}
+80
View File
@@ -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 %}
+79
View File
@@ -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 %}
+27
View File
@@ -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 %}
+3 -15
View File
@@ -1,22 +1,10 @@
{% extends "base.html" %} {% extends "admin/layout.html" %}
{% block title %}Фото — Админка{% endblock %} {% block title %}Фото — Админка{% endblock %}
{% block admin_title %}Все фотографии{% endblock %}
{% block content %} {% block admin_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" %}
{% with photos=photos, show_owner=true, delete_mode='admin', empty_title='Нет фотографий', empty_text='Пользователи ещё не загружали фото' %} {% with photos=photos, show_owner=true, delete_mode='admin', empty_title='Нет фотографий', empty_text='Пользователи ещё не загружали фото' %}
{% include "partials/photo_gallery.html" %} {% include "partials/photo_gallery.html" %}
{% endwith %} {% endwith %}
</div>
</section>
{% endblock %} {% endblock %}
+133
View File
@@ -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 (01)</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 %}
+13 -15
View File
@@ -1,19 +1,9 @@
{% extends "base.html" %} {% extends "admin/layout.html" %}
{% block title %}Пользователи — Админка{% endblock %} {% block title %}Пользователи — Админка{% endblock %}
{% block admin_title %}Пользователи{% endblock %}
{% block content %} {% block admin_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" %}
<div class="admin-table-wrap"> <div class="admin-table-wrap">
<table class="admin-table"> <table class="admin-table">
<thead> <thead>
@@ -21,6 +11,7 @@
<th>ID</th> <th>ID</th>
<th>Логин</th> <th>Логин</th>
<th>Email</th> <th>Email</th>
<th>Группа</th>
<th>Фото</th> <th>Фото</th>
<th>Роль</th> <th>Роль</th>
<th>Статус</th> <th>Статус</th>
@@ -34,6 +25,15 @@
<td>{{ user.id }}</td> <td>{{ user.id }}</td>
<td>{{ user.username }}</td> <td>{{ user.username }}</td>
<td>{{ user.email }}</td> <td>{{ user.email }}</td>
<td>
<form action="{{ url_for('admin.set_user_group', user_id=user.id) }}" method="post" class="group-assign-form">
<select name="group_id" class="form-select form-select--sm" onchange="this.form.submit()">
{% for group in groups %}
<option value="{{ group.id }}" {% if user.group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
{% endfor %}
</select>
</form>
</td>
<td>{{ user.photo_count }}</td> <td>{{ user.photo_count }}</td>
<td> <td>
{% if user.is_admin %} {% if user.is_admin %}
@@ -74,6 +74,4 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</section>
{% endblock %} {% endblock %}
+28
View File
@@ -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 %}
+23 -1
View File
@@ -11,6 +11,7 @@
{% include "partials/alerts.html" %} {% include "partials/alerts.html" %}
{% if auth_settings.password_login_enabled %}
<form method="post" class="auth-form"> <form method="post" class="auth-form">
<div class="form-group"> <div class="form-group">
<label for="login">Логин или email</label> <label for="login">Логин или email</label>
@@ -24,13 +25,34 @@
<input type="checkbox" name="remember"> <input type="checkbox" name="remember">
<span>Запомнить меня</span> <span>Запомнить меня</span>
</label> </label>
{% include "partials/captcha.html" %}
<button type="submit" class="btn btn--primary btn--full">Войти</button> <button type="submit" class="btn btn--primary btn--full">Войти</button>
</form> </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"> <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> </p>
</div> </div>
</div> </div>
</section> </section>
{% endblock %} {% 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 %}
+5
View File
@@ -28,6 +28,7 @@
<label for="password2">Подтверждение пароля</label> <label for="password2">Подтверждение пароля</label>
<input type="password" id="password2" name="password2" required minlength="6" autocomplete="new-password" placeholder="повторите пароль"> <input type="password" id="password2" name="password2" required minlength="6" autocomplete="new-password" placeholder="повторите пароль">
</div> </div>
{% include "partials/captcha.html" %}
<button type="submit" class="btn btn--primary btn--full">Создать аккаунт</button> <button type="submit" class="btn btn--primary btn--full">Создать аккаунт</button>
</form> </form>
@@ -38,3 +39,7 @@
</div> </div>
</section> </section>
{% endblock %} {% endblock %}
{% block scripts %}
{% include "partials/captcha_scripts.html" %}
{% endblock %}
+25
View File
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Новый пароль — PhotoHost{% endblock %}
{% block content %}
<section class="auth-section">
<div class="container auth-container">
<div class="auth-card">
<h1 class="auth-card__title">Новый пароль</h1>
{% include "partials/alerts.html" %}
<form method="post" class="auth-form">
<div class="form-group">
<label for="password">Новый пароль</label>
<input type="password" id="password" name="password" required minlength="6">
</div>
<div class="form-group">
<label for="password2">Подтверждение</label>
<input type="password" id="password2" name="password2" required minlength="6">
</div>
<button type="submit" class="btn btn--primary btn--full">Сохранить пароль</button>
</form>
</div>
</div>
</section>
{% endblock %}
+15
View File
@@ -24,6 +24,7 @@
<a href="{{ url_for('main.index') }}" class="nav__link">Главная</a> <a href="{{ url_for('main.index') }}" class="nav__link">Главная</a>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<a href="{{ url_for('cabinet.index') }}" class="nav__link">Личный кабинет</a> <a href="{{ url_for('cabinet.index') }}" class="nav__link">Личный кабинет</a>
<a href="{{ url_for('folders.list_folders') }}" class="nav__link">Папки</a>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<a href="{{ url_for('admin.dashboard') }}" class="nav__link nav__link--admin">Админка</a> <a href="{{ url_for('admin.dashboard') }}" class="nav__link nav__link--admin">Админка</a>
{% endif %} {% endif %}
@@ -31,8 +32,10 @@
<a href="{{ url_for('auth.logout') }}" class="nav__link">Выйти</a> <a href="{{ url_for('auth.logout') }}" class="nav__link">Выйти</a>
{% else %} {% else %}
<a href="{{ url_for('auth.login') }}" class="nav__link">Вход</a> <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> <a href="{{ url_for('auth.register') }}" class="nav__link nav__link--accent">Регистрация</a>
{% endif %} {% endif %}
{% endif %}
</nav> </nav>
</div> </div>
</header> </header>
@@ -41,14 +44,26 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
{% with banners=site_banners.get('footer', []), position='footer' %}
{% include "partials/banners.html" %}
{% endwith %}
<footer class="footer"> <footer class="footer">
<div class="container footer__inner"> <div class="container footer__inner">
<p>PhotoHost — Python + PostgreSQL + Docker</p> <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> <p class="footer__muted">Храните и делитесь фотографиями просто</p>
</div> </div>
</footer> </footer>
{% include "partials/cookie_banner.html" %}
<script src="{{ url_for('static', filename='js/main.js') }}"></script> <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 %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>
+91
View File
@@ -0,0 +1,91 @@
{% extends "base.html" %}
{% block title %}Папки — PhotoHost{% endblock %}
{% block content %}
<section class="page-header">
<div class="container">
<h1 class="page-header__title">Мои папки</h1>
<p class="page-header__subtitle">Создавайте папки, делитесь ссылками, защищайте паролем и приглашайте пользователей по email.</p>
</div>
</section>
{% include "partials/alerts.html" %}
<section class="admin-section">
<div class="container">
<div class="admin-panel folder-create">
<h2 class="admin-panel__title">Создать папку</h2>
{% if quota and not quota.folders_unlimited and quota.folder_count >= quota.folder_limit %}
<p class="form-hint form-hint--warn">Достигнут лимит папок: {{ quota.folder_count }} / {{ quota.folder_limit }}</p>
{% else %}
<form method="post" action="{{ url_for('folders.create_folder') }}" class="auth-form folder-create__form">
<div class="form-group">
<label for="name">Название</label>
<input type="text" id="name" name="name" required minlength="2" placeholder="Отпуск 2025">
</div>
<div class="form-group">
<label for="access_password">Пароль доступа (необязательно)</label>
<input type="password" id="access_password" name="access_password" minlength="4" placeholder="для приватной папки">
</div>
<label class="form-checkbox">
<input type="checkbox" name="is_private" checked>
<span>Приватная папка (доступ по ссылке, паролю или приглашению)</span>
</label>
<button type="submit" class="btn btn--primary">Создать папку</button>
</form>
{% endif %}
{% if quota %}
<p class="form-hint">
Лимит группы «{{ quota.group.name if quota.group else 'Пользователи' }}»:
папки {{ quota.folder_count }}{% if not quota.folders_unlimited %} / {{ quota.folder_limit }}{% else %} / без лимита{% endif %},
фото {{ quota.photo_count }}{% if not quota.photos_unlimited %} / {{ quota.photo_limit }}{% else %} / без лимита{% endif %}
</p>
{% endif %}
</div>
<h2 class="section-title">Мои папки</h2>
{% if owned_folders %}
<div class="folder-grid">
{% for folder in owned_folders %}
<article class="folder-card">
<div class="folder-card__icon">📁</div>
<h3 class="folder-card__title">{{ folder.name }}</h3>
<p class="folder-card__meta">
{{ folder.photo_count }} фото
{% if folder.is_private %} · приватная{% endif %}
{% if folder.has_password %} · с паролем{% endif %}
</p>
<div class="folder-card__actions">
<a href="{{ url_for('folders.view_folder', folder_id=folder.id) }}" class="btn btn--ghost btn--sm">Открыть</a>
<a href="{{ url_for('folders.folder_settings', folder_id=folder.id) }}" class="btn btn--ghost btn--sm">Настройки</a>
</div>
</article>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-state__icon">📁</div>
<h3>У вас пока нет папок</h3>
<p>Создайте первую папку для организации фото</p>
</div>
{% endif %}
{% if shared_folders %}
<h2 class="section-title" style="margin-top: 40px;">Общие со мной</h2>
<div class="folder-grid">
{% for folder in shared_folders %}
<article class="folder-card folder-card--shared">
<div class="folder-card__icon">🤝</div>
<h3 class="folder-card__title">{{ folder.name }}</h3>
<p class="folder-card__meta">@{{ folder.owner.username }} · {{ folder.photo_count }} фото</p>
<div class="folder-card__actions">
<a href="{{ url_for('folders.view_folder', folder_id=folder.id) }}" class="btn btn--ghost btn--sm">Открыть</a>
</div>
</article>
{% endfor %}
</div>
{% endif %}
</div>
</section>
{% endblock %}
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Пароль папки — {{ folder.name }}{% endblock %}
{% block content %}
<section class="auth-section">
<div class="container auth-container">
<div class="auth-card">
<h1 class="auth-card__title">Папка «{{ folder.name }}»</h1>
<p class="auth-card__subtitle">Введите пароль для доступа</p>
{% include "partials/alerts.html" %}
<form method="post" class="auth-form">
<div class="form-group">
<label for="password">Пароль папки</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn btn--primary btn--full">Открыть</button>
</form>
</div>
</div>
</section>
{% endblock %}
+142
View File
@@ -0,0 +1,142 @@
{% extends "base.html" %}
{% block title %}Настройки — {{ folder.name }}{% endblock %}
{% block content %}
<section class="page-header">
<div class="container">
<h1 class="page-header__title">Настройки папки</h1>
<p class="page-header__subtitle">{{ folder.name }}</p>
<div class="page-header__actions">
<a href="{{ url_for('folders.view_folder', folder_id=folder.id) }}" class="btn btn--ghost">К папке</a>
</div>
</div>
</section>
<section class="admin-section">
<div class="container">
{% include "partials/alerts.html" %}
<div class="admin-grid">
<div class="admin-panel">
<h2 class="admin-panel__title">Основные настройки</h2>
<form method="post" class="auth-form">
<input type="hidden" name="action" value="save">
<div class="form-group">
<label for="name">Название</label>
<input type="text" id="name" name="name" value="{{ folder.name }}" required minlength="2">
</div>
<label class="form-checkbox">
<input type="checkbox" name="is_private" {% if folder.is_private %}checked{% endif %}>
<span>Приватная папка</span>
</label>
<div class="form-group">
<label for="access_password">Новый пароль доступа</label>
<input type="password" id="access_password" name="access_password" minlength="4" placeholder="оставьте пустым, если не меняете">
</div>
{% if folder.has_password %}
<label class="form-checkbox">
<input type="checkbox" name="remove_password">
<span>Убрать пароль</span>
</label>
{% endif %}
<button type="submit" class="btn btn--primary">Сохранить</button>
</form>
</div>
<div class="admin-panel">
<h2 class="admin-panel__title">Ссылка для sharing</h2>
<p class="folder-share-url">{{ share_url }}</p>
<div class="folder-card__actions">
<button type="button" class="btn btn--ghost btn--sm copy-btn" data-url="{{ share_url }}">Копировать</button>
<form method="post" style="display:inline">
<input type="hidden" name="action" value="regenerate_link">
<button type="submit" class="btn btn--ghost btn--sm">Обновить ссылку</button>
</form>
</div>
</div>
</div>
<div class="admin-panel" style="margin-top: 24px;">
<h2 class="admin-panel__title">Пригласить по email</h2>
<form method="post" action="{{ url_for('folders.invite_member', folder_id=folder.id) }}" class="auth-form folder-create__form">
<div class="form-group">
<label for="email">Email пользователя</label>
<input type="email" id="email" name="email" required placeholder="user@example.com">
</div>
<div class="form-group">
<label for="role">Роль</label>
<select id="role" name="role" class="form-select">
<option value="viewer">Просмотр</option>
<option value="editor">Редактор (загрузка и удаление)</option>
</select>
</div>
<button type="submit" class="btn btn--primary">Добавить / пригласить</button>
</form>
<p class="folder-hint">Если пользователь ещё не зарегистрирован, доступ откроется автоматически после регистрации с этим email.</p>
</div>
{% if members %}
<div class="admin-panel" style="margin-top: 24px;">
<h2 class="admin-panel__title">Участники</h2>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr><th>Пользователь</th><th>Email</th><th>Роль</th><th></th></tr>
</thead>
<tbody>
{% for member in members %}
<tr>
<td>{{ member.user.username }}</td>
<td>{{ member.user.email }}</td>
<td>{{ 'Редактор' if member.role == 'editor' else 'Просмотр' }}</td>
<td>
<form method="post" action="{{ url_for('folders.remove_member', folder_id=folder.id, user_id=member.user_id) }}">
<button type="submit" class="btn btn--danger btn--sm">Удалить</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if invites %}
<div class="admin-panel" style="margin-top: 24px;">
<h2 class="admin-panel__title">Ожидают регистрации</h2>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr><th>Email</th><th>Роль</th><th></th></tr>
</thead>
<tbody>
{% for invite in invites %}
<tr>
<td>{{ invite.email }}</td>
<td>{{ 'Редактор' if invite.role == 'editor' else 'Просмотр' }}</td>
<td>
<form method="post" action="{{ url_for('folders.remove_invite', folder_id=folder.id, invite_id=invite.id) }}">
<button type="submit" class="btn btn--danger btn--sm">Отменить</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<div class="admin-panel admin-panel--danger" style="margin-top: 24px;">
<h2 class="admin-panel__title">Удалить папку</h2>
<p class="folder-hint">Все фото внутри папки будут удалены безвозвратно.</p>
<form method="post" onsubmit="return confirm('Удалить папку и все фото в ней?');">
<input type="hidden" name="action" value="delete">
<button type="submit" class="btn btn--danger">Удалить папку</button>
</form>
</div>
</div>
</section>
{% endblock %}
+44
View File
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}{{ folder.name }} — PhotoHost{% endblock %}
{% block content %}
<section class="page-header">
<div class="container">
<h1 class="page-header__title">📁 {{ folder.name }}</h1>
<p class="page-header__subtitle">
{% if folder.is_private %}Приватная папка{% else %}Публичная папка{% endif %}
{% if folder.has_password %} · защищена паролем{% endif %}
· {{ photos|length }} фото
</p>
<div class="page-header__actions">
<a href="{{ url_for('folders.list_folders') }}" class="btn btn--ghost">← Все папки</a>
{% if folder.owner_id == current_user.id %}
<a href="{{ url_for('folders.folder_settings', folder_id=folder.id) }}" class="btn btn--ghost">Настройки</a>
{% endif %}
<button type="button" class="btn btn--ghost copy-btn" data-url="{{ share_url }}">Копировать ссылку</button>
</div>
</div>
</section>
{% include "partials/alerts.html" %}
{% if can_edit %}
<section id="upload" class="upload-section">
<div class="container">
<h2 class="section-title">Загрузить в папку</h2>
{% with folder_id=folder.id, max_bulk_upload=max_bulk_upload %}
{% include "partials/upload_form.html" %}
{% endwith %}
</div>
</section>
{% endif %}
<section class="gallery-section">
<div class="container">
{% with photos=photos, empty_title='В папке пока нет фото', empty_text='Загрузите первое изображение' %}
{% include "partials/photo_gallery.html" %}
{% endwith %}
</div>
</section>
{% endblock %}
+81 -18
View File
@@ -9,6 +9,7 @@
<h1 class="page-header__title">Личный кабинет</h1> <h1 class="page-header__title">Личный кабинет</h1>
<p class="page-header__subtitle">Привет, {{ current_user.username }}! Управляйте своими фотографиями.</p> <p class="page-header__subtitle">Привет, {{ current_user.username }}! Управляйте своими фотографиями.</p>
<div class="page-header__actions"> <div class="page-header__actions">
<a href="{{ url_for('folders.list_folders') }}" class="btn btn--primary">Мои папки</a>
<a href="{{ url_for('cabinet.profile') }}" class="btn btn--ghost">Настройки профиля</a> <a href="{{ url_for('cabinet.profile') }}" class="btn btn--ghost">Настройки профиля</a>
</div> </div>
</div> </div>
@@ -16,6 +17,10 @@
{% include "partials/alerts.html" %} {% include "partials/alerts.html" %}
{% with banners=site_banners.get('cabinet', []), position='cabinet' %}
{% include "partials/banners.html" %}
{% endwith %}
<section class="stats-bar"> <section class="stats-bar">
<div class="container stats"> <div class="container stats">
<div class="stat-card"> <div class="stat-card">
@@ -31,32 +36,90 @@
<span class="stat-card__label">на файл</span> <span class="stat-card__label">на файл</span>
</div> </div>
</div> </div>
{% if quota %}
<div class="container quota-bar-wrap">
<div class="quota-bar">
<div class="quota-bar__header">
<span>Квота: {{ quota.group.name if quota.group else 'Пользователи' }}</span>
<span>
{{ format_size(quota.used) }}
{% if not quota.unlimited %}
/ {{ quota.group.disk_quota_mb }} МБ
{% else %}
/ без лимита
{% endif %}
</span>
</div>
{% if not quota.unlimited %}
<div class="quota-bar__track">
<div class="quota-bar__fill {% if quota.percent > 90 %}quota-bar__fill--warn{% endif %}" style="width: {{ quota.percent }}%"></div>
</div>
{% endif %}
<div 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>
<section id="upload" class="upload-section"> <section id="upload" class="upload-section">
<div class="container"> <div class="container">
<h2 class="section-title">Загрузить фото</h2> <h2 class="section-title">Загрузить фото</h2>
<form action="{{ url_for('main.upload') }}" method="post" enctype="multipart/form-data" class="upload-form" id="uploadForm"> {% with folder_id=None, max_bulk_upload=max_bulk_upload %}
<div class="dropzone" id="dropzone"> {% include "partials/upload_form.html" %}
<input type="file" name="photo" id="photoInput" accept="image/png,image/jpeg,image/gif,image/webp,image/bmp" hidden> {% endwith %}
<div class="dropzone__icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 16V4m0 0L8 8m4-4l4 4"/>
<path d="M20 16.5v1a2.5 2.5 0 01-2.5 2.5h-11A2.5 2.5 0 014 17.5v-1"/>
</svg>
</div>
<p class="dropzone__title">Перетащите фото сюда</p>
<p class="dropzone__hint">или нажмите для выбора файла</p>
<div class="dropzone__preview" id="preview" hidden>
<img id="previewImg" alt="Предпросмотр">
<span id="previewName"></span>
</div>
</div>
<button type="submit" class="btn btn--primary" id="submitBtn" disabled>Загрузить</button>
</form>
</div> </div>
</section> </section>
{% if folders %}
<section class="gallery-section">
<div class="container">
<div class="gallery-header">
<h2 class="section-title">Недавние папки</h2>
<a href="{{ url_for('folders.list_folders') }}" class="btn btn--ghost btn--sm">Все папки</a>
</div>
<div class="folder-grid">
{% for folder in folders %}
<article class="folder-card">
<div class="folder-card__icon">📁</div>
<h3 class="folder-card__title">{{ folder.name }}</h3>
<p class="folder-card__meta">{{ folder.photo_count }} фото</p>
<a href="{{ url_for('folders.view_folder', folder_id=folder.id) }}" class="btn btn--ghost btn--sm">Открыть</a>
</article>
{% endfor %}
</div>
</div>
</section>
{% endif %}
<section class="gallery-section"> <section class="gallery-section">
<div class="container"> <div class="container">
<div class="gallery-header"> <div class="gallery-header">
+111 -10
View File
@@ -6,13 +6,13 @@
<section class="page-header"> <section class="page-header">
<div class="container"> <div class="container">
<h1 class="page-header__title">Настройки профиля</h1> <h1 class="page-header__title">Настройки профиля</h1>
<p class="page-header__subtitle">Измените email или пароль</p> <p class="page-header__subtitle">Безопасность, passkey, сессии и GDPR</p>
</div> </div>
</section> </section>
<section class="auth-section"> <section class="auth-section profile-section">
<div class="container auth-container"> <div class="container profile-grid">
<div class="auth-card auth-card--wide"> <div class="auth-card auth-card--wide profile-card">
{% include "partials/alerts.html" %} {% include "partials/alerts.html" %}
<div class="profile-info"> <div class="profile-info">
@@ -30,7 +30,9 @@
</div> </div>
</div> </div>
<h2 class="profile-card__title">Email и пароль</h2>
<form method="post" class="auth-form"> <form method="post" class="auth-form">
<input type="hidden" name="action" value="save">
<div class="form-group"> <div class="form-group">
<label for="email">Email</label> <label for="email">Email</label>
<input type="email" id="email" name="email" required value="{{ current_user.email }}"> <input type="email" id="email" name="email" required value="{{ current_user.email }}">
@@ -41,19 +43,118 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="new_password">Новый пароль (необязательно)</label> <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>
<div class="form-group"> <div class="form-group">
<label for="new_password2">Подтверждение нового пароля</label> <label for="new_password2">Подтверждение нового пароля</label>
<input type="password" id="new_password2" name="new_password2" minlength="6"> <input type="password" id="new_password2" name="new_password2" minlength="6">
</div> </div>
<button type="submit" class="btn btn--primary btn--full">Сохранить</button> <button type="submit" class="btn btn--primary">Сохранить</button>
</form> </form>
<p class="auth-card__footer">
<a href="{{ url_for('cabinet.index') }}">← Вернуться в кабинет</a>
</p>
</div> </div>
<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> </section>
{% endblock %} {% endblock %}
{% block scripts %}
{% if auth_settings.passkey_enabled %}
<script src="{{ url_for('static', filename='js/passkey.js') }}"></script>
{% endif %}
{% endblock %}
+8 -25
View File
@@ -38,7 +38,11 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
</section> </section>
{% with banners=site_banners.get('main', []), position='main' %}
{% include "partials/banners.html" %}
{% endwith %}
{% include "partials/alerts.html" %} {% include "partials/alerts.html" %}
@@ -46,30 +50,9 @@
<section id="upload" class="upload-section"> <section id="upload" class="upload-section">
<div class="container"> <div class="container">
<h2 class="section-title">Загрузить фото</h2> <h2 class="section-title">Загрузить фото</h2>
<form action="{{ url_for('main.upload') }}" method="post" enctype="multipart/form-data" class="upload-form" id="uploadForm"> {% with folder_id=None, max_bulk_upload=max_bulk_upload %}
<div class="dropzone" id="dropzone"> {% include "partials/upload_form.html" %}
<input type="file" name="photo" id="photoInput" accept="image/png,image/jpeg,image/gif,image/webp,image/bmp" hidden> {% endwith %}
<div class="dropzone__icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 16V4m0 0L8 8m4-4l4 4"/>
<path d="M20 16.5v1a2.5 2.5 0 01-2.5 2.5h-11A2.5 2.5 0 014 17.5v-1"/>
</svg>
</div>
<p class="dropzone__title">Перетащите фото сюда</p>
<p class="dropzone__hint">или нажмите для выбора файла</p>
<p class="dropzone__formats">PNG · JPG · GIF · WEBP · BMP</p>
<div class="dropzone__preview" id="preview" hidden>
<img id="previewImg" alt="Предпросмотр">
<span id="previewName"></span>
</div>
</div>
<button type="submit" class="btn btn--primary" id="submitBtn" disabled>
<span>Загрузить на сервер</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</button>
</form>
</div> </div>
</section> </section>
{% endif %} {% endif %}
+26
View File
@@ -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 %}
+48
View File
@@ -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 %}
+39
View File
@@ -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 %}
+15
View File
@@ -0,0 +1,15 @@
{% if banners %}
<div class="ad-banners ad-banners--{{ position|default('default') }}">
{% for banner in banners %}
<div class="ad-banner">
{% if banner.link_url %}
<a href="{{ banner.link_url }}" class="ad-banner__link" target="_blank" rel="noopener sponsored">
<img src="{{ banner.image_url }}" alt="{{ banner.alt_text or banner.title }}" class="ad-banner__img" loading="lazy">
</a>
{% else %}
<img src="{{ banner.image_url }}" alt="{{ banner.alt_text or banner.title }}" class="ad-banner__img" loading="lazy">
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
+12
View File
@@ -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 %}
+17
View File
@@ -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>
+19 -2
View File
@@ -1,6 +1,9 @@
{% if photos %} {% if photos %}
<div class="gallery"> <div class="gallery">
{% for photo in photos %} {% 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 }}"> <article class="photo-card" data-id="{{ photo.id }}">
<div class="photo-card__image-wrap"> <div class="photo-card__image-wrap">
<img <img
@@ -10,8 +13,22 @@
loading="lazy" loading="lazy"
> >
<div class="photo-card__overlay"> <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> </button>
<a href="{{ photo.url }}" target="_blank" class="btn btn--ghost btn--sm">Открыть</a> <a href="{{ photo.url }}" target="_blank" class="btn btn--ghost btn--sm">Открыть</a>
</div> </div>
+36
View File
@@ -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="Закрыть">&times;</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>
+47
View File
@@ -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&#10;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>
+38
View File
@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}{{ folder.name }} — Shared folder{% endblock %}
{% block content %}
<section class="page-header">
<div class="container">
<h1 class="page-header__title">📁 {{ folder.name }}</h1>
<p class="page-header__subtitle">Общая папка · {{ photos|length }} фото</p>
{% if share_url %}
<div class="page-header__actions">
<button type="button" class="btn btn--ghost copy-btn" data-url="{{ share_url }}">Копировать ссылку</button>
</div>
{% endif %}
</div>
</section>
{% include "partials/alerts.html" %}
{% if can_edit %}
<section id="upload" class="upload-section">
<div class="container">
<h2 class="section-title">Загрузить в папку</h2>
{% with folder_id=folder.id, max_bulk_upload=max_bulk_upload %}
{% include "partials/upload_form.html" %}
{% endwith %}
</div>
</section>
{% endif %}
<section class="gallery-section">
<div class="container">
{% with photos=photos, empty_title='В папке пока нет фото' %}
{% include "partials/photo_gallery.html" %}
{% endwith %}
</div>
</section>
{% endblock %}
+22
View File
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Пароль — {{ folder.name }}{% endblock %}
{% block content %}
<section class="auth-section">
<div class="container auth-container">
<div class="auth-card">
<h1 class="auth-card__title">Папка «{{ folder.name }}»</h1>
<p class="auth-card__subtitle">Эта папка защищена паролем</p>
{% include "partials/alerts.html" %}
<form method="post" class="auth-form">
<div class="form-group">
<label for="password">Пароль</label>
<input type="password" id="password" name="password" required autofocus>
</div>
<button type="submit" class="btn btn--primary btn--full">Открыть папку</button>
</form>
</div>
</div>
</section>
{% endblock %}
+296
View File
@@ -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,
}
+19
View File
@@ -29,11 +29,30 @@ services:
ADMIN_USERNAME: ${ADMIN_USERNAME:-} ADMIN_USERNAME: ${ADMIN_USERNAME:-}
ADMIN_EMAIL: ${ADMIN_EMAIL:-} ADMIN_EMAIL: ${ADMIN_EMAIL:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
DEFAULT_GROUP_QUOTA_MB: ${DEFAULT_GROUP_QUOTA_MB:-100}
GIT_REPO_PATH: /repo
GIT_REMOTE_URL: ${GIT_REMOTE_URL:-https://git.evilfox.cc/test2/fotohost.git}
ALLOW_GIT_DEPLOY: ${ALLOW_GIT_DEPLOY:-false}
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: volumes:
- uploads_data:/app/uploads - uploads_data:/app/uploads
- .:/repo
- /var/run/docker.sock:/var/run/docker.sock
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
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: volumes:
postgres_data: postgres_data:
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
set -e
# Git deploy needs write access to mounted /repo; never fail container start on chown errors.
if [ "$ALLOW_GIT_DEPLOY" = "true" ] || [ "$ALLOW_GIT_DEPLOY" = "1" ] || [ "$ALLOW_GIT_DEPLOY" = "yes" ]; then
if [ -d /repo/.git ]; then
chown -R appuser:appuser /repo 2>/dev/null || true
chmod -R u+rwX /repo/.git 2>/dev/null || true
elif [ -d /repo ]; then
chown -R appuser:appuser /repo 2>/dev/null || true
fi
fi
# Run DB migrations once before gunicorn workers start.
gosu appuser python /app/init_db.py || exit 1
export SKIP_DB_INIT=1
exec gosu appuser "$@"
+6
View File
@@ -0,0 +1,6 @@
bind = "0.0.0.0:8000"
workers = 2
timeout = 120
accesslog = "-"
errorlog = "-"
loglevel = "info"
+10
View File
@@ -0,0 +1,10 @@
import sys
try:
from app import create_app
create_app(setup_database=True)
print("Database init OK", flush=True)
except Exception as exc:
print(f"Database init FAILED: {exc}", file=sys.stderr, flush=True)
raise
+5
View File
@@ -6,3 +6,8 @@ gunicorn==23.0.0
Pillow==11.1.0 Pillow==11.1.0
python-dotenv==1.0.1 python-dotenv==1.0.1
Werkzeug==3.1.3 Werkzeug==3.1.3
boto3==1.35.99
paramiko==3.5.1
requests==2.32.3
qrcode[pil]==8.0
webauthn==2.2.0
+1 -1
View File
@@ -1,3 +1,3 @@
from app import create_app from app import create_app
app = create_app() app = create_app(setup_database=False)