Initial commit: InviteMe production redesign

This commit is contained in:
InviteMe Developer
2026-09-01 10:52:35 +03:00
commit 0d0868d998
71 changed files with 6912 additions and 0 deletions
View File
+39
View File
@@ -0,0 +1,39 @@
from pathlib import Path
from flask import current_app
def _base_upload_folder():
return Path(current_app.config["UPLOAD_FOLDER"])
def event_photo_folder(event_id):
return Path(current_app.config["PHOTO_FOLDER"]) / f"event_{event_id}"
def event_qr_folder(event_id):
return Path(current_app.config["QR_FOLDER"]) / f"event_{event_id}"
def event_logo_folder(event_id):
return Path(current_app.config["LOGO_FOLDER"]) / f"event_{event_id}"
def event_tickets_folder(event_id):
return _base_upload_folder() / "tickets" / f"event_{event_id}"
def ensure_event_folders(event_id):
"""Создаёт все папки для конкретного мероприятия."""
for folder in [event_photo_folder, event_qr_folder, event_logo_folder, event_tickets_folder]:
folder(event_id).mkdir(parents=True, exist_ok=True)
def safe_child_path(folder, filename):
"""Resolve a database filename without allowing traversal outside its folder."""
if not filename:
return None
folder = Path(folder).resolve()
candidate = (folder / filename).resolve()
if not candidate.is_relative_to(folder):
return None
return candidate
+35
View File
@@ -0,0 +1,35 @@
from types import SimpleNamespace
from flask import current_app
from redis import Redis
from rq import Queue
from rq.job import JobStatus
ACTIVE_JOB_STATUSES = {JobStatus.QUEUED, JobStatus.STARTED, JobStatus.DEFERRED, JobStatus.SCHEDULED}
def get_task_queue():
redis_url = current_app.config.get("TASK_QUEUE_REDIS_URL", "")
if not redis_url:
raise RuntimeError("Очередь фоновых задач не настроена")
connection = Redis.from_url(redis_url, socket_connect_timeout=3, socket_timeout=3)
return Queue("inviteme", connection=connection, default_timeout=3600)
def enqueue_event_invitations(event_id, initiated_by_id):
if current_app.config.get("TASK_QUEUE_EAGER"):
from app.tasks import send_event_invitations_in_context
result = send_event_invitations_in_context(event_id, initiated_by_id)
return SimpleNamespace(id="eager", result=result), True
queue = get_task_queue()
job_id = f"event-invitations-{event_id}"
existing = queue.fetch_job(job_id)
if existing and existing.get_status(refresh=True) in ACTIVE_JOB_STATUSES:
return existing, False
if existing:
existing.delete()
return queue.enqueue(
"app.tasks.send_event_invitations", event_id, initiated_by_id,
job_id=job_id, job_timeout=3600, result_ttl=3600, failure_ttl=86400,
), True
+34
View File
@@ -0,0 +1,34 @@
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
from flask import current_app
PREFIX = "fernet:"
def _fernet():
configured_key = str(current_app.config["CREDENTIAL_ENCRYPTION_KEY"]).encode("utf-8")
derived_key = base64.urlsafe_b64encode(hashlib.sha256(configured_key).digest())
return Fernet(derived_key)
def encrypt_secret(value):
if not value:
return ""
if value.startswith(PREFIX):
return value
token = _fernet().encrypt(value.encode("utf-8")).decode("ascii")
return f"{PREFIX}{token}"
def decrypt_secret(value):
if not value:
return ""
if not value.startswith(PREFIX):
return value
try:
return _fernet().decrypt(value[len(PREFIX):].encode("ascii")).decode("utf-8")
except InvalidToken as exc:
raise RuntimeError("Не удалось расшифровать почтовые учётные данные") from exc
+121
View File
@@ -0,0 +1,121 @@
import io
import os
import re
import secrets
import unicodedata
import warnings
import zipfile
from pathlib import Path
from PIL import Image, ImageOps
ALLOWED_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "webp"}
_FORMAT_EXTENSIONS = {
"JPEG": {"jpg", "jpeg"},
"PNG": {"png"},
"WEBP": {"webp"},
}
def validate_password(password, min_length=12):
"""Return a user-facing error or None when a password is acceptable."""
if len(password) < min_length:
return f"Пароль должен содержать не менее {min_length} символов"
groups = sum([
any(c.islower() for c in password),
any(c.isupper() for c in password),
any(c.isdigit() for c in password),
any(not c.isalnum() for c in password),
])
if groups < 3:
return "Используйте минимум три группы: строчные, заглавные, цифры и спецсимволы"
return None
def safe_upload_filename(filename, max_length=160):
"""Keep Unicode names while removing path and control characters."""
if not filename:
raise ValueError("Пустое имя файла")
normalized = unicodedata.normalize("NFKC", str(filename)).replace("\\", "/")
name = normalized.rsplit("/", 1)[-1].strip().strip(".")
name = "".join(ch for ch in name if unicodedata.category(ch)[0] != "C")
name = re.sub(r"[^\w.()\- ]", "_", name, flags=re.UNICODE)
name = re.sub(r"\s+", " ", name).strip()
if not name or name in {".", ".."}:
raise ValueError("Небезопасное имя файла")
suffix = Path(name).suffix
stem_limit = max_length - len(suffix)
name = f"{Path(name).stem[:stem_limit]}{suffix}"
return name
def image_extension(filename):
return Path(filename).suffix.lower().lstrip(".")
def validate_zip_size(stream, max_files, max_uncompressed_bytes):
"""Reject compressed containers that could expand beyond safe limits."""
position = stream.tell()
try:
with zipfile.ZipFile(stream, "r") as archive:
members = [member for member in archive.infolist() if not member.is_dir()]
if len(members) > max_files:
raise ValueError("В архиве слишком много файлов")
if sum(member.file_size for member in members) > max_uncompressed_bytes:
raise ValueError("Архив слишком большой после распаковки")
except zipfile.BadZipFile as exc:
raise ValueError("Некорректный ZIP-контейнер") from exc
finally:
stream.seek(position)
def save_validated_image(stream, destination, max_bytes, max_pixels):
"""Validate and re-encode a single raster image, stripping metadata."""
filename = Path(destination).name
extension = image_extension(filename)
if extension not in ALLOWED_IMAGE_EXTENSIONS:
raise ValueError("Разрешены только JPG, PNG и WebP")
data = stream.read(max_bytes + 1)
if len(data) > max_bytes:
raise ValueError("Файл изображения слишком большой")
if not data:
raise ValueError("Файл изображения пуст")
Image.MAX_IMAGE_PIXELS = max_pixels
try:
with warnings.catch_warnings():
warnings.simplefilter("error", Image.DecompressionBombWarning)
with Image.open(io.BytesIO(data)) as source:
image_format = (source.format or "").upper()
if image_format not in _FORMAT_EXTENSIONS:
raise ValueError("Неподдерживаемый формат изображения")
if extension not in _FORMAT_EXTENSIONS[image_format]:
raise ValueError("Расширение файла не соответствует его содержимому")
if getattr(source, "n_frames", 1) != 1:
raise ValueError("Анимированные изображения не поддерживаются")
source.load()
image = ImageOps.exif_transpose(source)
image = image.convert("RGB") if image_format == "JPEG" else image.copy()
except (Image.DecompressionBombError, Image.DecompressionBombWarning):
raise ValueError("Слишком большое разрешение изображения")
except (OSError, SyntaxError) as exc:
raise ValueError("Файл не является корректным изображением") from exc
destination = Path(destination)
destination.parent.mkdir(parents=True, exist_ok=True)
temp_path = destination.with_name(f".{destination.name}.{secrets.token_hex(8)}.uploading")
save_options = {"optimize": True}
if image_format == "JPEG":
save_options.update({"quality": 90, "progressive": True})
try:
image.save(temp_path, format=image_format, **save_options)
os.replace(temp_path, destination)
finally:
if temp_path.exists():
temp_path.unlink()
return destination
+27
View File
@@ -0,0 +1,27 @@
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from flask import current_app
def get_timezone():
tz_name = current_app.config.get("TIMEZONE", "UTC")
return ZoneInfo(tz_name)
def utc_to_local(dt):
"""Конвертирует наивное UTC-время в локальное наивное время."""
if dt is None:
return None
# Считаем, что хранимое время — UTC
utc_dt = dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc)
local_dt = utc_dt.astimezone(get_timezone())
return local_dt.replace(tzinfo=None)
def local_to_utc(dt):
"""Конвертирует наивное локальное время в UTC-наивное для хранения."""
if dt is None:
return None
local_dt = dt.replace(tzinfo=get_timezone())
utc_dt = local_dt.astimezone(timezone.utc)
return utc_dt.replace(tzinfo=None)