CoolFace
Apppublic

Rhinox13/chatapi

sourceHugging Faceupdated 3mo agoView on Hugging Face
0likes
image_assets.py404 linesDownload Raw Back to services
1from __future__ import annotations
2
3import base64
4import binascii
5import hashlib
6import json
7import re
8from pathlib import Path
9from typing import Any
10
11from ..repositories import SystemConfigStore
12from ..repositories.users import UserStore
13
14
15_DATA_IMAGE_RE = re.compile(r"^data:(image/[a-zA-Z0-9.+-]+);base64,(.+)$", re.IGNORECASE | re.DOTALL)
16_UPLOAD_URL_RE = re.compile(r"/api/uploads/imgs/([A-Za-z0-9._-]+)(?:\?.*)?$", re.IGNORECASE)
17ALLOWED_IMAGE_MIME_TYPES = {
18    "image/png",
19    "image/jpeg",
20    "image/jpg",
21    "image/gif",
22    "image/webp",
23    "image/avif",
24    "image/bmp",
25    "image/tiff",
26}
27
28
29def _normalize_key(value: str) -> str:
30    return re.sub(r"[^a-z0-9]+", "", value.lower())
31
32
33def _mime_to_extension(mime_type: str) -> str:
34    normalized = mime_type.lower().split(";", 1)[0].strip()
35    mapping = {
36        "image/jpeg": "jpg",
37        "image/jpg": "jpg",
38        "image/png": "png",
39        "image/gif": "gif",
40        "image/webp": "webp",
41        "image/avif": "avif",
42        "image/bmp": "bmp",
43        "image/tiff": "tiff",
44    }
45    return mapping.get(normalized, normalized.rsplit("/", 1)[-1] or "img")
46
47
48def _mime_from_magic(image_bytes: bytes) -> str | None:
49    if image_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
50        return "image/png"
51    if image_bytes.startswith(b"\xff\xd8\xff"):
52        return "image/jpeg"
53    if image_bytes.startswith(b"GIF87a") or image_bytes.startswith(b"GIF89a"):
54        return "image/gif"
55    if image_bytes.startswith(b"RIFF") and image_bytes[8:12] == b"WEBP":
56        return "image/webp"
57    if image_bytes.startswith(b"BM"):
58        return "image/bmp"
59    if image_bytes.startswith(b"\x00\x00\x00") and b"ftypavif" in image_bytes[:32]:
60        return "image/avif"
61    if image_bytes.lstrip().startswith(b"<?xml") or b"<svg" in image_bytes[:256].lower():
62        return None
63    return None
64
65
66def _is_image_key(key: str) -> bool:
67    normalized = _normalize_key(key)
68    return any(token in normalized for token in ("image", "img", "photo", "picture", "thumbnail", "avatar", "filedata", "basedata", "binary"))
69
70
71def _looks_like_base64(value: str) -> bool:
72    candidate = re.sub(r"\s+", "", value)
73    if len(candidate) < 32 or len(candidate) % 4 != 0:
74        return False
75    return bool(re.fullmatch(r"[A-Za-z0-9+/=]+", candidate))
76
77
78def _try_parse_structured_content(raw: str) -> Any | None:
79    try:
80        return json.loads(raw)
81    except (TypeError, json.JSONDecodeError):
82        pass
83
84    trimmed = raw.strip()
85    if not trimmed or trimmed[0] not in "[{":
86        return None
87
88    normalized = ""
89    in_single_quote = False
90    in_double_quote = False
91    escape_next = False
92
93    for char in trimmed:
94        if escape_next:
95            normalized += char
96            escape_next = False
97            continue
98        if char == "\\":
99            normalized += char
100            escape_next = True
101            continue
102        if char == "'" and not in_double_quote:
103            normalized += '"'
104            in_single_quote = not in_single_quote
105            continue
106        if char == '"' and not in_single_quote:
107            normalized += char
108            in_double_quote = not in_double_quote
109            continue
110        normalized += '\\"' if in_single_quote and char == '"' else char
111
112    normalized = normalized.replace("None", "null").replace("True", "true").replace("False", "false")
113    try:
114        return json.loads(normalized)
115    except (TypeError, json.JSONDecodeError):
116        return None
117
118
119def _decode_base64_image(value: str, *, mime_type_hint: str | None = None) -> tuple[bytes, str] | None:
120    text = value.strip()
121    data_url_match = _DATA_IMAGE_RE.match(text)
122    if data_url_match:
123        mime_type = data_url_match.group(1)
124        payload = data_url_match.group(2)
125    else:
126        if not _looks_like_base64(text):
127            return None
128        mime_type = mime_type_hint or ""
129        payload = text
130    try:
131        image_bytes = base64.b64decode(payload, validate=False)
132    except (binascii.Error, ValueError):
133        return None
134    detected_mime = _mime_from_magic(image_bytes)
135    if detected_mime is not None:
136        mime_type = detected_mime
137    if not mime_type.startswith("image/"):
138        return None
139    return image_bytes, mime_type
140
141
142class ImageAssetStore:
143    def __init__(
144        self,
145        base_dir: Path,
146        route_prefix: str = "/api/uploads/imgs",
147        system_config_store: SystemConfigStore | None = None,
148        user_store: UserStore | None = None,
149    ):
150        self.base_dir = base_dir
151        self.route_prefix = route_prefix.rstrip("/")
152        self.system_config_store = system_config_store
153        self.user_store = user_store
154        self._request_bytes = 0
155        self.base_dir.mkdir(parents=True, exist_ok=True)
156
157    @property
158    def route_root(self) -> str:
159        return self.route_prefix
160
161    def public_url(self, filename: str) -> str:
162        return f"{self.route_root}/{filename}"
163
164    def _filename_for(self, image_bytes: bytes, mime_type: str) -> str:
165        digest = hashlib.sha256(image_bytes).hexdigest()
166        ext = _mime_to_extension(mime_type)
167        return f"{digest}.{ext}"
168
169    def _config_int(self, key: str, default: int = 0) -> int:
170        if self.system_config_store is None:
171            return default
172        try:
173            return max(0, int(self.system_config_store.get_system_config(key, str(default)) or default))
174        except ValueError:
175            return default
176
177    def _placeholder_for_expired(self, filename: str) -> str:
178        return f"{self.route_root}/{filename}"
179
180    def _can_store(self, image_bytes: bytes) -> bool:
181        size = len(image_bytes)
182        single_limit = self._config_int("value.image_max_single_bytes", 0)
183        if single_limit > 0 and size > single_limit:
184            return False
185
186        request_limit = self._config_int("value.image_max_request_bytes", 0)
187        if request_limit > 0 and self._request_bytes + size > request_limit:
188            return False
189
190        total_limit = self._config_int("value.image_max_total_bytes", 0)
191        if total_limit > 0:
192            self.prune_oldest_to_fit(max(0, total_limit - size))
193            if self.storage_usage()["total_bytes"] + size > total_limit:
194                return False
195        return True
196
197    def _track_owner(self, filename: str, owner_id: str | None, mime_type: str) -> None:
198        if self.user_store is None or not owner_id:
199            return
200        self.user_store.set_uploaded_image_owner(filename, owner_id, mime_type)
201
202    def store_data_url(self, value: str, *, owner_id: str | None = None) -> str | None:
203        decoded = _decode_base64_image(value)
204        if decoded is None:
205            return None
206        image_bytes, mime_type = decoded
207        if mime_type not in ALLOWED_IMAGE_MIME_TYPES:
208            return None
209        filename = self._filename_for(image_bytes, mime_type)
210        if not self._can_store(image_bytes):
211            return self._placeholder_for_expired(filename)
212        path = self.base_dir / filename
213        if not path.exists():
214            path.write_bytes(image_bytes)
215        self._track_owner(filename, owner_id, mime_type)
216        self._request_bytes += len(image_bytes)
217        return self.public_url(filename)
218
219    def normalize_request_data(self, value: Any, *, owner_id: str | None = None) -> Any:
220        self._request_bytes = 0
221        return self._normalize_node(value, owner_id=owner_id)
222
223    def _rewrite_string(self, value: str, *, key: str = "", owner_id: str | None = None) -> str:
224        data_url_url = self.store_data_url(value, owner_id=owner_id)
225        if data_url_url is not None:
226            return data_url_url
227
228        decoded = _decode_base64_image(value)
229        if decoded is not None:
230            image_bytes, mime_type = decoded
231            if mime_type not in ALLOWED_IMAGE_MIME_TYPES:
232                return value
233            filename = self._filename_for(image_bytes, mime_type)
234            if not self._can_store(image_bytes):
235                return self._placeholder_for_expired(filename)
236            path = self.base_dir / filename
237            if not path.exists():
238                path.write_bytes(image_bytes)
239            self._track_owner(filename, owner_id, mime_type)
240            self._request_bytes += len(image_bytes)
241            return self.public_url(filename)
242
243        if _is_image_key(key):
244            decoded = _decode_base64_image(value)
245            if decoded is not None:
246                image_bytes, mime_type = decoded
247                if mime_type not in ALLOWED_IMAGE_MIME_TYPES:
248                    return value
249                filename = self._filename_for(image_bytes, mime_type)
250                if not self._can_store(image_bytes):
251                    return self._placeholder_for_expired(filename)
252                path = self.base_dir / filename
253                if not path.exists():
254                    path.write_bytes(image_bytes)
255                self._track_owner(filename, owner_id, mime_type)
256                self._request_bytes += len(image_bytes)
257                return self.public_url(filename)
258        return value
259
260    def _normalize_node(self, value: Any, *, key: str = "", owner_id: str | None = None) -> Any:
261        if isinstance(value, str):
262            parsed = _try_parse_structured_content(value)
263            if parsed is not None:
264                return self._normalize_node(parsed, key=key, owner_id=owner_id)
265            return self._rewrite_string(value, key=key, owner_id=owner_id)
266        if isinstance(value, list):
267            return [self._normalize_node(item, key=key, owner_id=owner_id) for item in value]
268        if isinstance(value, dict):
269            rewritten: dict[str, Any] = {}
270            for item_key, item_value in value.items():
271                rewritten[item_key] = self._normalize_node(item_value, key=str(item_key), owner_id=owner_id)
272            return rewritten
273        return value
274
275    def rewrite_value(self, value: Any, *, key: str = "", owner_id: str | None = None) -> Any:
276        return self._normalize_node(value, key=key, owner_id=owner_id)
277
278    def normalize_content(self, content: Any, *, owner_id: str | None = None) -> str:
279        if isinstance(content, str):
280            parsed = _try_parse_structured_content(content)
281            if parsed is not None:
282                rewritten = self.rewrite_value(parsed, key="content", owner_id=owner_id)
283                return json.dumps(rewritten, ensure_ascii=False, separators=(",", ":"))
284        rewritten = self.rewrite_value(content, key="content", owner_id=owner_id)
285        if isinstance(rewritten, str):
286            return rewritten.replace("\r\n", "\n").replace("\\r\\n", "\n").replace("\\n", "\n")
287        return json.dumps(rewritten, ensure_ascii=False, separators=(",", ":"))
288
289    def backfill_owners_from_messages(self, messages: list[Any], owner_lookup: dict[str, str]) -> None:
290        if self.user_store is None:
291            return
292        for message in messages:
293            owner_id = owner_lookup.get(str(getattr(message, "conversation_id", "")).strip(), "")
294            if not owner_id:
295                continue
296            for match in _UPLOAD_URL_RE.finditer(str(getattr(message, "content", "") or "")):
297                filename = match.group(1)
298                if filename:
299                    mime_type = ""
300                    suffix = Path(filename).suffix.lower()
301                    if suffix == ".png":
302                        mime_type = "image/png"
303                    elif suffix in {".jpg", ".jpeg"}:
304                        mime_type = "image/jpeg"
305                    elif suffix == ".gif":
306                        mime_type = "image/gif"
307                    elif suffix == ".webp":
308                        mime_type = "image/webp"
309                    elif suffix == ".avif":
310                        mime_type = "image/avif"
311                    elif suffix == ".bmp":
312                        mime_type = "image/bmp"
313                    elif suffix in {".tif", ".tiff"}:
314                        mime_type = "image/tiff"
315                    self.user_store.set_uploaded_image_owner(filename, owner_id, mime_type)
316
317    def referenced_filenames(self, content: str) -> set[str]:
318        if not content:
319            return set()
320        pattern = re.compile(
321            rf"(?:https?://[^\s\"']+)?{re.escape(self.route_root)}/([A-Za-z0-9._-]+)",
322            re.IGNORECASE,
323        )
324        return {match.group(1) for match in pattern.finditer(content)}
325
326    def cleanup_orphans(self, messages: list[Any]) -> list[str]:
327        referenced: set[str] = set()
328        for message in messages:
329            content = getattr(message, "content", "")
330            if isinstance(content, str):
331                referenced.update(self.referenced_filenames(content))
332
333        deleted: list[str] = []
334        if not self.base_dir.exists():
335            return deleted
336
337        for path in self.base_dir.iterdir():
338            if not path.is_file():
339                continue
340            if path.name in referenced:
341                continue
342            path.unlink(missing_ok=True)
343            deleted.append(path.name)
344        return deleted
345
346    def storage_usage(self, messages: list[Any] | None = None) -> dict[str, int]:
347        total_bytes = 0
348        file_count = 0
349        orphan_bytes = 0
350        referenced: set[str] | None = None
351        if messages is not None:
352            referenced = set()
353            for message in messages:
354                content = getattr(message, "content", "")
355                if isinstance(content, str):
356                    referenced.update(self.referenced_filenames(content))
357
358        if not self.base_dir.exists():
359            return {
360                "total_bytes": 0,
361                "file_count": 0,
362                "orphan_bytes": 0,
363                "orphan_count": 0,
364            }
365
366        orphan_count = 0
367        for path in self.base_dir.iterdir():
368            if not path.is_file():
369                continue
370            size = path.stat().st_size
371            total_bytes += size
372            file_count += 1
373            if referenced is not None and path.name not in referenced:
374                orphan_bytes += size
375                orphan_count += 1
376
377        return {
378            "total_bytes": total_bytes,
379            "file_count": file_count,
380            "orphan_bytes": orphan_bytes,
381            "orphan_count": orphan_count,
382        }
383
384    def prune_oldest_to_fit(self, target_bytes: int) -> list[str]:
385        target_bytes = max(0, int(target_bytes))
386        files = [
387            path
388            for path in self.base_dir.iterdir()
389            if path.is_file()
390        ] if self.base_dir.exists() else []
391        total = sum(path.stat().st_size for path in files)
392        if total <= target_bytes:
393            return []
394
395        deleted: list[str] = []
396        for path in sorted(files, key=lambda item: item.stat().st_mtime):
397            if total <= target_bytes:
398                break
399            size = path.stat().st_size
400            path.unlink(missing_ok=True)
401            total -= size
402            deleted.append(path.name)
403        return deleted
404