9 Commits

27 changed files with 980 additions and 90 deletions
+2
View File
@@ -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
View File
@@ -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"]
+66
View File
@@ -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,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` | Вход | | POST | `/auth/login` | Вход |
| GET | `/cabinet/` | Личный кабинет | | GET | `/cabinet/` | Личный кабинет |
| GET | `/admin/` | Админ-панель | | GET | `/admin/` | Админ-панель |
| GET | `/admin/banners` | Управление рекламными баннерами |
| POST | `/upload` | Загрузка фото (auth) | | POST | `/upload` | Загрузка фото (auth) |
| GET | `/uploads/<filename>` | Прямая ссылка на файл | | GET | `/uploads/<filename>` | Прямая ссылка на файл |
| GET | `/api/photos` | JSON-список всех фото | | GET | `/api/photos` | JSON-список всех фото |
@@ -501,6 +545,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
View File
@@ -21,7 +21,7 @@ def load_user(user_id):
return db.session.get(User, int(user_id)) return db.session.get(User, int(user_id))
def create_app(): def create_app(setup_database=True):
app = Flask(__name__) app = Flask(__name__)
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-change-me") app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-change-me")
@@ -30,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
View File
@@ -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():
+16
View File
@@ -0,0 +1,16 @@
from app.models import AdBanner
def get_banners(position=None):
query = AdBanner.query.filter_by(is_active=True).order_by(AdBanner.sort_order, AdBanner.id)
if position:
query = query.filter_by(position=position)
return query.all()
def get_banners_by_position():
banners = get_banners()
grouped = {}
for banner in banners:
grouped.setdefault(banner.position, []).append(banner)
return grouped
+60 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+43
View File
@@ -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
View File
@@ -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,
} }
+13
View File
@@ -2,6 +2,7 @@ import os
from flask import ( from flask import (
Blueprint, Blueprint,
Response,
abort, abort,
current_app, current_app,
flash, flash,
@@ -21,10 +22,22 @@ 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
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()
+115
View File
@@ -1045,3 +1045,118 @@ 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;
}
+1
View File
@@ -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>
+126
View File
@@ -0,0 +1,126 @@
{% extends "base.html" %}
{% block title %}Рекламные баннеры — Админка{% endblock %}
{% block content %}
<section class="page-header page-header--admin">
<div class="container">
<h1 class="page-header__title">Рекламные баннеры</h1>
<p class="page-header__subtitle">Баннеры на главной, в кабинете и в подвале сайта</p>
</div>
</section>
<section class="admin-section">
<div class="container">
{% include "admin/_nav.html" %}
{% include "partials/alerts.html" %}
<div class="admin-panel folder-create">
<h2 class="admin-panel__title">Добавить баннер</h2>
<form method="post" class="auth-form folder-create__form">
<div class="form-group">
<label for="title">Название (для админки)</label>
<input type="text" id="title" name="title" required minlength="2" placeholder="Промо лето">
</div>
<div class="form-group">
<label for="image_url">URL изображения</label>
<input type="url" id="image_url" name="image_url" required placeholder="https://example.com/banner.jpg">
</div>
<div class="form-group">
<label for="link_url">Ссылка при клике (необязательно)</label>
<input type="url" id="link_url" name="link_url" placeholder="https://example.com">
</div>
<div class="form-group">
<label for="alt_text">Alt-текст</label>
<input type="text" id="alt_text" name="alt_text" placeholder="Описание баннера">
</div>
<div class="form-group">
<label for="position">Позиция</label>
<select id="position" name="position" class="form-select">
{% for key, label in positions.items() %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="sort_order">Порядок (меньше — выше)</label>
<input type="number" id="sort_order" name="sort_order" value="0">
</div>
<label class="form-checkbox">
<input type="checkbox" name="is_active" checked>
<span>Активен</span>
</label>
<button type="submit" class="btn btn--primary">Добавить баннер</button>
</form>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Баннер</th>
<th>Позиция</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for banner in banners %}
<tr>
<td>
<div class="banner-preview">
<img src="{{ banner.image_url }}" alt="" class="banner-preview__img">
<div>
<strong>{{ banner.title }}</strong>
{% if banner.link_url %}<br><small>{{ banner.link_url }}</small>{% endif %}
</div>
</div>
</td>
<td>{{ banner.position_label }}</td>
<td>
{% if banner.is_active %}
<span class="badge badge--success">активен</span>
{% else %}
<span class="badge badge--muted">выключен</span>
{% endif %}
</td>
<td>
<form method="post" action="{{ url_for('admin.edit_banner', banner_id=banner.id) }}" class="banner-edit-form">
<input type="text" name="title" value="{{ banner.title }}" required minlength="2" class="form-inline-input">
<input type="url" name="image_url" value="{{ banner.image_url }}" required class="form-inline-input">
<input type="url" name="link_url" value="{{ banner.link_url or '' }}" placeholder="Ссылка" class="form-inline-input">
<select name="position" class="form-select form-select--sm">
{% for key, label in positions.items() %}
<option value="{{ key }}" {% if banner.position == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<input type="number" name="sort_order" value="{{ banner.sort_order }}" class="form-inline-input form-inline-input--sm">
<label class="form-checkbox form-checkbox--inline">
<input type="checkbox" name="is_active" {% if banner.is_active %}checked{% endif %}>
<span>Активен</span>
</label>
<button type="submit" class="btn btn--ghost btn--sm">Сохранить</button>
</form>
<div class="banner-actions">
<form method="post" action="{{ url_for('admin.toggle_banner', banner_id=banner.id) }}">
<button type="submit" class="btn btn--ghost btn--sm">
{% if banner.is_active %}Выключить{% else %}Включить{% endif %}
</button>
</form>
<form method="post" action="{{ url_for('admin.delete_banner', banner_id=banner.id) }}" onsubmit="return confirm('Удалить баннер?');">
<button type="submit" class="btn btn--danger btn--sm">Удалить</button>
</form>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="4">Баннеров пока нет</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
+20 -4
View File
@@ -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 %}
+4
View File
@@ -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>
+11
View File
@@ -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>
+34
View File
@@ -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 %}
+5 -1
View File
@@ -38,7 +38,11 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
</section> </section>
{% with banners=site_banners.get('main', []), position='main' %}
{% include "partials/banners.html" %}
{% endwith %}
{% include "partials/alerts.html" %} {% include "partials/alerts.html" %}
+15
View File
@@ -0,0 +1,15 @@
{% if banners %}
<div class="ad-banners ad-banners--{{ position|default('default') }}">
{% for banner in banners %}
<div class="ad-banner">
{% if banner.link_url %}
<a href="{{ banner.link_url }}" class="ad-banner__link" target="_blank" rel="noopener sponsored">
<img src="{{ banner.image_url }}" alt="{{ banner.alt_text or banner.title }}" class="ad-banner__img" loading="lazy">
</a>
{% else %}
<img src="{{ banner.image_url }}" alt="{{ banner.alt_text or banner.title }}" class="ad-banner__img" loading="lazy">
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
+5 -1
View File
@@ -6,7 +6,7 @@ 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
@@ -56,6 +56,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": []}
+10
View File
@@ -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:
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
set -e
# Git deploy needs write access to mounted /repo; never fail container start on chown errors.
if [ "$ALLOW_GIT_DEPLOY" = "true" ] || [ "$ALLOW_GIT_DEPLOY" = "1" ] || [ "$ALLOW_GIT_DEPLOY" = "yes" ]; then
if [ -d /repo/.git ]; then
chown -R appuser:appuser /repo 2>/dev/null || true
chmod -R u+rwX /repo/.git 2>/dev/null || true
elif [ -d /repo ]; then
chown -R appuser:appuser /repo 2>/dev/null || true
fi
fi
# Run DB migrations once before gunicorn workers start.
gosu appuser python /app/init_db.py || exit 1
export SKIP_DB_INIT=1
exec gosu appuser "$@"
+6
View File
@@ -0,0 +1,6 @@
bind = "0.0.0.0:8000"
workers = 2
timeout = 120
accesslog = "-"
errorlog = "-"
loglevel = "info"
+10
View File
@@ -0,0 +1,10 @@
import sys
try:
from app import create_app
create_app(setup_database=True)
print("Database init OK", flush=True)
except Exception as exc:
print(f"Database init FAILED: {exc}", file=sys.stderr, flush=True)
raise
+1 -1
View File
@@ -1,3 +1,3 @@
from app import create_app from app import create_app
app = create_app() app = create_app(setup_database=False)