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_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
View File
@@ -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"]
+66
View File
@@ -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`
---
## Технологии
+20 -8
View File
@@ -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,21 +74,30 @@ def create_app():
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 (
create_first_admin,
ensure_default_group,
ensure_photo_storage_column,
ensure_schema,
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_folder_schema()
ensure_site_settings(app)
ensure_photo_storage_column()
create_first_admin(app)
return app
+120 -4
View File
@@ -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():
+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()
if "photos" in tables:
columns = {col["name"] for col in inspector.get_columns("photos")}
if "user_id" not in columns:
db.session.execute(
text("ALTER TABLE photos ADD COLUMN user_id INTEGER REFERENCES users(id)")
text(
"ALTER TABLE photos ADD COLUMN IF NOT EXISTS "
"user_id INTEGER REFERENCES users(id)"
)
)
db.session.commit()
if "users" in tables and "user_groups" in tables:
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)")
text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS "
"group_id INTEGER REFERENCES user_groups(id)"
)
)
db.session.commit()
def ensure_default_group(app):
default_quota = int(os.getenv("DEFAULT_GROUP_QUOTA_MB", "100"))
default_max_folders = int(os.getenv("DEFAULT_GROUP_MAX_FOLDERS", "10"))
default_max_photos = int(os.getenv("DEFAULT_GROUP_MAX_PHOTOS", "500"))
default_group = UserGroup.query.filter_by(is_default=True).first()
if not default_group:
@@ -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,12 +95,31 @@ 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.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):
slug = re.sub(r"[^a-z0-9]+", "-", name.lower().strip())
slug = slug.strip("-") or "group"
+136 -29
View File
@@ -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
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():
+18 -4
View File
@@ -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)")
text(
"ALTER TABLE photos ADD COLUMN IF NOT EXISTS "
"folder_id INTEGER REFERENCES folders(id)"
)
)
db.session.commit()
+43
View File
@@ -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
View File
@@ -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,
}
+13
View File
@@ -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()
+115
View File
@@ -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;
}
+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.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>
+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">
<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-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 %}
+4
View File
@@ -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>
+11
View File
@@ -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>
+34
View File
@@ -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 %}
+5 -1
View File
@@ -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" %}
+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.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": []}
+10
View File
@@ -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:
+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
app = create_app()
app = create_app(setup_database=False)