Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4f0eaa7d9 | |||
| 49abcc20b4 | |||
| b014e64c5d | |||
| e334a7b32c | |||
| 5353c82066 | |||
| 82fdb60f5e | |||
| 28eb9e19f5 | |||
| 69715ecd06 | |||
| 6a6704bc4b | |||
| d10f25eb06 |
@@ -16,6 +16,8 @@ ADMIN_PASSWORD=change_me_admin_password
|
|||||||
|
|
||||||
# Default user group quota in MB (0 = unlimited)
|
# Default user group quota in MB (0 = unlimited)
|
||||||
DEFAULT_GROUP_QUOTA_MB=100
|
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)
|
# Git deploy from admin panel (requires repo mount and docker socket)
|
||||||
ALLOW_GIT_DEPLOY=false
|
ALLOW_GIT_DEPLOY=false
|
||||||
|
|||||||
+10
-5
@@ -6,6 +6,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libpq-dev \
|
libpq-dev \
|
||||||
gcc \
|
gcc \
|
||||||
git \
|
git \
|
||||||
|
gosu \
|
||||||
docker.io \
|
docker.io \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
@@ -14,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"]
|
||||||
|
|||||||
@@ -312,6 +312,8 @@ docker compose up -d --build
|
|||||||
| `/cabinet/profile` | Настройки профиля, смена пароля |
|
| `/cabinet/profile` | Настройки профиля, смена пароля |
|
||||||
| `/admin/` | Панель администратора (только admin) |
|
| `/admin/` | Панель администратора (только admin) |
|
||||||
| `/admin/users` | Управление пользователями |
|
| `/admin/users` | Управление пользователями |
|
||||||
|
| `/admin/groups` | Группы: квота диска, лимиты папок и фото |
|
||||||
|
| `/admin/banners` | Рекламные баннеры на сайте |
|
||||||
| `/admin/photos` | Все фото на сервере |
|
| `/admin/photos` | Все фото на сервере |
|
||||||
|
|
||||||
**Права доступа:**
|
**Права доступа:**
|
||||||
@@ -321,6 +323,73 @@ 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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Полезные команды
|
## Полезные команды
|
||||||
|
|
||||||
| Действие | Команда |
|
| Действие | Команда |
|
||||||
@@ -449,7 +518,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>` | Удаление фото |
|
||||||
@@ -501,6 +572,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`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Технологии
|
## Технологии
|
||||||
|
|||||||
+20
-8
@@ -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,6 +30,7 @@ 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"}
|
||||||
@@ -59,8 +60,10 @@ def create_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 ( # noqa: F401
|
from app.models import ( # noqa: F401
|
||||||
|
AdBanner,
|
||||||
Folder,
|
Folder,
|
||||||
FolderInvite,
|
FolderInvite,
|
||||||
FolderMember,
|
FolderMember,
|
||||||
@@ -71,21 +74,30 @@ def create_app():
|
|||||||
UserGroup,
|
UserGroup,
|
||||||
)
|
)
|
||||||
|
|
||||||
db.create_all()
|
@app.context_processor
|
||||||
|
def inject_banners():
|
||||||
|
from app.banner_service import get_banners_by_position
|
||||||
|
|
||||||
|
try:
|
||||||
|
return {"site_banners": get_banners_by_position()}
|
||||||
|
except Exception:
|
||||||
|
return {"site_banners": {}}
|
||||||
|
|
||||||
|
if setup_database:
|
||||||
|
with app.app_context():
|
||||||
from app.bootstrap import (
|
from app.bootstrap import (
|
||||||
create_first_admin,
|
create_first_admin,
|
||||||
ensure_default_group,
|
ensure_default_group,
|
||||||
ensure_photo_storage_column,
|
|
||||||
ensure_schema,
|
|
||||||
ensure_site_settings,
|
ensure_site_settings,
|
||||||
|
run_schema_migrations,
|
||||||
)
|
)
|
||||||
from app.folders import ensure_folder_schema
|
|
||||||
|
|
||||||
ensure_schema()
|
db.create_all()
|
||||||
|
run_schema_migrations()
|
||||||
|
|
||||||
|
if os.getenv("SKIP_DB_INIT") != "1":
|
||||||
ensure_default_group(app)
|
ensure_default_group(app)
|
||||||
ensure_folder_schema()
|
|
||||||
ensure_site_settings(app)
|
ensure_site_settings(app)
|
||||||
ensure_photo_storage_column()
|
|
||||||
create_first_admin(app)
|
create_first_admin(app)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
+120
-4
@@ -15,8 +15,8 @@ from app.deploy_utils import (
|
|||||||
get_deploy_status,
|
get_deploy_status,
|
||||||
is_deploy_enabled,
|
is_deploy_enabled,
|
||||||
)
|
)
|
||||||
from app.models import Photo, User, UserGroup
|
from app.models import AdBanner, Photo, User, UserGroup
|
||||||
from app.quota_utils import get_user_storage_used
|
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.settings_service import get_settings, update_settings_from_form
|
||||||
from app.storage_service import delete_photo_file
|
from app.storage_service import delete_photo_file
|
||||||
|
|
||||||
@@ -131,6 +131,12 @@ def groups():
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
name = request.form.get("name", "").strip()
|
name = request.form.get("name", "").strip()
|
||||||
quota_mb = request.form.get("disk_quota_mb", type=int) or 100
|
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:
|
if len(name) < 2:
|
||||||
flash("Название группы — минимум 2 символа", "error")
|
flash("Название группы — минимум 2 символа", "error")
|
||||||
@@ -144,7 +150,13 @@ def groups():
|
|||||||
slug = f"{base_slug}-{counter}"
|
slug = f"{base_slug}-{counter}"
|
||||||
counter += 1
|
counter += 1
|
||||||
|
|
||||||
group = UserGroup(name=name, slug=slug, disk_quota_mb=max(0, quota_mb))
|
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.add(group)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f"Группа «{name}» создана", "success")
|
flash(f"Группа «{name}» создана", "success")
|
||||||
@@ -154,7 +166,14 @@ def groups():
|
|||||||
group_stats = []
|
group_stats = []
|
||||||
for group in all_groups:
|
for group in all_groups:
|
||||||
used = sum(get_user_storage_used(u.id) for u in group.users)
|
used = sum(get_user_storage_used(u.id) for u in group.users)
|
||||||
group_stats.append({"group": group, "storage_used": used})
|
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)
|
return render_template("admin/groups.html", group_stats=group_stats)
|
||||||
|
|
||||||
|
|
||||||
@@ -164,6 +183,8 @@ def edit_group(group_id):
|
|||||||
group = UserGroup.query.get_or_404(group_id)
|
group = UserGroup.query.get_or_404(group_id)
|
||||||
name = request.form.get("name", "").strip()
|
name = request.form.get("name", "").strip()
|
||||||
quota_mb = request.form.get("disk_quota_mb", type=int)
|
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:
|
if len(name) < 2:
|
||||||
flash("Название группы — минимум 2 символа", "error")
|
flash("Название группы — минимум 2 символа", "error")
|
||||||
@@ -177,6 +198,10 @@ def edit_group(group_id):
|
|||||||
group.name = name
|
group.name = name
|
||||||
if quota_mb is not None:
|
if quota_mb is not None:
|
||||||
group.disk_quota_mb = max(0, quota_mb)
|
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()
|
db.session.commit()
|
||||||
flash(f"Группа «{group.name}» обновлена", "success")
|
flash(f"Группа «{group.name}» обновлена", "success")
|
||||||
return redirect(url_for("admin.groups"))
|
return redirect(url_for("admin.groups"))
|
||||||
@@ -202,6 +227,97 @@ def delete_group(group_id):
|
|||||||
return redirect(url_for("admin.groups"))
|
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():
|
||||||
|
|||||||
@@ -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
|
||||||
+60
-10
@@ -12,24 +12,28 @@ 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")}
|
|
||||||
if "user_id" not in columns:
|
|
||||||
db.session.execute(
|
db.session.execute(
|
||||||
text("ALTER TABLE photos ADD COLUMN user_id INTEGER REFERENCES users(id)")
|
text(
|
||||||
|
"ALTER TABLE photos ADD COLUMN IF NOT EXISTS "
|
||||||
|
"user_id INTEGER REFERENCES users(id)"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
if "users" in tables and "user_groups" in tables:
|
if "users" in tables and "user_groups" in tables:
|
||||||
columns = {col["name"] for col in inspector.get_columns("users")}
|
|
||||||
if "group_id" not in columns:
|
|
||||||
db.session.execute(
|
db.session.execute(
|
||||||
text("ALTER TABLE users ADD COLUMN group_id INTEGER REFERENCES user_groups(id)")
|
text(
|
||||||
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS "
|
||||||
|
"group_id INTEGER REFERENCES user_groups(id)"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
def ensure_default_group(app):
|
def ensure_default_group(app):
|
||||||
default_quota = int(os.getenv("DEFAULT_GROUP_QUOTA_MB", "100"))
|
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()
|
default_group = UserGroup.query.filter_by(is_default=True).first()
|
||||||
|
|
||||||
if not default_group:
|
if not default_group:
|
||||||
@@ -41,16 +45,43 @@ def ensure_default_group(app):
|
|||||||
name="Пользователи",
|
name="Пользователи",
|
||||||
slug="users",
|
slug="users",
|
||||||
disk_quota_mb=default_quota,
|
disk_quota_mb=default_quota,
|
||||||
|
max_folders=default_max_folders,
|
||||||
|
max_photos=default_max_photos,
|
||||||
is_default=True,
|
is_default=True,
|
||||||
)
|
)
|
||||||
db.session.add(default_group)
|
db.session.add(default_group)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
app.logger.info("Default user group 'users' created with %s MB quota", default_quota)
|
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})
|
User.query.filter(User.group_id.is_(None)).update({"group_id": default_group.id})
|
||||||
db.session.commit()
|
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):
|
def ensure_site_settings(app):
|
||||||
from app.models import SiteSettings
|
from app.models import SiteSettings
|
||||||
|
|
||||||
@@ -64,12 +95,31 @@ def ensure_photo_storage_column():
|
|||||||
inspector = inspect(db.engine)
|
inspector = inspect(db.engine)
|
||||||
if "photos" not in inspector.get_table_names():
|
if "photos" not in inspector.get_table_names():
|
||||||
return
|
return
|
||||||
columns = {col["name"] for col in inspector.get_columns("photos")}
|
db.session.execute(
|
||||||
if "storage_backend" not in columns:
|
text(
|
||||||
db.session.execute(text("ALTER TABLE photos ADD COLUMN storage_backend VARCHAR(20) DEFAULT 'local'"))
|
"ALTER TABLE photos ADD COLUMN IF NOT EXISTS "
|
||||||
|
"storage_backend VARCHAR(20) DEFAULT 'local'"
|
||||||
|
)
|
||||||
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def run_schema_migrations():
|
||||||
|
ensure_schema()
|
||||||
|
ensure_group_limit_columns()
|
||||||
|
from app.folders import ensure_folder_schema
|
||||||
|
|
||||||
|
ensure_folder_schema()
|
||||||
|
ensure_photo_storage_column()
|
||||||
|
|
||||||
|
|
||||||
|
def run_database_setup(app):
|
||||||
|
run_schema_migrations()
|
||||||
|
ensure_default_group(app)
|
||||||
|
ensure_site_settings(app)
|
||||||
|
create_first_admin(app)
|
||||||
|
|
||||||
|
|
||||||
def slugify(name):
|
def slugify(name):
|
||||||
slug = re.sub(r"[^a-z0-9]+", "-", name.lower().strip())
|
slug = re.sub(r"[^a-z0-9]+", "-", name.lower().strip())
|
||||||
slug = slug.strip("-") or "group"
|
slug = slug.strip("-") or "group"
|
||||||
|
|||||||
+136
-29
@@ -17,58 +17,165 @@ def get_git_remote():
|
|||||||
return os.getenv("GIT_REMOTE_URL", "").strip()
|
return os.getenv("GIT_REMOTE_URL", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_name():
|
||||||
|
return os.getenv("CONTAINER_NAME", os.getenv("HOSTNAME", "photohost-web"))
|
||||||
|
|
||||||
|
|
||||||
def _repo_ready():
|
def _repo_ready():
|
||||||
repo = get_repo_path()
|
repo = get_repo_path()
|
||||||
return os.path.isdir(repo) and os.path.isdir(os.path.join(repo, ".git"))
|
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):
|
def run_git(args, timeout=120):
|
||||||
if not _repo_ready():
|
if not _repo_ready():
|
||||||
return False, f"Git-репозиторий не найден: {get_repo_path()}"
|
return False, f"Git-репозиторий не найден: {get_repo_path()}"
|
||||||
|
|
||||||
result = subprocess.run(
|
repo = get_repo_path()
|
||||||
["git", "-C", get_repo_path()] + args,
|
cmd = _git_base_cmd(repo) + args
|
||||||
capture_output=True,
|
ok, output = _run_subprocess(cmd, timeout=timeout)
|
||||||
text=True,
|
if ok:
|
||||||
timeout=timeout,
|
return True, output
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
permission_error = "permission denied" in output.lower() and "config" in output.lower()
|
||||||
return False, (result.stderr or result.stdout or "Git error").strip()
|
if permission_error and _docker_available():
|
||||||
return True, result.stdout.strip()
|
_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():
|
def fetch_remote():
|
||||||
remote = get_git_remote()
|
remote = get_git_remote()
|
||||||
if remote:
|
if not remote:
|
||||||
ok, msg = run_git(["remote", "set-url", "origin", remote])
|
|
||||||
if not ok:
|
|
||||||
return False, msg
|
|
||||||
return run_git(["fetch", "--all", "--tags", "--prune"], timeout=180)
|
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():
|
def list_tags():
|
||||||
ok, msg = fetch_remote()
|
ok, err, lines = run_ls_remote(["--tags"])
|
||||||
if not ok:
|
if not ok:
|
||||||
return [], msg
|
return [], err
|
||||||
ok, out = run_git(["tag", "--sort=-version:refname"])
|
|
||||||
if not ok:
|
tags = []
|
||||||
return [], out
|
for line in lines:
|
||||||
return [line for line in out.splitlines() if line.strip()], None
|
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():
|
def list_branches():
|
||||||
ok, msg = fetch_remote()
|
ok, err, lines = run_ls_remote(["--heads"])
|
||||||
if not ok:
|
if not ok:
|
||||||
return [], msg
|
return [], err
|
||||||
ok, out = run_git(["branch", "-a", "--format=%(refname:short)"])
|
|
||||||
if not ok:
|
|
||||||
return [], out
|
|
||||||
branches = []
|
branches = []
|
||||||
for line in out.splitlines():
|
for line in lines:
|
||||||
name = line.strip().replace("origin/", "")
|
parts = line.split()
|
||||||
if name and name not in branches and "HEAD" not in name:
|
if len(parts) < 2:
|
||||||
branches.append(name)
|
continue
|
||||||
return branches, None
|
ref = parts[1]
|
||||||
|
if ref.startswith("refs/heads/"):
|
||||||
|
branches.append(ref.removeprefix("refs/heads/"))
|
||||||
|
|
||||||
|
return sorted(set(branches)), None
|
||||||
|
|
||||||
|
|
||||||
def get_current_version():
|
def get_current_version():
|
||||||
|
|||||||
+18
-4
@@ -22,6 +22,7 @@ from app.folder_utils import (
|
|||||||
unlock_folder,
|
unlock_folder,
|
||||||
)
|
)
|
||||||
from app.models import Folder, FolderInvite, FolderMember, Photo, User
|
from app.models import Folder, FolderInvite, FolderMember, Photo, User
|
||||||
|
from app.quota_utils import check_folder_limit
|
||||||
from app.settings_service import get_settings
|
from app.settings_service import get_settings
|
||||||
from app.storage_service import delete_photo_file
|
from app.storage_service import delete_photo_file
|
||||||
|
|
||||||
@@ -31,6 +32,8 @@ bp = Blueprint("folders", __name__)
|
|||||||
@bp.route("/cabinet/folders")
|
@bp.route("/cabinet/folders")
|
||||||
@login_required
|
@login_required
|
||||||
def list_folders():
|
def list_folders():
|
||||||
|
from app.quota_utils import quota_status
|
||||||
|
|
||||||
process_pending_invites(current_user)
|
process_pending_invites(current_user)
|
||||||
owned = Folder.query.filter_by(owner_id=current_user.id).order_by(Folder.created_at.desc()).all()
|
owned = Folder.query.filter_by(owner_id=current_user.id).order_by(Folder.created_at.desc()).all()
|
||||||
shared = (
|
shared = (
|
||||||
@@ -42,7 +45,12 @@ def list_folders():
|
|||||||
.order_by(Folder.created_at.desc())
|
.order_by(Folder.created_at.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return render_template("cabinet/folders/list.html", owned_folders=owned, shared_folders=shared)
|
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"])
|
@bp.route("/cabinet/folders/create", methods=["POST"])
|
||||||
@@ -56,6 +64,11 @@ def create_folder():
|
|||||||
flash("Название папки — минимум 2 символа", "error")
|
flash("Название папки — минимум 2 символа", "error")
|
||||||
return redirect(url_for("folders.list_folders"))
|
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)
|
folder = Folder(name=name, owner_id=current_user.id, is_private=is_private)
|
||||||
if access_password:
|
if access_password:
|
||||||
if len(access_password) < 4:
|
if len(access_password) < 4:
|
||||||
@@ -306,9 +319,10 @@ def ensure_folder_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")}
|
|
||||||
if "folder_id" not in columns:
|
|
||||||
db.session.execute(
|
db.session.execute(
|
||||||
text("ALTER TABLE photos ADD COLUMN folder_id INTEGER REFERENCES folders(id)")
|
text(
|
||||||
|
"ALTER TABLE photos ADD COLUMN IF NOT EXISTS "
|
||||||
|
"folder_id INTEGER REFERENCES folders(id)"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ class UserGroup(db.Model):
|
|||||||
name = db.Column(db.String(80), unique=True, nullable=False)
|
name = db.Column(db.String(80), unique=True, nullable=False)
|
||||||
slug = db.Column(db.String(80), unique=True, nullable=False, index=True)
|
slug = db.Column(db.String(80), unique=True, nullable=False, index=True)
|
||||||
disk_quota_mb = db.Column(db.Integer, nullable=False, default=100)
|
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)
|
is_default = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
created_at = db.Column(
|
created_at = db.Column(
|
||||||
db.DateTime,
|
db.DateTime,
|
||||||
@@ -150,6 +152,47 @@ class UserGroup(db.Model):
|
|||||||
return "Без лимита"
|
return "Без лимита"
|
||||||
return f"{self.disk_quota_mb} МБ"
|
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):
|
class Folder(db.Model):
|
||||||
__tablename__ = "folders"
|
__tablename__ = "folders"
|
||||||
|
|||||||
+76
-4
@@ -1,7 +1,7 @@
|
|||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from app.models import Photo, User, UserGroup
|
from app.models import Folder, Photo, User, UserGroup
|
||||||
|
|
||||||
|
|
||||||
def get_default_group():
|
def get_default_group():
|
||||||
@@ -21,12 +21,52 @@ def get_user_storage_used(user_id):
|
|||||||
return int(result or 0)
|
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):
|
def get_group_quota_bytes(group):
|
||||||
if not group or group.disk_quota_mb == 0:
|
if not group or group.disk_quota_mb == 0:
|
||||||
return None
|
return None
|
||||||
return group.disk_quota_mb * 1024 * 1024
|
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):
|
def check_upload_quota(user, new_file_size):
|
||||||
group = get_user_group(user)
|
group = get_user_group(user)
|
||||||
quota_bytes = get_group_quota_bytes(group)
|
quota_bytes = get_group_quota_bytes(group)
|
||||||
@@ -35,9 +75,7 @@ def check_upload_quota(user, new_file_size):
|
|||||||
|
|
||||||
used = get_user_storage_used(user.id)
|
used = get_user_storage_used(user.id)
|
||||||
if used + new_file_size > quota_bytes:
|
if used + new_file_size > quota_bytes:
|
||||||
from app.models import Photo as _Photo
|
used_human = Photo(file_size=used).size_human if used else "0 Б"
|
||||||
|
|
||||||
used_human = _Photo(file_size=used).size_human if used else "0 Б"
|
|
||||||
quota_human = f"{group.disk_quota_mb} МБ"
|
quota_human = f"{group.disk_quota_mb} МБ"
|
||||||
return False, f"Превышена квота группы «{group.name}»: {used_human} / {quota_human}"
|
return False, f"Превышена квота группы «{group.name}»: {used_human} / {quota_human}"
|
||||||
return True, ""
|
return True, ""
|
||||||
@@ -46,7 +84,13 @@ def check_upload_quota(user, new_file_size):
|
|||||||
def quota_status(user):
|
def quota_status(user):
|
||||||
group = get_user_group(user)
|
group = get_user_group(user)
|
||||||
used = get_user_storage_used(user.id)
|
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)
|
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:
|
if quota_bytes is None:
|
||||||
return {
|
return {
|
||||||
"group": group,
|
"group": group,
|
||||||
@@ -54,12 +98,40 @@ def quota_status(user):
|
|||||||
"quota_bytes": None,
|
"quota_bytes": None,
|
||||||
"percent": 0,
|
"percent": 0,
|
||||||
"unlimited": True,
|
"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
|
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 {
|
return {
|
||||||
"group": group,
|
"group": group,
|
||||||
"used": used,
|
"used": used,
|
||||||
"quota_bytes": quota_bytes,
|
"quota_bytes": quota_bytes,
|
||||||
"percent": percent,
|
"percent": percent,
|
||||||
"unlimited": False,
|
"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,
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-1
@@ -2,6 +2,7 @@ import os
|
|||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
|
Response,
|
||||||
abort,
|
abort,
|
||||||
current_app,
|
current_app,
|
||||||
flash,
|
flash,
|
||||||
@@ -20,11 +21,23 @@ from app.folder_utils import can_edit_folder
|
|||||||
from app.models import Folder, Photo
|
from app.models import Folder, Photo
|
||||||
from app.settings_service import get_settings
|
from app.settings_service import get_settings
|
||||||
from app.storage_service import delete_photo_file, get_photo_stream
|
from app.storage_service import delete_photo_file, get_photo_stream
|
||||||
from app.upload_service import process_uploads
|
from app.upload_service import process_uploads, process_url_uploads
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
bp = Blueprint("main", __name__)
|
bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/health")
|
||||||
|
def health():
|
||||||
|
try:
|
||||||
|
db.session.execute(text("SELECT 1"))
|
||||||
|
db.session.remove()
|
||||||
|
return Response("ok\n", mimetype="text/plain")
|
||||||
|
except Exception as exc:
|
||||||
|
db.session.remove()
|
||||||
|
return Response(f"error: {exc}\n", status=503, mimetype="text/plain")
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/")
|
@bp.route("/")
|
||||||
def index():
|
def index():
|
||||||
photos = Photo.query.order_by(Photo.created_at.desc()).limit(24).all()
|
photos = Photo.query.order_by(Photo.created_at.desc()).limit(24).all()
|
||||||
@@ -51,6 +64,18 @@ def upload():
|
|||||||
if not can_edit_folder(folder):
|
if not can_edit_folder(folder):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
image_urls = request.form.get("image_urls", "").strip()
|
||||||
|
max_upload_mb = current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024)
|
||||||
|
|
||||||
|
if image_urls:
|
||||||
|
result = process_url_uploads(
|
||||||
|
image_urls,
|
||||||
|
current_user,
|
||||||
|
folder,
|
||||||
|
current_app.config["ALLOWED_EXTENSIONS"],
|
||||||
|
max_upload_mb,
|
||||||
|
)
|
||||||
|
else:
|
||||||
result = process_uploads(
|
result = process_uploads(
|
||||||
request.files,
|
request.files,
|
||||||
current_user,
|
current_user,
|
||||||
@@ -102,6 +127,28 @@ 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):
|
||||||
photo = Photo.query.filter_by(filename=filename).first()
|
photo = Photo.query.filter_by(filename=filename).first()
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
def photo_absolute_url(photo, base_url):
|
||||||
|
return f"{base_url.rstrip('/')}{photo.url}"
|
||||||
|
|
||||||
|
|
||||||
|
def photo_bbcode(photo, base_url):
|
||||||
|
return f"[img]{photo_absolute_url(photo, base_url)}[/img]"
|
||||||
|
|
||||||
|
|
||||||
|
def photo_html(photo, base_url):
|
||||||
|
url = photo_absolute_url(photo, base_url)
|
||||||
|
name = photo.original_name.replace('"', """)
|
||||||
|
return f'<img src="{url}" alt="{name}">'
|
||||||
+275
-1
@@ -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;
|
||||||
@@ -1045,3 +1047,275 @@ body {
|
|||||||
.settings-form .admin-panel {
|
.settings-form .admin-panel {
|
||||||
margin-bottom: 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+107
-12
@@ -1,10 +1,31 @@
|
|||||||
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;
|
||||||
|
|
||||||
@@ -40,6 +61,25 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
uploadForm.addEventListener("submit", (e) => {
|
||||||
|
const activePanel = document.querySelector(".upload-panel--active");
|
||||||
|
if (!activePanel) return;
|
||||||
|
|
||||||
|
if (activePanel.dataset.panel === "urls") {
|
||||||
|
const urls = document.getElementById("imageUrls");
|
||||||
|
if (!urls || !urls.value.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
showToast("Укажите хотя бы одну ссылку");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activePanel.dataset.panel === "files" && photoInput.files.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
showToast("Выберите файлы для загрузки");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function assignFiles(fileList) {
|
function assignFiles(fileList) {
|
||||||
const dt = new DataTransfer();
|
const dt = new DataTransfer();
|
||||||
const limit = Math.min(fileList.length, maxFiles);
|
const limit = Math.min(fileList.length, maxFiles);
|
||||||
@@ -68,26 +108,81 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
};
|
};
|
||||||
reader.readAsDataURL(first);
|
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");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<a href="{{ url_for('admin.dashboard') }}" class="admin-nav__link {% if request.endpoint == 'admin.dashboard' %}admin-nav__link--active{% endif %}">Обзор</a>
|
<a href="{{ url_for('admin.dashboard') }}" class="admin-nav__link {% if request.endpoint == 'admin.dashboard' %}admin-nav__link--active{% endif %}">Обзор</a>
|
||||||
<a href="{{ url_for('admin.users') }}" class="admin-nav__link {% if request.endpoint == 'admin.users' %}admin-nav__link--active{% endif %}">Пользователи</a>
|
<a href="{{ url_for('admin.users') }}" class="admin-nav__link {% if request.endpoint == 'admin.users' %}admin-nav__link--active{% endif %}">Пользователи</a>
|
||||||
<a href="{{ url_for('admin.groups') }}" class="admin-nav__link {% if request.endpoint in ['admin.groups', 'admin.edit_group', 'admin.delete_group'] %}admin-nav__link--active{% endif %}">Группы</a>
|
<a href="{{ url_for('admin.groups') }}" class="admin-nav__link {% if request.endpoint in ['admin.groups', 'admin.edit_group', 'admin.delete_group'] %}admin-nav__link--active{% endif %}">Группы</a>
|
||||||
|
<a href="{{ url_for('admin.banners') }}" class="admin-nav__link {% if request.endpoint in ['admin.banners', 'admin.edit_banner', 'admin.delete_banner', 'admin.toggle_banner'] %}admin-nav__link--active{% endif %}">Баннеры</a>
|
||||||
<a href="{{ url_for('admin.photos') }}" class="admin-nav__link {% if request.endpoint == 'admin.photos' %}admin-nav__link--active{% endif %}">Фото</a>
|
<a href="{{ url_for('admin.photos') }}" class="admin-nav__link {% if request.endpoint == 'admin.photos' %}admin-nav__link--active{% endif %}">Фото</a>
|
||||||
<a href="{{ url_for('admin.deploy') }}" class="admin-nav__link {% if request.endpoint == 'admin.deploy' %}admin-nav__link--active{% endif %}">Версии Git</a>
|
<a href="{{ url_for('admin.deploy') }}" class="admin-nav__link {% if request.endpoint == 'admin.deploy' %}admin-nav__link--active{% endif %}">Версии Git</a>
|
||||||
<a href="{{ url_for('admin.settings') }}" class="admin-nav__link {% if request.endpoint == 'admin.settings' %}admin-nav__link--active{% endif %}">Настройки</a>
|
<a href="{{ url_for('admin.settings') }}" class="admin-nav__link {% if request.endpoint == 'admin.settings' %}admin-nav__link--active{% endif %}">Настройки</a>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Рекламные баннеры — Админка{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-header page-header--admin">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="page-header__title">Рекламные баннеры</h1>
|
||||||
|
<p class="page-header__subtitle">Баннеры на главной, в кабинете и в подвале сайта</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-section">
|
||||||
|
<div class="container">
|
||||||
|
{% include "admin/_nav.html" %}
|
||||||
|
{% include "partials/alerts.html" %}
|
||||||
|
|
||||||
|
<div class="admin-panel folder-create">
|
||||||
|
<h2 class="admin-panel__title">Добавить баннер</h2>
|
||||||
|
<form method="post" class="auth-form folder-create__form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title">Название (для админки)</label>
|
||||||
|
<input type="text" id="title" name="title" required minlength="2" placeholder="Промо лето">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="image_url">URL изображения</label>
|
||||||
|
<input type="url" id="image_url" name="image_url" required placeholder="https://example.com/banner.jpg">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="link_url">Ссылка при клике (необязательно)</label>
|
||||||
|
<input type="url" id="link_url" name="link_url" placeholder="https://example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="alt_text">Alt-текст</label>
|
||||||
|
<input type="text" id="alt_text" name="alt_text" placeholder="Описание баннера">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="position">Позиция</label>
|
||||||
|
<select id="position" name="position" class="form-select">
|
||||||
|
{% for key, label in positions.items() %}
|
||||||
|
<option value="{{ key }}">{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sort_order">Порядок (меньше — выше)</label>
|
||||||
|
<input type="number" id="sort_order" name="sort_order" value="0">
|
||||||
|
</div>
|
||||||
|
<label class="form-checkbox">
|
||||||
|
<input type="checkbox" name="is_active" checked>
|
||||||
|
<span>Активен</span>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn--primary">Добавить баннер</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Баннер</th>
|
||||||
|
<th>Позиция</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for banner in banners %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="banner-preview">
|
||||||
|
<img src="{{ banner.image_url }}" alt="" class="banner-preview__img">
|
||||||
|
<div>
|
||||||
|
<strong>{{ banner.title }}</strong>
|
||||||
|
{% if banner.link_url %}<br><small>{{ banner.link_url }}</small>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ banner.position_label }}</td>
|
||||||
|
<td>
|
||||||
|
{% if banner.is_active %}
|
||||||
|
<span class="badge badge--success">активен</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge--muted">выключен</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="{{ url_for('admin.edit_banner', banner_id=banner.id) }}" class="banner-edit-form">
|
||||||
|
<input type="text" name="title" value="{{ banner.title }}" required minlength="2" class="form-inline-input">
|
||||||
|
<input type="url" name="image_url" value="{{ banner.image_url }}" required class="form-inline-input">
|
||||||
|
<input type="url" name="link_url" value="{{ banner.link_url or '' }}" placeholder="Ссылка" class="form-inline-input">
|
||||||
|
<select name="position" class="form-select form-select--sm">
|
||||||
|
{% for key, label in positions.items() %}
|
||||||
|
<option value="{{ key }}" {% if banner.position == key %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input type="number" name="sort_order" value="{{ banner.sort_order }}" class="form-inline-input form-inline-input--sm">
|
||||||
|
<label class="form-checkbox form-checkbox--inline">
|
||||||
|
<input type="checkbox" name="is_active" {% if banner.is_active %}checked{% endif %}>
|
||||||
|
<span>Активен</span>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn--ghost btn--sm">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
<div class="banner-actions">
|
||||||
|
<form method="post" action="{{ url_for('admin.toggle_banner', banner_id=banner.id) }}">
|
||||||
|
<button type="submit" class="btn btn--ghost btn--sm">
|
||||||
|
{% if banner.is_active %}Выключить{% else %}Включить{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('admin.delete_banner', banner_id=banner.id) }}" onsubmit="return confirm('Удалить баннер?');">
|
||||||
|
<button type="submit" class="btn btn--danger btn--sm">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4">Баннеров пока нет</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<section class="page-header page-header--admin">
|
<section class="page-header page-header--admin">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="page-header__title">Группы пользователей</h1>
|
<h1 class="page-header__title">Группы пользователей</h1>
|
||||||
<p class="page-header__subtitle">Квоты дискового пространства и назначение пользователей</p>
|
<p class="page-header__subtitle">Квоты диска, лимиты папок и фото для каждой группы</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -23,20 +23,32 @@
|
|||||||
<label for="name">Название</label>
|
<label for="name">Название</label>
|
||||||
<input type="text" id="name" name="name" required minlength="2" placeholder="VIP">
|
<input type="text" id="name" name="name" required minlength="2" placeholder="VIP">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="disk_quota_mb">Квота (МБ, 0 = без лимита)</label>
|
<label for="disk_quota_mb">Квота (МБ, 0 = без лимита)</label>
|
||||||
<input type="number" id="disk_quota_mb" name="disk_quota_mb" min="0" value="500">
|
<input type="number" id="disk_quota_mb" name="disk_quota_mb" min="0" value="500">
|
||||||
</div>
|
</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>
|
<button type="submit" class="btn btn--primary">Создать группу</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-table-wrap">
|
<div class="admin-table-wrap">
|
||||||
<table class="admin-table">
|
<table class="admin-table admin-table--groups">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Группа</th>
|
<th>Группа</th>
|
||||||
<th>Квота</th>
|
<th>Квота</th>
|
||||||
|
<th>Папки</th>
|
||||||
|
<th>Фото</th>
|
||||||
<th>Пользователей</th>
|
<th>Пользователей</th>
|
||||||
<th>Занято</th>
|
<th>Занято</th>
|
||||||
<th>Действия</th>
|
<th>Действия</th>
|
||||||
@@ -51,12 +63,16 @@
|
|||||||
{% if group.is_default %}<span class="badge badge--success">по умолчанию</span>{% endif %}
|
{% if group.is_default %}<span class="badge badge--success">по умолчанию</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ group.quota_label }}</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>{{ group.user_count }}</td>
|
||||||
<td>{{ format_size(item.storage_used) }}</td>
|
<td>{{ format_size(item.storage_used) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="{{ url_for('admin.edit_group', group_id=group.id) }}" class="group-edit-form">
|
<form method="post" action="{{ url_for('admin.edit_group', group_id=group.id) }}" class="group-edit-form">
|
||||||
<input type="text" name="name" value="{{ group.name }}" required minlength="2" class="form-inline-input">
|
<input type="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">
|
<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>
|
<button type="submit" class="btn btn--ghost btn--sm">Сохранить</button>
|
||||||
</form>
|
</form>
|
||||||
{% if not group.is_default %}
|
{% if not group.is_default %}
|
||||||
|
|||||||
@@ -42,6 +42,10 @@
|
|||||||
{% 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>
|
||||||
@@ -50,6 +54,7 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
|
{% include "partials/share_modal.html" %}
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="admin-panel folder-create">
|
<div class="admin-panel folder-create">
|
||||||
<h2 class="admin-panel__title">Создать папку</h2>
|
<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">
|
<form method="post" action="{{ url_for('folders.create_folder') }}" class="auth-form folder-create__form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name">Название</label>
|
<label for="name">Название</label>
|
||||||
@@ -31,6 +34,14 @@
|
|||||||
</label>
|
</label>
|
||||||
<button type="submit" class="btn btn--primary">Создать папку</button>
|
<button type="submit" class="btn btn--primary">Создать папку</button>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
|
|
||||||
<h2 class="section-title">Мои папки</h2>
|
<h2 class="section-title">Мои папки</h2>
|
||||||
|
|||||||
@@ -17,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">
|
||||||
@@ -51,6 +55,36 @@
|
|||||||
<div class="quota-bar__fill {% if quota.percent > 90 %}quota-bar__fill--warn{% endif %}" style="width: {{ quota.percent }}%"></div>
|
<div class="quota-bar__fill {% if quota.percent > 90 %}quota-bar__fill--warn{% endif %}" style="width: {{ quota.percent }}%"></div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -40,6 +40,10 @@
|
|||||||
</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" %}
|
||||||
|
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<div class="share-modal" id="shareModal" hidden>
|
||||||
|
<div class="share-modal__backdrop" data-close-share></div>
|
||||||
|
<div class="share-modal__dialog" role="dialog" aria-labelledby="shareModalTitle">
|
||||||
|
<button type="button" class="share-modal__close" data-close-share aria-label="Закрыть">×</button>
|
||||||
|
<h3 class="share-modal__title" id="shareModalTitle">Поделиться</h3>
|
||||||
|
<p class="share-modal__name" id="shareModalName"></p>
|
||||||
|
|
||||||
|
<div class="share-modal__qr-wrap">
|
||||||
|
<img id="shareModalQr" class="share-modal__qr" alt="QR-код">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="share-field">
|
||||||
|
<label for="shareModalUrl">Прямая ссылка</label>
|
||||||
|
<div class="share-field__row">
|
||||||
|
<input id="shareModalUrl" type="text" readonly>
|
||||||
|
<button type="button" class="btn btn--ghost btn--sm copy-btn" data-target="shareModalUrl">Копировать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="share-field">
|
||||||
|
<label for="shareModalBbcode">BBCode для форумов</label>
|
||||||
|
<div class="share-field__row">
|
||||||
|
<input id="shareModalBbcode" type="text" readonly>
|
||||||
|
<button type="button" class="btn btn--ghost btn--sm copy-btn" data-target="shareModalBbcode">Копировать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="share-field">
|
||||||
|
<label for="shareModalHtml">HTML для сайтов</label>
|
||||||
|
<div class="share-field__row">
|
||||||
|
<input id="shareModalHtml" type="text" readonly>
|
||||||
|
<button type="button" class="btn btn--ghost btn--sm copy-btn" data-target="shareModalHtml">Копировать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
<form action="{{ url_for('main.upload') }}" method="post" enctype="multipart/form-data" class="upload-form" id="uploadForm">
|
<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 %}
|
{% 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">
|
<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>
|
<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">
|
<div class="dropzone__icon">
|
||||||
@@ -17,6 +24,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn--primary" id="submitBtn" disabled>
|
<button type="submit" class="btn btn--primary" id="submitBtn" disabled>
|
||||||
<span>Загрузить</span>
|
<span>Загрузить файлы</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-panel" data-panel="urls">
|
||||||
|
<div class="url-upload">
|
||||||
|
<label for="imageUrls" class="url-upload__label">Прямые ссылки на изображения</label>
|
||||||
|
<textarea
|
||||||
|
id="imageUrls"
|
||||||
|
name="image_urls"
|
||||||
|
class="url-upload__input"
|
||||||
|
rows="5"
|
||||||
|
placeholder="https://example.com/photo.jpg https://example.com/image.png"
|
||||||
|
></textarea>
|
||||||
|
<p class="url-upload__hint">По одной ссылке в строке. Поддерживаются HTTP и HTTPS.</p>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn--primary" id="submitUrlBtn">
|
||||||
|
<span>Загрузить по ссылкам</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+201
-1
@@ -1,15 +1,33 @@
|
|||||||
|
import ipaddress
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
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 werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from app.models import Photo
|
from app.models import Photo
|
||||||
from app.quota_utils import check_upload_quota
|
from app.quota_utils import check_photo_count_limit, check_upload_quota
|
||||||
from app.settings_service import get_settings
|
from app.settings_service import get_settings
|
||||||
from app.storage_service import save_photo_file
|
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):
|
def allowed_file(filename, allowed_extensions):
|
||||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in allowed_extensions
|
return "." in filename and filename.rsplit(".", 1)[1].lower() in allowed_extensions
|
||||||
@@ -24,6 +42,184 @@ def collect_upload_files(request_files):
|
|||||||
return [f for f in files if f and f.filename]
|
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):
|
def process_uploads(request_files, user, folder, allowed_extensions):
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
max_bulk = settings.max_bulk_upload or 100
|
max_bulk = settings.max_bulk_upload or 100
|
||||||
@@ -56,6 +252,10 @@ def process_uploads(request_files, user, folder, allowed_extensions):
|
|||||||
if not valid_files:
|
if not valid_files:
|
||||||
return {"uploaded": 0, "errors": errors, "photos": []}
|
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)
|
ok, quota_msg = check_upload_quota(user, total_size)
|
||||||
if not ok:
|
if not ok:
|
||||||
return {"uploaded": 0, "errors": [quota_msg], "photos": []}
|
return {"uploaded": 0, "errors": [quota_msg], "photos": []}
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ services:
|
|||||||
GIT_REPO_PATH: /repo
|
GIT_REPO_PATH: /repo
|
||||||
GIT_REMOTE_URL: ${GIT_REMOTE_URL:-https://git.evilfox.cc/test2/fotohost.git}
|
GIT_REMOTE_URL: ${GIT_REMOTE_URL:-https://git.evilfox.cc/test2/fotohost.git}
|
||||||
ALLOW_GIT_DEPLOY: ${ALLOW_GIT_DEPLOY:-false}
|
ALLOW_GIT_DEPLOY: ${ALLOW_GIT_DEPLOY:-false}
|
||||||
|
CONTAINER_NAME: photohost-web
|
||||||
|
GIT_CONFIG_COUNT: "1"
|
||||||
|
GIT_CONFIG_KEY_0: safe.directory
|
||||||
|
GIT_CONFIG_VALUE_0: /repo
|
||||||
volumes:
|
volumes:
|
||||||
- uploads_data:/app/uploads
|
- uploads_data:/app/uploads
|
||||||
- .:/repo
|
- .:/repo
|
||||||
@@ -40,6 +44,12 @@ services:
|
|||||||
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:
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Git deploy needs write access to mounted /repo; never fail container start on chown errors.
|
||||||
|
if [ "$ALLOW_GIT_DEPLOY" = "true" ] || [ "$ALLOW_GIT_DEPLOY" = "1" ] || [ "$ALLOW_GIT_DEPLOY" = "yes" ]; then
|
||||||
|
if [ -d /repo/.git ]; then
|
||||||
|
chown -R appuser:appuser /repo 2>/dev/null || true
|
||||||
|
chmod -R u+rwX /repo/.git 2>/dev/null || true
|
||||||
|
elif [ -d /repo ]; then
|
||||||
|
chown -R appuser:appuser /repo 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run DB migrations once before gunicorn workers start.
|
||||||
|
gosu appuser python /app/init_db.py || exit 1
|
||||||
|
|
||||||
|
export SKIP_DB_INIT=1
|
||||||
|
exec gosu appuser "$@"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
bind = "0.0.0.0:8000"
|
||||||
|
workers = 2
|
||||||
|
timeout = 120
|
||||||
|
accesslog = "-"
|
||||||
|
errorlog = "-"
|
||||||
|
loglevel = "info"
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
create_app(setup_database=True)
|
||||||
|
print("Database init OK", flush=True)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Database init FAILED: {exc}", file=sys.stderr, flush=True)
|
||||||
|
raise
|
||||||
@@ -8,3 +8,5 @@ python-dotenv==1.0.1
|
|||||||
Werkzeug==3.1.3
|
Werkzeug==3.1.3
|
||||||
boto3==1.35.99
|
boto3==1.35.99
|
||||||
paramiko==3.5.1
|
paramiko==3.5.1
|
||||||
|
requests==2.32.3
|
||||||
|
qrcode[pil]==8.0
|
||||||
|
|||||||
Reference in New Issue
Block a user