dsh-plugin-image-workbench 1.1.5 → 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.
Files changed (172) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +5 -3
  3. package/README.md +7 -3
  4. package/client.js +241 -241
  5. package/cordis.patch.yml +14 -14
  6. package/host.js +51 -0
  7. package/install.mjs +333 -387
  8. package/package.json +3 -2
  9. package/scripts/fetch-style-images.mjs +104 -0
  10. package/scripts/vendor-snapshot.py +154 -154
  11. package/server/access.py +26 -26
  12. package/server/app.py +1329 -1329
  13. package/server/catalog.py +147 -147
  14. package/server/composer.py +391 -391
  15. package/server/drafts.py +158 -158
  16. package/server/history.py +575 -575
  17. package/server/imaging.py +172 -172
  18. package/server/pool.py +1335 -1335
  19. package/server/pool_admin.py +921 -921
  20. package/server/pool_service.py +1073 -1073
  21. package/server/requirements.txt +6 -6
  22. package/server/smoke_check.py +402 -402
  23. package/server/static/access.html +202 -202
  24. package/vendor/chatgpt2api/source/.env.example +82 -82
  25. package/vendor/chatgpt2api/source/.python-version +1 -1
  26. package/vendor/chatgpt2api/source/LICENSE +21 -21
  27. package/vendor/chatgpt2api/source/README.md +391 -391
  28. package/vendor/chatgpt2api/source/VERSION +1 -1
  29. package/vendor/chatgpt2api/source/api/__init__.py +2 -2
  30. package/vendor/chatgpt2api/source/api/accounts.py +537 -537
  31. package/vendor/chatgpt2api/source/api/ai.py +217 -217
  32. package/vendor/chatgpt2api/source/api/app.py +62 -62
  33. package/vendor/chatgpt2api/source/api/errors.py +46 -46
  34. package/vendor/chatgpt2api/source/api/image_inputs.py +309 -309
  35. package/vendor/chatgpt2api/source/api/image_tasks.py +120 -120
  36. package/vendor/chatgpt2api/source/api/support.py +134 -134
  37. package/vendor/chatgpt2api/source/api/system.py +365 -365
  38. package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -50
  39. package/vendor/chatgpt2api/source/config.default.json +108 -108
  40. package/vendor/chatgpt2api/source/main.py +9 -9
  41. package/vendor/chatgpt2api/source/pyproject.toml +27 -27
  42. package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -186
  43. package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -163
  44. package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -13
  45. package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -385
  46. package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -129
  47. package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -92
  48. package/vendor/chatgpt2api/source/services/account_service.py +1757 -1757
  49. package/vendor/chatgpt2api/source/services/auth_service.py +253 -253
  50. package/vendor/chatgpt2api/source/services/backup_service.py +671 -671
  51. package/vendor/chatgpt2api/source/services/config.py +667 -667
  52. package/vendor/chatgpt2api/source/services/content_filter.py +242 -242
  53. package/vendor/chatgpt2api/source/services/cpa_service.py +315 -315
  54. package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -260
  55. package/vendor/chatgpt2api/source/services/image_service.py +376 -376
  56. package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -405
  57. package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -76
  58. package/vendor/chatgpt2api/source/services/image_task_service.py +550 -550
  59. package/vendor/chatgpt2api/source/services/log_service.py +337 -337
  60. package/vendor/chatgpt2api/source/services/model_service.py +182 -182
  61. package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -267
  62. package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -2763
  63. package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -32
  64. package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -2
  65. package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -307
  66. package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -238
  67. package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -1644
  68. package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -124
  69. package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -19
  70. package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -316
  71. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -86
  72. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -42
  73. package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -53
  74. package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -453
  75. package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -160
  76. package/vendor/chatgpt2api/source/services/proxy_service.py +648 -648
  77. package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -5
  78. package/vendor/chatgpt2api/source/services/storage/base.py +38 -38
  79. package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -194
  80. package/vendor/chatgpt2api/source/services/storage/factory.py +104 -104
  81. package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -183
  82. package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -95
  83. package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -531
  84. package/vendor/chatgpt2api/source/utils/helper.py +469 -469
  85. package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -346
  86. package/vendor/chatgpt2api/source/utils/log.py +110 -110
  87. package/vendor/chatgpt2api/source/utils/pkce.py +19 -19
  88. package/vendor/chatgpt2api/source/utils/pow.py +207 -207
  89. package/vendor/chatgpt2api/source/utils/sentinel.py +159 -159
  90. package/vendor/chatgpt2api/source/utils/turnstile.py +184 -184
  91. package/vendor/chatgpt2api/source/uv.lock +904 -904
  92. package/vendor/chatgpt2api/source/web/bun.lock +1501 -1501
  93. package/vendor/chatgpt2api/source/web/components.json +24 -24
  94. package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -17
  95. package/vendor/chatgpt2api/source/web/next.config.ts +40 -40
  96. package/vendor/chatgpt2api/source/web/package.json +58 -58
  97. package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -5
  98. package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -925
  99. package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -1307
  100. package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -224
  101. package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -444
  102. package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -9
  103. package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -9
  104. package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -172
  105. package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -169
  106. package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -39
  107. package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -59
  108. package/vendor/chatgpt2api/source/web/src/app/globals.css +230 -230
  109. package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -542
  110. package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -554
  111. package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -176
  112. package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -1799
  113. package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -732
  114. package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -50
  115. package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -101
  116. package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -356
  117. package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -30
  118. package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -341
  119. package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -447
  120. package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -71
  121. package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -491
  122. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -108
  123. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -169
  124. package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -185
  125. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -379
  126. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -127
  127. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -200
  128. package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -12
  129. package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -886
  130. package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -81
  131. package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -422
  132. package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -135
  133. package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -1069
  134. package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -49
  135. package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -24
  136. package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -434
  137. package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -46
  138. package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -18
  139. package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -14
  140. package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -263
  141. package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -256
  142. package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -41
  143. package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -59
  144. package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -46
  145. package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -61
  146. package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -27
  147. package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -267
  148. package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -132
  149. package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -13
  150. package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -19
  151. package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -24
  152. package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -149
  153. package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -38
  154. package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -175
  155. package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -143
  156. package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -29
  157. package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -91
  158. package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -269
  159. package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -18
  160. package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -127
  161. package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -6
  162. package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -85
  163. package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -926
  164. package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -26
  165. package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -25
  166. package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -93
  167. package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -89
  168. package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -6
  169. package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -112
  170. package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -33
  171. package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -318
  172. package/vendor/chatgpt2api/source/web/tsconfig.json +42 -42
@@ -1,405 +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()
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()