diff --git a/api/main.py b/api/main.py index 381af118..72559e70 100644 --- a/api/main.py +++ b/api/main.py @@ -73,7 +73,7 @@ async def security_and_cache_middleware(request: Request, call_next): path = request.url.path if path.startswith("/api/web/uploads/") and request.method == "GET" and response.status_code == 200: - response.headers.setdefault("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400") + response.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable") return response if request.method == "GET" and response.status_code == 200 and "application/json" in content_type: diff --git a/api/v2/routes/_data_uri_migration.py b/api/v2/routes/_data_uri_migration.py new file mode 100644 index 00000000..797a74bb --- /dev/null +++ b/api/v2/routes/_data_uri_migration.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import base64 +import re +import uuid +from pathlib import Path +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + + +UPLOAD_DIR = Path("static/web_uploads") +DATA_URI_THRESHOLD_BYTES = 2048 + +_DATA_URI_RE = re.compile(r"^data:([\w./+-]+);base64,(.+)$", re.DOTALL) + +_MIME_TO_EXT = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + "image/svg+xml": ".svg", + "video/mp4": ".mp4", + "video/webm": ".webm", +} + + +def _save_data_uri_to_file(data_uri: str) -> str | None: + match = _DATA_URI_RE.match(data_uri) + if not match: + return None + mime = match.group(1).strip().lower() + payload = match.group(2) + ext = _MIME_TO_EXT.get(mime) + if not ext: + return None + try: + cleaned = "".join(payload.split()) + decoded = base64.b64decode(cleaned, validate=False) + except Exception: + return None + if not decoded: + return None + UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + name = f"{uuid.uuid4().hex}{ext}" + (UPLOAD_DIR / name).write_bytes(decoded) + return f"/api/web/uploads/{name}" + + +def migrate_json_data_uris(value: Any) -> tuple[Any, int]: + replaced = 0 + + def walk(node: Any) -> Any: + nonlocal replaced + if isinstance(node, str): + if not node.startswith("data:"): + return node + if len(node) < DATA_URI_THRESHOLD_BYTES: + return node + url = _save_data_uri_to_file(node) + if url is None: + return node + replaced += 1 + return url + if isinstance(node, list): + return [walk(item) for item in node] + if isinstance(node, dict): + return {key: walk(item) for key, item in node.items()} + return node + + return walk(value), replaced + + +async def run_startup_data_uri_migration(session: AsyncSession) -> tuple[int, int]: + from database.models import ( + WebBlock, + WebPageVariant, + WebPageVariantBlock, + WebTheme, + ) + + rows_updated = 0 + uris_replaced = 0 + + for theme in (await session.execute(select(WebTheme))).scalars().all(): + cleaned, replaced = migrate_json_data_uris(theme.tokens or {}) + if replaced: + theme.tokens = cleaned + rows_updated += 1 + uris_replaced += replaced + + for variant in (await session.execute(select(WebPageVariant))).scalars().all(): + cleaned, replaced = migrate_json_data_uris(variant.theme_tokens or {}) + if replaced: + variant.theme_tokens = cleaned + rows_updated += 1 + uris_replaced += replaced + + for block in (await session.execute(select(WebBlock))).scalars().all(): + cleaned, replaced = migrate_json_data_uris(block.data or {}) + if replaced: + block.data = cleaned + rows_updated += 1 + uris_replaced += replaced + + for block in (await session.execute(select(WebPageVariantBlock))).scalars().all(): + cleaned, replaced = migrate_json_data_uris(block.data or {}) + if replaced: + block.data = cleaned + rows_updated += 1 + uris_replaced += replaced + + return rows_updated, uris_replaced diff --git a/api/v2/routes/web.py b/api/v2/routes/web.py index 97abb8b1..a90ce6de 100644 --- a/api/v2/routes/web.py +++ b/api/v2/routes/web.py @@ -36,6 +36,8 @@ from database.models import ( ) from logger import logger +from api.v2.routes._data_uri_migration import migrate_json_data_uris + UPLOAD_DIR = Path("static/web_uploads") ALLOWED_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".mp4", ".webm"}) @@ -427,7 +429,10 @@ async def update_web_page_theme( if not slug or len(slug) > 64 or not _SLUG_RE.match(slug): raise HTTPException(400, "Некорректный slug страницы") current, _ = await _resolve_variant(session, slug, variant) - current.theme_tokens = body.tokens + cleaned_tokens, replaced = migrate_json_data_uris(body.tokens) + if replaced: + logger.info("[web] theme PUT slug={} replaced {} data: URI(s)", slug, replaced) + current.theme_tokens = cleaned_tokens await session.flush() await bump_site_revision(session) return WebPageThemeResponse( @@ -449,18 +454,26 @@ async def update_web_page( current, _ = await _resolve_variant(session, slug, variant) await session.execute(delete(WebPageVariantBlock).where(WebPageVariantBlock.variant_id == current.id)) + total_replaced = 0 for block in body.blocks: + cleaned_data, replaced = migrate_json_data_uris(block.data) + total_replaced += replaced session.add( WebPageVariantBlock( variant_id=current.id, order=block.order, type=block.type, - data=block.data, + data=cleaned_data, ) ) if body.theme is not None: - current.theme_tokens = body.theme.tokens + cleaned_theme, theme_replaced = migrate_json_data_uris(body.theme.tokens) + total_replaced += theme_replaced + current.theme_tokens = cleaned_theme + + if total_replaced: + logger.info("[web] page PUT slug={} replaced {} data: URI(s)", slug, total_replaced) await session.flush() await bump_site_revision(session) diff --git a/api/v2/schemas/payment_links.py b/api/v2/schemas/payment_links.py index 1b9efc4c..cdccbf56 100644 --- a/api/v2/schemas/payment_links.py +++ b/api/v2/schemas/payment_links.py @@ -8,9 +8,9 @@ class PaymentLinkCreateRequest(BaseModel): identity_id: str | None = Field(None, description="ID идентичности; tg_id будет взят из привязки") amount: int | float = Field(..., gt=0, description="Сумма оплаты") currency: str = Field(default="RUB", description="Валюта (например RUB)") - provider_id: str = Field( - ..., - description="Идентификатор кассы: ROBOKASSA, FREEKASSA, YOOKASSA, YOOMONEY, KASSAI_CARDS, KASSAI_SBP, HELEKET и др.", + provider_id: str | None = Field( + default=None, + description="Идентификатор кассы: ROBOKASSA, FREEKASSA, YOOKASSA, YOOMONEY, KASSAI_CARDS, KASSAI_SBP, HELEKET и др. Если не задан — берётся первый доступный.", ) success_url: str | None = Field(None, description="URL перенаправления после успешной оплаты") failure_url: str | None = Field(None, description="URL перенаправления после неуспешной оплаты") diff --git a/core/bootstrap.py b/core/bootstrap.py index 0201dd5b..cbdc0d70 100644 --- a/core/bootstrap.py +++ b/core/bootstrap.py @@ -1,3 +1,5 @@ +from logger import logger + from database import async_session_maker from database.db import warm_pool from database.settings_cache import settings_cache @@ -31,3 +33,48 @@ async def bootstrap() -> None: await session.commit() await settings_cache.load(session) await publish_runtime_snapshot() + + try: + from api.v2.routes._data_uri_migration import run_startup_data_uri_migration + + async with async_session_maker() as session: + rows_updated, uris_replaced = await run_startup_data_uri_migration(session) + if uris_replaced: + await session.commit() + logger.info( + "[bootstrap] data: URI migration: rows_updated={} uris_replaced={}", + rows_updated, + uris_replaced, + ) + except Exception as exc: + logger.warning("[bootstrap] data: URI migration failed: {}", exc) + + try: + from config import API_TOKEN_TTL_DAYS + from database.identity_sessions import cleanup_expired_sessions + + async with async_session_maker() as session: + if API_TOKEN_TTL_DAYS is None: + from sqlalchemy import delete + + from database.models import IdentitySession + + result = await session.execute(delete(IdentitySession)) + removed = int(result.rowcount or 0) + if removed: + await session.commit() + logger.info( + "[bootstrap] API_TOKEN_TTL_DAYS=None → identity sessions wiped on restart: {}", + removed, + ) + else: + removed = await cleanup_expired_sessions(session) + if removed: + await session.commit() + logger.info( + "[bootstrap] expired identity sessions removed (TTL={}d): {}", + API_TOKEN_TTL_DAYS, + removed, + ) + except Exception as exc: + logger.warning("[bootstrap] identity-session cleanup failed: {}", exc) diff --git a/utils/versioning.py b/utils/versioning.py index 47063b68..1a1487e9 100644 --- a/utils/versioning.py +++ b/utils/versioning.py @@ -92,4 +92,4 @@ def get_git_commit_number() -> str: def get_version() -> str: - return f"v.6-b2304260016 {get_git_commit_number()}" + return f"v.6-b2704260038 {get_git_commit_number()}"