dsh-plugin-image-workbench 1.1.5 → 1.1.7

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