Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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_GROUP_QUOTA_MB=100
|
||||
DEFAULT_GROUP_MAX_FOLDERS=10
|
||||
DEFAULT_GROUP_MAX_PHOTOS=500
|
||||
|
||||
# Git deploy from admin panel (requires repo mount and docker socket)
|
||||
ALLOW_GIT_DEPLOY=false
|
||||
|
||||
+10
-5
@@ -6,6 +6,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev \
|
||||
gcc \
|
||||
git \
|
||||
gosu \
|
||||
docker.io \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -14,13 +15,17 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /app/uploads && adduser --disabled-password --gecos "" appuser \
|
||||
&& chown -R appuser:appuser /app
|
||||
|
||||
USER appuser
|
||||
RUN mkdir -p /app/uploads \
|
||||
&& adduser --disabled-password --gecos "" appuser \
|
||||
&& chown -R appuser:appuser /app \
|
||||
&& chmod +x /app/entrypoint.sh
|
||||
|
||||
ENV FLASK_APP=wsgi:app
|
||||
ENV GIT_CONFIG_COUNT=1
|
||||
ENV GIT_CONFIG_KEY_0=safe.directory
|
||||
ENV GIT_CONFIG_VALUE_0=/repo
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120", "wsgi:app"]
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD ["gunicorn", "-c", "gunicorn.conf.py", "wsgi:app"]
|
||||
|
||||
@@ -312,6 +312,8 @@ docker compose up -d --build
|
||||
| `/cabinet/profile` | Настройки профиля, смена пароля |
|
||||
| `/admin/` | Панель администратора (только admin) |
|
||||
| `/admin/users` | Управление пользователями |
|
||||
| `/admin/groups` | Группы: квота диска, лимиты папок и фото |
|
||||
| `/admin/banners` | Рекламные баннеры на сайте |
|
||||
| `/admin/photos` | Все фото на сервере |
|
||||
|
||||
**Права доступа:**
|
||||
@@ -321,6 +323,47 @@ 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,6 +492,7 @@ python wsgi.py
|
||||
| POST | `/auth/login` | Вход |
|
||||
| GET | `/cabinet/` | Личный кабинет |
|
||||
| GET | `/admin/` | Админ-панель |
|
||||
| GET | `/admin/banners` | Управление рекламными баннерами |
|
||||
| POST | `/upload` | Загрузка фото (auth) |
|
||||
| GET | `/uploads/<filename>` | Прямая ссылка на файл |
|
||||
| GET | `/api/photos` | JSON-список всех фото |
|
||||
@@ -501,6 +545,28 @@ docker compose restart web
|
||||
docker compose down && docker compose up -d
|
||||
```
|
||||
|
||||
**502 Bad Gateway (Nginx)**
|
||||
|
||||
Nginx не может достучаться до контейнера `web`. Проверьте:
|
||||
|
||||
```bash
|
||||
cd ~/fotohost
|
||||
docker compose ps
|
||||
docker compose logs --tail=100 web
|
||||
curl -I http://127.0.0.1:8080/health
|
||||
```
|
||||
|
||||
Частые причины после обновления:
|
||||
|
||||
1. Контейнер `photohost-web` не запущен или перезапускается — смотрите логи `docker compose logs web`
|
||||
2. В Nginx указан неверный порт — должен совпадать с `APP_PORT` из `.env` (по умолчанию `8080`):
|
||||
|
||||
```nginx
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
```
|
||||
|
||||
3. База данных ещё не готова — подождите 30–60 секунд и выполните `docker compose restart web`
|
||||
|
||||
---
|
||||
|
||||
## Технологии
|
||||
|
||||
+28
-16
@@ -21,7 +21,7 @@ def load_user(user_id):
|
||||
return db.session.get(User, int(user_id))
|
||||
|
||||
|
||||
def create_app():
|
||||
def create_app(setup_database=True):
|
||||
app = Flask(__name__)
|
||||
|
||||
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-change-me")
|
||||
@@ -30,6 +30,7 @@ def create_app():
|
||||
"postgresql://photohost:photohost_secret@localhost:5432/photohost",
|
||||
)
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {"pool_pre_ping": True}
|
||||
app.config["UPLOAD_FOLDER"] = os.getenv("UPLOAD_FOLDER", "uploads")
|
||||
app.config["MAX_CONTENT_LENGTH"] = int(os.getenv("MAX_UPLOAD_MB", "10")) * 1024 * 1024
|
||||
app.config["ALLOWED_EXTENSIONS"] = {"png", "jpg", "jpeg", "gif", "webp", "bmp"}
|
||||
@@ -59,8 +60,10 @@ def create_app():
|
||||
|
||||
register_cli(app)
|
||||
|
||||
# Ensure models are registered even when DB setup runs in init_db.py.
|
||||
with app.app_context():
|
||||
from app.models import ( # noqa: F401
|
||||
AdBanner,
|
||||
Folder,
|
||||
FolderInvite,
|
||||
FolderMember,
|
||||
@@ -71,22 +74,31 @@ def create_app():
|
||||
UserGroup,
|
||||
)
|
||||
|
||||
db.create_all()
|
||||
from app.bootstrap import (
|
||||
create_first_admin,
|
||||
ensure_default_group,
|
||||
ensure_photo_storage_column,
|
||||
ensure_schema,
|
||||
ensure_site_settings,
|
||||
)
|
||||
from app.folders import ensure_folder_schema
|
||||
@app.context_processor
|
||||
def inject_banners():
|
||||
from app.banner_service import get_banners_by_position
|
||||
|
||||
ensure_schema()
|
||||
ensure_default_group(app)
|
||||
ensure_folder_schema()
|
||||
ensure_site_settings(app)
|
||||
ensure_photo_storage_column()
|
||||
create_first_admin(app)
|
||||
try:
|
||||
return {"site_banners": get_banners_by_position()}
|
||||
except Exception:
|
||||
return {"site_banners": {}}
|
||||
|
||||
if setup_database:
|
||||
with app.app_context():
|
||||
from app.bootstrap import (
|
||||
create_first_admin,
|
||||
ensure_default_group,
|
||||
ensure_site_settings,
|
||||
run_schema_migrations,
|
||||
)
|
||||
|
||||
db.create_all()
|
||||
run_schema_migrations()
|
||||
|
||||
if os.getenv("SKIP_DB_INIT") != "1":
|
||||
ensure_default_group(app)
|
||||
ensure_site_settings(app)
|
||||
create_first_admin(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
+120
-4
@@ -15,8 +15,8 @@ from app.deploy_utils import (
|
||||
get_deploy_status,
|
||||
is_deploy_enabled,
|
||||
)
|
||||
from app.models import Photo, User, UserGroup
|
||||
from app.quota_utils import get_user_storage_used
|
||||
from app.models import AdBanner, Photo, User, UserGroup
|
||||
from app.quota_utils import get_user_folder_count, get_user_photo_count, get_user_storage_used
|
||||
from app.settings_service import get_settings, update_settings_from_form
|
||||
from app.storage_service import delete_photo_file
|
||||
|
||||
@@ -131,6 +131,12 @@ def groups():
|
||||
if request.method == "POST":
|
||||
name = request.form.get("name", "").strip()
|
||||
quota_mb = request.form.get("disk_quota_mb", type=int) or 100
|
||||
max_folders = request.form.get("max_folders", type=int)
|
||||
max_photos = request.form.get("max_photos", type=int)
|
||||
if max_folders is None:
|
||||
max_folders = 10
|
||||
if max_photos is None:
|
||||
max_photos = 500
|
||||
|
||||
if len(name) < 2:
|
||||
flash("Название группы — минимум 2 символа", "error")
|
||||
@@ -144,7 +150,13 @@ def groups():
|
||||
slug = f"{base_slug}-{counter}"
|
||||
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.commit()
|
||||
flash(f"Группа «{name}» создана", "success")
|
||||
@@ -154,7 +166,14 @@ def groups():
|
||||
group_stats = []
|
||||
for group in all_groups:
|
||||
used = sum(get_user_storage_used(u.id) for u in group.users)
|
||||
group_stats.append({"group": group, "storage_used": used})
|
||||
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)
|
||||
|
||||
|
||||
@@ -164,6 +183,8 @@ def edit_group(group_id):
|
||||
group = UserGroup.query.get_or_404(group_id)
|
||||
name = request.form.get("name", "").strip()
|
||||
quota_mb = request.form.get("disk_quota_mb", type=int)
|
||||
max_folders = request.form.get("max_folders", type=int)
|
||||
max_photos = request.form.get("max_photos", type=int)
|
||||
|
||||
if len(name) < 2:
|
||||
flash("Название группы — минимум 2 символа", "error")
|
||||
@@ -177,6 +198,10 @@ def edit_group(group_id):
|
||||
group.name = name
|
||||
if quota_mb is not None:
|
||||
group.disk_quota_mb = max(0, quota_mb)
|
||||
if max_folders is not None:
|
||||
group.max_folders = max(0, max_folders)
|
||||
if max_photos is not None:
|
||||
group.max_photos = max(0, max_photos)
|
||||
db.session.commit()
|
||||
flash(f"Группа «{group.name}» обновлена", "success")
|
||||
return redirect(url_for("admin.groups"))
|
||||
@@ -202,6 +227,97 @@ def delete_group(group_id):
|
||||
return redirect(url_for("admin.groups"))
|
||||
|
||||
|
||||
@bp.route("/banners", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def banners():
|
||||
if request.method == "POST":
|
||||
title = request.form.get("title", "").strip()
|
||||
image_url = request.form.get("image_url", "").strip()
|
||||
link_url = request.form.get("link_url", "").strip() or None
|
||||
alt_text = request.form.get("alt_text", "").strip() or None
|
||||
position = request.form.get("position", "main").strip()
|
||||
sort_order = request.form.get("sort_order", type=int) or 0
|
||||
is_active = request.form.get("is_active") == "on"
|
||||
|
||||
if len(title) < 2:
|
||||
flash("Название баннера — минимум 2 символа", "error")
|
||||
elif not image_url:
|
||||
flash("Укажите URL изображения", "error")
|
||||
elif position not in AdBanner.POSITIONS:
|
||||
flash("Неверная позиция баннера", "error")
|
||||
else:
|
||||
banner = AdBanner(
|
||||
title=title,
|
||||
image_url=image_url,
|
||||
link_url=link_url,
|
||||
alt_text=alt_text or title,
|
||||
position=position,
|
||||
sort_order=sort_order,
|
||||
is_active=is_active,
|
||||
)
|
||||
db.session.add(banner)
|
||||
db.session.commit()
|
||||
flash(f"Баннер «{title}» добавлен", "success")
|
||||
return redirect(url_for("admin.banners"))
|
||||
|
||||
all_banners = AdBanner.query.order_by(AdBanner.position, AdBanner.sort_order, AdBanner.id).all()
|
||||
return render_template("admin/banners.html", banners=all_banners, positions=AdBanner.POSITIONS)
|
||||
|
||||
|
||||
@bp.route("/banners/<int:banner_id>/edit", methods=["POST"])
|
||||
@admin_required
|
||||
def edit_banner(banner_id):
|
||||
banner = AdBanner.query.get_or_404(banner_id)
|
||||
title = request.form.get("title", "").strip()
|
||||
image_url = request.form.get("image_url", "").strip()
|
||||
link_url = request.form.get("link_url", "").strip() or None
|
||||
alt_text = request.form.get("alt_text", "").strip() or None
|
||||
position = request.form.get("position", banner.position).strip()
|
||||
sort_order = request.form.get("sort_order", type=int)
|
||||
is_active = request.form.get("is_active") == "on"
|
||||
|
||||
if len(title) < 2:
|
||||
flash("Название баннера — минимум 2 символа", "error")
|
||||
elif not image_url:
|
||||
flash("Укажите URL изображения", "error")
|
||||
elif position not in AdBanner.POSITIONS:
|
||||
flash("Неверная позиция баннера", "error")
|
||||
else:
|
||||
banner.title = title
|
||||
banner.image_url = image_url
|
||||
banner.link_url = link_url
|
||||
banner.alt_text = alt_text or title
|
||||
banner.position = position
|
||||
if sort_order is not None:
|
||||
banner.sort_order = sort_order
|
||||
banner.is_active = is_active
|
||||
db.session.commit()
|
||||
flash(f"Баннер «{banner.title}» обновлён", "success")
|
||||
|
||||
return redirect(url_for("admin.banners"))
|
||||
|
||||
|
||||
@bp.route("/banners/<int:banner_id>/delete", methods=["POST"])
|
||||
@admin_required
|
||||
def delete_banner(banner_id):
|
||||
banner = AdBanner.query.get_or_404(banner_id)
|
||||
db.session.delete(banner)
|
||||
db.session.commit()
|
||||
flash("Баннер удалён", "success")
|
||||
return redirect(url_for("admin.banners"))
|
||||
|
||||
|
||||
@bp.route("/banners/<int:banner_id>/toggle", methods=["POST"])
|
||||
@admin_required
|
||||
def toggle_banner(banner_id):
|
||||
banner = AdBanner.query.get_or_404(banner_id)
|
||||
banner.is_active = not banner.is_active
|
||||
db.session.commit()
|
||||
state = "включён" if banner.is_active else "выключен"
|
||||
flash(f"Баннер «{banner.title}» {state}", "success")
|
||||
return redirect(url_for("admin.banners"))
|
||||
|
||||
|
||||
@bp.route("/photos")
|
||||
@admin_required
|
||||
def photos():
|
||||
|
||||
@@ -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
|
||||
+65
-15
@@ -12,24 +12,28 @@ def ensure_schema():
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if "photos" in tables:
|
||||
columns = {col["name"] for col in inspector.get_columns("photos")}
|
||||
if "user_id" not in columns:
|
||||
db.session.execute(
|
||||
text("ALTER TABLE photos ADD COLUMN user_id INTEGER REFERENCES users(id)")
|
||||
db.session.execute(
|
||||
text(
|
||||
"ALTER TABLE photos ADD COLUMN IF NOT EXISTS "
|
||||
"user_id INTEGER REFERENCES users(id)"
|
||||
)
|
||||
db.session.commit()
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
if "users" in tables and "user_groups" in tables:
|
||||
columns = {col["name"] for col in inspector.get_columns("users")}
|
||||
if "group_id" not in columns:
|
||||
db.session.execute(
|
||||
text("ALTER TABLE users ADD COLUMN group_id INTEGER REFERENCES user_groups(id)")
|
||||
db.session.execute(
|
||||
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):
|
||||
default_quota = int(os.getenv("DEFAULT_GROUP_QUOTA_MB", "100"))
|
||||
default_max_folders = int(os.getenv("DEFAULT_GROUP_MAX_FOLDERS", "10"))
|
||||
default_max_photos = int(os.getenv("DEFAULT_GROUP_MAX_PHOTOS", "500"))
|
||||
default_group = UserGroup.query.filter_by(is_default=True).first()
|
||||
|
||||
if not default_group:
|
||||
@@ -41,16 +45,43 @@ def ensure_default_group(app):
|
||||
name="Пользователи",
|
||||
slug="users",
|
||||
disk_quota_mb=default_quota,
|
||||
max_folders=default_max_folders,
|
||||
max_photos=default_max_photos,
|
||||
is_default=True,
|
||||
)
|
||||
db.session.add(default_group)
|
||||
db.session.commit()
|
||||
app.logger.info("Default user group 'users' created 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})
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def ensure_group_limit_columns():
|
||||
inspector = inspect(db.engine)
|
||||
if "user_groups" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
db.session.execute(
|
||||
text(
|
||||
"ALTER TABLE user_groups ADD COLUMN IF NOT EXISTS "
|
||||
"max_folders INTEGER NOT NULL DEFAULT 10"
|
||||
)
|
||||
)
|
||||
db.session.execute(
|
||||
text(
|
||||
"ALTER TABLE user_groups ADD COLUMN IF NOT EXISTS "
|
||||
"max_photos INTEGER NOT NULL DEFAULT 500"
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def ensure_site_settings(app):
|
||||
from app.models import SiteSettings
|
||||
|
||||
@@ -64,10 +95,29 @@ def ensure_photo_storage_column():
|
||||
inspector = inspect(db.engine)
|
||||
if "photos" not in inspector.get_table_names():
|
||||
return
|
||||
columns = {col["name"] for col in inspector.get_columns("photos")}
|
||||
if "storage_backend" not in columns:
|
||||
db.session.execute(text("ALTER TABLE photos ADD COLUMN storage_backend VARCHAR(20) DEFAULT 'local'"))
|
||||
db.session.commit()
|
||||
db.session.execute(
|
||||
text(
|
||||
"ALTER TABLE photos ADD COLUMN IF NOT EXISTS "
|
||||
"storage_backend VARCHAR(20) DEFAULT 'local'"
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def run_schema_migrations():
|
||||
ensure_schema()
|
||||
ensure_group_limit_columns()
|
||||
from app.folders import ensure_folder_schema
|
||||
|
||||
ensure_folder_schema()
|
||||
ensure_photo_storage_column()
|
||||
|
||||
|
||||
def run_database_setup(app):
|
||||
run_schema_migrations()
|
||||
ensure_default_group(app)
|
||||
ensure_site_settings(app)
|
||||
create_first_admin(app)
|
||||
|
||||
|
||||
def slugify(name):
|
||||
|
||||
+137
-30
@@ -17,58 +17,165 @@ def get_git_remote():
|
||||
return os.getenv("GIT_REMOTE_URL", "").strip()
|
||||
|
||||
|
||||
def get_container_name():
|
||||
return os.getenv("CONTAINER_NAME", os.getenv("HOSTNAME", "photohost-web"))
|
||||
|
||||
|
||||
def _repo_ready():
|
||||
repo = get_repo_path()
|
||||
return os.path.isdir(repo) and os.path.isdir(os.path.join(repo, ".git"))
|
||||
|
||||
|
||||
def _docker_available():
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "info"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
def _git_base_cmd(repo):
|
||||
return ["git", "-c", f"safe.directory={repo}", "-C", repo]
|
||||
|
||||
|
||||
def _run_subprocess(cmd, timeout=120, cwd=None):
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
cwd=cwd,
|
||||
)
|
||||
output = (result.stderr or result.stdout or "").strip()
|
||||
return result.returncode == 0, output
|
||||
|
||||
|
||||
def _run_git_as_root(args, timeout=120):
|
||||
repo = get_repo_path()
|
||||
container = get_container_name()
|
||||
cmd = ["docker", "exec", "-u", "0", container] + _git_base_cmd(repo) + args
|
||||
return _run_subprocess(cmd, timeout=timeout)
|
||||
|
||||
|
||||
def _fix_repo_permissions():
|
||||
if not _docker_available() or not _repo_ready():
|
||||
return False
|
||||
|
||||
repo = get_repo_path()
|
||||
container = get_container_name()
|
||||
fix_cmd = [
|
||||
"docker",
|
||||
"exec",
|
||||
"-u",
|
||||
"0",
|
||||
container,
|
||||
"sh",
|
||||
"-c",
|
||||
f"chown -R appuser:appuser {repo} && chmod -R u+rwX {repo}/.git",
|
||||
]
|
||||
ok, _ = _run_subprocess(fix_cmd, timeout=60)
|
||||
return ok
|
||||
|
||||
|
||||
def run_git(args, timeout=120):
|
||||
if not _repo_ready():
|
||||
return False, f"Git-репозиторий не найден: {get_repo_path()}"
|
||||
|
||||
result = subprocess.run(
|
||||
["git", "-C", get_repo_path()] + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False, (result.stderr or result.stdout or "Git error").strip()
|
||||
return True, result.stdout.strip()
|
||||
repo = get_repo_path()
|
||||
cmd = _git_base_cmd(repo) + args
|
||||
ok, output = _run_subprocess(cmd, timeout=timeout)
|
||||
if ok:
|
||||
return True, output
|
||||
|
||||
permission_error = "permission denied" in output.lower() and "config" in output.lower()
|
||||
if permission_error and _docker_available():
|
||||
_fix_repo_permissions()
|
||||
ok, output = _run_subprocess(cmd, timeout=timeout)
|
||||
if ok:
|
||||
return True, output
|
||||
ok, output = _run_git_as_root(args, timeout=timeout)
|
||||
if ok:
|
||||
return True, output
|
||||
|
||||
return False, output or "Git error"
|
||||
|
||||
|
||||
def run_ls_remote(extra_args=None, timeout=60):
|
||||
remote = get_git_remote()
|
||||
if not remote:
|
||||
return False, "GIT_REMOTE_URL не задан", []
|
||||
|
||||
cmd = ["git", "ls-remote", remote]
|
||||
if extra_args:
|
||||
cmd.extend(extra_args)
|
||||
|
||||
ok, output = _run_subprocess(cmd, timeout=timeout)
|
||||
if not ok:
|
||||
return False, output or "ls-remote error", []
|
||||
return True, "", output.splitlines()
|
||||
|
||||
|
||||
def fetch_remote():
|
||||
remote = get_git_remote()
|
||||
if 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)
|
||||
if not remote:
|
||||
return run_git(["fetch", "--all", "--tags", "--prune"], timeout=180)
|
||||
|
||||
# Fetch by URL only — never run `git remote set-url` or other config writes.
|
||||
return run_git(
|
||||
[
|
||||
"fetch",
|
||||
"--tags",
|
||||
"--prune",
|
||||
remote,
|
||||
"+refs/heads/*:refs/heads/*",
|
||||
"+refs/tags/*:refs/tags/*",
|
||||
],
|
||||
timeout=180,
|
||||
)
|
||||
|
||||
|
||||
def list_tags():
|
||||
ok, msg = fetch_remote()
|
||||
ok, err, lines = run_ls_remote(["--tags"])
|
||||
if not ok:
|
||||
return [], msg
|
||||
ok, out = run_git(["tag", "--sort=-version:refname"])
|
||||
if not ok:
|
||||
return [], out
|
||||
return [line for line in out.splitlines() if line.strip()], None
|
||||
return [], err
|
||||
|
||||
tags = []
|
||||
for line in lines:
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
ref = parts[1]
|
||||
if not ref.startswith("refs/tags/"):
|
||||
continue
|
||||
tag = ref.removeprefix("refs/tags/")
|
||||
if tag.endswith("^{}"):
|
||||
continue
|
||||
tags.append(tag)
|
||||
|
||||
tags = sorted(set(tags), reverse=True)
|
||||
return tags, None
|
||||
|
||||
|
||||
def list_branches():
|
||||
ok, msg = fetch_remote()
|
||||
ok, err, lines = run_ls_remote(["--heads"])
|
||||
if not ok:
|
||||
return [], msg
|
||||
ok, out = run_git(["branch", "-a", "--format=%(refname:short)"])
|
||||
if not ok:
|
||||
return [], out
|
||||
return [], err
|
||||
|
||||
branches = []
|
||||
for line in out.splitlines():
|
||||
name = line.strip().replace("origin/", "")
|
||||
if name and name not in branches and "HEAD" not in name:
|
||||
branches.append(name)
|
||||
return branches, None
|
||||
for line in lines:
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
ref = parts[1]
|
||||
if ref.startswith("refs/heads/"):
|
||||
branches.append(ref.removeprefix("refs/heads/"))
|
||||
|
||||
return sorted(set(branches)), None
|
||||
|
||||
|
||||
def get_current_version():
|
||||
|
||||
+20
-6
@@ -22,6 +22,7 @@ from app.folder_utils import (
|
||||
unlock_folder,
|
||||
)
|
||||
from app.models import Folder, FolderInvite, FolderMember, Photo, User
|
||||
from app.quota_utils import check_folder_limit
|
||||
from app.settings_service import get_settings
|
||||
from app.storage_service import delete_photo_file
|
||||
|
||||
@@ -31,6 +32,8 @@ bp = Blueprint("folders", __name__)
|
||||
@bp.route("/cabinet/folders")
|
||||
@login_required
|
||||
def list_folders():
|
||||
from app.quota_utils import quota_status
|
||||
|
||||
process_pending_invites(current_user)
|
||||
owned = Folder.query.filter_by(owner_id=current_user.id).order_by(Folder.created_at.desc()).all()
|
||||
shared = (
|
||||
@@ -42,7 +45,12 @@ def list_folders():
|
||||
.order_by(Folder.created_at.desc())
|
||||
.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"])
|
||||
@@ -56,6 +64,11 @@ def create_folder():
|
||||
flash("Название папки — минимум 2 символа", "error")
|
||||
return redirect(url_for("folders.list_folders"))
|
||||
|
||||
ok, limit_msg = check_folder_limit(current_user)
|
||||
if not ok:
|
||||
flash(limit_msg, "error")
|
||||
return redirect(url_for("folders.list_folders"))
|
||||
|
||||
folder = Folder(name=name, owner_id=current_user.id, is_private=is_private)
|
||||
if access_password:
|
||||
if len(access_password) < 4:
|
||||
@@ -306,9 +319,10 @@ def ensure_folder_schema():
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if "photos" in tables:
|
||||
columns = {col["name"] for col in inspector.get_columns("photos")}
|
||||
if "folder_id" not in columns:
|
||||
db.session.execute(
|
||||
text("ALTER TABLE photos ADD COLUMN folder_id INTEGER REFERENCES folders(id)")
|
||||
db.session.execute(
|
||||
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)
|
||||
slug = db.Column(db.String(80), unique=True, nullable=False, index=True)
|
||||
disk_quota_mb = db.Column(db.Integer, nullable=False, default=100)
|
||||
max_folders = db.Column(db.Integer, nullable=False, default=10)
|
||||
max_photos = db.Column(db.Integer, nullable=False, default=500)
|
||||
is_default = db.Column(db.Boolean, nullable=False, default=False)
|
||||
created_at = db.Column(
|
||||
db.DateTime,
|
||||
@@ -150,6 +152,47 @@ class UserGroup(db.Model):
|
||||
return "Без лимита"
|
||||
return f"{self.disk_quota_mb} МБ"
|
||||
|
||||
@property
|
||||
def folders_limit_label(self):
|
||||
if self.max_folders == 0:
|
||||
return "Без лимита"
|
||||
return str(self.max_folders)
|
||||
|
||||
@property
|
||||
def photos_limit_label(self):
|
||||
if self.max_photos == 0:
|
||||
return "Без лимита"
|
||||
return str(self.max_photos)
|
||||
|
||||
|
||||
class AdBanner(db.Model):
|
||||
__tablename__ = "ad_banners"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(120), nullable=False)
|
||||
image_url = db.Column(db.String(500), nullable=False)
|
||||
link_url = db.Column(db.String(500), nullable=True)
|
||||
alt_text = db.Column(db.String(200), nullable=True)
|
||||
position = db.Column(db.String(30), nullable=False, default="main", index=True)
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
sort_order = db.Column(db.Integer, nullable=False, default=0)
|
||||
created_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
POSITIONS = {
|
||||
"main": "Главная (под hero)",
|
||||
"cabinet": "Личный кабинет",
|
||||
"sidebar": "Боковая колонка",
|
||||
"footer": "Подвал",
|
||||
}
|
||||
|
||||
@property
|
||||
def position_label(self):
|
||||
return self.POSITIONS.get(self.position, self.position)
|
||||
|
||||
|
||||
class Folder(db.Model):
|
||||
__tablename__ = "folders"
|
||||
|
||||
+76
-4
@@ -1,7 +1,7 @@
|
||||
from sqlalchemy import func
|
||||
|
||||
from app import db
|
||||
from app.models import Photo, User, UserGroup
|
||||
from app.models import Folder, Photo, User, UserGroup
|
||||
|
||||
|
||||
def get_default_group():
|
||||
@@ -21,12 +21,52 @@ def get_user_storage_used(user_id):
|
||||
return int(result or 0)
|
||||
|
||||
|
||||
def get_user_photo_count(user_id):
|
||||
return Photo.query.filter_by(user_id=user_id).count()
|
||||
|
||||
|
||||
def get_user_folder_count(user_id):
|
||||
return Folder.query.filter_by(owner_id=user_id).count()
|
||||
|
||||
|
||||
def get_group_quota_bytes(group):
|
||||
if not group or group.disk_quota_mb == 0:
|
||||
return None
|
||||
return group.disk_quota_mb * 1024 * 1024
|
||||
|
||||
|
||||
def _limit_reached(current, limit):
|
||||
return limit > 0 and current >= limit
|
||||
|
||||
|
||||
def check_folder_limit(user):
|
||||
group = get_user_group(user)
|
||||
if not group or group.max_folders == 0:
|
||||
return True, ""
|
||||
|
||||
count = get_user_folder_count(user.id)
|
||||
if _limit_reached(count, group.max_folders):
|
||||
return False, (
|
||||
f"Достигнут лимит папок группы «{group.name}»: "
|
||||
f"{count} / {group.max_folders}"
|
||||
)
|
||||
return True, ""
|
||||
|
||||
|
||||
def check_photo_count_limit(user, additional_count=1):
|
||||
group = get_user_group(user)
|
||||
if not group or group.max_photos == 0:
|
||||
return True, ""
|
||||
|
||||
count = get_user_photo_count(user.id)
|
||||
if count + additional_count > group.max_photos:
|
||||
return False, (
|
||||
f"Достигнут лимит фото группы «{group.name}»: "
|
||||
f"{count} / {group.max_photos}"
|
||||
)
|
||||
return True, ""
|
||||
|
||||
|
||||
def check_upload_quota(user, new_file_size):
|
||||
group = get_user_group(user)
|
||||
quota_bytes = get_group_quota_bytes(group)
|
||||
@@ -35,9 +75,7 @@ def check_upload_quota(user, new_file_size):
|
||||
|
||||
used = get_user_storage_used(user.id)
|
||||
if used + new_file_size > quota_bytes:
|
||||
from app.models import Photo as _Photo
|
||||
|
||||
used_human = _Photo(file_size=used).size_human if used else "0 Б"
|
||||
used_human = Photo(file_size=used).size_human if used else "0 Б"
|
||||
quota_human = f"{group.disk_quota_mb} МБ"
|
||||
return False, f"Превышена квота группы «{group.name}»: {used_human} / {quota_human}"
|
||||
return True, ""
|
||||
@@ -46,7 +84,13 @@ def check_upload_quota(user, new_file_size):
|
||||
def quota_status(user):
|
||||
group = get_user_group(user)
|
||||
used = get_user_storage_used(user.id)
|
||||
photo_count = get_user_photo_count(user.id)
|
||||
folder_count = get_user_folder_count(user.id)
|
||||
quota_bytes = get_group_quota_bytes(group)
|
||||
|
||||
photos_unlimited = not group or group.max_photos == 0
|
||||
folders_unlimited = not group or group.max_folders == 0
|
||||
|
||||
if quota_bytes is None:
|
||||
return {
|
||||
"group": group,
|
||||
@@ -54,12 +98,40 @@ def quota_status(user):
|
||||
"quota_bytes": None,
|
||||
"percent": 0,
|
||||
"unlimited": True,
|
||||
"photo_count": photo_count,
|
||||
"photo_limit": group.max_photos if group else 0,
|
||||
"photos_unlimited": photos_unlimited,
|
||||
"photos_percent": 0,
|
||||
"folder_count": folder_count,
|
||||
"folder_limit": group.max_folders if group else 0,
|
||||
"folders_unlimited": folders_unlimited,
|
||||
"folders_percent": 0,
|
||||
}
|
||||
|
||||
percent = min(100, int(used / quota_bytes * 100)) if quota_bytes else 0
|
||||
photos_percent = (
|
||||
min(100, int(photo_count / group.max_photos * 100))
|
||||
if group and group.max_photos
|
||||
else 0
|
||||
)
|
||||
folders_percent = (
|
||||
min(100, int(folder_count / group.max_folders * 100))
|
||||
if group and group.max_folders
|
||||
else 0
|
||||
)
|
||||
|
||||
return {
|
||||
"group": group,
|
||||
"used": used,
|
||||
"quota_bytes": quota_bytes,
|
||||
"percent": percent,
|
||||
"unlimited": False,
|
||||
"photo_count": photo_count,
|
||||
"photo_limit": group.max_photos if group else 0,
|
||||
"photos_unlimited": photos_unlimited,
|
||||
"photos_percent": photos_percent,
|
||||
"folder_count": folder_count,
|
||||
"folder_limit": group.max_folders if group else 0,
|
||||
"folders_unlimited": folders_unlimited,
|
||||
"folders_percent": folders_percent,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
Response,
|
||||
abort,
|
||||
current_app,
|
||||
flash,
|
||||
@@ -21,10 +22,22 @@ from app.models import Folder, Photo
|
||||
from app.settings_service import get_settings
|
||||
from app.storage_service import delete_photo_file, get_photo_stream
|
||||
from app.upload_service import process_uploads
|
||||
from sqlalchemy import text
|
||||
|
||||
bp = Blueprint("main", __name__)
|
||||
|
||||
|
||||
@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("/")
|
||||
def index():
|
||||
photos = Photo.query.order_by(Photo.created_at.desc()).limit(24).all()
|
||||
|
||||
@@ -1045,3 +1045,118 @@ body {
|
||||
.settings-form .admin-panel {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.quota-bar__limits {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.quota-bar__track--sm {
|
||||
height: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
margin-top: 12px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-hint--warn {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.ad-banners {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.ad-banners--main,
|
||||
.ad-banners--cabinet {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.ad-banners--footer {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.ad-banner {
|
||||
width: min(100%, 728px);
|
||||
}
|
||||
|
||||
.ad-banner__link {
|
||||
display: block;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.ad-banner__link:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.ad-banner__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.banner-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.banner-preview__img {
|
||||
width: 120px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.banner-edit-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.banner-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-checkbox--inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.badge--muted {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-table--groups td {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
@@ -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.users') }}" class="admin-nav__link {% if request.endpoint == 'admin.users' %}admin-nav__link--active{% endif %}">Пользователи</a>
|
||||
<a href="{{ url_for('admin.groups') }}" class="admin-nav__link {% if request.endpoint in ['admin.groups', 'admin.edit_group', 'admin.delete_group'] %}admin-nav__link--active{% endif %}">Группы</a>
|
||||
<a href="{{ url_for('admin.banners') }}" class="admin-nav__link {% if request.endpoint in ['admin.banners', 'admin.edit_banner', 'admin.delete_banner', 'admin.toggle_banner'] %}admin-nav__link--active{% endif %}">Баннеры</a>
|
||||
<a href="{{ url_for('admin.photos') }}" class="admin-nav__link {% if request.endpoint == 'admin.photos' %}admin-nav__link--active{% endif %}">Фото</a>
|
||||
<a href="{{ url_for('admin.deploy') }}" class="admin-nav__link {% if request.endpoint == 'admin.deploy' %}admin-nav__link--active{% endif %}">Версии Git</a>
|
||||
<a href="{{ url_for('admin.settings') }}" class="admin-nav__link {% if request.endpoint == 'admin.settings' %}admin-nav__link--active{% endif %}">Настройки</a>
|
||||
|
||||
@@ -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">
|
||||
<div class="container">
|
||||
<h1 class="page-header__title">Группы пользователей</h1>
|
||||
<p class="page-header__subtitle">Квоты дискового пространства и назначение пользователей</p>
|
||||
<p class="page-header__subtitle">Квоты диска, лимиты папок и фото для каждой группы</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -23,20 +23,32 @@
|
||||
<label for="name">Название</label>
|
||||
<input type="text" id="name" name="name" required minlength="2" placeholder="VIP">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="disk_quota_mb">Квота (МБ, 0 = без лимита)</label>
|
||||
<input type="number" id="disk_quota_mb" name="disk_quota_mb" min="0" value="500">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="disk_quota_mb">Квота (МБ, 0 = без лимита)</label>
|
||||
<input type="number" id="disk_quota_mb" name="disk_quota_mb" min="0" value="500">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="max_folders">Макс. папок (0 = без лимита)</label>
|
||||
<input type="number" id="max_folders" name="max_folders" min="0" value="20">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="max_photos">Макс. фото (0 = без лимита)</label>
|
||||
<input type="number" id="max_photos" name="max_photos" min="0" value="1000">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary">Создать группу</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<table class="admin-table admin-table--groups">
|
||||
<thead>
|
||||
<tr>
|
||||
<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 %}
|
||||
</td>
|
||||
<td>{{ group.quota_label }}</td>
|
||||
<td>{{ item.folder_count }}{% if group.max_folders %} / {{ group.max_folders }}{% endif %}</td>
|
||||
<td>{{ item.photo_count }}{% if group.max_photos %} / {{ group.max_photos }}{% endif %}</td>
|
||||
<td>{{ group.user_count }}</td>
|
||||
<td>{{ format_size(item.storage_used) }}</td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('admin.edit_group', group_id=group.id) }}" class="group-edit-form">
|
||||
<input type="text" name="name" value="{{ group.name }}" required minlength="2" class="form-inline-input">
|
||||
<input type="number" name="disk_quota_mb" value="{{ group.disk_quota_mb }}" min="0" class="form-inline-input form-inline-input--sm">
|
||||
<input type="text" name="name" value="{{ group.name }}" required minlength="2" class="form-inline-input" title="Название">
|
||||
<input type="number" name="disk_quota_mb" value="{{ group.disk_quota_mb }}" min="0" class="form-inline-input form-inline-input--sm" title="Квота МБ">
|
||||
<input type="number" name="max_folders" value="{{ group.max_folders }}" min="0" class="form-inline-input form-inline-input--sm" title="Макс. папок">
|
||||
<input type="number" name="max_photos" value="{{ group.max_photos }}" min="0" class="form-inline-input form-inline-input--sm" title="Макс. фото">
|
||||
<button type="submit" class="btn btn--ghost btn--sm">Сохранить</button>
|
||||
</form>
|
||||
{% if not group.is_default %}
|
||||
|
||||
@@ -42,6 +42,10 @@
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
{% with banners=site_banners.get('footer', []), position='footer' %}
|
||||
{% include "partials/banners.html" %}
|
||||
{% endwith %}
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container footer__inner">
|
||||
<p>PhotoHost — Python + PostgreSQL + Docker</p>
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
<div class="container">
|
||||
<div class="admin-panel folder-create">
|
||||
<h2 class="admin-panel__title">Создать папку</h2>
|
||||
{% if quota and not quota.folders_unlimited and quota.folder_count >= quota.folder_limit %}
|
||||
<p class="form-hint form-hint--warn">Достигнут лимит папок: {{ quota.folder_count }} / {{ quota.folder_limit }}</p>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('folders.create_folder') }}" class="auth-form folder-create__form">
|
||||
<div class="form-group">
|
||||
<label for="name">Название</label>
|
||||
@@ -31,6 +34,14 @@
|
||||
</label>
|
||||
<button type="submit" class="btn btn--primary">Создать папку</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if quota %}
|
||||
<p class="form-hint">
|
||||
Лимит группы «{{ quota.group.name if quota.group else 'Пользователи' }}»:
|
||||
папки {{ quota.folder_count }}{% if not quota.folders_unlimited %} / {{ quota.folder_limit }}{% else %} / без лимита{% endif %},
|
||||
фото {{ quota.photo_count }}{% if not quota.photos_unlimited %} / {{ quota.photo_limit }}{% else %} / без лимита{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Мои папки</h2>
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
|
||||
{% include "partials/alerts.html" %}
|
||||
|
||||
{% with banners=site_banners.get('cabinet', []), position='cabinet' %}
|
||||
{% include "partials/banners.html" %}
|
||||
{% endwith %}
|
||||
|
||||
<section class="stats-bar">
|
||||
<div class="container stats">
|
||||
<div class="stat-card">
|
||||
@@ -51,6 +55,36 @@
|
||||
<div class="quota-bar__fill {% if quota.percent > 90 %}quota-bar__fill--warn{% endif %}" style="width: {{ quota.percent }}%"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="quota-bar__limits">
|
||||
<span>
|
||||
Папки:
|
||||
{{ quota.folder_count }}
|
||||
{% if not quota.folders_unlimited %}
|
||||
/ {{ quota.folder_limit }}
|
||||
{% else %}
|
||||
/ без лимита
|
||||
{% endif %}
|
||||
</span>
|
||||
<span>
|
||||
Фото:
|
||||
{{ quota.photo_count }}
|
||||
{% if not quota.photos_unlimited %}
|
||||
/ {{ quota.photo_limit }}
|
||||
{% else %}
|
||||
/ без лимита
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% if not quota.folders_unlimited %}
|
||||
<div class="quota-bar__track quota-bar__track--sm">
|
||||
<div class="quota-bar__fill {% if quota.folders_percent > 90 %}quota-bar__fill--warn{% endif %}" style="width: {{ quota.folders_percent }}%"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not quota.photos_unlimited %}
|
||||
<div class="quota-bar__track quota-bar__track--sm">
|
||||
<div class="quota-bar__fill {% if quota.photos_percent > 90 %}quota-bar__fill--warn{% endif %}" style="width: {{ quota.photos_percent }}%"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -38,7 +38,11 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{% with banners=site_banners.get('main', []), position='main' %}
|
||||
{% include "partials/banners.html" %}
|
||||
{% endwith %}
|
||||
|
||||
{% include "partials/alerts.html" %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -6,7 +6,7 @@ from werkzeug.utils import secure_filename
|
||||
|
||||
from app import db
|
||||
from app.models import Photo
|
||||
from app.quota_utils import check_upload_quota
|
||||
from app.quota_utils import check_photo_count_limit, check_upload_quota
|
||||
from app.settings_service import get_settings
|
||||
from app.storage_service import save_photo_file
|
||||
|
||||
@@ -56,6 +56,10 @@ def process_uploads(request_files, user, folder, allowed_extensions):
|
||||
if not valid_files:
|
||||
return {"uploaded": 0, "errors": errors, "photos": []}
|
||||
|
||||
ok, photo_limit_msg = check_photo_count_limit(user, len(valid_files))
|
||||
if not ok:
|
||||
return {"uploaded": 0, "errors": [photo_limit_msg], "photos": []}
|
||||
|
||||
ok, quota_msg = check_upload_quota(user, total_size)
|
||||
if not ok:
|
||||
return {"uploaded": 0, "errors": [quota_msg], "photos": []}
|
||||
|
||||
@@ -33,6 +33,10 @@ services:
|
||||
GIT_REPO_PATH: /repo
|
||||
GIT_REMOTE_URL: ${GIT_REMOTE_URL:-https://git.evilfox.cc/test2/fotohost.git}
|
||||
ALLOW_GIT_DEPLOY: ${ALLOW_GIT_DEPLOY:-false}
|
||||
CONTAINER_NAME: photohost-web
|
||||
GIT_CONFIG_COUNT: "1"
|
||||
GIT_CONFIG_KEY_0: safe.directory
|
||||
GIT_CONFIG_VALUE_0: /repo
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
- .:/repo
|
||||
@@ -40,6 +44,12 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5)"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 8
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Git deploy needs write access to mounted /repo; never fail container start on chown errors.
|
||||
if [ "$ALLOW_GIT_DEPLOY" = "true" ] || [ "$ALLOW_GIT_DEPLOY" = "1" ] || [ "$ALLOW_GIT_DEPLOY" = "yes" ]; then
|
||||
if [ -d /repo/.git ]; then
|
||||
chown -R appuser:appuser /repo 2>/dev/null || true
|
||||
chmod -R u+rwX /repo/.git 2>/dev/null || true
|
||||
elif [ -d /repo ]; then
|
||||
chown -R appuser:appuser /repo 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run DB migrations once before gunicorn workers start.
|
||||
gosu appuser python /app/init_db.py || exit 1
|
||||
|
||||
export SKIP_DB_INIT=1
|
||||
exec gosu appuser "$@"
|
||||
@@ -0,0 +1,6 @@
|
||||
bind = "0.0.0.0:8000"
|
||||
workers = 2
|
||||
timeout = 120
|
||||
accesslog = "-"
|
||||
errorlog = "-"
|
||||
loglevel = "info"
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import sys
|
||||
|
||||
try:
|
||||
from app import create_app
|
||||
|
||||
create_app(setup_database=True)
|
||||
print("Database init OK", flush=True)
|
||||
except Exception as exc:
|
||||
print(f"Database init FAILED: {exc}", file=sys.stderr, flush=True)
|
||||
raise
|
||||
Reference in New Issue
Block a user