dsh-plugin-image-workbench 0.0.0-stage → 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +258 -0
- package/README.md +260 -2
- package/client.js +241 -0
- package/cordis.patch.yml +14 -0
- package/host.js +692 -0
- package/install.mjs +333 -0
- package/package.json +58 -4
- package/scripts/fetch-style-images.mjs +104 -0
- package/scripts/vendor-snapshot.py +154 -0
- package/server/access.py +26 -0
- package/server/app.py +1329 -0
- package/server/catalog.json +1 -0
- package/server/catalog.py +147 -0
- package/server/composer.py +391 -0
- package/server/drafts.py +158 -0
- package/server/history.py +575 -0
- package/server/imaging.py +172 -0
- package/server/pool.py +1335 -0
- package/server/pool_admin.py +921 -0
- package/server/pool_service.py +1073 -0
- package/server/requirements.txt +6 -0
- package/server/smoke_check.py +402 -0
- package/server/static/access.html +202 -0
- package/server/static/index.html +4725 -0
- package/vendor/chatgpt2api/README.md +13 -0
- package/vendor/chatgpt2api/SOURCE.json +11 -0
- package/vendor/chatgpt2api/runtime/uv.exe +0 -0
- package/vendor/chatgpt2api/source/.env.example +82 -0
- package/vendor/chatgpt2api/source/.python-version +1 -0
- package/vendor/chatgpt2api/source/LICENSE +21 -0
- package/vendor/chatgpt2api/source/README.md +391 -0
- package/vendor/chatgpt2api/source/VERSION +1 -0
- package/vendor/chatgpt2api/source/api/__init__.py +2 -0
- package/vendor/chatgpt2api/source/api/accounts.py +537 -0
- package/vendor/chatgpt2api/source/api/ai.py +217 -0
- package/vendor/chatgpt2api/source/api/app.py +62 -0
- package/vendor/chatgpt2api/source/api/errors.py +46 -0
- package/vendor/chatgpt2api/source/api/image_inputs.py +309 -0
- package/vendor/chatgpt2api/source/api/image_tasks.py +120 -0
- package/vendor/chatgpt2api/source/api/support.py +134 -0
- package/vendor/chatgpt2api/source/api/system.py +365 -0
- package/vendor/chatgpt2api/source/assets/atlascloud.svg +1 -0
- package/vendor/chatgpt2api/source/assets/ipwo.png +0 -0
- package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -0
- package/vendor/chatgpt2api/source/config.default.json +108 -0
- package/vendor/chatgpt2api/source/main.py +9 -0
- package/vendor/chatgpt2api/source/pyproject.toml +27 -0
- package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -0
- package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -0
- package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -0
- package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -0
- package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -0
- package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -0
- package/vendor/chatgpt2api/source/services/__init__.py +0 -0
- package/vendor/chatgpt2api/source/services/account_service.py +1757 -0
- package/vendor/chatgpt2api/source/services/auth_service.py +253 -0
- package/vendor/chatgpt2api/source/services/backup_service.py +671 -0
- package/vendor/chatgpt2api/source/services/config.py +667 -0
- package/vendor/chatgpt2api/source/services/content_filter.py +242 -0
- package/vendor/chatgpt2api/source/services/cpa_service.py +315 -0
- package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -0
- package/vendor/chatgpt2api/source/services/image_service.py +376 -0
- package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -0
- package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -0
- package/vendor/chatgpt2api/source/services/image_task_service.py +550 -0
- package/vendor/chatgpt2api/source/services/log_service.py +337 -0
- package/vendor/chatgpt2api/source/services/model_service.py +182 -0
- package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -0
- package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -0
- package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -0
- package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -0
- package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -0
- package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -0
- package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -0
- package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -0
- package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -0
- package/vendor/chatgpt2api/source/services/proxy_service.py +648 -0
- package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -0
- package/vendor/chatgpt2api/source/services/storage/base.py +38 -0
- package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -0
- package/vendor/chatgpt2api/source/services/storage/factory.py +104 -0
- package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -0
- package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -0
- package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -0
- package/vendor/chatgpt2api/source/utils/__init__.py +0 -0
- package/vendor/chatgpt2api/source/utils/helper.py +469 -0
- package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -0
- package/vendor/chatgpt2api/source/utils/log.py +110 -0
- package/vendor/chatgpt2api/source/utils/pkce.py +19 -0
- package/vendor/chatgpt2api/source/utils/pow.py +207 -0
- package/vendor/chatgpt2api/source/utils/sentinel.py +159 -0
- package/vendor/chatgpt2api/source/utils/turnstile.py +184 -0
- package/vendor/chatgpt2api/source/uv.lock +904 -0
- package/vendor/chatgpt2api/source/web/bun.lock +1501 -0
- package/vendor/chatgpt2api/source/web/components.json +24 -0
- package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -0
- package/vendor/chatgpt2api/source/web/next.config.ts +40 -0
- package/vendor/chatgpt2api/source/web/package.json +58 -0
- package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -0
- package/vendor/chatgpt2api/source/web/public/github.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/next.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/openai.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/vercel.svg +1 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/app/favicon.ico +0 -0
- package/vendor/chatgpt2api/source/web/src/app/globals.css +231 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -0
- package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -0
- package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -0
- package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -0
- package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -0
- package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -0
- package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -0
- package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -0
- package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -0
- package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -0
- package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -0
- package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -0
- package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -0
- package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -0
- package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -0
- package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -0
- package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -0
- package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -0
- package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -0
- package/vendor/chatgpt2api/source/web/tsconfig.json +42 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from threading import Lock
|
|
11
|
+
from urllib.parse import quote, urlparse
|
|
12
|
+
|
|
13
|
+
from curl_cffi import requests
|
|
14
|
+
from fastapi import HTTPException
|
|
15
|
+
from PIL import Image
|
|
16
|
+
|
|
17
|
+
from services.config import DATA_DIR, config
|
|
18
|
+
|
|
19
|
+
IMAGE_INDEX_FILE = DATA_DIR / "image_index.json"
|
|
20
|
+
IMAGE_INDEX_LOCK = Lock()
|
|
21
|
+
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ImageStorageError(RuntimeError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class StoredImage:
|
|
30
|
+
rel: str
|
|
31
|
+
url: str
|
|
32
|
+
storage: str
|
|
33
|
+
size: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _clean(value: object) -> str:
|
|
37
|
+
return str(value or "").strip()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _now_iso() -> str:
|
|
41
|
+
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _safe_relative_path(path: str) -> str:
|
|
45
|
+
value = str(path or "").strip().replace("\\", "/").lstrip("/")
|
|
46
|
+
if not value:
|
|
47
|
+
raise HTTPException(status_code=404, detail="image not found")
|
|
48
|
+
parts = Path(value).parts
|
|
49
|
+
if any(part in {"", ".", ".."} for part in parts):
|
|
50
|
+
raise HTTPException(status_code=404, detail="image not found")
|
|
51
|
+
return Path(*parts).as_posix()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _image_dimensions(payload: bytes) -> tuple[int, int] | None:
|
|
55
|
+
try:
|
|
56
|
+
with Image.open(io.BytesIO(payload)) as image:
|
|
57
|
+
return image.size
|
|
58
|
+
except Exception:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_image_rel(path: str) -> bool:
|
|
63
|
+
try:
|
|
64
|
+
safe_rel = _safe_relative_path(path)
|
|
65
|
+
except HTTPException:
|
|
66
|
+
return False
|
|
67
|
+
return Path(safe_rel).suffix.lower() in IMAGE_EXTENSIONS
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _local_image_path(relative_path: str) -> Path:
|
|
71
|
+
rel = _safe_relative_path(relative_path)
|
|
72
|
+
root = config.images_dir.resolve()
|
|
73
|
+
path = (root / rel).resolve()
|
|
74
|
+
try:
|
|
75
|
+
path.relative_to(root)
|
|
76
|
+
except ValueError as exc:
|
|
77
|
+
raise HTTPException(status_code=404, detail="image not found") from exc
|
|
78
|
+
return path
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _read_json_object(path: Path) -> dict[str, object]:
|
|
82
|
+
if not path.exists():
|
|
83
|
+
return {}
|
|
84
|
+
try:
|
|
85
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
86
|
+
except Exception:
|
|
87
|
+
return {}
|
|
88
|
+
return data if isinstance(data, dict) else {}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _write_json_object(path: Path, data: dict[str, object]) -> None:
|
|
92
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
94
|
+
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
95
|
+
tmp_path.replace(path)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class WebDAVClient:
|
|
99
|
+
def __init__(self, settings: dict[str, object]):
|
|
100
|
+
self.url = _clean(settings.get("webdav_url")).rstrip("/")
|
|
101
|
+
self.username = _clean(settings.get("webdav_username"))
|
|
102
|
+
self.password = _clean(settings.get("webdav_password"))
|
|
103
|
+
self.root_path = _clean(settings.get("webdav_root_path")).strip("/")
|
|
104
|
+
self.session = requests.Session()
|
|
105
|
+
|
|
106
|
+
def _auth_kwargs(self) -> dict[str, object]:
|
|
107
|
+
return {"auth": (self.username, self.password)} if self.username or self.password else {}
|
|
108
|
+
|
|
109
|
+
def _request(self, method: str, url: str, **kwargs):
|
|
110
|
+
response = self.session.request(method, url, timeout=30, **self._auth_kwargs(), **kwargs)
|
|
111
|
+
if response.status_code >= 400 and not (method == "MKCOL" and response.status_code in {405}):
|
|
112
|
+
raise ImageStorageError(f"WebDAV {method} failed: HTTP {response.status_code}")
|
|
113
|
+
return response
|
|
114
|
+
|
|
115
|
+
def remote_url(self, rel: str = "") -> str:
|
|
116
|
+
parts = [part for part in [self.root_path, _safe_relative_path(rel) if rel else ""] if part]
|
|
117
|
+
encoded = "/".join(quote(part, safe="") for item in parts for part in item.split("/") if part)
|
|
118
|
+
return f"{self.url}/{encoded}" if encoded else self.url
|
|
119
|
+
|
|
120
|
+
def ensure_dirs(self, rel: str) -> None:
|
|
121
|
+
parts = [part for part in [self.root_path, Path(_safe_relative_path(rel)).parent.as_posix()] if part and part != "."]
|
|
122
|
+
current = self.url
|
|
123
|
+
for item in "/".join(parts).split("/"):
|
|
124
|
+
if not item:
|
|
125
|
+
continue
|
|
126
|
+
current = f"{current}/{quote(item, safe='')}"
|
|
127
|
+
response = self.session.request("MKCOL", current, timeout=30, **self._auth_kwargs())
|
|
128
|
+
if response.status_code in {201, 405}:
|
|
129
|
+
continue
|
|
130
|
+
if response.status_code >= 400:
|
|
131
|
+
raise ImageStorageError(f"WebDAV MKCOL failed: HTTP {response.status_code}")
|
|
132
|
+
|
|
133
|
+
def put(self, rel: str, payload: bytes, content_type: str = "image/png") -> str:
|
|
134
|
+
self.ensure_dirs(rel)
|
|
135
|
+
url = self.remote_url(rel)
|
|
136
|
+
self._request("PUT", url, data=payload, headers={"Content-Type": content_type})
|
|
137
|
+
return url
|
|
138
|
+
|
|
139
|
+
def get(self, rel: str) -> bytes:
|
|
140
|
+
response = self._request("GET", self.remote_url(rel))
|
|
141
|
+
return bytes(response.content)
|
|
142
|
+
|
|
143
|
+
def delete(self, rel: str) -> bool:
|
|
144
|
+
response = self.session.request("DELETE", self.remote_url(rel), timeout=30, **self._auth_kwargs())
|
|
145
|
+
if response.status_code in {200, 202, 204, 404}:
|
|
146
|
+
return response.status_code != 404
|
|
147
|
+
raise ImageStorageError(f"WebDAV DELETE failed: HTTP {response.status_code}")
|
|
148
|
+
|
|
149
|
+
def test(self) -> dict[str, object]:
|
|
150
|
+
if not self.url:
|
|
151
|
+
return {"ok": False, "status": 0, "error": "WebDAV URL is required"}
|
|
152
|
+
if urlparse(self.url).scheme not in {"http", "https"}:
|
|
153
|
+
return {"ok": False, "status": 0, "error": "invalid WebDAV URL"}
|
|
154
|
+
test_rel = ".chatgpt2api_webdav_test.txt"
|
|
155
|
+
try:
|
|
156
|
+
self.put(test_rel, b"chatgpt2api webdav test\n", content_type="text/plain")
|
|
157
|
+
self.delete(test_rel)
|
|
158
|
+
return {"ok": True, "status": 200, "error": None}
|
|
159
|
+
except ImageStorageError as exc:
|
|
160
|
+
return {"ok": False, "status": 0, "error": str(exc)}
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
return {"ok": False, "status": 0, "error": str(exc) or exc.__class__.__name__}
|
|
163
|
+
finally:
|
|
164
|
+
self.session.close()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class ImageStorageService:
|
|
168
|
+
def __init__(self, index_file: Path = IMAGE_INDEX_FILE):
|
|
169
|
+
self.index_file = index_file
|
|
170
|
+
self._index_lock = IMAGE_INDEX_LOCK
|
|
171
|
+
|
|
172
|
+
def settings(self) -> dict[str, object]:
|
|
173
|
+
return config.get_image_storage_settings()
|
|
174
|
+
|
|
175
|
+
def mode(self) -> str:
|
|
176
|
+
return _clean(self.settings().get("mode")) or "local"
|
|
177
|
+
|
|
178
|
+
def _load_index(self) -> dict[str, dict[str, object]]:
|
|
179
|
+
raw = _read_json_object(self.index_file)
|
|
180
|
+
items = raw.get("items")
|
|
181
|
+
if not isinstance(items, dict):
|
|
182
|
+
return {}
|
|
183
|
+
return {str(key): value for key, value in items.items() if isinstance(value, dict)}
|
|
184
|
+
|
|
185
|
+
def _load_clean_index(self) -> dict[str, dict[str, object]]:
|
|
186
|
+
items = self._load_index()
|
|
187
|
+
return {rel: item for rel, item in items.items() if _is_image_rel(rel)}
|
|
188
|
+
|
|
189
|
+
def _save_index(self, items: dict[str, dict[str, object]]) -> None:
|
|
190
|
+
_write_json_object(self.index_file, {"items": items})
|
|
191
|
+
|
|
192
|
+
def _public_url(self, rel: str, base_url: str | None = None) -> str:
|
|
193
|
+
settings = self.settings()
|
|
194
|
+
public_base_url = _clean(settings.get("public_base_url"))
|
|
195
|
+
if public_base_url:
|
|
196
|
+
return f"{public_base_url.rstrip('/')}/{_safe_relative_path(rel)}"
|
|
197
|
+
return f"{(base_url or config.base_url).rstrip('/')}/images/{_safe_relative_path(rel)}"
|
|
198
|
+
|
|
199
|
+
def make_relative_path(self, image_data: bytes) -> str:
|
|
200
|
+
file_hash = hashlib.md5(image_data).hexdigest()
|
|
201
|
+
filename = f"{int(time.time())}_{file_hash}.png"
|
|
202
|
+
relative_dir = Path(time.strftime("%Y"), time.strftime("%m"), time.strftime("%d"))
|
|
203
|
+
return f"{relative_dir.as_posix()}/{filename}"
|
|
204
|
+
|
|
205
|
+
def save(self, image_data: bytes, base_url: str | None = None) -> StoredImage:
|
|
206
|
+
config.cleanup_old_images()
|
|
207
|
+
rel = self.make_relative_path(image_data)
|
|
208
|
+
mode = self.mode()
|
|
209
|
+
if mode not in {"local", "webdav", "both"}:
|
|
210
|
+
mode = "local"
|
|
211
|
+
stored_local = False
|
|
212
|
+
stored_webdav = False
|
|
213
|
+
remote_url = ""
|
|
214
|
+
|
|
215
|
+
if mode in {"local", "both"}:
|
|
216
|
+
path = _local_image_path(rel)
|
|
217
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
218
|
+
path.write_bytes(image_data)
|
|
219
|
+
stored_local = True
|
|
220
|
+
|
|
221
|
+
if mode in {"webdav", "both"}:
|
|
222
|
+
remote_url = WebDAVClient(self.settings()).put(rel, image_data)
|
|
223
|
+
stored_webdav = True
|
|
224
|
+
|
|
225
|
+
dimensions = _image_dimensions(image_data)
|
|
226
|
+
item = {
|
|
227
|
+
"rel": rel,
|
|
228
|
+
"path": rel,
|
|
229
|
+
"name": Path(rel).name,
|
|
230
|
+
"date": "-".join(rel.split("/")[:3]),
|
|
231
|
+
"size": len(image_data),
|
|
232
|
+
"created_at": _now_iso(),
|
|
233
|
+
"storage": "both" if stored_local and stored_webdav else ("webdav" if stored_webdav else "local"),
|
|
234
|
+
"local": stored_local,
|
|
235
|
+
"webdav": stored_webdav,
|
|
236
|
+
"remote_url": remote_url,
|
|
237
|
+
}
|
|
238
|
+
if dimensions:
|
|
239
|
+
item["width"], item["height"] = dimensions
|
|
240
|
+
with self._index_lock:
|
|
241
|
+
items = self._load_clean_index()
|
|
242
|
+
items[rel] = item
|
|
243
|
+
self._save_index(items)
|
|
244
|
+
return StoredImage(rel=rel, url=self._public_url(rel, base_url), storage=str(item["storage"]), size=len(image_data))
|
|
245
|
+
|
|
246
|
+
def get_bytes(self, rel: str) -> bytes:
|
|
247
|
+
safe_rel = _safe_relative_path(rel)
|
|
248
|
+
if not _is_image_rel(safe_rel):
|
|
249
|
+
raise HTTPException(status_code=404, detail="image not found")
|
|
250
|
+
path = _local_image_path(safe_rel)
|
|
251
|
+
if path.is_file():
|
|
252
|
+
return path.read_bytes()
|
|
253
|
+
item = self._load_clean_index().get(safe_rel, {})
|
|
254
|
+
if item.get("webdav"):
|
|
255
|
+
return WebDAVClient(self.settings()).get(safe_rel)
|
|
256
|
+
raise HTTPException(status_code=404, detail="image not found")
|
|
257
|
+
|
|
258
|
+
def exists(self, rel: str) -> bool:
|
|
259
|
+
safe_rel = _safe_relative_path(rel)
|
|
260
|
+
if not _is_image_rel(safe_rel):
|
|
261
|
+
return False
|
|
262
|
+
if _local_image_path(safe_rel).is_file():
|
|
263
|
+
return True
|
|
264
|
+
item = self._load_clean_index().get(safe_rel, {})
|
|
265
|
+
return bool(item.get("webdav"))
|
|
266
|
+
|
|
267
|
+
def has_local(self, rel: str) -> bool:
|
|
268
|
+
safe_rel = _safe_relative_path(rel)
|
|
269
|
+
return _is_image_rel(safe_rel) and _local_image_path(safe_rel).is_file()
|
|
270
|
+
|
|
271
|
+
def list_items(self, base_url: str, start_date: str = "", end_date: str = "") -> list[dict[str, object]]:
|
|
272
|
+
with self._index_lock:
|
|
273
|
+
indexed = self._load_clean_index()
|
|
274
|
+
root = config.images_dir
|
|
275
|
+
changed = False
|
|
276
|
+
for path in root.rglob("*"):
|
|
277
|
+
if not path.is_file() or not _is_image_rel(path.name):
|
|
278
|
+
continue
|
|
279
|
+
rel = path.relative_to(root).as_posix()
|
|
280
|
+
if rel in indexed:
|
|
281
|
+
continue
|
|
282
|
+
dimensions = None
|
|
283
|
+
try:
|
|
284
|
+
dimensions = _image_dimensions(path.read_bytes())
|
|
285
|
+
except Exception:
|
|
286
|
+
dimensions = None
|
|
287
|
+
indexed[rel] = {
|
|
288
|
+
"rel": rel,
|
|
289
|
+
"path": rel,
|
|
290
|
+
"name": path.name,
|
|
291
|
+
"date": "-".join(rel.split("/")[:3]) if len(rel.split("/")) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d"),
|
|
292
|
+
"size": path.stat().st_size,
|
|
293
|
+
"created_at": datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
|
|
294
|
+
"storage": "local",
|
|
295
|
+
"local": True,
|
|
296
|
+
"webdav": False,
|
|
297
|
+
**({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}),
|
|
298
|
+
}
|
|
299
|
+
changed = True
|
|
300
|
+
|
|
301
|
+
items: list[dict[str, object]] = []
|
|
302
|
+
for rel, item in list(indexed.items()):
|
|
303
|
+
if not _is_image_rel(rel):
|
|
304
|
+
indexed.pop(rel, None)
|
|
305
|
+
changed = True
|
|
306
|
+
continue
|
|
307
|
+
local = _local_image_path(rel).is_file()
|
|
308
|
+
webdav = bool(item.get("webdav"))
|
|
309
|
+
if not local and not webdav:
|
|
310
|
+
indexed.pop(rel, None)
|
|
311
|
+
changed = True
|
|
312
|
+
continue
|
|
313
|
+
storage = "both" if local and webdav else ("webdav" if webdav else "local")
|
|
314
|
+
if item.get("local") != local or item.get("storage") != storage:
|
|
315
|
+
item = {
|
|
316
|
+
**item,
|
|
317
|
+
"local": local,
|
|
318
|
+
"storage": storage,
|
|
319
|
+
}
|
|
320
|
+
indexed[rel] = item
|
|
321
|
+
changed = True
|
|
322
|
+
day = str(item.get("date") or "")
|
|
323
|
+
if start_date and day < start_date:
|
|
324
|
+
continue
|
|
325
|
+
if end_date and day > end_date:
|
|
326
|
+
continue
|
|
327
|
+
items.append({
|
|
328
|
+
**item,
|
|
329
|
+
"rel": rel,
|
|
330
|
+
"path": rel,
|
|
331
|
+
"url": self._public_url(rel, base_url),
|
|
332
|
+
})
|
|
333
|
+
if changed:
|
|
334
|
+
self._save_index(indexed)
|
|
335
|
+
items.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
|
|
336
|
+
return items
|
|
337
|
+
|
|
338
|
+
def delete(self, rel: str) -> bool:
|
|
339
|
+
safe_rel = _safe_relative_path(rel)
|
|
340
|
+
removed = False
|
|
341
|
+
path = _local_image_path(safe_rel)
|
|
342
|
+
if path.is_file():
|
|
343
|
+
path.unlink()
|
|
344
|
+
removed = True
|
|
345
|
+
with self._index_lock:
|
|
346
|
+
items = self._load_clean_index()
|
|
347
|
+
item = items.get(safe_rel, {})
|
|
348
|
+
if item.get("webdav"):
|
|
349
|
+
try:
|
|
350
|
+
removed = WebDAVClient(self.settings()).delete(safe_rel) or removed
|
|
351
|
+
except ImageStorageError:
|
|
352
|
+
if not removed:
|
|
353
|
+
raise
|
|
354
|
+
if safe_rel in items:
|
|
355
|
+
items.pop(safe_rel, None)
|
|
356
|
+
self._save_index(items)
|
|
357
|
+
return removed
|
|
358
|
+
|
|
359
|
+
def sync_all(self) -> dict[str, int]:
|
|
360
|
+
settings = self.settings()
|
|
361
|
+
if self.mode() not in {"webdav", "both"}:
|
|
362
|
+
raise ImageStorageError("WebDAV 图片存储未启用")
|
|
363
|
+
uploaded = 0
|
|
364
|
+
skipped = 0
|
|
365
|
+
failed = 0
|
|
366
|
+
with self._index_lock:
|
|
367
|
+
items = self._load_clean_index()
|
|
368
|
+
client = WebDAVClient(settings)
|
|
369
|
+
for path in sorted(config.images_dir.rglob("*")):
|
|
370
|
+
if not path.is_file() or not _is_image_rel(path.name):
|
|
371
|
+
continue
|
|
372
|
+
rel = path.relative_to(config.images_dir).as_posix()
|
|
373
|
+
item = items.get(rel, {})
|
|
374
|
+
if item.get("webdav"):
|
|
375
|
+
skipped += 1
|
|
376
|
+
continue
|
|
377
|
+
try:
|
|
378
|
+
payload = path.read_bytes()
|
|
379
|
+
remote_url = client.put(rel, payload)
|
|
380
|
+
dimensions = _image_dimensions(payload)
|
|
381
|
+
items[rel] = {
|
|
382
|
+
**item,
|
|
383
|
+
"rel": rel,
|
|
384
|
+
"path": rel,
|
|
385
|
+
"name": path.name,
|
|
386
|
+
"date": "-".join(rel.split("/")[:3]) if len(rel.split("/")) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d"),
|
|
387
|
+
"size": len(payload),
|
|
388
|
+
"created_at": str(item.get("created_at") or datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")),
|
|
389
|
+
"storage": "both",
|
|
390
|
+
"local": True,
|
|
391
|
+
"webdav": True,
|
|
392
|
+
"remote_url": remote_url,
|
|
393
|
+
**({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}),
|
|
394
|
+
}
|
|
395
|
+
uploaded += 1
|
|
396
|
+
except Exception:
|
|
397
|
+
failed += 1
|
|
398
|
+
self._save_index(items)
|
|
399
|
+
return {"uploaded": uploaded, "skipped": skipped, "failed": failed}
|
|
400
|
+
|
|
401
|
+
def test_webdav(self) -> dict[str, object]:
|
|
402
|
+
return WebDAVClient(self.settings()).test()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
image_storage_service = ImageStorageService()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from services.config import DATA_DIR
|
|
7
|
+
|
|
8
|
+
TAGS_FILE = DATA_DIR / "image_tags.json"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _ensure_file() -> None:
|
|
12
|
+
TAGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
if not TAGS_FILE.exists():
|
|
14
|
+
TAGS_FILE.write_text("{}", encoding="utf-8")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_tags() -> dict[str, list[str]]:
|
|
18
|
+
_ensure_file()
|
|
19
|
+
try:
|
|
20
|
+
data = json.loads(TAGS_FILE.read_text(encoding="utf-8"))
|
|
21
|
+
except Exception:
|
|
22
|
+
return {}
|
|
23
|
+
return data if isinstance(data, dict) else {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def save_tags(data: dict[str, list[str]]) -> None:
|
|
27
|
+
_ensure_file()
|
|
28
|
+
TAGS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_tags(image_rel: str) -> list[str]:
|
|
32
|
+
return load_tags().get(image_rel, [])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def set_tags(image_rel: str, tags: list[str]) -> list[str]:
|
|
36
|
+
data = load_tags()
|
|
37
|
+
cleaned = list(dict.fromkeys(t.strip() for t in tags if t.strip()))
|
|
38
|
+
if cleaned:
|
|
39
|
+
data[image_rel] = cleaned
|
|
40
|
+
else:
|
|
41
|
+
data.pop(image_rel, None)
|
|
42
|
+
save_tags(data)
|
|
43
|
+
return cleaned
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def remove_tags(image_rel: str) -> None:
|
|
47
|
+
data = load_tags()
|
|
48
|
+
if data.pop(image_rel, None) is not None:
|
|
49
|
+
save_tags(data)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def delete_tag(tag: str) -> int:
|
|
53
|
+
"""从所有图片中删除指定标签,返回受影响的图片数。"""
|
|
54
|
+
data = load_tags()
|
|
55
|
+
count = 0
|
|
56
|
+
for rel in list(data):
|
|
57
|
+
if tag in data[rel]:
|
|
58
|
+
data[rel] = [t for t in data[rel] if t != tag]
|
|
59
|
+
if not data[rel]:
|
|
60
|
+
del data[rel]
|
|
61
|
+
count += 1
|
|
62
|
+
if count > 0:
|
|
63
|
+
save_tags(data)
|
|
64
|
+
return count
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_all_tags() -> list[str]:
|
|
68
|
+
data = load_tags()
|
|
69
|
+
seen: set[str] = set()
|
|
70
|
+
result: list[str] = []
|
|
71
|
+
for tags in data.values():
|
|
72
|
+
for t in tags:
|
|
73
|
+
if t not in seen:
|
|
74
|
+
seen.add(t)
|
|
75
|
+
result.append(t)
|
|
76
|
+
return result
|