feat(workspace): 完成图片资产存储与引用
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
"""工作区图片资产:原图归 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
|
||||
Reference in New Issue
Block a user