142 lines
5.9 KiB
Python
142 lines
5.9 KiB
Python
"""工作区图片资产:原图归 Vault,SQLite 保存元数据与笔记引用。"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path, PurePosixPath
|
|
from uuid import uuid4
|
|
|
|
from app import host_bridge
|
|
from app.config import get_settings
|
|
from app.database.db import connect_knowledge, transaction
|
|
from app.errors import ApiError
|
|
from app.services.vault_paths import resolve_in_vault
|
|
|
|
MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
|
|
|
|
|
def _image_kind(data: bytes) -> tuple[str, str]:
|
|
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
return "png", "image/png"
|
|
if data.startswith(b"\xff\xd8\xff"):
|
|
return "jpg", "image/jpeg"
|
|
if data.startswith((b"GIF87a", b"GIF89a")):
|
|
return "gif", "image/gif"
|
|
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
return "webp", "image/webp"
|
|
raise ApiError(415, "WORKSPACE_IMAGE_UNSUPPORTED", "仅支持 PNG、JPEG、GIF 和 WebP 图片。")
|
|
|
|
|
|
def _desktop() -> bool:
|
|
return get_settings().environment == "desktop"
|
|
|
|
|
|
def _vault_id() -> str:
|
|
return host_bridge.vault_id.get() or "default"
|
|
|
|
|
|
def _validate_asset_path(path: str) -> str:
|
|
normalized = PurePosixPath(path.replace("\\", "/"))
|
|
parts = normalized.parts
|
|
if normalized.is_absolute() or ".." in parts or len(parts) != 3 or parts[0] != "attachments":
|
|
raise ApiError(400, "INVALID_PATH", "图片路径不属于工作区附件目录。")
|
|
return normalized.as_posix()
|
|
|
|
|
|
def _write_web(path: str, data: bytes) -> None:
|
|
target = resolve_in_vault(path)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
if target.exists():
|
|
if target.read_bytes() != data:
|
|
raise ApiError(409, "RESOURCE_CONFLICT", "附件路径已有不同内容。")
|
|
return
|
|
temporary = target.with_name(f".{target.name}.{uuid4().hex}.tmp")
|
|
try:
|
|
temporary.write_bytes(data)
|
|
os.replace(temporary, target)
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
def _write_desktop(path: str, data: bytes) -> None:
|
|
if host_bridge.active is None:
|
|
raise ApiError(503, "HOST_UNAVAILABLE", "桌面 Host 不可用。")
|
|
try:
|
|
host_bridge.active.call(
|
|
"workspace.assets.write", vault_id=_vault_id(), path=path,
|
|
content_base64=base64.b64encode(data).decode("ascii"), operation_id=str(uuid4()),
|
|
)
|
|
except RuntimeError as error:
|
|
raise ApiError(409 if str(error) == "REVISION_CONFLICT" else 503,
|
|
str(error), "写入工作区图片失败。") from None
|
|
|
|
|
|
def _record(*, digest: str, path: str, media_type: str, size: int, original_name: str,
|
|
note_id: str, note_path: str, source: str) -> None:
|
|
asset_id = f"asset_{digest}"
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
conn = connect_knowledge()
|
|
try:
|
|
with transaction(conn):
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO workspace_assets(asset_id,path,content_hash,media_type,size,original_name,created_at) VALUES(?,?,?,?,?,?,?)",
|
|
(asset_id, path, digest, media_type, size, Path(original_name).name[:255], now),
|
|
)
|
|
if note_path:
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO workspace_asset_links(asset_id,note_id,note_path,source,created_at) VALUES(?,?,?,?,?)",
|
|
(asset_id, note_id, note_path.replace("\\", "/").lstrip("/"), source, now),
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def store(data: bytes, *, original_name: str, note_id: str, note_path: str, source: str) -> dict:
|
|
if not data:
|
|
raise ApiError(400, "WORKSPACE_IMAGE_EMPTY", "图片内容为空。")
|
|
if len(data) > MAX_IMAGE_BYTES:
|
|
raise ApiError(413, "WORKSPACE_IMAGE_TOO_LARGE", "工作区图片不能超过 5 MiB。")
|
|
if source not in {"paste", "drop", "upload"}:
|
|
raise ApiError(400, "WORKSPACE_IMAGE_SOURCE_INVALID", "图片来源无效。")
|
|
extension, media_type = _image_kind(data)
|
|
digest = hashlib.sha256(data).hexdigest()
|
|
asset_id = f"asset_{digest}"
|
|
path = f"attachments/{digest[:2]}/{digest}.{extension}"
|
|
(_write_desktop if _desktop() else _write_web)(path, data)
|
|
|
|
_record(digest=digest, path=path, media_type=media_type, size=len(data),
|
|
original_name=Path(original_name).name or f"image.{extension}", note_id=note_id,
|
|
note_path=note_path, source=source)
|
|
return {"asset_id": asset_id, "path": path, "content_hash": digest,
|
|
"media_type": media_type, "size": len(data), "original_name": Path(original_name).name}
|
|
|
|
|
|
def read(path: str, *, note_id: str = "", note_path: str = "") -> tuple[bytes, str]:
|
|
path = _validate_asset_path(path)
|
|
if _desktop():
|
|
if host_bridge.active is None:
|
|
raise ApiError(503, "HOST_UNAVAILABLE", "桌面 Host 不可用。")
|
|
try:
|
|
result = host_bridge.active.call("workspace.assets.read", vault_id=_vault_id(), path=path)
|
|
data = base64.b64decode(result["content_base64"], validate=True)
|
|
except (RuntimeError, KeyError, ValueError):
|
|
raise ApiError(404, "RESOURCE_NOT_FOUND", "工作区图片不存在。") from None
|
|
else:
|
|
target = resolve_in_vault(path)
|
|
if not target.is_file() or target.is_symlink():
|
|
raise ApiError(404, "RESOURCE_NOT_FOUND", "工作区图片不存在。")
|
|
data = target.read_bytes()
|
|
if len(data) > MAX_IMAGE_BYTES:
|
|
raise ApiError(413, "WORKSPACE_IMAGE_TOO_LARGE", "工作区图片超过读取上限。")
|
|
_, media_type = _image_kind(data)
|
|
digest = hashlib.sha256(data).hexdigest()
|
|
expected = PurePosixPath(path).stem
|
|
if digest != expected:
|
|
raise ApiError(409, "WORKSPACE_IMAGE_HASH_MISMATCH", "工作区图片内容与路径哈希不一致。")
|
|
_record(digest=digest, path=path, media_type=media_type, size=len(data),
|
|
original_name=PurePosixPath(path).name, note_id=note_id, note_path=note_path,
|
|
source="sync")
|
|
return data, media_type
|