116 lines
3.3 KiB
Python
116 lines
3.3 KiB
Python
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
|