mirror of
https://github.com/langgenius/dify.git
synced 2026-09-02 14:25:35 +08:00
191 lines
7.5 KiB
Python
191 lines
7.5 KiB
Python
"""Validation and bounded loading for KnowledgeFS query-image UploadFile references."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from collections.abc import Sequence
|
|
from dataclasses import dataclass
|
|
|
|
from core.db.session_factory import session_factory
|
|
from extensions.ext_storage import storage
|
|
from libs.datetime_utils import naive_utc_now
|
|
from models.enums import CreatorUserRole
|
|
from models.model import UploadFile
|
|
from services.file_service import FileService
|
|
|
|
QUERY_IMAGE_MAX_COUNT = 4
|
|
QUERY_IMAGE_MAX_BYTES = 10 * 1024 * 1024
|
|
QUERY_IMAGE_MAX_TOTAL_BYTES = 32 * 1024 * 1024
|
|
QUERY_IMAGE_MIME_TYPES = frozenset({"image/gif", "image/jpeg", "image/png", "image/webp"})
|
|
|
|
|
|
class KnowledgeFSQueryImageError(ValueError):
|
|
"""A safe validation error raised before query model or retrieval work starts."""
|
|
|
|
def __init__(self, code: str, message: str) -> None:
|
|
super().__init__(message)
|
|
self.code = code
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class KnowledgeFSQueryImageMetadata:
|
|
upload_file_id: str
|
|
byte_size: int
|
|
mime_type: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class KnowledgeFSResolvedQueryImage(KnowledgeFSQueryImageMetadata):
|
|
body: bytes
|
|
sha256: str
|
|
|
|
|
|
def validate_query_image_references(
|
|
*,
|
|
tenant_id: str,
|
|
account_id: str,
|
|
upload_file_ids: Sequence[str],
|
|
mark_used: bool,
|
|
) -> list[KnowledgeFSQueryImageMetadata]:
|
|
"""Validate tenant/actor ownership and static bounds in one database round trip."""
|
|
|
|
normalized_ids = _validate_reference_ids(upload_file_ids)
|
|
if not normalized_ids:
|
|
return []
|
|
|
|
with session_factory.create_session() as session:
|
|
files_by_id = FileService.get_upload_files_by_ids(tenant_id, normalized_ids, session=session)
|
|
result: list[KnowledgeFSQueryImageMetadata] = []
|
|
total_bytes = 0
|
|
for upload_file_id in normalized_ids:
|
|
upload_file = files_by_id.get(upload_file_id)
|
|
if upload_file is None:
|
|
raise KnowledgeFSQueryImageError("QUERY_IMAGE_NOT_FOUND", "Query image was not found")
|
|
_assert_actor_owned(upload_file, account_id=account_id)
|
|
mime_type = _validate_metadata(upload_file)
|
|
total_bytes += upload_file.size
|
|
result.append(
|
|
KnowledgeFSQueryImageMetadata(
|
|
upload_file_id=upload_file_id,
|
|
byte_size=upload_file.size,
|
|
mime_type=mime_type,
|
|
)
|
|
)
|
|
if mark_used:
|
|
upload_file.used = True
|
|
upload_file.used_by = account_id
|
|
upload_file.used_at = naive_utc_now()
|
|
|
|
if total_bytes > QUERY_IMAGE_MAX_TOTAL_BYTES:
|
|
raise KnowledgeFSQueryImageError(
|
|
"QUERY_IMAGE_TOTAL_TOO_LARGE",
|
|
f"Query images exceed aggregate max bytes {QUERY_IMAGE_MAX_TOTAL_BYTES}",
|
|
)
|
|
if mark_used:
|
|
session.commit()
|
|
return result
|
|
|
|
|
|
def load_query_image(*, tenant_id: str, account_id: str, upload_file_id: str) -> KnowledgeFSResolvedQueryImage:
|
|
"""Load and sniff one validated image from Dify's configured object storage."""
|
|
|
|
normalized_ids = _validate_reference_ids([upload_file_id])
|
|
with session_factory.create_session() as session:
|
|
upload_file = FileService.get_upload_files_by_ids(tenant_id, normalized_ids, session=session).get(
|
|
upload_file_id
|
|
)
|
|
if upload_file is None:
|
|
raise KnowledgeFSQueryImageError("QUERY_IMAGE_NOT_FOUND", "Query image was not found")
|
|
_assert_actor_owned(upload_file, account_id=account_id)
|
|
declared_mime_type = _validate_metadata(upload_file)
|
|
object_key = upload_file.key
|
|
declared_size = upload_file.size
|
|
|
|
body = _load_bounded_body(object_key=object_key, expected_size=declared_size)
|
|
if not body:
|
|
raise KnowledgeFSQueryImageError("QUERY_IMAGE_EMPTY", "Query image is empty")
|
|
detected_mime_type = _detect_image_mime_type(body)
|
|
if detected_mime_type != declared_mime_type:
|
|
raise KnowledgeFSQueryImageError(
|
|
"QUERY_IMAGE_MIME_MISMATCH", "Query image content does not match its MIME type"
|
|
)
|
|
|
|
return KnowledgeFSResolvedQueryImage(
|
|
upload_file_id=upload_file_id,
|
|
byte_size=len(body),
|
|
mime_type=detected_mime_type,
|
|
body=body,
|
|
sha256=hashlib.sha256(body).hexdigest(),
|
|
)
|
|
|
|
|
|
def _load_bounded_body(*, object_key: str, expected_size: int) -> bytes:
|
|
chunks: list[bytes] = []
|
|
total_bytes = 0
|
|
stream = storage.load(object_key, stream=True)
|
|
try:
|
|
for chunk in stream:
|
|
if not isinstance(chunk, (bytes, bytearray, memoryview)):
|
|
raise KnowledgeFSQueryImageError("QUERY_IMAGE_SIZE_INVALID", "Query image stream is invalid")
|
|
body_chunk = bytes(chunk)
|
|
total_bytes += len(body_chunk)
|
|
if total_bytes > QUERY_IMAGE_MAX_BYTES or total_bytes > expected_size:
|
|
raise KnowledgeFSQueryImageError(
|
|
"QUERY_IMAGE_SIZE_INVALID", "Query image size does not match its metadata"
|
|
)
|
|
chunks.append(body_chunk)
|
|
finally:
|
|
close = getattr(stream, "close", None)
|
|
if callable(close):
|
|
close()
|
|
|
|
if total_bytes != expected_size:
|
|
raise KnowledgeFSQueryImageError("QUERY_IMAGE_SIZE_INVALID", "Query image size does not match its metadata")
|
|
return b"".join(chunks)
|
|
|
|
|
|
def _validate_reference_ids(upload_file_ids: Sequence[str]) -> list[str]:
|
|
normalized = [str(value).strip() for value in upload_file_ids]
|
|
if len(normalized) > QUERY_IMAGE_MAX_COUNT:
|
|
raise KnowledgeFSQueryImageError(
|
|
"QUERY_IMAGE_COUNT_EXCEEDED", f"queryImages exceeds max count {QUERY_IMAGE_MAX_COUNT}"
|
|
)
|
|
if any(not value for value in normalized):
|
|
raise KnowledgeFSQueryImageError("QUERY_IMAGE_REFERENCE_INVALID", "Query image reference is invalid")
|
|
if len(set(normalized)) != len(normalized):
|
|
raise KnowledgeFSQueryImageError(
|
|
"QUERY_IMAGE_REFERENCE_DUPLICATE", "queryImages must not contain duplicate uploadFileId values"
|
|
)
|
|
return normalized
|
|
|
|
|
|
def _assert_actor_owned(upload_file: UploadFile, *, account_id: str) -> None:
|
|
if upload_file.created_by_role != CreatorUserRole.ACCOUNT or upload_file.created_by != account_id:
|
|
# Deliberately use the same not-found result as an unknown id to avoid disclosing foreign files.
|
|
raise KnowledgeFSQueryImageError("QUERY_IMAGE_NOT_FOUND", "Query image was not found")
|
|
|
|
|
|
def _validate_metadata(upload_file: UploadFile) -> str:
|
|
mime_type = (upload_file.mime_type or "").split(";", 1)[0].strip().lower()
|
|
if mime_type not in QUERY_IMAGE_MIME_TYPES:
|
|
raise KnowledgeFSQueryImageError("QUERY_IMAGE_MIME_UNSUPPORTED", "Query image MIME type is not supported")
|
|
if upload_file.size < 1:
|
|
raise KnowledgeFSQueryImageError("QUERY_IMAGE_EMPTY", "Query image is empty")
|
|
if upload_file.size > QUERY_IMAGE_MAX_BYTES:
|
|
raise KnowledgeFSQueryImageError(
|
|
"QUERY_IMAGE_TOO_LARGE", f"Query image exceeds max bytes {QUERY_IMAGE_MAX_BYTES}"
|
|
)
|
|
return mime_type
|
|
|
|
|
|
def _detect_image_mime_type(body: bytes) -> str:
|
|
if body.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
return "image/png"
|
|
if body.startswith(b"\xff\xd8\xff"):
|
|
return "image/jpeg"
|
|
if body.startswith((b"GIF87a", b"GIF89a")):
|
|
return "image/gif"
|
|
if len(body) >= 12 and body.startswith(b"RIFF") and body[8:12] == b"WEBP":
|
|
return "image/webp"
|
|
raise KnowledgeFSQueryImageError("QUERY_IMAGE_CONTENT_UNSUPPORTED", "Query image content is not supported")
|