chatgpt2api / services /image_storage_service.py
huanx's picture
Deploy chatgpt2api to Hugging Face Space
bf53440 verified
Raw
History Blame Contribute Delete
16 kB
from __future__ import annotations
import hashlib
import io
import json
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from threading import Lock
from urllib.parse import quote, urlparse
from curl_cffi import requests
from fastapi import HTTPException
from PIL import Image
from services.config import DATA_DIR, config
IMAGE_INDEX_FILE = DATA_DIR / "image_index.json"
IMAGE_INDEX_LOCK = Lock()
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
class ImageStorageError(RuntimeError):
pass
@dataclass(frozen=True)
class StoredImage:
rel: str
url: str
storage: str
size: int
def _clean(value: object) -> str:
return str(value or "").strip()
def _now_iso() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _safe_relative_path(path: str) -> str:
value = str(path or "").strip().replace("\\", "/").lstrip("/")
if not value:
raise HTTPException(status_code=404, detail="image not found")
parts = Path(value).parts
if any(part in {"", ".", ".."} for part in parts):
raise HTTPException(status_code=404, detail="image not found")
return Path(*parts).as_posix()
def _image_dimensions(payload: bytes) -> tuple[int, int] | None:
try:
with Image.open(io.BytesIO(payload)) as image:
return image.size
except Exception:
return None
def _is_image_rel(path: str) -> bool:
try:
safe_rel = _safe_relative_path(path)
except HTTPException:
return False
return Path(safe_rel).suffix.lower() in IMAGE_EXTENSIONS
def _local_image_path(relative_path: str) -> Path:
rel = _safe_relative_path(relative_path)
root = config.images_dir.resolve()
path = (root / rel).resolve()
try:
path.relative_to(root)
except ValueError as exc:
raise HTTPException(status_code=404, detail="image not found") from exc
return path
def _read_json_object(path: Path) -> dict[str, object]:
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
return data if isinstance(data, dict) else {}
def _write_json_object(path: Path, data: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(path.suffix + ".tmp")
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(path)
class WebDAVClient:
def __init__(self, settings: dict[str, object]):
self.url = _clean(settings.get("webdav_url")).rstrip("/")
self.username = _clean(settings.get("webdav_username"))
self.password = _clean(settings.get("webdav_password"))
self.root_path = _clean(settings.get("webdav_root_path")).strip("/")
self.session = requests.Session()
def _auth_kwargs(self) -> dict[str, object]:
return {"auth": (self.username, self.password)} if self.username or self.password else {}
def _request(self, method: str, url: str, **kwargs):
response = self.session.request(method, url, timeout=30, **self._auth_kwargs(), **kwargs)
if response.status_code >= 400 and not (method == "MKCOL" and response.status_code in {405}):
raise ImageStorageError(f"WebDAV {method} failed: HTTP {response.status_code}")
return response
def remote_url(self, rel: str = "") -> str:
parts = [part for part in [self.root_path, _safe_relative_path(rel) if rel else ""] if part]
encoded = "/".join(quote(part, safe="") for item in parts for part in item.split("/") if part)
return f"{self.url}/{encoded}" if encoded else self.url
def ensure_dirs(self, rel: str) -> None:
parts = [part for part in [self.root_path, Path(_safe_relative_path(rel)).parent.as_posix()] if part and part != "."]
current = self.url
for item in "/".join(parts).split("/"):
if not item:
continue
current = f"{current}/{quote(item, safe='')}"
response = self.session.request("MKCOL", current, timeout=30, **self._auth_kwargs())
if response.status_code in {201, 405}:
continue
if response.status_code >= 400:
raise ImageStorageError(f"WebDAV MKCOL failed: HTTP {response.status_code}")
def put(self, rel: str, payload: bytes, content_type: str = "image/png") -> str:
self.ensure_dirs(rel)
url = self.remote_url(rel)
self._request("PUT", url, data=payload, headers={"Content-Type": content_type})
return url
def get(self, rel: str) -> bytes:
response = self._request("GET", self.remote_url(rel))
return bytes(response.content)
def delete(self, rel: str) -> bool:
response = self.session.request("DELETE", self.remote_url(rel), timeout=30, **self._auth_kwargs())
if response.status_code in {200, 202, 204, 404}:
return response.status_code != 404
raise ImageStorageError(f"WebDAV DELETE failed: HTTP {response.status_code}")
def test(self) -> dict[str, object]:
if not self.url:
return {"ok": False, "status": 0, "error": "WebDAV URL is required"}
if urlparse(self.url).scheme not in {"http", "https"}:
return {"ok": False, "status": 0, "error": "invalid WebDAV URL"}
test_rel = ".chatgpt2api_webdav_test.txt"
try:
self.put(test_rel, b"chatgpt2api webdav test\n", content_type="text/plain")
self.delete(test_rel)
return {"ok": True, "status": 200, "error": None}
except ImageStorageError as exc:
return {"ok": False, "status": 0, "error": str(exc)}
except Exception as exc:
return {"ok": False, "status": 0, "error": str(exc) or exc.__class__.__name__}
finally:
self.session.close()
class ImageStorageService:
def __init__(self, index_file: Path = IMAGE_INDEX_FILE):
self.index_file = index_file
self._index_lock = IMAGE_INDEX_LOCK
def settings(self) -> dict[str, object]:
return config.get_image_storage_settings()
def mode(self) -> str:
return _clean(self.settings().get("mode")) or "local"
def _load_index(self) -> dict[str, dict[str, object]]:
raw = _read_json_object(self.index_file)
items = raw.get("items")
if not isinstance(items, dict):
return {}
return {str(key): value for key, value in items.items() if isinstance(value, dict)}
def _load_clean_index(self) -> dict[str, dict[str, object]]:
items = self._load_index()
return {rel: item for rel, item in items.items() if _is_image_rel(rel)}
def _save_index(self, items: dict[str, dict[str, object]]) -> None:
_write_json_object(self.index_file, {"items": items})
def _public_url(self, rel: str, base_url: str | None = None) -> str:
settings = self.settings()
public_base_url = _clean(settings.get("public_base_url"))
if public_base_url:
return f"{public_base_url.rstrip('/')}/{_safe_relative_path(rel)}"
return f"{(base_url or config.base_url).rstrip('/')}/images/{_safe_relative_path(rel)}"
def make_relative_path(self, image_data: bytes) -> str:
file_hash = hashlib.md5(image_data).hexdigest()
filename = f"{int(time.time())}_{file_hash}.png"
relative_dir = Path(time.strftime("%Y"), time.strftime("%m"), time.strftime("%d"))
return f"{relative_dir.as_posix()}/{filename}"
def save(self, image_data: bytes, base_url: str | None = None) -> StoredImage:
config.cleanup_old_images()
rel = self.make_relative_path(image_data)
mode = self.mode()
if mode not in {"local", "webdav", "both"}:
mode = "local"
stored_local = False
stored_webdav = False
remote_url = ""
if mode in {"local", "both"}:
path = _local_image_path(rel)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(image_data)
stored_local = True
if mode in {"webdav", "both"}:
remote_url = WebDAVClient(self.settings()).put(rel, image_data)
stored_webdav = True
dimensions = _image_dimensions(image_data)
item = {
"rel": rel,
"path": rel,
"name": Path(rel).name,
"date": "-".join(rel.split("/")[:3]),
"size": len(image_data),
"created_at": _now_iso(),
"storage": "both" if stored_local and stored_webdav else ("webdav" if stored_webdav else "local"),
"local": stored_local,
"webdav": stored_webdav,
"remote_url": remote_url,
}
if dimensions:
item["width"], item["height"] = dimensions
with self._index_lock:
items = self._load_clean_index()
items[rel] = item
self._save_index(items)
return StoredImage(rel=rel, url=self._public_url(rel, base_url), storage=str(item["storage"]), size=len(image_data))
def get_bytes(self, rel: str) -> bytes:
safe_rel = _safe_relative_path(rel)
if not _is_image_rel(safe_rel):
raise HTTPException(status_code=404, detail="image not found")
path = _local_image_path(safe_rel)
if path.is_file():
return path.read_bytes()
item = self._load_clean_index().get(safe_rel, {})
if item.get("webdav"):
return WebDAVClient(self.settings()).get(safe_rel)
raise HTTPException(status_code=404, detail="image not found")
def exists(self, rel: str) -> bool:
safe_rel = _safe_relative_path(rel)
if not _is_image_rel(safe_rel):
return False
if _local_image_path(safe_rel).is_file():
return True
item = self._load_clean_index().get(safe_rel, {})
return bool(item.get("webdav"))
def has_local(self, rel: str) -> bool:
safe_rel = _safe_relative_path(rel)
return _is_image_rel(safe_rel) and _local_image_path(safe_rel).is_file()
def list_items(self, base_url: str, start_date: str = "", end_date: str = "") -> list[dict[str, object]]:
with self._index_lock:
indexed = self._load_clean_index()
root = config.images_dir
changed = False
for path in root.rglob("*"):
if not path.is_file() or not _is_image_rel(path.name):
continue
rel = path.relative_to(root).as_posix()
if rel in indexed:
continue
dimensions = None
try:
dimensions = _image_dimensions(path.read_bytes())
except Exception:
dimensions = None
indexed[rel] = {
"rel": rel,
"path": rel,
"name": path.name,
"date": "-".join(rel.split("/")[:3]) if len(rel.split("/")) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d"),
"size": path.stat().st_size,
"created_at": datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
"storage": "local",
"local": True,
"webdav": False,
**({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}),
}
changed = True
items: list[dict[str, object]] = []
for rel, item in list(indexed.items()):
if not _is_image_rel(rel):
indexed.pop(rel, None)
changed = True
continue
local = _local_image_path(rel).is_file()
webdav = bool(item.get("webdav"))
if not local and not webdav:
indexed.pop(rel, None)
changed = True
continue
storage = "both" if local and webdav else ("webdav" if webdav else "local")
if item.get("local") != local or item.get("storage") != storage:
item = {
**item,
"local": local,
"storage": storage,
}
indexed[rel] = item
changed = True
day = str(item.get("date") or "")
if start_date and day < start_date:
continue
if end_date and day > end_date:
continue
items.append({
**item,
"rel": rel,
"path": rel,
"url": self._public_url(rel, base_url),
})
if changed:
self._save_index(indexed)
items.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
return items
def delete(self, rel: str) -> bool:
safe_rel = _safe_relative_path(rel)
removed = False
path = _local_image_path(safe_rel)
if path.is_file():
path.unlink()
removed = True
with self._index_lock:
items = self._load_clean_index()
item = items.get(safe_rel, {})
if item.get("webdav"):
try:
removed = WebDAVClient(self.settings()).delete(safe_rel) or removed
except ImageStorageError:
if not removed:
raise
if safe_rel in items:
items.pop(safe_rel, None)
self._save_index(items)
return removed
def sync_all(self) -> dict[str, int]:
settings = self.settings()
if self.mode() not in {"webdav", "both"}:
raise ImageStorageError("WebDAV 图片存储未启用")
uploaded = 0
skipped = 0
failed = 0
with self._index_lock:
items = self._load_clean_index()
client = WebDAVClient(settings)
for path in sorted(config.images_dir.rglob("*")):
if not path.is_file() or not _is_image_rel(path.name):
continue
rel = path.relative_to(config.images_dir).as_posix()
item = items.get(rel, {})
if item.get("webdav"):
skipped += 1
continue
try:
payload = path.read_bytes()
remote_url = client.put(rel, payload)
dimensions = _image_dimensions(payload)
items[rel] = {
**item,
"rel": rel,
"path": rel,
"name": path.name,
"date": "-".join(rel.split("/")[:3]) if len(rel.split("/")) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d"),
"size": len(payload),
"created_at": str(item.get("created_at") or datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")),
"storage": "both",
"local": True,
"webdav": True,
"remote_url": remote_url,
**({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}),
}
uploaded += 1
except Exception:
failed += 1
self._save_index(items)
return {"uploaded": uploaded, "skipped": skipped, "failed": failed}
def test_webdav(self) -> dict[str, object]:
return WebDAVClient(self.settings()).test()
image_storage_service = ImageStorageService()