diff --git a/README.zh-CN.md b/README.zh-CN.md
index 640baa2..ac1d6ac 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -245,6 +245,7 @@ ALLOW_UNAUTHENTICATED=true .venv/bin/granian --interface asgi backend.app.main:a
| `PROMPT_OPTIMIZER_*` | 可选提示词优化器配置。 |
| `AI_ASSISTANT_*` | AI Assistant 默认启用;如需关闭,设置 `AI_ASSISTANT_ENABLED=false`。API URL、密钥、文本模型、超时、路径和 host allowlist 复用 `PROMPT_OPTIMIZER_*`。`AI_ASSISTANT_MAX_CONCURRENCY` 限制并发上游 Assistant 调用,`AI_ASSISTANT_BATCH_MAX_IMAGES` 限制单次 Gallery AI 批量分析图片数。 |
| `R2_*` | 可选 Cloudflare R2 Gallery 备份配置;自定义 endpoint host 需要配置 `R2_ENDPOINT_HOST_ALLOWLIST`。 |
+| `NODEIMAGE_API_KEY` | 可选 NodeImage API key,用于服务端 Gallery 图片上传;也可在 Web Settings 中配置 env ref。 |
| `PUBLIC_ORIGIN` / `ALLOWED_HOSTS` | 反向代理 Host/CSRF 加固。 |
| `ENABLE_NGINX_ACCEL_REDIRECT` / `PUBLIC_IMAGE_BASE_URL` / `PUBLIC_THUMBNAIL_BASE_URL` | 可选 nginx/CDN 图片字节服务行为。 |
| `GRANIAN_*` | 生产运行时进程、线程和静态资源调优。 |
@@ -262,10 +263,10 @@ Overall Config 会把 override 持久化到 SQLite。部分配置可热更新;
3. 打开 Settings。
4. 创建或选择 API 预设。
5. 设置 API base URL、API path、模型、response format 和 API key/env ref。
-6. 按需配置 SOCKS5 代理、webhook、提示词优化器、AI Assistant、R2 备份或 Overall Config override。
+6. 按需配置 SOCKS5 代理、webhook、提示词优化器、AI Assistant、R2 备份、NodeImage 上传或 Overall Config override。
7. 保存预设,必要时执行健康检查。
8. 输入 prompt 生成图片,或上传/选择源图执行编辑。
-9. 在 Gallery 中复用参数、筛选、收藏、批量操作、导入导出或执行 R2 同步。
+9. 在 Gallery 中复用参数、筛选、收藏、批量操作、导入导出、执行 R2 同步或上传到 NodeImage。
## 支持的上游路径
diff --git a/README.zh-TW.md b/README.zh-TW.md
index 969f932..085146e 100644
--- a/README.zh-TW.md
+++ b/README.zh-TW.md
@@ -245,6 +245,7 @@ ALLOW_UNAUTHENTICATED=true .venv/bin/granian --interface asgi backend.app.main:a
| `PROMPT_OPTIMIZER_*` | 選用的伺服器端提示詞最佳化器設定。 |
| `AI_ASSISTANT_*` | AI Assistant 預設啟用;可設 `AI_ASSISTANT_ENABLED=false` 關閉。API URL、密鑰、文字模型、逾時、路徑與 Host 允許清單沿用 `PROMPT_OPTIMIZER_*`。`AI_ASSISTANT_MAX_CONCURRENCY` 限制同時進行的上游 Assistant 呼叫,`AI_ASSISTANT_BATCH_MAX_IMAGES` 限制單次 Gallery AI 批次處理的圖片數。 |
| `R2_*` | 選用的 Cloudflare R2 Gallery 備份設定;自訂 Endpoint Host 須設定 `R2_ENDPOINT_HOST_ALLOWLIST`。 |
+| `NODEIMAGE_API_KEY` | 選用的 NodeImage API key,用於從伺服器上傳 Gallery 圖片;也可在 Web Settings 中設定 env ref。 |
| `PUBLIC_ORIGIN` / `ALLOWED_HOSTS` | Reverse Proxy Host/CSRF 強化。 |
| `ENABLE_NGINX_ACCEL_REDIRECT` / `PUBLIC_IMAGE_BASE_URL` / `PUBLIC_THUMBNAIL_BASE_URL` | 選用的 nginx/CDN 圖片位元組傳送行為。 |
| `GRANIAN_*` | 正式環境程序、執行緒與靜態資源調校。 |
@@ -262,10 +263,10 @@ Overall Config 會將 Override 持久化至 SQLite。部分設定可熱更新;
3. 開啟 Settings。
4. 建立或選取 API 預設。
5. 設定 API base URL、API path、模型、response format 與 API key/env ref。
-6. 視需要設定 SOCKS5 Proxy、webhook、提示詞最佳化器、AI Assistant、R2 備份或 Overall Config Override。
+6. 視需要設定 SOCKS5 Proxy、webhook、提示詞最佳化器、AI Assistant、R2 備份、NodeImage 上傳或 Overall Config Override。
7. 儲存預設,必要時執行健康檢查。
8. 輸入 Prompt 生成圖片,或上傳/選取來源圖片進行編輯。
-9. 在 Gallery 中沿用參數、篩選、收藏、批次操作、匯入/匯出,或執行 R2 同步。
+9. 在 Gallery 中沿用參數、篩選、收藏、批次操作、匯入/匯出、執行 R2 同步,或上傳至 NodeImage。
## 支援的上游路徑
diff --git a/VERSION b/VERSION
index 66d62a8..c432e90 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-v1.4.1
+v1.4.2
diff --git a/backend/app/api/app_state.py b/backend/app/api/app_state.py
index efec9ab..ab767c1 100644
--- a/backend/app/api/app_state.py
+++ b/backend/app/api/app_state.py
@@ -239,7 +239,14 @@ async def lifespan(app: FastAPI):
job_scheduler.run_image_unit_dispatcher(app.state.worker_id)
)
app.state.gallery_export_lock = asyncio.Lock()
- app.state.gallery_job_subscribers = {"export": {}, "export_direct": {}, "sync": {}, "import": {}, "ai_analyze": {}}
+ app.state.gallery_job_subscribers = {
+ "export": {},
+ "export_direct": {},
+ "sync": {},
+ "import": {},
+ "ai_analyze": {},
+ "nodeimage_upload": {},
+ }
app.state.gallery_job_sse_poller_tasks = {}
from ..services import gallery_jobs, gallery_maintenance
from ..services import assistant_batch
@@ -256,6 +263,9 @@ async def lifespan(app: FastAPI):
app.state.gallery_import_dispatcher_task = asyncio.create_task(
gallery_jobs.run_gallery_import_dispatcher(app.state.worker_id)
)
+ app.state.gallery_nodeimage_upload_dispatcher_task = asyncio.create_task(
+ gallery_jobs.run_gallery_nodeimage_upload_dispatcher(app.state.worker_id)
+ )
app.state.gallery_export_gc_task = asyncio.create_task(
gallery_maintenance.gc_gallery_export_jobs(app.state.worker_id)
)
@@ -311,6 +321,13 @@ async def lifespan(app: FastAPI):
gallery_import_dispatcher_task = getattr(app.state, "gallery_import_dispatcher_task", None)
if gallery_import_dispatcher_task and not gallery_import_dispatcher_task.done():
gallery_import_dispatcher_task.cancel()
+ gallery_nodeimage_upload_dispatcher_task = getattr(
+ app.state,
+ "gallery_nodeimage_upload_dispatcher_task",
+ None,
+ )
+ if gallery_nodeimage_upload_dispatcher_task and not gallery_nodeimage_upload_dispatcher_task.done():
+ gallery_nodeimage_upload_dispatcher_task.cancel()
thumbnail_dispatcher_task = getattr(app.state, "thumbnail_dispatcher_task", None)
if thumbnail_dispatcher_task and not thumbnail_dispatcher_task.done():
thumbnail_dispatcher_task.cancel()
@@ -363,6 +380,7 @@ async def lifespan(app: FastAPI):
gallery_export_dispatcher_task,
gallery_sync_dispatcher_task,
gallery_import_dispatcher_task,
+ gallery_nodeimage_upload_dispatcher_task,
gc_task,
file_gc_task,
scheduled_sync_task,
diff --git a/backend/app/api/presets.py b/backend/app/api/presets.py
index 4eed9f2..ffde66e 100644
--- a/backend/app/api/presets.py
+++ b/backend/app/api/presets.py
@@ -18,6 +18,10 @@ from ..core.validators import (
normalize_webhook_url,
resolve_env_var_ref,
)
+from ..integrations.nodeimage.client import (
+ NodeImageConfigurationError,
+ resolve_nodeimage_settings,
+)
from ..repositories.settings import (
load_ai_assistant_settings,
load_prompt_optimizer_settings,
@@ -83,7 +87,7 @@ def api_key_response_fields(api_key: str) -> dict:
"api_key_env_var": env_var,
"api_key_secret_id": None,
}
- if value:
+ if value in secrets.configured_secret_ids():
return {
"api_key_masked": value,
"has_api_key": True,
@@ -91,6 +95,14 @@ def api_key_response_fields(api_key: str) -> dict:
"api_key_env_var": None,
"api_key_secret_id": value,
}
+ if value:
+ return {
+ "api_key_masked": mask_key(value),
+ "has_api_key": True,
+ "api_key_source": "stored",
+ "api_key_env_var": None,
+ "api_key_secret_id": None,
+ }
return {
"api_key_masked": "***",
"has_api_key": False,
@@ -100,19 +112,6 @@ def api_key_response_fields(api_key: str) -> dict:
}
-def nodeimage_api_key_response_fields(api_key: str) -> dict:
- value = str(api_key or "").strip()
- if value and not get_api_key_env_var(value) and value not in secrets.configured_secret_ids():
- return {
- "api_key_masked": mask_key(value),
- "has_api_key": True,
- "api_key_source": "stored",
- "api_key_env_var": None,
- "api_key_secret_id": None,
- }
- return api_key_response_fields(value)
-
-
def secret_response_fields(value: str, prefix: str) -> dict:
fields = api_key_response_fields(value)
return {
@@ -519,9 +518,15 @@ def build_r2_backup_settings_response(raw: dict | None) -> R2BackupSettingsRespo
def build_nodeimage_settings_response(raw: dict | None) -> NodeImageSettingsResponse:
settings = load_nodeimage_settings() if raw is None else raw
+ try:
+ resolve_nodeimage_settings(settings, require_enabled=False)
+ api_key_resolvable = True
+ except NodeImageConfigurationError:
+ api_key_resolvable = False
return NodeImageSettingsResponse(
enabled=bool(settings.get("enabled", False)),
- **nodeimage_api_key_response_fields(str(settings.get("api_key") or "")),
+ api_key_resolvable=api_key_resolvable,
+ **api_key_response_fields(str(settings.get("api_key") or "")),
)
diff --git a/backend/app/api/routers/edits.py b/backend/app/api/routers/edits.py
index 60403d6..290fc1d 100644
--- a/backend/app/api/routers/edits.py
+++ b/backend/app/api/routers/edits.py
@@ -1,5 +1,4 @@
import asyncio
-import mimetypes
import os
import tempfile
from pathlib import Path
@@ -15,14 +14,17 @@ from ...services.job_queue import (
queue_edit_job,
)
from ..uploads import (
- IMAGE_UPLOAD_CONTENT_TYPES,
is_image_upload,
resolve_upload_content_type,
validate_upload_image_bytes,
)
from ...core import settings as config
from ...repositories.gallery.queries import get_gallery_entry
-from ...repositories.image_files import safe_image_path, validate_image_file
+from ...repositories.image_files import (
+ image_content_type_for_filename,
+ safe_image_path,
+ validate_image_file,
+)
from ...schemas.generation import EditRequest, GenerateJobResponse
from ...services.blocking import run_db_operation, run_image_operation
@@ -251,10 +253,7 @@ async def read_gallery_edit_source(image_id: str) -> EditImageSource:
if not path or not path.exists():
raise HTTPException(status_code=404, detail="Gallery image file not found")
- image_content_type = (
- mimetypes.guess_type(path.name)[0]
- or IMAGE_UPLOAD_CONTENT_TYPES.get(path.suffix.lower(), "application/octet-stream")
- )
+ image_content_type = image_content_type_for_filename(path.name)
return await run_image_operation(
copy_edit_source_file_to_temp,
diff --git a/backend/app/api/routers/gallery_batch.py b/backend/app/api/routers/gallery_batch.py
index 8e52e19..02a99d7 100644
--- a/backend/app/api/routers/gallery_batch.py
+++ b/backend/app/api/routers/gallery_batch.py
@@ -2,7 +2,6 @@ import asyncio
import hashlib
import inspect
import logging
-import mimetypes
import os
import time
import uuid
@@ -83,8 +82,7 @@ from ...repositories.image_files import (
safe_image_path,
safe_thumbnail_path,
)
-from ...repositories.settings import load_r2_backup_settings
-from ...repositories.settings import load_nodeimage_settings
+from ...repositories.settings import load_nodeimage_settings, load_r2_backup_settings
from ...repositories.thumbnail_jobs import (
THUMBNAIL_JOB_LEASE_SECONDS,
claim_next_thumbnail_job,
@@ -109,20 +107,11 @@ from ...schemas.gallery import (
GallerySyncRequest,
GallerySyncJobStatus,
)
-from ...schemas.nodeimage import (
- NodeImageBatchUploadItem,
- NodeImageBatchUploadResponse,
-)
-from ...integrations.nodeimage.client import (
- NodeImageAuthError,
- NodeImageConfigurationError,
- NodeImageUploadError,
- resolve_nodeimage_settings,
- upload_image_bytes,
-)
+from ...services.gallery_maintenance import kick_gallery_job_dispatchers
from ...services.gallery_common import *
from ...services.gallery_jobs import *
from ...services.gallery_maintenance import kick_gallery_file_gc
+from ...schemas.nodeimage import NodeImageUploadJobStatus
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -293,7 +282,8 @@ async def download_gallery_batch(req: GalleryBatchRequest):
@router.post(
"/api/gallery/batch/nodeimage-upload",
- response_model=NodeImageBatchUploadResponse,
+ response_model=NodeImageUploadJobStatus,
+ status_code=202,
)
async def upload_gallery_batch_to_nodeimage(req: GalleryBatchRequest):
ids, entries, requested_count, missing_ids = await _resolve_nodeimage_batch_ids(req)
@@ -301,120 +291,19 @@ async def upload_gallery_batch_to_nodeimage(req: GalleryBatchRequest):
raise HTTPException(status_code=404, detail="Gallery entries not found")
try:
- effective = resolve_nodeimage_settings(
+ resolve_nodeimage_settings(
await asyncio.to_thread(load_nodeimage_settings)
)
except NodeImageConfigurationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
- auth_error = "NodeImage API key was rejected."
- auth_failed = asyncio.Event()
- semaphore = asyncio.Semaphore(4)
-
- def error_item(entry: GalleryEntry, error: str) -> NodeImageBatchUploadItem:
- return NodeImageBatchUploadItem(
- image_id=entry.id,
- filename=entry.filename,
- status="error",
- error=error,
- )
-
- async def read_entry(
- entry: GalleryEntry,
- ) -> tuple[Path | None, bytes | None, NodeImageBatchUploadItem | None]:
- path = safe_image_path(entry.filename)
- if not path:
- return None, None, error_item(entry, "Image file not found")
- try:
- if not path.exists():
- return None, None, error_item(entry, "Image file not found")
- return path, await asyncio.to_thread(path.read_bytes), None
- except OSError:
- return path, None, error_item(entry, "Image file could not be read")
-
- async def upload_entry(
- entry: GalleryEntry,
- *,
- prepared: tuple[Path, bytes] | None = None,
- ) -> NodeImageBatchUploadItem:
- async with semaphore:
- if auth_failed.is_set():
- return error_item(entry, auth_error)
- try:
- if prepared is None:
- path, image_bytes, read_error = await read_entry(entry)
- if read_error:
- return read_error
- assert path is not None and image_bytes is not None
- else:
- path, image_bytes = prepared
- if auth_failed.is_set():
- return error_item(entry, auth_error)
- result = await upload_image_bytes(image_bytes, path.name, effective)
- return NodeImageBatchUploadItem(
- image_id=entry.id,
- filename=entry.filename,
- status="ok",
- url=result.url,
- markdown=result.markdown,
- )
- except NodeImageAuthError:
- auth_failed.set()
- return error_item(entry, auth_error)
- except NodeImageUploadError as exc:
- return error_item(entry, str(exc))
- except Exception as exc:
- logger.error(
- "Unexpected NodeImage batch upload failure for gallery entry %s (%s)",
- entry.id,
- type(exc).__name__,
- )
- return error_item(entry, "Unexpected upload failure")
-
- result_by_id: dict[str, NodeImageBatchUploadItem] = {}
- probe_entry: GalleryEntry | None = None
- probe_file: tuple[Path, bytes] | None = None
- for entry in entries:
- path, image_bytes, read_error = await read_entry(entry)
- if read_error:
- result_by_id[entry.id] = read_error
- continue
- assert path is not None and image_bytes is not None
- probe_entry = entry
- probe_file = (path, image_bytes)
- break
-
- if probe_entry is not None and probe_file is not None:
- probe_result = await upload_entry(probe_entry, prepared=probe_file)
- result_by_id[probe_entry.id] = probe_result
- remaining_entries = [
- entry for entry in entries if entry.id not in result_by_id
- ]
- if auth_failed.is_set():
- for entry in remaining_entries:
- _path, _image_bytes, read_error = await read_entry(entry)
- result_by_id[entry.id] = read_error or error_item(entry, auth_error)
- else:
- uploaded_items = await asyncio.gather(
- *(upload_entry(entry) for entry in remaining_entries)
- )
- result_by_id.update({item.image_id: item for item in uploaded_items})
-
- for missing_id in missing_ids:
- result_by_id[missing_id] = NodeImageBatchUploadItem(
- image_id=missing_id,
- filename=None,
- status="error",
- error="Gallery entry not found",
- )
- results = [result_by_id[image_id] for image_id in ids if image_id in result_by_id]
- uploaded_count = sum(item.status == "ok" for item in results)
- return NodeImageBatchUploadResponse(
- requested_count=requested_count,
- uploaded_count=uploaded_count,
- failed_count=len(results) - uploaded_count,
- results=results,
+ job = await _create_reserved_nodeimage_upload_job(
+ ids,
+ requested_count,
+ missing_ids,
)
+ kick_gallery_job_dispatchers()
+ return NodeImageUploadJobStatus(**_nodeimage_upload_payload(job))
@router.delete("/api/gallery", response_model=MessageResponse)
diff --git a/backend/app/api/routers/gallery_queries.py b/backend/app/api/routers/gallery_queries.py
index e83bf2d..8b3498d 100644
--- a/backend/app/api/routers/gallery_queries.py
+++ b/backend/app/api/routers/gallery_queries.py
@@ -2,7 +2,6 @@ import asyncio
import hashlib
import inspect
import logging
-import mimetypes
import os
import time
import uuid
@@ -81,11 +80,11 @@ from ...repositories.gallery.sync_state import (
)
from ...repositories.image_files import (
THUMBNAIL_CONTENT_TYPE,
+ image_content_type_for_filename,
safe_image_path,
safe_thumbnail_path,
)
-from ...repositories.settings import load_r2_backup_settings
-from ...repositories.settings import load_nodeimage_settings
+from ...repositories.settings import load_nodeimage_settings, load_r2_backup_settings
from ...repositories.thumbnail_jobs import (
THUMBNAIL_JOB_LEASE_SECONDS,
claim_next_thumbnail_job,
@@ -119,7 +118,7 @@ from ...integrations.nodeimage.client import (
NodeImageConfigurationError,
NodeImageUploadError,
resolve_nodeimage_settings,
- upload_image_bytes,
+ upload_image_file,
)
from ...services.gallery_common import *
from ...services.gallery_maintenance import kick_thumbnail_dispatcher
@@ -303,22 +302,23 @@ async def upload_gallery_item_to_nodeimage(image_id: str):
if not entry:
raise HTTPException(status_code=404, detail="Gallery entry not found")
- path = await asyncio.to_thread(_resolve_gallery_image_path, entry.filename)
- if not path:
+ path = safe_image_path(entry.filename)
+ if not path or not path.exists():
raise HTTPException(status_code=404, detail="Image file not found")
try:
effective = resolve_nodeimage_settings(
await asyncio.to_thread(load_nodeimage_settings)
)
- image_bytes = await asyncio.to_thread(path.read_bytes)
- result = await upload_image_bytes(image_bytes, path.name, effective)
+ result = await upload_image_file(path, path.name, effective)
except NodeImageConfigurationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except NodeImageAuthError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except NodeImageUploadError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
+ except FileNotFoundError as exc:
+ raise HTTPException(status_code=404, detail="Image file not found") from exc
except OSError as exc:
raise HTTPException(status_code=404, detail="Image file could not be read") from exc
@@ -338,7 +338,7 @@ async def _image_file_response(filename: str, *, download: bool = False):
if not path:
raise HTTPException(status_code=404, detail="Image not found")
- media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
+ media_type = image_content_type_for_filename(path.name)
if config.ENABLE_NGINX_ACCEL_REDIRECT:
return _x_accel_response(
path,
diff --git a/backend/app/api/routers/gallery_tasks.py b/backend/app/api/routers/gallery_tasks.py
index 839734c..b37a3ce 100644
--- a/backend/app/api/routers/gallery_tasks.py
+++ b/backend/app/api/routers/gallery_tasks.py
@@ -2,7 +2,6 @@ import asyncio
import hashlib
import inspect
import logging
-import mimetypes
import os
import uuid
from collections.abc import Iterable, Iterator
@@ -45,6 +44,7 @@ from ...repositories.coordination import (
get_gallery_job,
get_gallery_jobs_updated_at_edges,
list_gallery_job_ids_with_files,
+ request_gallery_job_cancellation,
release_background_lease,
release_import_upload_reservation,
reserve_gallery_job_capacity,
@@ -109,6 +109,7 @@ from ...schemas.gallery import (
GallerySyncRequest,
GallerySyncJobStatus,
)
+from ...schemas.nodeimage import NodeImageUploadJobStatus
from ...services.gallery_common import *
from ...services.gallery_jobs import *
from ...services.gallery_maintenance import (
@@ -349,6 +350,49 @@ async def stream_gallery_import_job(job_id: str, request: Request):
)
+@router.get("/api/gallery/nodeimage-upload-jobs/{job_id}", response_model=NodeImageUploadJobStatus)
+async def get_gallery_nodeimage_upload_job(job_id: str):
+ job = await asyncio.to_thread(get_gallery_job, NODEIMAGE_UPLOAD_JOB_KIND, job_id)
+ if not job:
+ raise HTTPException(status_code=404, detail="NodeImage upload job not found")
+ return NodeImageUploadJobStatus(**_nodeimage_upload_payload(job))
+
+
+@router.get("/api/gallery/nodeimage-upload-jobs/{job_id}/events")
+async def stream_gallery_nodeimage_upload_job(job_id: str, request: Request):
+ return await stream_gallery_job(
+ kind=NODEIMAGE_UPLOAD_JOB_KIND,
+ job_id=job_id,
+ request=request,
+ event_name="nodeimage_upload",
+ terminal_statuses=NODEIMAGE_UPLOAD_TERMINAL_STATUSES,
+ payload_builder=_nodeimage_upload_payload,
+ not_found_detail="NodeImage upload job not found",
+ )
+
+
+async def _cancel_gallery_nodeimage_upload_job(job_id: str):
+ job = await asyncio.to_thread(
+ request_gallery_job_cancellation,
+ NODEIMAGE_UPLOAD_JOB_KIND,
+ job_id,
+ )
+ if not job:
+ raise HTTPException(status_code=404, detail="NodeImage upload job not found")
+ _publish_gallery_job_sse(job)
+ return NodeImageUploadJobStatus(**_nodeimage_upload_payload(job))
+
+
+@router.delete("/api/gallery/nodeimage-upload-jobs/{job_id}", response_model=NodeImageUploadJobStatus)
+async def cancel_gallery_nodeimage_upload_job(job_id: str):
+ return await _cancel_gallery_nodeimage_upload_job(job_id)
+
+
+@router.post("/api/gallery/nodeimage-upload-jobs/{job_id}/cancel", response_model=NodeImageUploadJobStatus)
+async def request_gallery_nodeimage_upload_cancellation(job_id: str):
+ return await _cancel_gallery_nodeimage_upload_job(job_id)
+
+
@router.get("/api/download-all")
async def download_all_images(export_job_id: str | None = Query(default=None)):
gallery_count = await asyncio.to_thread(get_gallery_count)
diff --git a/backend/app/api/routers/settings.py b/backend/app/api/routers/settings.py
index 321d528..8f9a35e 100644
--- a/backend/app/api/routers/settings.py
+++ b/backend/app/api/routers/settings.py
@@ -16,7 +16,9 @@ from ..presets import (
get_active_preset,
get_api_key_env_var,
get_api_presets,
+ get_effective_preset_api_key,
get_exception_message,
+ get_nodeimage_settings,
get_preset_by_id,
get_upstream_socks5_proxy,
get_webhook_url,
@@ -339,8 +341,6 @@ async def update_settings(req: SettingsRequest):
_validate_r2_secret_bindings(updated_r2)
await asyncio.to_thread(save_r2_backup_settings, updated_r2)
if req.nodeimage is not None:
- from ..presets import get_nodeimage_settings
-
current_nodeimage = get_nodeimage_settings()
updated_nodeimage = apply_nodeimage_settings(
current_nodeimage,
diff --git a/backend/app/api/uploads.py b/backend/app/api/uploads.py
index c5532ca..41cd2c0 100644
--- a/backend/app/api/uploads.py
+++ b/backend/app/api/uploads.py
@@ -1,4 +1,3 @@
-import mimetypes
from pathlib import Path
from fastapi import HTTPException, UploadFile
@@ -8,6 +7,7 @@ from ..repositories.image_files import (
IMAGE_EXTENSION_FORMATS,
IMAGE_FILE_EXTENSIONS,
IMAGE_FORMAT_CONTENT_TYPES,
+ image_content_type_for_filename,
validate_image_header_bytes,
)
@@ -23,14 +23,7 @@ def resolve_upload_content_type(upload: UploadFile) -> str:
if upload.content_type and upload.content_type.startswith("image/"):
return upload.content_type
- guessed_type = mimetypes.guess_type(upload.filename or "")[0]
- if guessed_type and guessed_type.startswith("image/"):
- return guessed_type
-
- return IMAGE_UPLOAD_CONTENT_TYPES.get(
- Path(upload.filename or "").suffix.lower(),
- "application/octet-stream",
- )
+ return image_content_type_for_filename(upload.filename or "")
def is_image_upload(upload: UploadFile) -> bool:
diff --git a/backend/app/core/overall_config.py b/backend/app/core/overall_config.py
index 4de4cea..8943684 100644
--- a/backend/app/core/overall_config.py
+++ b/backend/app/core/overall_config.py
@@ -124,6 +124,7 @@ OVERALL_CONFIG_REGISTRY: tuple[OverallConfigSpec, ...] = (
_spec("WEBHOOK_TIMEOUT_SECONDS", "float", "5", "Webhooks", "Webhook timeout per attempt.", min_value=0.1),
_spec("WEBHOOK_MAX_ATTEMPTS", "int", "3", "Webhooks", "Webhook delivery attempts.", min_value=1),
_spec("MAX_FILE_SIZE_MB", "int", "50", "Limits", "Max uploaded/downloaded image size.", min_value=1),
+ _spec("NODEIMAGE_UPLOAD_CONCURRENCY", "int", "4", "NodeImage", "Concurrent NodeImage uploads per worker process.", min_value=1),
_spec("MAX_JSON_BODY_MB", "int", "1", "Limits", "Max JSON request body size.", min_value=1),
_spec("MAX_UPSTREAM_JSON_MB", "int", "128", "Limits", "Max upstream JSON/SSE response size.", min_value=1),
_spec("MAX_IMAGE_PIXELS", "int", "100000000", "Limits", "Max decoded image pixels.", min_value=1),
diff --git a/backend/app/core/secrets.py b/backend/app/core/secrets.py
index 03e4c35..fa5dcde 100644
--- a/backend/app/core/secrets.py
+++ b/backend/app/core/secrets.py
@@ -296,6 +296,58 @@ def resolve_secret(
return value
+def resolve_secret_reference(
+ value: object,
+ *,
+ purpose: SecretPurpose,
+ target_url: str,
+ host_allowlist: str,
+ field_name: str,
+) -> str:
+ """Resolve a settings secret reference without exposing its source details.
+
+ Settings may contain an empty value, an explicit environment reference, a
+ predeclared registry ID, or (when plaintext storage is enabled at the
+ settings boundary) a literal value. Registry IDs are always validated for
+ purpose, origin, and the startup host allowlist before resolution.
+ """
+
+ raw = str(value or "").strip()
+ if not raw:
+ return ""
+
+ # Keep the parser local to avoid making the validators module import the
+ # secrets registry during application startup.
+ from .validators import get_env_var_ref_name, resolve_env_var_ref
+
+ env_var = get_env_var_ref_name(raw)
+ if env_var:
+ resolved = resolve_env_var_ref(raw)
+ if resolved:
+ return resolved
+ raise SecretRegistryError(
+ f"{field_name} environment variable {env_var} is not set or empty."
+ )
+
+ if "${" in raw or "}" in raw:
+ raise SecretRegistryError(
+ f"{field_name} env ref must be formatted as ${{ENV_VAR_NAME}}."
+ )
+
+ if raw in configured_secret_ids():
+ try:
+ return resolve_secret(
+ raw,
+ purpose=purpose,
+ target_url=target_url,
+ host_allowlist=host_allowlist,
+ )
+ except SecretRegistryError as exc:
+ raise SecretRegistryError(f"{field_name}: {exc}") from exc
+
+ return raw
+
+
def active_secret_values() -> tuple[str, ...]:
global _active_secret_values_cache
diff --git a/backend/app/core/settings.py b/backend/app/core/settings.py
index f691431..9d7565f 100644
--- a/backend/app/core/settings.py
+++ b/backend/app/core/settings.py
@@ -95,6 +95,7 @@ WEBHOOK_SIGNING_SECRET = os.getenv("WEBHOOK_SIGNING_SECRET", "").strip()
WEBHOOK_TIMEOUT_SECONDS = float(os.getenv("WEBHOOK_TIMEOUT_SECONDS", "5"))
WEBHOOK_MAX_ATTEMPTS = int(os.getenv("WEBHOOK_MAX_ATTEMPTS", "3"))
MAX_FILE_SIZE_MB = int(os.getenv("MAX_FILE_SIZE_MB", "50"))
+NODEIMAGE_UPLOAD_CONCURRENCY = max(1, int(os.getenv("NODEIMAGE_UPLOAD_CONCURRENCY", "4")))
MAX_JSON_BODY_MB = max(1, int(os.getenv("MAX_JSON_BODY_MB", "1")))
MAX_UPSTREAM_JSON_MB = max(1, int(os.getenv("MAX_UPSTREAM_JSON_MB", "128")))
MAX_UPSTREAM_IMAGE_BYTES_PER_TASK_MB = max(
diff --git a/backend/app/integrations/nodeimage/__init__.py b/backend/app/integrations/nodeimage/__init__.py
index fb258bc..590bcf8 100644
--- a/backend/app/integrations/nodeimage/__init__.py
+++ b/backend/app/integrations/nodeimage/__init__.py
@@ -5,10 +5,13 @@ from .client import (
NodeImageAuthError,
NodeImageConfigurationError,
NodeImageEffectiveSettings,
+ NodeImageTransientError,
NodeImageUploadError,
NodeImageUploadResult,
resolve_nodeimage_settings,
upload_image_bytes,
+ upload_image_file,
+ upload_image_source,
)
__all__ = [
@@ -16,8 +19,11 @@ __all__ = [
"NodeImageAuthError",
"NodeImageConfigurationError",
"NodeImageEffectiveSettings",
+ "NodeImageTransientError",
"NodeImageUploadError",
"NodeImageUploadResult",
"resolve_nodeimage_settings",
"upload_image_bytes",
+ "upload_image_file",
+ "upload_image_source",
]
diff --git a/backend/app/integrations/nodeimage/client.py b/backend/app/integrations/nodeimage/client.py
index 691fc7d..6476165 100644
--- a/backend/app/integrations/nodeimage/client.py
+++ b/backend/app/integrations/nodeimage/client.py
@@ -4,27 +4,30 @@ from __future__ import annotations
import asyncio
import json
-import mimetypes
+from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
-from urllib.parse import urlsplit
+from urllib.parse import quote, urlsplit
import aiohttp
from ...core import secrets
from ...core.redaction import redact_sensitive_text
-from ...core.validators import get_env_var_ref_name, resolve_env_var_ref
+from ...repositories.image_files import image_content_type_for_filename
from ..session_pool import TIMEOUT_NODEIMAGE, get_pool
NODEIMAGE_API_URL = "https://api.nodeimage.com"
NODEIMAGE_UPLOAD_URL = f"{NODEIMAGE_API_URL}/api/upload"
NODEIMAGE_HOST_ALLOWLIST = "api.nodeimage.com"
MAX_NODEIMAGE_RESPONSE_BYTES = 2 * 1024 * 1024
+NODEIMAGE_MAX_ATTEMPTS = 2
+NODEIMAGE_RETRY_BACKOFF_SECONDS = 0.2
RETRYABLE_CONNECT_ERRORS = (
aiohttp.ClientConnectorError,
aiohttp.ClientProxyConnectionError,
)
+NodeImageFileSource = Path | str | Callable[[], Any]
class NodeImageConfigurationError(ValueError):
@@ -39,6 +42,14 @@ class NodeImageUploadError(RuntimeError):
pass
+class NodeImageTransientError(NodeImageUploadError):
+ """An upload failure that is safe to retry before a response is complete."""
+
+
+class _NodeImageResponseReadError(NodeImageUploadError):
+ pass
+
+
@dataclass(frozen=True)
class NodeImageEffectiveSettings:
enabled: bool
@@ -52,28 +63,16 @@ class NodeImageUploadResult:
def _resolve_secret(value: Any) -> str:
- raw = str(value or "").strip()
- if not raw:
- return ""
- env_var = get_env_var_ref_name(raw)
- if env_var:
- resolved = resolve_env_var_ref(raw)
- if resolved:
- return resolved
- raise NodeImageConfigurationError(
- f"NodeImage API key environment variable {env_var} is not set or empty."
- )
- if raw not in secrets.configured_secret_ids():
- return raw
try:
- return secrets.resolve_secret(
- raw,
+ return secrets.resolve_secret_reference(
+ value,
purpose="nodeimage_api_key",
target_url=NODEIMAGE_API_URL,
host_allowlist=NODEIMAGE_HOST_ALLOWLIST,
+ field_name="NodeImage API key",
)
except secrets.SecretRegistryError as exc:
- raise NodeImageConfigurationError(f"NodeImage API key: {exc}") from exc
+ raise NodeImageConfigurationError(str(exc)) from exc
def resolve_nodeimage_settings(
@@ -107,14 +106,18 @@ async def _read_response_json(response: aiohttp.ClientResponse) -> dict[str, Any
chunks.append(chunk)
except NodeImageUploadError:
raise
- except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
- raise NodeImageUploadError("NodeImage returned an unreadable response.") from exc
+ except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as exc:
+ raise _NodeImageResponseReadError(
+ "NodeImage returned an unreadable response."
+ ) from exc
try:
parsed = json.loads(b"".join(chunks).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
- raise NodeImageUploadError("NodeImage returned an invalid JSON response.") from exc
+ raise _NodeImageResponseReadError(
+ "NodeImage returned an invalid JSON response."
+ ) from exc
if not isinstance(parsed, dict):
- raise NodeImageUploadError("NodeImage returned an invalid response.")
+ raise _NodeImageResponseReadError("NodeImage returned an invalid response.")
return parsed
@@ -143,79 +146,146 @@ def _validate_direct_url(value: Any) -> str:
return direct
-async def upload_image_bytes(
- image_bytes: bytes,
+def _markdown_escape_alt(value: str) -> str:
+ # Preserve ordinary filename punctuation while escaping inline constructs.
+ escaped = str(value or "image.png").replace("\\", "\\\\")
+ for character in "`*_[]()<>":
+ escaped = escaped.replace(character, f"\\{character}")
+ return escaped
+
+
+def _markdown_for_direct_url(direct: str, filename: str) -> str:
+ safe_filename = Path(str(filename or "image.png")).name or "image.png"
+ alt = _markdown_escape_alt(safe_filename)
+ encoded_url = quote(direct, safe=":/?#[]@!$&'*+,;=%-._~")
+ return f""
+
+
+def _open_file_source(source: NodeImageFileSource):
+ if isinstance(source, (str, Path)):
+ return Path(source).open("rb")
+ handle = source()
+ if not hasattr(handle, "read") or not hasattr(handle, "close"):
+ raise NodeImageUploadError("NodeImage file source was not readable.")
+ return handle
+
+
+async def _upload_image_source_once(
+ image_source: bytes | NodeImageFileSource,
+ safe_filename: str,
+ effective: NodeImageEffectiveSettings,
+) -> NodeImageUploadResult:
+ file_handle = None
+ if not isinstance(image_source, bytes):
+ try:
+ file_handle = _open_file_source(image_source)
+ except OSError:
+ raise
+ except NodeImageUploadError:
+ raise
+ except Exception as exc:
+ raise NodeImageUploadError("Image file could not be read") from exc
+
+ try:
+ form_source = image_source if isinstance(image_source, bytes) else file_handle
+ form = aiohttp.FormData()
+ form.add_field(
+ "image",
+ form_source,
+ filename=safe_filename,
+ content_type=image_content_type_for_filename(safe_filename),
+ )
+ session = get_pool().get(timeout_kind=TIMEOUT_NODEIMAGE)
+ async with session.post(
+ NODEIMAGE_UPLOAD_URL,
+ data=form,
+ headers={"X-API-Key": effective.api_key},
+ allow_redirects=False,
+ ) as response:
+ if response.status in {401, 403}:
+ raise NodeImageAuthError("NodeImage API key was rejected.")
+ try:
+ payload = await _read_response_json(response)
+ except _NodeImageResponseReadError as exc:
+ if response.status >= 500:
+ raise NodeImageTransientError(str(exc)) from exc
+ raise
+
+ message = _error_text(payload, response.status, effective.api_key)
+ if response.status >= 500:
+ raise NodeImageTransientError(message)
+ if _is_auth_error(message):
+ raise NodeImageAuthError("NodeImage API key was rejected.")
+ if response.status >= 400 or payload.get("success") is False:
+ raise NodeImageUploadError(message)
+
+ links = payload.get("links")
+ if not isinstance(links, dict):
+ raise NodeImageUploadError(
+ "NodeImage response did not include upload links."
+ )
+ direct = _validate_direct_url(links.get("direct"))
+ return NodeImageUploadResult(
+ url=direct,
+ markdown=_markdown_for_direct_url(direct, safe_filename),
+ )
+ except NodeImageAuthError:
+ raise
+ except NodeImageTransientError:
+ raise
+ except NodeImageUploadError:
+ raise
+ except RETRYABLE_CONNECT_ERRORS as exc:
+ raise NodeImageTransientError("NodeImage upload request failed.") from exc
+ except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as exc:
+ raise NodeImageUploadError("NodeImage upload request failed.") from exc
+ finally:
+ if file_handle is not None:
+ file_handle.close()
+
+
+async def upload_image_source(
+ image_source: bytes | NodeImageFileSource,
filename: str,
effective: NodeImageEffectiveSettings,
) -> NodeImageUploadResult:
if not effective.enabled:
raise NodeImageConfigurationError("NodeImage upload is disabled.")
- if not image_bytes:
+ if isinstance(image_source, bytes) and not image_source:
+ raise NodeImageUploadError("Image file is empty.")
+ if (
+ isinstance(image_source, (str, Path))
+ and Path(image_source).stat().st_size <= 0
+ ):
raise NodeImageUploadError("Image file is empty.")
safe_filename = Path(str(filename or "image.png")).name or "image.png"
- content_type = mimetypes.guess_type(safe_filename)[0] or "application/octet-stream"
- headers = {"X-API-Key": effective.api_key}
-
- for attempt in range(2):
- form = aiohttp.FormData()
- form.add_field(
- "image",
- image_bytes,
- filename=safe_filename,
- content_type=content_type,
- )
+ attempt = 1
+ while True:
try:
- session = get_pool().get(timeout_kind=TIMEOUT_NODEIMAGE)
- async with session.post(
- NODEIMAGE_UPLOAD_URL,
- data=form,
- headers=headers,
- allow_redirects=False,
- ) as response:
- if response.status in {401, 403}:
- raise NodeImageAuthError("NodeImage API key was rejected.")
- try:
- payload = await _read_response_json(response)
- except NodeImageUploadError as exc:
- if response.status >= 500 and attempt == 0:
- await asyncio.sleep(0.2)
- continue
- raise
+ return await _upload_image_source_once(
+ image_source,
+ safe_filename,
+ effective,
+ )
+ except NodeImageTransientError:
+ if attempt >= NODEIMAGE_MAX_ATTEMPTS:
+ raise
+ attempt += 1
+ await asyncio.sleep(NODEIMAGE_RETRY_BACKOFF_SECONDS)
- message = _error_text(payload, response.status, effective.api_key)
- if response.status >= 500:
- if attempt == 0:
- await asyncio.sleep(0.2)
- continue
- raise NodeImageUploadError(message)
- if _is_auth_error(message):
- raise NodeImageAuthError("NodeImage API key was rejected.")
- if response.status >= 400 or payload.get("success") is False:
- raise NodeImageUploadError(message)
- links = payload.get("links")
- if not isinstance(links, dict):
- raise NodeImageUploadError(
- "NodeImage response did not include upload links."
- )
- direct = _validate_direct_url(links.get("direct"))
- markdown = str(links.get("markdown") or "").strip()
- if not markdown:
- raise NodeImageUploadError(
- "NodeImage response did not include upload links."
- )
- return NodeImageUploadResult(url=direct, markdown=markdown)
- except NodeImageAuthError:
- raise
- except NodeImageUploadError:
- raise
- except RETRYABLE_CONNECT_ERRORS as exc:
- if attempt == 0:
- await asyncio.sleep(0.2)
- continue
- raise NodeImageUploadError("NodeImage upload request failed.") from exc
- except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as exc:
- raise NodeImageUploadError("NodeImage upload request failed.") from exc
+async def upload_image_file(
+ path: Path | str,
+ filename: str,
+ effective: NodeImageEffectiveSettings,
+) -> NodeImageUploadResult:
+ return await upload_image_source(path, filename, effective)
- raise NodeImageUploadError("NodeImage upload request failed.")
+
+async def upload_image_bytes(
+ image_bytes: bytes,
+ filename: str,
+ effective: NodeImageEffectiveSettings,
+) -> NodeImageUploadResult:
+ return await upload_image_source(image_bytes, filename, effective)
diff --git a/backend/app/integrations/r2/config.py b/backend/app/integrations/r2/config.py
index 87c628b..3a5c862 100644
--- a/backend/app/integrations/r2/config.py
+++ b/backend/app/integrations/r2/config.py
@@ -10,12 +10,7 @@ from typing import Any, Callable, Iterable
from ...core import secrets
from ...core import settings as config
from ...core.utils import utc_now
-from ...core.validators import (
- get_env_var_ref_name,
- normalize_r2_endpoint_url,
- validate_r2_endpoint_url,
- resolve_env_var_ref,
-)
+from ...core.validators import normalize_r2_endpoint_url, validate_r2_endpoint_url
from ...repositories.image_files import safe_image_path
@@ -150,28 +145,16 @@ def _resolve_secret(
purpose: secrets.SecretPurpose,
endpoint_url: str,
) -> str:
- raw = str(value or "").strip()
- if not raw:
- return ""
- env_var = get_env_var_ref_name(raw)
- if env_var:
- resolved = resolve_env_var_ref(raw)
- if resolved:
- return resolved
- raise R2ConfigurationError(
- f"{field_name} environment variable {env_var} is not set or empty."
- )
- if raw not in secrets.configured_secret_ids():
- return raw
try:
- return secrets.resolve_secret(
- raw,
+ return secrets.resolve_secret_reference(
+ value,
purpose=purpose,
target_url=endpoint_url,
host_allowlist=config.R2_ENDPOINT_HOST_ALLOWLIST,
+ field_name=field_name,
)
except secrets.SecretRegistryError as exc:
- raise R2ConfigurationError(f"{field_name}: {exc}") from exc
+ raise R2ConfigurationError(str(exc)) from exc
def resolve_r2_backup_settings(
diff --git a/backend/app/integrations/r2/sync.py b/backend/app/integrations/r2/sync.py
index f89d68a..7d8a776 100644
--- a/backend/app/integrations/r2/sync.py
+++ b/backend/app/integrations/r2/sync.py
@@ -1,5 +1,4 @@
import logging
-import mimetypes
import uuid
from contextlib import nullcontext
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -16,7 +15,7 @@ from ...core.validators import (
normalize_r2_endpoint_url,
resolve_env_var_ref,
)
-from ...repositories.image_files import safe_image_path
+from ...repositories.image_files import image_content_type_for_filename, safe_image_path
HealthStatus = str
@@ -230,7 +229,7 @@ def _local_sync_candidates_for_batch(
def _content_type_for(path: Path) -> str:
- return mimetypes.guess_type(path.name)[0] or "application/octet-stream"
+ return image_content_type_for_filename(path.name)
def _metadata_for_entry(entry: Any, byte_size: int) -> dict[str, str]:
diff --git a/backend/app/repositories/coordination.py b/backend/app/repositories/coordination.py
index 11bf855..0280a3a 100644
--- a/backend/app/repositories/coordination.py
+++ b/backend/app/repositories/coordination.py
@@ -268,6 +268,27 @@ def update_gallery_job(
with _connect() as conn:
with _transaction(conn):
if lease_owner:
+ if "payload_json" in normalized:
+ current_payload_row = conn.execute(
+ "SELECT payload_json FROM gallery_jobs WHERE job_id = ? AND lease_owner = ?",
+ (job_id, lease_owner),
+ ).fetchone()
+ current_payload = _json_loads_dict(
+ current_payload_row["payload_json"]
+ ) if current_payload_row else {}
+ next_payload = _json_loads_dict(normalized["payload_json"])
+ if current_payload.get("cancel_requested"):
+ next_payload["cancel_requested"] = True
+ if current_payload.get("cancel_requested_at"):
+ next_payload.setdefault(
+ "cancel_requested_at",
+ current_payload["cancel_requested_at"],
+ )
+ normalized["payload_json"] = json.dumps(
+ next_payload,
+ ensure_ascii=False,
+ sort_keys=True,
+ )
cursor = conn.execute(
f"""
UPDATE gallery_jobs
@@ -337,6 +358,119 @@ def update_gallery_job_progress(
return cursor.rowcount > 0
+def request_gallery_job_cancellation(kind: str, job_id: str) -> dict[str, Any] | None:
+ """Request cooperative cancellation while preserving completed item results."""
+ _ensure_database()
+ normalized_kind = str(kind or "").strip()
+ normalized_job_id = str(job_id or "").strip()
+ if not normalized_kind or not normalized_job_id:
+ return None
+ now = utc_now()
+ with _connect() as conn:
+ with _transaction(conn):
+ row = conn.execute(
+ f"""
+ SELECT {', '.join(GALLERY_JOB_COLUMNS)}
+ FROM gallery_jobs
+ WHERE kind = ? AND job_id = ?
+ """,
+ (normalized_kind, normalized_job_id),
+ ).fetchone()
+ if not row:
+ return None
+
+ status = str(row["status"] or "")
+ payload = _json_loads_dict(row["payload_json"])
+ if status == "queued":
+ ids = [str(value) for value in payload.get("ids") or [] if str(value)]
+ existing_results = [
+ value
+ for value in payload.get("results") or []
+ if isinstance(value, dict) and str(value.get("image_id") or "")
+ ]
+ existing_ids = {str(value["image_id"]) for value in existing_results}
+ results = list(existing_results)
+ for image_id in ids:
+ if image_id in existing_ids:
+ continue
+ results.append(
+ {
+ "image_id": image_id,
+ "filename": None,
+ "status": "cancelled",
+ "url": None,
+ "markdown": None,
+ "error": "Cancelled before upload",
+ }
+ )
+ payload["results"] = results
+ requested_count = max(
+ int(row["requested_count"] or 0),
+ len(ids),
+ )
+ uploaded_count = sum(value.get("status") == "ok" for value in results)
+ failed_count = sum(value.get("status") == "error" for value in results)
+ conn.execute(
+ """
+ UPDATE gallery_jobs
+ SET status = 'cancelled',
+ stage = 'cancelled',
+ message = 'NodeImage upload cancelled',
+ progress = 100,
+ requested_count = ?,
+ processed_count = ?,
+ uploaded_count = ?,
+ failed_count = ?,
+ completed_at = ?,
+ updated_at = ?,
+ error = NULL,
+ lease_owner = NULL,
+ lease_expires_at = NULL,
+ payload_json = ?
+ WHERE kind = ? AND job_id = ? AND status = 'queued'
+ """,
+ (
+ requested_count,
+ requested_count,
+ uploaded_count,
+ failed_count,
+ now,
+ now,
+ json.dumps(payload, ensure_ascii=False, sort_keys=True),
+ normalized_kind,
+ normalized_job_id,
+ ),
+ )
+ elif status == "running":
+ payload["cancel_requested"] = True
+ payload["cancel_requested_at"] = now
+ conn.execute(
+ """
+ UPDATE gallery_jobs
+ SET stage = 'cancelling',
+ message = 'Cancelling NodeImage upload',
+ updated_at = ?,
+ payload_json = ?
+ WHERE kind = ? AND job_id = ? AND status = 'running'
+ """,
+ (
+ now,
+ json.dumps(payload, ensure_ascii=False, sort_keys=True),
+ normalized_kind,
+ normalized_job_id,
+ ),
+ )
+ row = conn.execute(
+ f"""
+ SELECT {', '.join(GALLERY_JOB_COLUMNS)}
+ FROM gallery_jobs
+ WHERE kind = ? AND job_id = ?
+ """,
+ (normalized_kind, normalized_job_id),
+ ).fetchone()
+ return _gallery_job_from_row(row) if row else None
+
+
def renew_gallery_job_lease(
*,
job_id: str,
@@ -656,22 +790,30 @@ def cleanup_expired_gallery_jobs(kind: str) -> list[dict[str, Any]]:
return [_gallery_job_from_row(row) for row in rows]
-def cleanup_stale_gallery_jobs(kind: str, ttl_seconds: int) -> list[dict[str, Any]]:
+def cleanup_stale_gallery_jobs(
+ kind: str,
+ ttl_seconds: int,
+ terminal_statuses: Sequence[str] = ("success", "error"),
+) -> list[dict[str, Any]]:
_ensure_database()
cutoff = datetime.fromtimestamp(
time.time() - max(0, int(ttl_seconds or 0)),
tz=timezone.utc,
).isoformat()
+ statuses = [str(status) for status in terminal_statuses if str(status)]
+ if not statuses:
+ return []
+ placeholders = ", ".join("?" for _ in statuses)
with _connect() as conn:
with _transaction(conn):
rows = conn.execute(
f"""
SELECT {", ".join(GALLERY_JOB_COLUMNS)}
FROM gallery_jobs
- WHERE kind = ? AND status IN ('success', 'error')
+ WHERE kind = ? AND status IN ({placeholders})
AND updated_at <= ?
""",
- (kind, cutoff),
+ (kind, *statuses, cutoff),
).fetchall()
if rows:
conn.executemany(
diff --git a/backend/app/repositories/db.py b/backend/app/repositories/db.py
index a160814..0e0dba8 100644
--- a/backend/app/repositories/db.py
+++ b/backend/app/repositories/db.py
@@ -396,7 +396,11 @@ def _default_secret_reference(secret_id: str, value: str | None) -> str:
return ""
-def _default_r2_secret_reference(secret_id: str, env_var: str, value: str | None) -> str:
+def _default_env_backed_secret_reference(
+ secret_id: str,
+ env_var: str,
+ value: str | None,
+) -> str:
if secret_id in configured_secret_ids():
return secret_id
normalized = str(value or "").strip()
@@ -650,12 +654,12 @@ def _default_r2_backup_settings() -> dict:
"bucket_name": config.R2_BUCKET_NAME,
"region": config.R2_REGION or "auto",
"key_prefix": _normalize_r2_key_prefix(config.R2_KEY_PREFIX),
- "access_key_id": _default_r2_secret_reference(
+ "access_key_id": _default_env_backed_secret_reference(
"builtin-r2-access-key-id",
"R2_ACCESS_KEY_ID",
config.R2_ACCESS_KEY_ID,
),
- "secret_access_key": _default_r2_secret_reference(
+ "secret_access_key": _default_env_backed_secret_reference(
"builtin-r2-secret-access-key",
"R2_SECRET_ACCESS_KEY",
config.R2_SECRET_ACCESS_KEY,
@@ -667,7 +671,7 @@ def _default_r2_backup_settings() -> dict:
def _default_nodeimage_settings() -> dict:
return {
"enabled": False,
- "api_key": _default_r2_secret_reference(
+ "api_key": _default_env_backed_secret_reference(
"builtin-nodeimage-api-key",
"NODEIMAGE_API_KEY",
config.NODEIMAGE_API_KEY,
diff --git a/backend/app/repositories/image_files.py b/backend/app/repositories/image_files.py
index 731aab6..ecc23d6 100644
--- a/backend/app/repositories/image_files.py
+++ b/backend/app/repositories/image_files.py
@@ -1,4 +1,5 @@
import io
+import mimetypes
import struct
import tempfile
import uuid
@@ -85,6 +86,16 @@ PILLOW_FORMATS = {
}
+def image_content_type_for_filename(filename: str | Path) -> str:
+ """Return the repository's stable image MIME type for a filename."""
+
+ name = str(filename or "")
+ image_format = IMAGE_EXTENSION_FORMATS.get(Path(name).suffix.lower())
+ if image_format:
+ return IMAGE_FORMAT_CONTENT_TYPES[image_format]
+ return mimetypes.guess_type(name)[0] or "application/octet-stream"
+
+
def configure_pillow_image_limits() -> None:
if Image is not None:
Image.MAX_IMAGE_PIXELS = config.MAX_IMAGE_PIXELS
diff --git a/backend/app/schemas/nodeimage.py b/backend/app/schemas/nodeimage.py
index 3d2f8a7..7fe4173 100644
--- a/backend/app/schemas/nodeimage.py
+++ b/backend/app/schemas/nodeimage.py
@@ -11,7 +11,7 @@ class NodeImageUploadResponse(BaseModel):
class NodeImageBatchUploadItem(BaseModel):
image_id: str
filename: Optional[str] = None
- status: Literal["ok", "error"]
+ status: Literal["ok", "error", "cancelled"]
url: Optional[str] = None
markdown: Optional[str] = None
error: Optional[str] = None
@@ -22,3 +22,28 @@ class NodeImageBatchUploadResponse(BaseModel):
uploaded_count: int
failed_count: int
results: list[NodeImageBatchUploadItem] = Field(default_factory=list)
+
+
+class NodeImageUploadJobStatus(BaseModel):
+ job_id: str
+ status: Literal["queued", "running", "success", "partial_failure", "cancelled", "error"]
+ stage: Optional[str] = None
+ message: Optional[str] = None
+ progress: int = 0
+ requested_count: int = 0
+ processed_count: int = 0
+ uploaded_count: int = 0
+ failed_count: int = 0
+ cancelled_count: int = 0
+ results: list[NodeImageBatchUploadItem] = Field(default_factory=list)
+ created_at: Optional[str] = None
+ started_at: Optional[str] = None
+ completed_at: Optional[str] = None
+ updated_at: Optional[str] = None
+ error: Optional[str] = None
+ status_url: Optional[str] = None
+ events_url: Optional[str] = None
+ cancel_url: Optional[str] = None
+
+
+NodeImageBatchUploadCreateResponse = NodeImageUploadJobStatus
diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py
index 4fd360e..cb246a6 100644
--- a/backend/app/schemas/settings.py
+++ b/backend/app/schemas/settings.py
@@ -416,6 +416,7 @@ class NodeImageSettingsResponse(BaseModel):
enabled: bool = False
api_key_masked: str = "***"
has_api_key: bool = False
+ api_key_resolvable: bool = False
api_key_source: ApiKeySource = "empty"
api_key_env_var: Optional[str] = None
api_key_secret_id: Optional[str] = None
diff --git a/backend/app/services/gallery_common.py b/backend/app/services/gallery_common.py
index 7bee11a..fc70029 100644
--- a/backend/app/services/gallery_common.py
+++ b/backend/app/services/gallery_common.py
@@ -120,9 +120,12 @@ IMMUTABLE_GALLERY_CACHE_CONTROL = "private, max-age=31536000, immutable"
GALLERY_EXPORT_TERMINAL_STATUSES = {"success", "error"}
GALLERY_SYNC_TERMINAL_STATUSES = {"success", "error"}
GALLERY_IMPORT_TERMINAL_STATUSES = {"success", "error"}
+NODEIMAGE_UPLOAD_TERMINAL_STATUSES = {"success", "partial_failure", "cancelled", "error"}
MAX_ACTIVE_EXPORT_JOBS = 5
MAX_ACTIVE_SYNC_JOBS = 1
MAX_ACTIVE_IMPORT_JOBS = 1
+MAX_ACTIVE_NODEIMAGE_UPLOAD_JOBS = 1
+MAX_ACTIVE_NODEIMAGE_JOBS = MAX_ACTIVE_NODEIMAGE_UPLOAD_JOBS
EXPORT_JOB_TTL_SECONDS = 1800
EXPORT_JOB_GC_INTERVAL_SECONDS = 300
SYNC_JOB_TTL_SECONDS = 1800
@@ -148,6 +151,8 @@ GALLERY_SELECTION_TOKEN_KIND = "batch_selection"
GALLERY_SELECTION_TOKEN_TTL_SECONDS = 15 * 60
AI_ANALYZE_JOB_KIND = "ai_analyze"
AI_ANALYZE_JOB_TTL_SECONDS = 1800
+NODEIMAGE_UPLOAD_JOB_KIND = "nodeimage_upload"
+NODEIMAGE_UPLOAD_JOB_TTL_SECONDS = 1800
def _trusted_gallery_job_dir(kind: str) -> Path:
diff --git a/backend/app/services/gallery_jobs.py b/backend/app/services/gallery_jobs.py
index fb2d613..c72e1be 100644
--- a/backend/app/services/gallery_jobs.py
+++ b/backend/app/services/gallery_jobs.py
@@ -2,7 +2,6 @@ import asyncio
import hashlib
import inspect
import logging
-import mimetypes
import os
import time
import uuid
@@ -54,6 +53,7 @@ from ..repositories.coordination import (
release_background_lease,
release_import_upload_reservation,
reserve_gallery_job_capacity,
+ renew_gallery_job_lease,
update_gallery_job,
update_gallery_job_progress,
)
@@ -88,7 +88,7 @@ from ..repositories.image_files import (
safe_image_path,
safe_thumbnail_path,
)
-from ..repositories.settings import load_r2_backup_settings
+from ..repositories.settings import load_nodeimage_settings, load_r2_backup_settings
from ..repositories.thumbnail_jobs import (
THUMBNAIL_JOB_LEASE_SECONDS,
claim_next_thumbnail_job,
@@ -113,6 +113,15 @@ from ..schemas.gallery import (
GallerySyncRequest,
GallerySyncJobStatus,
)
+from ..schemas.nodeimage import NodeImageBatchUploadItem
+from ..integrations.nodeimage.client import (
+ NodeImageAuthError,
+ NodeImageConfigurationError,
+ NodeImageUploadError,
+ NodeImageUploadResult,
+ resolve_nodeimage_settings,
+ upload_image_file,
+)
from .claim_loop import run_claim_loop
from .gallery_common import (
BACKGROUND_TASK_ERROR_BACKOFF_INITIAL_SECONDS,
@@ -128,7 +137,11 @@ from .gallery_common import (
GALLERY_JOB_SSE_QUEUE_MAXSIZE,
MAX_ACTIVE_EXPORT_JOBS,
MAX_ACTIVE_IMPORT_JOBS,
+ MAX_ACTIVE_NODEIMAGE_UPLOAD_JOBS,
MAX_ACTIVE_SYNC_JOBS,
+ NODEIMAGE_UPLOAD_JOB_KIND,
+ NODEIMAGE_UPLOAD_JOB_TTL_SECONDS,
+ NODEIMAGE_UPLOAD_TERMINAL_STATUSES,
PRIVATE_GALLERY_CACHE_CONTROL,
GalleryProgressThrottler,
_progress_item_count,
@@ -138,6 +151,15 @@ from .gallery_common import (
logger = logging.getLogger(__name__)
+NODEIMAGE_UPLOAD_SEMAPHORE = asyncio.Semaphore(
+ max(1, int(getattr(config, "NODEIMAGE_UPLOAD_CONCURRENCY", 4) or 1))
+)
+_NODEIMAGE_UPLOAD_SEMAPHORE_LOOP: asyncio.AbstractEventLoop | None = None
+_NODEIMAGE_UPLOAD_SEMAPHORE_CAPACITY = max(
+ 1,
+ int(getattr(config, "NODEIMAGE_UPLOAD_CONCURRENCY", 4) or 1),
+)
+
async def _gallery_zip_response(
entries,
filename_prefix: str,
@@ -499,6 +521,70 @@ def _gallery_import_payload(job: dict) -> dict:
return payload
+def _nodeimage_result_item(
+ image_id: str,
+ filename: str | None,
+ status: str,
+ *,
+ url: str | None = None,
+ markdown: str | None = None,
+ error: str | None = None,
+) -> dict:
+ return NodeImageBatchUploadItem(
+ image_id=str(image_id),
+ filename=filename,
+ status=status,
+ url=url,
+ markdown=markdown,
+ error=error,
+ ).model_dump()
+
+
+def _nodeimage_result_counts(results: Iterable[dict]) -> tuple[int, int, int, int]:
+ normalized = list(results)
+ uploaded_count = sum(item.get("status") == "ok" for item in normalized)
+ failed_count = sum(item.get("status") == "error" for item in normalized)
+ cancelled_count = sum(item.get("status") == "cancelled" for item in normalized)
+ return len(normalized), uploaded_count, failed_count, cancelled_count
+
+
+def _nodeimage_upload_payload(job: dict) -> dict:
+ stored_payload = job.get("payload") or {}
+ job_id = str(job.get("job_id") or "")
+ encoded_job_id = quote(job_id, safe="") if job_id else ""
+ ids = [str(value) for value in stored_payload.get("ids") or [] if str(value)]
+ results_by_id: dict[str, dict] = {}
+ for value in stored_payload.get("results") or []:
+ if not isinstance(value, dict):
+ continue
+ image_id = str(value.get("image_id") or "")
+ if image_id and image_id not in results_by_id:
+ results_by_id[image_id] = value
+ results = [results_by_id[image_id] for image_id in ids if image_id in results_by_id]
+ _, uploaded_count, failed_count, cancelled_count = _nodeimage_result_counts(results)
+ return {
+ "job_id": job_id,
+ "status": job.get("status"),
+ "stage": job.get("stage"),
+ "message": job.get("message"),
+ "progress": job.get("progress") or 0,
+ "requested_count": job.get("requested_count") or len(ids),
+ "processed_count": job.get("processed_count") or len(results),
+ "uploaded_count": job.get("uploaded_count") or uploaded_count,
+ "failed_count": job.get("failed_count") or failed_count,
+ "cancelled_count": cancelled_count,
+ "results": results,
+ "created_at": job.get("created_at"),
+ "started_at": job.get("started_at"),
+ "completed_at": job.get("completed_at"),
+ "updated_at": job.get("updated_at"),
+ "error": job.get("error"),
+ "status_url": f"/api/gallery/nodeimage-upload-jobs/{encoded_job_id}" if encoded_job_id else None,
+ "events_url": f"/api/gallery/nodeimage-upload-jobs/{encoded_job_id}/events" if encoded_job_id else None,
+ "cancel_url": f"/api/gallery/nodeimage-upload-jobs/{encoded_job_id}/cancel" if encoded_job_id else None,
+ }
+
+
def _gallery_job_event_name(kind: str) -> str:
if kind == "sync":
return "sync"
@@ -506,6 +592,8 @@ def _gallery_job_event_name(kind: str) -> str:
return "import"
if kind == "ai_analyze":
return "analysis"
+ if kind == NODEIMAGE_UPLOAD_JOB_KIND:
+ return "nodeimage_upload"
return "export"
@@ -534,6 +622,8 @@ def _gallery_job_payload(kind: str, job: dict) -> dict:
return _gallery_import_payload(job)
if kind == "ai_analyze":
return _gallery_ai_analyze_payload(job)
+ if kind == NODEIMAGE_UPLOAD_JOB_KIND:
+ return _nodeimage_upload_payload(job)
return _gallery_export_payload(job)
@@ -942,30 +1032,269 @@ def _create_gallery_sync_job(total_count: int, payload: dict | None = None) -> d
)
-def _create_gallery_import_job(
+def _build_gallery_import_job(
zip_path: Path,
total_count: int,
payload: dict | None = None,
) -> dict:
job_id = os.urandom(16).hex()
now = utc_now()
- return create_gallery_job(
- job_id=job_id,
- kind="import",
- status="queued",
- stage="queued",
- message="Queued gallery ZIP import",
- progress=0,
- created_at=now,
- updated_at=now,
- error=None,
- path=str(zip_path),
- requested_count=total_count,
- processed_count=0,
- exported_count=0,
- missing_count=0,
- payload=payload or {},
+ return {
+ "job_id": job_id,
+ "kind": "import",
+ "status": "queued",
+ "stage": "queued",
+ "message": "Queued gallery ZIP import",
+ "progress": 0,
+ "created_at": now,
+ "updated_at": now,
+ "error": None,
+ "path": str(zip_path),
+ "requested_count": total_count,
+ "processed_count": 0,
+ "exported_count": 0,
+ "missing_count": 0,
+ "payload": payload or {},
+ }
+
+
+def _get_nodeimage_upload_semaphore() -> asyncio.Semaphore:
+ global NODEIMAGE_UPLOAD_SEMAPHORE
+ global _NODEIMAGE_UPLOAD_SEMAPHORE_CAPACITY, _NODEIMAGE_UPLOAD_SEMAPHORE_LOOP
+ current_loop = asyncio.get_running_loop()
+ capacity = max(1, int(getattr(config, "NODEIMAGE_UPLOAD_CONCURRENCY", 4) or 1))
+ if (
+ capacity != _NODEIMAGE_UPLOAD_SEMAPHORE_CAPACITY
+ or _NODEIMAGE_UPLOAD_SEMAPHORE_LOOP is not current_loop
+ ):
+ NODEIMAGE_UPLOAD_SEMAPHORE = asyncio.Semaphore(capacity)
+ _NODEIMAGE_UPLOAD_SEMAPHORE_CAPACITY = capacity
+ _NODEIMAGE_UPLOAD_SEMAPHORE_LOOP = current_loop
+ return NODEIMAGE_UPLOAD_SEMAPHORE
+
+
+def _build_nodeimage_upload_job(
+ ids: list[str],
+ requested_count: int,
+ missing_ids: list[str],
+) -> dict:
+ job_id = os.urandom(16).hex()
+ now = utc_now()
+ initial_results = [
+ _nodeimage_result_item(
+ image_id,
+ None,
+ "error",
+ error="Gallery entry not found",
+ )
+ for image_id in missing_ids
+ ]
+ processed_count, _uploaded_count, failed_count, _cancelled_count = _nodeimage_result_counts(initial_results)
+ return {
+ "job_id": job_id,
+ "kind": NODEIMAGE_UPLOAD_JOB_KIND,
+ "status": "queued",
+ "stage": "queued",
+ "message": "Queued NodeImage upload",
+ "progress": round((processed_count / max(1, requested_count)) * 100),
+ "requested_count": requested_count,
+ "processed_count": processed_count,
+ "uploaded_count": 0,
+ "failed_count": failed_count,
+ "created_at": now,
+ "updated_at": now,
+ "error": None,
+ "payload": {
+ "ids": list(ids),
+ "results": initial_results,
+ "cancel_requested": False,
+ },
+ }
+
+
+async def _create_reserved_nodeimage_upload_job(
+ ids: list[str],
+ requested_count: int,
+ missing_ids: list[str],
+) -> dict:
+ job = await asyncio.to_thread(
+ reserve_gallery_job_capacity,
+ job=_build_nodeimage_upload_job(ids, requested_count, missing_ids),
+ counted_kinds=(NODEIMAGE_UPLOAD_JOB_KIND,),
+ max_active=MAX_ACTIVE_NODEIMAGE_UPLOAD_JOBS,
)
+ if not job:
+ active_count = await asyncio.to_thread(
+ count_active_gallery_jobs,
+ NODEIMAGE_UPLOAD_JOB_KIND,
+ )
+ raise HTTPException(
+ status_code=429,
+ detail=f"Too many active NodeImage upload jobs ({active_count}). "
+ "Please wait for the existing upload to complete.",
+ )
+ return job
+
+
+NodeImageFileInspection = Path | dict
+
+
+def _inspect_nodeimage_file(entry: GalleryEntry) -> NodeImageFileInspection:
+ path = safe_image_path(entry.filename)
+ if not path:
+ return _nodeimage_result_item(
+ entry.id,
+ entry.filename,
+ "error",
+ error="Image file not found",
+ )
+ try:
+ file_size = path.stat().st_size
+ except FileNotFoundError:
+ return _nodeimage_result_item(
+ entry.id,
+ entry.filename,
+ "error",
+ error="Image file not found",
+ )
+ except OSError:
+ return _nodeimage_result_item(
+ entry.id,
+ entry.filename,
+ "error",
+ error="Image file could not be read",
+ )
+ if file_size > config.MAX_FILE_SIZE_MB * 1024 * 1024:
+ return _nodeimage_result_item(
+ entry.id,
+ entry.filename,
+ "error",
+ error=f"Image file is too large. Max size is {config.MAX_FILE_SIZE_MB} MB",
+ )
+ try:
+ with path.open("rb"):
+ pass
+ except FileNotFoundError:
+ return _nodeimage_result_item(
+ entry.id,
+ entry.filename,
+ "error",
+ error="Image file not found",
+ )
+ except OSError:
+ return _nodeimage_result_item(
+ entry.id,
+ entry.filename,
+ "error",
+ error="Image file could not be read",
+ )
+ if file_size <= 0:
+ return _nodeimage_result_item(
+ entry.id,
+ entry.filename,
+ "error",
+ error="Image file is empty",
+ )
+ return path
+
+
+async def _nodeimage_job_cancel_requested(job_id: str) -> bool:
+ current = await asyncio.to_thread(get_gallery_job, NODEIMAGE_UPLOAD_JOB_KIND, job_id)
+ if not current:
+ return True
+ return bool((current.get("payload") or {}).get("cancel_requested"))
+
+
+def _nodeimage_cancelled_item(entry: GalleryEntry) -> dict:
+ return _nodeimage_result_item(
+ entry.id,
+ entry.filename,
+ "cancelled",
+ error="Cancelled before upload",
+ )
+
+
+async def _upload_nodeimage_entry(
+ job_id: str,
+ entry: GalleryEntry,
+ effective,
+ auth_failed: asyncio.Event,
+ *,
+ inspected_path: Path | None = None,
+) -> dict:
+ semaphore = _get_nodeimage_upload_semaphore()
+ async with semaphore:
+ if await _nodeimage_job_cancel_requested(job_id):
+ return _nodeimage_cancelled_item(entry)
+
+ if auth_failed.is_set():
+ inspection = await asyncio.to_thread(_inspect_nodeimage_file, entry)
+ if isinstance(inspection, dict):
+ return inspection
+ return _nodeimage_result_item(
+ entry.id,
+ entry.filename,
+ "error",
+ error="NodeImage API key was rejected.",
+ )
+
+ path = inspected_path
+ if path is None:
+ inspection = await asyncio.to_thread(_inspect_nodeimage_file, entry)
+ if isinstance(inspection, dict):
+ return inspection
+ path = inspection
+ if auth_failed.is_set():
+ return _nodeimage_result_item(
+ entry.id,
+ entry.filename,
+ "error",
+ error="NodeImage API key was rejected.",
+ )
+ try:
+ result: NodeImageUploadResult = await upload_image_file(
+ path,
+ path.name,
+ effective,
+ )
+ return _nodeimage_result_item(
+ entry.id,
+ entry.filename,
+ "ok",
+ url=result.url,
+ markdown=result.markdown,
+ )
+ except NodeImageAuthError:
+ auth_failed.set()
+ return _nodeimage_result_item(
+ entry.id,
+ entry.filename,
+ "error",
+ error="NodeImage API key was rejected.",
+ )
+ except NodeImageUploadError as exc:
+ return _nodeimage_result_item(
+ entry.id,
+ entry.filename,
+ "error",
+ error=str(exc) or "NodeImage upload failed.",
+ )
+ except FileNotFoundError:
+ return _nodeimage_result_item(entry.id, entry.filename, "error", error="Image file not found")
+ except OSError:
+ return _nodeimage_result_item(entry.id, entry.filename, "error", error="Image file could not be read")
+ except Exception as exc:
+ logger.exception(
+ "Unexpected NodeImage upload failure for gallery entry %s (%s)",
+ entry.id,
+ type(exc).__name__,
+ )
+ return _nodeimage_result_item(
+ entry.id,
+ entry.filename,
+ "error",
+ error="Unexpected upload failure",
+ )
async def _create_reserved_gallery_export_job(
@@ -1014,7 +1343,7 @@ async def _create_reserved_gallery_import_job(
) -> dict:
job = await asyncio.to_thread(
reserve_gallery_job_capacity,
- job=_create_gallery_import_job(zip_path, total_count, payload),
+ job=_build_gallery_import_job(zip_path, total_count, payload),
counted_kinds=("import",),
max_active=MAX_ACTIVE_IMPORT_JOBS,
)
@@ -1342,11 +1671,258 @@ async def _run_gallery_import_job(job: dict) -> None:
await asyncio.to_thread(release_import_upload_reservation, reservation_id)
+class _NodeImageLeaseLost(RuntimeError):
+ pass
+
+
+async def _publish_nodeimage_job(
+ job_id: str,
+ lease_owner: str,
+ updates: dict,
+) -> dict:
+ job = await run_db_operation(
+ update_gallery_job,
+ job_id,
+ updates,
+ lease_owner=lease_owner,
+ metric_name="update_nodeimage_upload_job",
+ )
+ if not job:
+ raise _NodeImageLeaseLost()
+ _publish_gallery_job_sse(job)
+ return job
+
+
+async def _run_nodeimage_upload_job(job: dict) -> None:
+ job_id = str(job["job_id"])
+ lease_owner = str(job.get("lease_owner") or "")
+ stored_payload = job.get("payload") or {}
+ ids = [str(value) for value in stored_payload.get("ids") or [] if str(value)]
+ payload = dict(stored_payload)
+ results_by_id: dict[str, dict] = {}
+ for value in payload.get("results") or []:
+ if not isinstance(value, dict):
+ continue
+ image_id = str(value.get("image_id") or "")
+ if image_id and image_id not in results_by_id:
+ results_by_id[image_id] = value
+ payload["ids"] = ids
+ requested_count = max(int(job.get("requested_count") or 0), len(ids))
+ auth_failed = asyncio.Event()
+ lease_lost = asyncio.Event()
+ stop_lease_renewal = asyncio.Event()
+
+ async def renew_lease() -> None:
+ try:
+ while True:
+ try:
+ await asyncio.wait_for(
+ stop_lease_renewal.wait(),
+ timeout=max(1.0, GALLERY_JOB_LEASE_SECONDS / 3),
+ )
+ return
+ except asyncio.TimeoutError:
+ pass
+ renewed = await asyncio.to_thread(
+ renew_gallery_job_lease,
+ job_id=job_id,
+ lease_owner=lease_owner,
+ lease_expires_at=_gallery_job_lease_expires_at(),
+ now=utc_now(),
+ )
+ if not renewed:
+ lease_lost.set()
+ return
+ except asyncio.CancelledError:
+ raise
+
+ renew_task = asyncio.create_task(renew_lease())
+
+ def ordered_results() -> list[dict]:
+ return [results_by_id[image_id] for image_id in ids if image_id in results_by_id]
+
+ async def persist_results(*, status: str = "running", stage: str = "uploading", message: str = "Uploading images to NodeImage") -> None:
+ results = ordered_results()
+ processed_count, uploaded_count, failed_count, _cancelled_count = _nodeimage_result_counts(results)
+ payload["results"] = results
+ if lease_lost.is_set():
+ raise _NodeImageLeaseLost()
+ await _publish_nodeimage_job(
+ job_id,
+ lease_owner,
+ {
+ "status": status,
+ "stage": stage,
+ "message": message,
+ "progress": 100 if status in NODEIMAGE_UPLOAD_TERMINAL_STATUSES else round(
+ (processed_count / max(1, requested_count)) * 100
+ ),
+ "requested_count": requested_count,
+ "processed_count": processed_count,
+ "uploaded_count": uploaded_count,
+ "failed_count": failed_count,
+ "payload": payload,
+ "error": None,
+ },
+ )
+
+ async def persist_item(item: dict) -> None:
+ results_by_id[str(item["image_id"])] = item
+ await persist_results()
+
+ try:
+ effective = resolve_nodeimage_settings(
+ await asyncio.to_thread(load_nodeimage_settings)
+ )
+ entries = await asyncio.to_thread(get_gallery_entries_by_ids, ids)
+ entries_by_id = {entry.id: entry for entry in entries}
+ for image_id in ids:
+ if image_id in results_by_id or image_id in entries_by_id:
+ continue
+ results_by_id[image_id] = _nodeimage_result_item(
+ image_id,
+ None,
+ "error",
+ error="Gallery entry not found",
+ )
+
+ pending_entries = [
+ entries_by_id[image_id]
+ for image_id in ids
+ if image_id in entries_by_id and image_id not in results_by_id
+ ]
+ await persist_results(message="Preparing NodeImage upload")
+
+ while pending_entries and not await _nodeimage_job_cancel_requested(job_id):
+ probe_entry = pending_entries.pop(0)
+ probe_inspection = await asyncio.to_thread(
+ _inspect_nodeimage_file,
+ probe_entry,
+ )
+ if isinstance(probe_inspection, dict):
+ await persist_item(probe_inspection)
+ continue
+ await persist_item(
+ await _upload_nodeimage_entry(
+ job_id,
+ probe_entry,
+ effective,
+ auth_failed,
+ inspected_path=probe_inspection,
+ )
+ )
+ break
+
+ if pending_entries and not await _nodeimage_job_cancel_requested(job_id):
+ tasks = [
+ asyncio.create_task(
+ _upload_nodeimage_entry(
+ job_id,
+ entry,
+ effective,
+ auth_failed,
+ )
+ )
+ for entry in pending_entries
+ ]
+ try:
+ for task in asyncio.as_completed(tasks):
+ await persist_item(await task)
+ finally:
+ for task in tasks:
+ if not task.done():
+ task.cancel()
+ await asyncio.gather(*tasks, return_exceptions=True)
+
+ if await _nodeimage_job_cancel_requested(job_id):
+ for entry in pending_entries:
+ if entry.id not in results_by_id:
+ results_by_id[entry.id] = _nodeimage_cancelled_item(entry)
+ results = ordered_results()
+ _processed_count, uploaded_count, failed_count, cancelled_count = _nodeimage_result_counts(results)
+ if cancelled_count:
+ terminal_status = "cancelled"
+ terminal_message = "NodeImage upload cancelled"
+ elif failed_count:
+ terminal_status = "partial_failure"
+ terminal_message = "NodeImage upload completed with failures"
+ else:
+ terminal_status = "success"
+ terminal_message = "NodeImage upload complete"
+ payload["results"] = results
+ await _publish_nodeimage_job(
+ job_id,
+ lease_owner,
+ {
+ "status": terminal_status,
+ "stage": "completed" if terminal_status != "cancelled" else "cancelled",
+ "message": terminal_message,
+ "progress": 100,
+ "requested_count": requested_count,
+ "processed_count": len(results),
+ "uploaded_count": uploaded_count,
+ "failed_count": failed_count,
+ "completed_at": utc_now(),
+ "lease_owner": None,
+ "lease_expires_at": None,
+ "payload": payload,
+ "error": None,
+ },
+ )
+ except _NodeImageLeaseLost:
+ logger.info("NodeImage upload job %s stopped after losing its lease", job_id)
+ except asyncio.CancelledError:
+ raise
+ except Exception:
+ logger.exception("NodeImage upload job %s failed", job_id)
+ try:
+ results = ordered_results()
+ remaining_error = "NodeImage upload job failed"
+ entries = await asyncio.to_thread(get_gallery_entries_by_ids, ids)
+ for entry in entries:
+ if entry.id not in results_by_id:
+ results_by_id[entry.id] = _nodeimage_result_item(
+ entry.id,
+ entry.filename,
+ "error",
+ error=remaining_error,
+ )
+ payload["results"] = ordered_results()
+ _processed_count, uploaded_count, failed_count, _cancelled_count = _nodeimage_result_counts(payload["results"])
+ await _publish_nodeimage_job(
+ job_id,
+ lease_owner,
+ {
+ "status": "error",
+ "stage": "error",
+ "message": "NodeImage upload failed",
+ "progress": 100,
+ "requested_count": requested_count,
+ "processed_count": len(payload["results"]),
+ "uploaded_count": uploaded_count,
+ "failed_count": failed_count,
+ "completed_at": utc_now(),
+ "lease_owner": None,
+ "lease_expires_at": None,
+ "payload": payload,
+ "error": remaining_error,
+ },
+ )
+ except _NodeImageLeaseLost:
+ logger.info("NodeImage upload job %s stopped after losing its lease", job_id)
+ finally:
+ stop_lease_renewal.set()
+ if not renew_task.done():
+ renew_task.cancel()
+ await asyncio.gather(renew_task, return_exceptions=True)
+
+
async def _run_gallery_job_dispatcher(kind: str, worker_id: str, running_limit: int) -> None:
runner_by_kind = {
"export": _run_gallery_export_job,
"sync": _run_gallery_sync_job,
"import": _run_gallery_import_job,
+ NODEIMAGE_UPLOAD_JOB_KIND: _run_nodeimage_upload_job,
}
runner = runner_by_kind[kind]
@@ -1389,5 +1965,13 @@ async def run_gallery_import_dispatcher(worker_id: str) -> None:
await _run_gallery_job_dispatcher("import", worker_id, MAX_ACTIVE_IMPORT_JOBS)
+async def run_gallery_nodeimage_upload_dispatcher(worker_id: str) -> None:
+ await _run_gallery_job_dispatcher(
+ NODEIMAGE_UPLOAD_JOB_KIND,
+ worker_id,
+ MAX_ACTIVE_NODEIMAGE_UPLOAD_JOBS,
+ )
+
+
__all__ = [name for name in globals() if not name.startswith("__")]
diff --git a/backend/app/services/gallery_maintenance.py b/backend/app/services/gallery_maintenance.py
index e66d1f6..391c03e 100644
--- a/backend/app/services/gallery_maintenance.py
+++ b/backend/app/services/gallery_maintenance.py
@@ -40,6 +40,9 @@ from .gallery_common import (
GALLERY_FILE_GC_INTERVAL_SECONDS,
IMPORT_JOB_TTL_SECONDS,
MAX_ACTIVE_SYNC_JOBS,
+ NODEIMAGE_UPLOAD_JOB_KIND,
+ NODEIMAGE_UPLOAD_JOB_TTL_SECONDS,
+ NODEIMAGE_UPLOAD_TERMINAL_STATUSES,
SCHEDULED_R2_SYNC_DISABLED_POLL_SECONDS,
SYNC_JOB_TTL_SECONDS,
THUMBNAIL_DISPATCH_INTERVAL_SECONDS,
@@ -53,6 +56,7 @@ from .gallery_jobs import (
_sleep_while_renewing_background_lease,
run_gallery_export_dispatcher,
run_gallery_import_dispatcher,
+ run_gallery_nodeimage_upload_dispatcher,
run_gallery_sync_dispatcher,
)
@@ -168,6 +172,7 @@ def kick_gallery_job_dispatchers() -> None:
("gallery_export_dispatcher_task", run_gallery_export_dispatcher),
("gallery_sync_dispatcher_task", run_gallery_sync_dispatcher),
("gallery_import_dispatcher_task", run_gallery_import_dispatcher),
+ ("gallery_nodeimage_upload_dispatcher_task", run_gallery_nodeimage_upload_dispatcher),
):
task = getattr(app.state, name, None)
if task and not task.done():
@@ -372,6 +377,12 @@ async def gc_gallery_export_jobs(worker_id: str) -> None:
AI_ANALYZE_JOB_KIND,
AI_ANALYZE_JOB_TTL_SECONDS,
)
+ stale_nodeimage_upload_jobs = await asyncio.to_thread(
+ cleanup_stale_gallery_jobs,
+ NODEIMAGE_UPLOAD_JOB_KIND,
+ NODEIMAGE_UPLOAD_JOB_TTL_SECONDS,
+ NODEIMAGE_UPLOAD_TERMINAL_STATUSES,
+ )
expired_direct_slots = await asyncio.to_thread(
cleanup_expired_gallery_jobs,
"export_direct",
@@ -398,15 +409,17 @@ async def gc_gallery_export_jobs(worker_id: str) -> None:
or stale_imports
or stale_direct_exports
or stale_ai_analyze_jobs
+ or stale_nodeimage_upload_jobs
or expired_direct_slots
):
logger.info(
- "GC cleaned up %d gallery export job(s), %d sync job(s), %d import job(s), %d completed direct export job(s), %d AI analyze job(s), and %d direct export slot(s)",
+ "GC cleaned up %d gallery export job(s), %d sync job(s), %d import job(s), %d completed direct export job(s), %d AI analyze job(s), %d NodeImage upload job(s), and %d direct export slot(s)",
len(stale_exports),
len(stale_syncs),
len(stale_imports),
len(stale_direct_exports),
len(stale_ai_analyze_jobs),
+ len(stale_nodeimage_upload_jobs),
len(expired_direct_slots),
)
exports_dir = Path(config.DATA_DIR) / "exports"
diff --git a/backend/tests/support/contract.py b/backend/tests/support/contract.py
index 1f2bd7a..d9c40d3 100644
--- a/backend/tests/support/contract.py
+++ b/backend/tests/support/contract.py
@@ -214,6 +214,7 @@ def _configure_runtime(tmp_path: Path, *, access_key: str = "", allow_unauthenti
config.R2_SECRET_ACCESS_KEY = ""
config.R2_SYNC_INTERVAL_HOURS = 0
config.NODEIMAGE_API_KEY = ""
+ config.NODEIMAGE_UPLOAD_CONCURRENCY = 4
config.MAX_SSE_SUBSCRIBERS_GLOBAL = 200
config.MAX_SSE_SUBSCRIBERS_PER_IP = 10
config.SSE_CONNECTION_TTL_SECONDS = 3600
diff --git a/backend/tests/test_access_settings_contract.py b/backend/tests/test_access_settings_contract.py
index aa2c72b..af66322 100644
--- a/backend/tests/test_access_settings_contract.py
+++ b/backend/tests/test_access_settings_contract.py
@@ -1497,19 +1497,20 @@ def test_r2_health_uses_draft_settings_and_preserves_masked_credentials(client,
"/api/settings/r2/health",
json={
"enabled": True,
- "endpoint_url": "https://draft.r2.cloudflarestorage.com",
+ "endpoint_url": "https://account.r2.cloudflarestorage.com",
"bucket_name": "draft-backups",
"region": "auto",
"key_prefix": "draft/",
"access_key_id": "********",
"secret_access_key": "********",
+ "use_credentials": True,
},
)
assert health.status_code == 200
assert health.json()["status"] == "ok"
assert health.json()["checks"][0]["name"] == "configuration"
- assert seen["draft"]["endpoint_url"] == "https://draft.r2.cloudflarestorage.com"
+ assert seen["draft"]["endpoint_url"] == "https://account.r2.cloudflarestorage.com"
assert seen["draft"]["bucket_name"] == "draft-backups"
assert seen["draft"]["access_key_id"] == "${TEST_R2_ACCESS_KEY_ID}"
assert seen["draft"]["secret_access_key"] == "${TEST_R2_SECRET_ACCESS_KEY}"
@@ -1808,7 +1809,10 @@ def test_prompt_snippets_crud_search_and_validation(client):
first_body["id"],
]
- searched = client.get("/api/prompt-snippets", params={"query": "portrait"})
+ searched = client.post(
+ "/api/prompt-snippets/search",
+ json={"query": "portrait"},
+ )
assert searched.status_code == 200
assert [snippet["id"] for snippet in searched.json()["snippets"]] == [
first_body["id"],
@@ -2049,7 +2053,10 @@ def test_preset_health_and_env_api_key_resolution(client, monkeypatch):
assert updated_body["api_key_masked"] == "${OPENAI_API_KEY}"
assert "env-secret" not in json.dumps(updated_body)
- health = client.post(f"/api/settings/presets/{active_preset_id}/health")
+ health = client.post(
+ f"/api/settings/presets/{active_preset_id}/health",
+ json={"use_credentials": True},
+ )
assert health.status_code == 200
assert health.json()["status"] == "ok"
assert seen["probe_key"] == "env-secret"
@@ -2101,32 +2108,23 @@ def test_overall_config_syncs_env_and_hot_override(client, monkeypatch):
assert item["value"] is False
-def test_overall_config_secret_mask_and_preserve(client):
+def test_overall_config_startup_secret_cannot_be_overridden(client):
response = client.put(
"/api/settings/overall-config",
json={"updates": [{"name": "WEBHOOK_SIGNING_SECRET", "value": "super-secret"}]},
)
assert response.status_code == 422
- assert "ALLOW_PLAINTEXT_SECRETS" in response.text
+ assert "process startup" in response.text
config.ALLOW_PLAINTEXT_SECRETS = True
response = client.put(
"/api/settings/overall-config",
json={"updates": [{"name": "WEBHOOK_SIGNING_SECRET", "value": "super-secret"}]},
)
- assert response.status_code == 200
- item = next(i for i in response.json()["items"] if i["name"] == "WEBHOOK_SIGNING_SECRET")
- assert item["value"] == "********"
- assert item["value_masked"] == "********"
- assert "super-secret" not in response.text
-
- response = client.put(
- "/api/settings/overall-config",
- json={"updates": [{"name": "WEBHOOK_SIGNING_SECRET", "value": "********"}]},
- )
- assert response.status_code == 200
+ assert response.status_code == 422
+ assert "process startup" in response.text
rows = settings_repo.list_overall_config_values()
- assert rows["WEBHOOK_SIGNING_SECRET"]["override_value"] == "super-secret"
+ assert rows["WEBHOOK_SIGNING_SECRET"]["override_value"] is None
def test_overall_config_secret_env_value_is_not_persisted(client, monkeypatch):
@@ -2165,12 +2163,18 @@ def test_overall_config_validation_errors(client):
def test_overall_config_restart_and_build_only_badges(client):
+ startup_only = client.put(
+ "/api/settings/overall-config",
+ json={"updates": [{"name": "IP_ALLOWLIST", "value": "192.0.2.1"}]},
+ )
+ assert startup_only.status_code == 422
+ assert "process startup" in startup_only.text
+
response = client.put(
"/api/settings/overall-config",
json={
"updates": [
{"name": "ACCESS_KEY_COOKIE_NAME", "value": "custom_access"},
- {"name": "IP_ALLOWLIST", "value": "192.0.2.1"},
{"name": "PYTHON_BASE_IMAGE", "value": "python:3.12-slim"},
]
},
@@ -2179,12 +2183,12 @@ def test_overall_config_restart_and_build_only_badges(client):
body = response.json()
assert set(body["restart_required_names"]) == {
"ACCESS_KEY_COOKIE_NAME",
- "IP_ALLOWLIST",
"PYTHON_BASE_IMAGE",
}
items = {item["name"]: item for item in body["items"]}
assert items["ACCESS_KEY_COOKIE_NAME"]["restart_required"] is True
assert items["IP_ALLOWLIST"]["restart_required"] is True
+ assert items["IP_ALLOWLIST"]["startup_only"] is True
assert items["PYTHON_BASE_IMAGE"]["build_only"] is True
@@ -2251,18 +2255,16 @@ def test_missing_env_api_key_is_reported(client, monkeypatch):
)
assert updated.status_code == 200
- health = client.post(f"/api/settings/presets/{active_preset_id}/health")
- assert health.status_code == 200
- body = health.json()
- assert body["status"] == "error"
- assert any(
- check["name"] == "api_key" and check["status"] == "error"
- for check in body["checks"]
+ health = client.post(
+ f"/api/settings/presets/{active_preset_id}/health",
+ json={"use_credentials": True},
)
+ assert health.status_code == 422
+ assert "MISSING_IMAGE_KEY" in health.json()["detail"]
resp = client.post(
"/api/generate",
json={"prompt": "missing env", "model": "gpt-image-2"},
)
- assert resp.status_code == 400
+ assert resp.status_code == 422
assert "MISSING_IMAGE_KEY" in resp.json()["detail"]
diff --git a/backend/tests/test_assistant_contract.py b/backend/tests/test_assistant_contract.py
index d7fdef7..89742f6 100644
--- a/backend/tests/test_assistant_contract.py
+++ b/backend/tests/test_assistant_contract.py
@@ -932,7 +932,7 @@ def test_ai_assistant_gallery_batch_analysis_validates_runtime_before_queueing(c
json={"ids": ["assistant-batch-config-error"]},
)
- assert submitted.status_code == 400
+ assert submitted.status_code == 422
assert "MISSING_ASSISTANT_API_KEY is not set or empty" in submitted.json()["detail"]
assert coordination_repo.count_active_gallery_jobs("ai_analyze") == 0
diff --git a/backend/tests/test_nodeimage_upload_contract.py b/backend/tests/test_nodeimage_upload_contract.py
index ecaf8a3..24c744f 100644
--- a/backend/tests/test_nodeimage_upload_contract.py
+++ b/backend/tests/test_nodeimage_upload_contract.py
@@ -1,6 +1,11 @@
import asyncio
+import io
import json
+import logging
+import threading
+import time
from pathlib import Path
+from types import SimpleNamespace
import aiohttp
import pytest
@@ -16,7 +21,9 @@ from backend.tests.support.contract import (
from backend.app.api.routers import gallery_batch
from backend.app.core import secrets
from backend.app.integrations.nodeimage import client as nodeimage_client
-from backend.app.services import gallery_common
+from backend.app.repositories.coordination import create_gallery_job, get_gallery_job
+from backend.app.schemas.gallery import GalleryEntry
+from backend.app.services import gallery_jobs
def _enable_nodeimage() -> None:
@@ -28,14 +35,54 @@ def _enable_nodeimage() -> None:
)
+def _wait_for_nodeimage_job(test_client, job_id: str, timeout: float = 5.0):
+ deadline = time.time() + timeout
+ last = None
+ while time.time() < deadline:
+ response = test_client.get(
+ f"/api/gallery/nodeimage-upload-jobs/{job_id}"
+ )
+ assert response.status_code == 200
+ last = response.json()
+ if last["status"] in {
+ "success",
+ "partial_failure",
+ "cancelled",
+ "error",
+ }:
+ return last
+ time.sleep(0.05)
+ raise AssertionError(f"NodeImage job {job_id} did not finish: {last}")
+
+
+def _start_nodeimage_batch(test_client, ids: list[str]):
+ response = test_client.post(
+ "/api/gallery/batch/nodeimage-upload",
+ json={"ids": ids},
+ )
+ assert response.status_code == 202
+ body = response.json()
+ assert body["status_url"].endswith(
+ f"/api/gallery/nodeimage-upload-jobs/{body['job_id']}"
+ )
+ assert body["events_url"].endswith(
+ f"/api/gallery/nodeimage-upload-jobs/{body['job_id']}/events"
+ )
+ assert body["cancel_url"].endswith(
+ f"/api/gallery/nodeimage-upload-jobs/{body['job_id']}/cancel"
+ )
+ return body
+
+
def test_single_nodeimage_upload_success_and_missing_entry(client, monkeypatch):
_fake_gallery_entry("node-single", "single", "1024x1024", "node-single.png")
_enable_nodeimage()
seen = {}
- async def fake_upload(image_bytes, filename, effective):
+ async def fake_upload(path, filename, effective):
seen.update(
- image_bytes=image_bytes,
+ path=path,
+ size=path.stat().st_size,
filename=filename,
api_key=effective.api_key,
)
@@ -44,7 +91,12 @@ def test_single_nodeimage_upload_success_and_missing_entry(client, monkeypatch):
markdown="",
)
- monkeypatch.setattr(gallery_queries_router, "upload_image_bytes", fake_upload)
+ monkeypatch.setattr(gallery_queries_router, "upload_image_file", fake_upload)
+ monkeypatch.setattr(
+ gallery_queries_router,
+ "_resolve_gallery_image_path",
+ lambda _filename: pytest.fail("single upload must not re-query gallery references"),
+ )
response = client.post("/api/gallery/node-single/nodeimage-upload")
@@ -54,13 +106,54 @@ def test_single_nodeimage_upload_success_and_missing_entry(client, monkeypatch):
"markdown": "",
}
assert seen == {
- "image_bytes": PNG_BYTES,
+ "path": Path(config.IMAGES_DIR) / "node-single.png",
+ "size": len(PNG_BYTES),
"filename": "node-single.png",
"api_key": "nodeimage-api-key",
}
assert client.post("/api/gallery/missing/nodeimage-upload").status_code == 404
+def test_single_nodeimage_upload_preserves_missing_and_unreadable_file_errors(
+ client,
+ monkeypatch,
+):
+ missing = _fake_gallery_entry(
+ "node-missing-file",
+ "missing",
+ "1024x1024",
+ "node-missing-file.png",
+ )
+ unreadable = _fake_gallery_entry(
+ "node-unreadable-file",
+ "unreadable",
+ "1024x1024",
+ "node-unreadable-file.png",
+ )
+ _enable_nodeimage()
+ (Path(config.IMAGES_DIR) / missing.filename).unlink()
+
+ missing_response = client.post(
+ "/api/gallery/node-missing-file/nodeimage-upload"
+ )
+ assert missing_response.status_code == 404
+ assert missing_response.json()["detail"] == "Image file not found"
+
+ async def unreadable_upload(*_args):
+ raise OSError("permission denied")
+
+ monkeypatch.setattr(
+ gallery_queries_router,
+ "upload_image_file",
+ unreadable_upload,
+ )
+ unreadable_response = client.post(
+ "/api/gallery/node-unreadable-file/nodeimage-upload"
+ )
+ assert unreadable_response.status_code == 404
+ assert unreadable_response.json()["detail"] == "Image file could not be read"
+
+
def test_single_nodeimage_upload_rejects_missing_config_and_upstream_failure(
client,
monkeypatch,
@@ -76,19 +169,19 @@ def test_single_nodeimage_upload_rejects_missing_config_and_upstream_failure(
async def fail_upload(*_args):
raise nodeimage_client.NodeImageUploadError("NodeImage quota exceeded")
- monkeypatch.setattr(gallery_queries_router, "upload_image_bytes", fail_upload)
+ monkeypatch.setattr(gallery_queries_router, "upload_image_file", fail_upload)
failed = client.post("/api/gallery/node-failure/nodeimage-upload")
assert failed.status_code == 502
assert failed.json()["detail"] == "NodeImage quota exceeded"
-def test_batch_nodeimage_upload_reports_partial_results(client, monkeypatch):
+def test_batch_nodeimage_upload_reports_partial_results(client, monkeypatch, caplog):
_fake_gallery_entry("node-batch-1", "one", "1024x1024", "node-batch-1.png")
_fake_gallery_entry("node-batch-2", "two", "1024x1024", "node-batch-2.png")
_fake_gallery_entry("node-batch-3", "three", "1024x1024", "node-batch-3.png")
_enable_nodeimage()
- async def fake_upload(_image_bytes, filename, _effective):
+ async def fake_upload(_path, filename, _effective):
if filename == "node-batch-2.png":
raise nodeimage_client.NodeImageUploadError("upstream unavailable")
if filename == "node-batch-3.png":
@@ -98,21 +191,19 @@ def test_batch_nodeimage_upload_reports_partial_results(client, monkeypatch):
markdown=f"",
)
- monkeypatch.setattr(gallery_batch, "upload_image_bytes", fake_upload)
- response = client.post(
- "/api/gallery/batch/nodeimage-upload",
- json={
- "ids": [
- "node-batch-1",
- "missing-node",
- "node-batch-2",
- "node-batch-3",
- ]
- },
+ monkeypatch.setattr(gallery_jobs, "upload_image_file", fake_upload)
+ caplog.set_level(logging.ERROR, logger=gallery_jobs.__name__)
+ created = _start_nodeimage_batch(
+ client,
+ [
+ "node-batch-1",
+ "missing-node",
+ "node-batch-2",
+ "node-batch-3",
+ ],
)
+ body = _wait_for_nodeimage_job(client, created["job_id"])
- assert response.status_code == 200
- body = response.json()
assert body["requested_count"] == 4
assert body["uploaded_count"] == 1
assert body["failed_count"] == 3
@@ -130,6 +221,13 @@ def test_batch_nodeimage_upload_reports_partial_results(client, monkeypatch):
assert body["results"][2]["filename"] == "node-batch-2.png"
assert body["results"][3]["error"] == "Unexpected upload failure"
assert body["results"][3]["filename"] == "node-batch-3.png"
+ assert "unexpected upstream shape" not in json.dumps(body)
+ unexpected_record = next(
+ record
+ for record in caplog.records
+ if "Unexpected NodeImage upload failure" in record.getMessage()
+ )
+ assert unexpected_record.exc_info is not None
def test_batch_nodeimage_auth_probe_stops_uploads_and_preserves_file_errors(
@@ -165,45 +263,33 @@ def test_batch_nodeimage_auth_probe_stops_uploads_and_preserves_file_errors(
calls = []
- async def reject_upload(_image_bytes, filename, _effective):
+ async def reject_upload(_path, filename, _effective):
calls.append(filename)
raise nodeimage_client.NodeImageAuthError(
"NodeImage API key was rejected."
)
- original_read_bytes = Path.read_bytes
+ unreadable_path = Path(config.IMAGES_DIR) / "node-unreadable.png"
+ unreadable_path.unlink()
+ unreadable_path.mkdir()
- def read_bytes(path):
- if path.name == "node-unreadable.png":
- raise PermissionError("not readable")
- return original_read_bytes(path)
+ def fail_read_bytes(_path):
+ raise AssertionError("NodeImage batch upload must not read the whole file")
- def fail_reference_check(_filename):
- raise AssertionError("batch upload performed a gallery reference query")
+ monkeypatch.setattr(gallery_jobs, "upload_image_file", reject_upload)
+ monkeypatch.setattr(Path, "read_bytes", fail_read_bytes)
- monkeypatch.setattr(gallery_batch, "upload_image_bytes", reject_upload)
- monkeypatch.setattr(Path, "read_bytes", read_bytes)
- monkeypatch.setattr(
- gallery_common,
- "is_gallery_filename_referenced",
- fail_reference_check,
+ created = _start_nodeimage_batch(
+ client,
+ [
+ "node-missing-file",
+ "missing-node",
+ "node-auth-probe",
+ "node-unreadable",
+ "node-not-attempted",
+ ],
)
-
- response = client.post(
- "/api/gallery/batch/nodeimage-upload",
- json={
- "ids": [
- "node-missing-file",
- "missing-node",
- "node-auth-probe",
- "node-unreadable",
- "node-not-attempted",
- ]
- },
- )
-
- assert response.status_code == 200
- body = response.json()
+ body = _wait_for_nodeimage_job(client, created["job_id"])
assert calls == ["node-auth-probe.png"]
assert body["requested_count"] == 5
assert body["uploaded_count"] == 0
@@ -246,7 +332,7 @@ def test_batch_nodeimage_probe_then_limits_concurrency(client, monkeypatch):
max_active = 0
calls = []
- async def fake_upload(_image_bytes, filename, _effective):
+ async def fake_upload(_path, filename, _effective):
nonlocal active, max_active
calls.append(filename)
active += 1
@@ -258,14 +344,10 @@ def test_batch_nodeimage_probe_then_limits_concurrency(client, monkeypatch):
markdown=f"",
)
- monkeypatch.setattr(gallery_batch, "upload_image_bytes", fake_upload)
- response = client.post(
- "/api/gallery/batch/nodeimage-upload",
- json={"ids": ids},
- )
-
- assert response.status_code == 200
- body = response.json()
+ config.NODEIMAGE_UPLOAD_CONCURRENCY = 4
+ monkeypatch.setattr(gallery_jobs, "upload_image_file", fake_upload)
+ created = _start_nodeimage_batch(client, ids)
+ body = _wait_for_nodeimage_job(client, created["job_id"])
assert calls[0] == "node-concurrent-0.png"
assert len(calls) == 7
assert max_active == 4
@@ -290,7 +372,7 @@ def test_batch_nodeimage_stops_queued_uploads_after_late_auth_failure(
calls = []
- async def fake_upload(_image_bytes, filename, _effective):
+ async def fake_upload(_path, filename, _effective):
calls.append(filename)
if filename == "node-late-auth-0.png":
return nodeimage_client.NodeImageUploadResult(
@@ -308,15 +390,14 @@ def test_batch_nodeimage_stops_queued_uploads_after_late_auth_failure(
markdown=f"",
)
- monkeypatch.setattr(gallery_batch, "upload_image_bytes", fake_upload)
- response = client.post(
- "/api/gallery/batch/nodeimage-upload",
- json={"ids": ids},
- )
-
- assert response.status_code == 200
- body = response.json()
- assert calls == [f"node-late-auth-{index}.png" for index in range(5)]
+ config.NODEIMAGE_UPLOAD_CONCURRENCY = 4
+ monkeypatch.setattr(gallery_jobs, "upload_image_file", fake_upload)
+ created = _start_nodeimage_batch(client, ids)
+ body = _wait_for_nodeimage_job(client, created["job_id"])
+ assert calls[0] == "node-late-auth-0.png"
+ assert set(calls) == {
+ f"node-late-auth-{index}.png" for index in range(5)
+ }
assert body["uploaded_count"] == 4
assert body["failed_count"] == 3
assert [item["error"] for item in body["results"][5:]] == [
@@ -346,13 +427,13 @@ def test_batch_nodeimage_upload_accepts_filtered_selection_token(client, monkeyp
)
_enable_nodeimage()
- async def fake_upload(_image_bytes, filename, _effective):
+ async def fake_upload(_path, filename, _effective):
return nodeimage_client.NodeImageUploadResult(
url=f"https://cdn.nodeimage.com/{filename}",
markdown=f"",
)
- monkeypatch.setattr(gallery_batch, "upload_image_bytes", fake_upload)
+ monkeypatch.setattr(gallery_jobs, "upload_image_file", fake_upload)
token_response = client.post(
"/api/gallery/batch/selection-tokens",
json={"filters": {"prompt": "nodeimage token match"}},
@@ -360,13 +441,12 @@ def test_batch_nodeimage_upload_accepts_filtered_selection_token(client, monkeyp
assert token_response.status_code == 201
assert token_response.json()["count"] == 2
- response = client.post(
+ created = client.post(
"/api/gallery/batch/nodeimage-upload",
json={"selection_token": token_response.json()["selection_token"]},
)
-
- assert response.status_code == 200
- body = response.json()
+ assert created.status_code == 202
+ body = _wait_for_nodeimage_job(client, created.json()["job_id"])
assert body["requested_count"] == 2
assert body["uploaded_count"] == 2
assert body["failed_count"] == 0
@@ -388,11 +468,7 @@ def test_batch_nodeimage_upload_rejects_selection_token_over_limit(
f"node-token-limit-{index}.png",
)
- async def fail_upload(_image_bytes, _filename, _effective):
- raise AssertionError("NodeImage upload should not start over the batch cap")
-
monkeypatch.setattr(gallery_batch, "NODEIMAGE_BATCH_MAX", 2)
- monkeypatch.setattr(gallery_batch, "upload_image_bytes", fail_upload)
token_response = client.post(
"/api/gallery/batch/selection-tokens",
json={"filters": {"prompt": "nodeimage token over limit"}},
@@ -410,10 +486,173 @@ def test_batch_nodeimage_upload_rejects_selection_token_over_limit(
assert "selected 3" in response.json()["detail"]
+def test_nodeimage_batch_rejects_oversized_files_before_upload(client, monkeypatch):
+ entry = _fake_gallery_entry(
+ "node-oversized",
+ "oversized",
+ "1024x1024",
+ "node-oversized.png",
+ )
+ _enable_nodeimage()
+ config.MAX_FILE_SIZE_MB = 1
+ oversized_path = Path(config.IMAGES_DIR) / entry.filename
+ oversized_path.write_bytes(b"x" * (config.MAX_FILE_SIZE_MB * 1024 * 1024 + 1))
+ calls = []
+
+ async def fail_upload(_path, filename, _effective):
+ calls.append(filename)
+ raise AssertionError("oversized files must fail before upload")
+
+ monkeypatch.setattr(gallery_jobs, "upload_image_file", fail_upload)
+ created = _start_nodeimage_batch(client, [entry.id])
+ body = _wait_for_nodeimage_job(client, created["job_id"])
+
+ assert calls == []
+ assert body["status"] == "partial_failure"
+ assert body["failed_count"] == 1
+ assert body["results"][0]["error"] == "Image file is too large. Max size is 1 MB"
+
+
+def test_nodeimage_batch_cancellation_preserves_inflight_results(client, monkeypatch):
+ ids = [f"node-cancel-{index}" for index in range(3)]
+ for image_id in ids:
+ _fake_gallery_entry(image_id, image_id, "1024x1024", f"{image_id}.png")
+ _enable_nodeimage()
+ config.NODEIMAGE_UPLOAD_CONCURRENCY = 1
+ started = threading.Event()
+ release = threading.Event()
+ calls = []
+
+ async def slow_upload(_path, filename, _effective):
+ calls.append(filename)
+ started.set()
+ await asyncio.to_thread(release.wait, 3)
+ return nodeimage_client.NodeImageUploadResult(
+ url=f"https://cdn.nodeimage.com/{filename}",
+ markdown=f"",
+ )
+
+ monkeypatch.setattr(gallery_jobs, "upload_image_file", slow_upload)
+ created = _start_nodeimage_batch(client, ids)
+ assert started.wait(2)
+
+ cancel_response = client.delete(
+ f"/api/gallery/nodeimage-upload-jobs/{created['job_id']}"
+ )
+ assert cancel_response.status_code == 200
+ assert cancel_response.json()["stage"] == "cancelling"
+ release.set()
+
+ body = _wait_for_nodeimage_job(client, created["job_id"])
+ assert body["status"] == "cancelled"
+ assert body["uploaded_count"] == 1
+ assert body["failed_count"] == 0
+ assert body["cancelled_count"] == 2
+ assert [item["status"] for item in body["results"]] == [
+ "ok",
+ "cancelled",
+ "cancelled",
+ ]
+ assert calls == [f"{ids[0]}.png"]
+
+
+def test_nodeimage_batch_capacity_is_reserved_before_dispatch(client, monkeypatch):
+ ids = ["node-capacity-0", "node-capacity-1"]
+ for image_id in ids:
+ _fake_gallery_entry(image_id, image_id, "1024x1024", f"{image_id}.png")
+ _enable_nodeimage()
+ started = threading.Event()
+ release = threading.Event()
+
+ async def slow_upload(_path, filename, _effective):
+ started.set()
+ await asyncio.to_thread(release.wait, 3)
+ return nodeimage_client.NodeImageUploadResult(
+ url=f"https://cdn.nodeimage.com/{filename}",
+ markdown=f"",
+ )
+
+ monkeypatch.setattr(gallery_jobs, "upload_image_file", slow_upload)
+ created = _start_nodeimage_batch(client, ids)
+ assert started.wait(2)
+
+ rejected = client.post(
+ "/api/gallery/batch/nodeimage-upload",
+ json={"ids": [ids[0]]},
+ )
+ assert rejected.status_code == 429
+ assert "active NodeImage upload jobs" in rejected.json()["detail"]
+ release.set()
+ _wait_for_nodeimage_job(client, created["job_id"])
+
+
+def test_nodeimage_batch_sse_replays_persisted_terminal_results(client, monkeypatch):
+ _fake_gallery_entry("node-sse", "sse", "1024x1024", "node-sse.png")
+ _enable_nodeimage()
+
+ async def fake_upload(_path, filename, _effective):
+ return nodeimage_client.NodeImageUploadResult(
+ url=f"https://cdn.nodeimage.com/{filename}",
+ markdown=f"",
+ )
+
+ monkeypatch.setattr(gallery_jobs, "upload_image_file", fake_upload)
+ created = _start_nodeimage_batch(client, ["node-sse"])
+ body = _wait_for_nodeimage_job(client, created["job_id"])
+ assert body["status"] == "success"
+
+ events = client.get(body["events_url"])
+ assert events.status_code == 200
+ assert "event: nodeimage_upload" in events.text
+ assert '"status":"success"' in events.text
+ assert "https://cdn.nodeimage.com/node-sse.png" in events.text
+
+
+def test_nodeimage_batch_recovery_skips_persisted_completed_items(client, monkeypatch):
+ ids = ["node-recovery-0", "node-recovery-1"]
+ for image_id in ids:
+ _fake_gallery_entry(image_id, image_id, "1024x1024", f"{image_id}.png")
+ _enable_nodeimage()
+ calls = []
+ first_result = gallery_jobs._nodeimage_result_item(
+ ids[0],
+ f"{ids[0]}.png",
+ "ok",
+ url="https://cdn.nodeimage.com/recovered.png",
+ markdown="",
+ )
+ job = gallery_jobs._build_nodeimage_upload_job(ids, 2, [])
+ job.update(
+ status="running",
+ stage="uploading",
+ lease_owner="recovery-worker",
+ lease_expires_at=gallery_jobs._gallery_job_lease_expires_at(),
+ payload={"ids": ids, "results": [first_result], "cancel_requested": False},
+ )
+ stored_job = create_gallery_job(**job)
+
+ async def fake_upload(_path, filename, _effective):
+ calls.append(filename)
+ return nodeimage_client.NodeImageUploadResult(
+ url=f"https://cdn.nodeimage.com/{filename}",
+ markdown=f"",
+ )
+
+ monkeypatch.setattr(gallery_jobs, "upload_image_file", fake_upload)
+ asyncio.run(gallery_jobs._run_nodeimage_upload_job(stored_job))
+ recovered = get_gallery_job("nodeimage_upload", stored_job["job_id"])
+
+ assert recovered is not None
+ assert recovered["status"] == "success"
+ assert calls == [f"{ids[1]}.png"]
+ assert [item["image_id"] for item in recovered["payload"]["results"]] == ids
+
+
def test_nodeimage_settings_round_trip_and_secret_origin_binding(client):
current = client.get("/api/settings").json()
assert current["nodeimage"]["enabled"] is False
assert current["nodeimage"]["has_api_key"] is False
+ assert current["nodeimage"]["api_key_resolvable"] is False
payload = {
"active_preset_id": current["active_preset_id"],
@@ -434,6 +673,7 @@ def test_nodeimage_settings_round_trip_and_secret_origin_binding(client):
"enabled": True,
"api_key_masked": "${TEST_NODEIMAGE_API_KEY}",
"has_api_key": True,
+ "api_key_resolvable": True,
"api_key_source": "env",
"api_key_env_var": "TEST_NODEIMAGE_API_KEY",
"api_key_secret_id": None,
@@ -457,6 +697,62 @@ def test_nodeimage_settings_round_trip_and_secret_origin_binding(client):
assert "not bound to the target origin" in rejected.json()["detail"]
+def test_nodeimage_settings_reports_whether_saved_key_can_be_resolved(
+ client,
+ monkeypatch,
+ request,
+):
+ settings_repo.save_nodeimage_settings(
+ {
+ "enabled": True,
+ "api_key": "${UNSET_NODEIMAGE_API_KEY}",
+ }
+ )
+ monkeypatch.delenv("UNSET_NODEIMAGE_API_KEY", raising=False)
+
+ unresolved = client.get("/api/settings")
+ assert unresolved.status_code == 200
+ assert unresolved.json()["nodeimage"] == {
+ "enabled": True,
+ "api_key_masked": "${UNSET_NODEIMAGE_API_KEY}",
+ "has_api_key": True,
+ "api_key_resolvable": False,
+ "api_key_source": "env",
+ "api_key_env_var": "UNSET_NODEIMAGE_API_KEY",
+ "api_key_secret_id": None,
+ }
+
+ monkeypatch.setenv("UNSET_NODEIMAGE_API_KEY", "resolved-nodeimage-key")
+ resolved = client.get("/api/settings")
+ assert resolved.status_code == 200
+ assert resolved.json()["nodeimage"]["api_key_resolvable"] is True
+
+ request.addfinalizer(lambda: secrets.configure_registry(""))
+ secrets.configure_registry(
+ json.dumps(
+ {
+ "nodeimage-invalid-binding": {
+ "purpose": "nodeimage_api_key",
+ "origin": "https://example.com",
+ "env": "UNSET_NODEIMAGE_API_KEY",
+ }
+ }
+ )
+ )
+ settings_repo.save_nodeimage_settings(
+ {
+ "enabled": True,
+ "api_key": "nodeimage-invalid-binding",
+ }
+ )
+ invalid_binding = client.get("/api/settings")
+ assert invalid_binding.status_code == 200
+ assert invalid_binding.json()["nodeimage"]["has_api_key"] is True
+ assert invalid_binding.json()["nodeimage"]["api_key_source"] == "registry"
+ assert invalid_binding.json()["nodeimage"]["api_key_resolvable"] is False
+ secrets.configure_registry("")
+
+
def test_nodeimage_settings_plaintext_path_is_opt_in_and_masked(client):
current = client.get("/api/settings").json()
payload = {
@@ -531,6 +827,53 @@ class _Pool:
return self.session
+class _TrackingFile(io.BytesIO):
+ pass
+
+
+def test_nodeimage_client_reopens_and_closes_streaming_source_on_retry(monkeypatch):
+ effective = nodeimage_client.NodeImageEffectiveSettings(True, "test-key")
+ session = _Session(
+ [
+ _Response(503, {"success": False, "error": "temporary failure"}),
+ _Response(
+ 200,
+ {
+ "success": True,
+ "links": {
+ "direct": "https://cdn.nodeimage.com/stream.png",
+ "markdown": "",
+ },
+ },
+ ),
+ ]
+ )
+ opened = []
+
+ def source():
+ handle = _TrackingFile(PNG_BYTES)
+ opened.append(handle)
+ return handle
+
+ monkeypatch.setattr(nodeimage_client, "get_pool", lambda: _Pool(session))
+
+ async def no_sleep(_delay):
+ return None
+
+ monkeypatch.setattr(nodeimage_client.asyncio, "sleep", no_sleep)
+ result = asyncio.run(
+ nodeimage_client.upload_image_source(source, "stream.png", effective)
+ )
+
+ assert result.url.endswith("stream.png")
+ assert len(opened) == 2
+ assert all(handle.closed for handle in opened)
+ assert all(
+ getattr(call[1]["data"], "_fields", [({}, {}, handle)])[0][2] is handle
+ for call, handle in zip(session.calls, opened)
+ )
+
+
def test_nodeimage_client_retries_5xx_once_and_does_not_retry_auth(monkeypatch):
effective = nodeimage_client.NodeImageEffectiveSettings(True, "test-key")
session = _Session(
@@ -677,6 +1020,170 @@ def test_nodeimage_client_retries_connect_error_once(monkeypatch):
assert len(session.calls) == 2
+def test_nodeimage_client_retries_unreadable_5xx_response_once(monkeypatch):
+ effective = nodeimage_client.NodeImageEffectiveSettings(True, "test-key")
+ session = _Session(
+ [
+ _Response(503, aiohttp.ClientConnectionError("response interrupted")),
+ _Response(
+ 200,
+ {
+ "success": True,
+ "links": {
+ "direct": "https://cdn.nodeimage.com/recovered.png",
+ },
+ },
+ ),
+ ]
+ )
+ monkeypatch.setattr(nodeimage_client, "get_pool", lambda: _Pool(session))
+
+ async def no_sleep(_delay):
+ return None
+
+ monkeypatch.setattr(nodeimage_client.asyncio, "sleep", no_sleep)
+ result = asyncio.run(
+ nodeimage_client.upload_image_bytes(PNG_BYTES, "recovered.png", effective)
+ )
+
+ assert result.url.endswith("recovered.png")
+ assert len(session.calls) == 2
+
+
+def test_nodeimage_client_generates_markdown_from_validated_direct_url(monkeypatch):
+ effective = nodeimage_client.NodeImageEffectiveSettings(True, "test-key")
+ direct = "https://cdn.nodeimage.com/image(1).png?caption=]()<>"
+ session = _Session(
+ [
+ _Response(
+ 200,
+ {
+ "success": True,
+ "links": {
+ "direct": direct,
+ "markdown": ")",
+ },
+ },
+ )
+ ]
+ )
+ monkeypatch.setattr(nodeimage_client, "get_pool", lambda: _Pool(session))
+
+ result = asyncio.run(
+ nodeimage_client.upload_image_bytes(
+ PNG_BYTES,
+ "../bad](*).png",
+ effective,
+ )
+ )
+
+ assert result.url == direct
+ assert result.markdown == (
+ r"![bad\]\(\*\).png](https://cdn.nodeimage.com/"
+ r"image%281%29.png?caption=]%28%29%3C%3E)"
+ )
+ assert "javascript" not in result.markdown
+
+
+@pytest.mark.parametrize(
+ ("filename", "expected_content_type"),
+ [
+ ("image.webp", "image/webp"),
+ ("image.avif", "image/avif"),
+ ("image.heif", "image/heif"),
+ ("image.unknown-extension", "application/octet-stream"),
+ ],
+)
+def test_nodeimage_client_uses_stable_content_types(
+ monkeypatch,
+ filename,
+ expected_content_type,
+):
+ effective = nodeimage_client.NodeImageEffectiveSettings(True, "test-key")
+ session = _Session(
+ [
+ _Response(
+ 200,
+ {
+ "success": True,
+ "links": {"direct": f"https://cdn.nodeimage.com/{filename}"},
+ },
+ )
+ ]
+ )
+ monkeypatch.setattr(nodeimage_client, "get_pool", lambda: _Pool(session))
+
+ asyncio.run(nodeimage_client.upload_image_bytes(PNG_BYTES, filename, effective))
+
+ form = session.calls[0][1]["data"]
+ assert form._fields[0][1]["Content-Type"] == expected_content_type
+
+
+def test_nodeimage_file_inspection_returns_path_or_structured_error(
+ tmp_path,
+ monkeypatch,
+):
+ entry = GalleryEntry(
+ id="inspection",
+ prompt="inspection",
+ size="1x1",
+ filename="inspection.png",
+ created_at="2026-01-01T00:00:00Z",
+ )
+
+ monkeypatch.setattr(gallery_jobs, "safe_image_path", lambda _filename: None)
+ missing = gallery_jobs._inspect_nodeimage_file(entry)
+ assert isinstance(missing, dict)
+ assert missing["error"] == "Image file not found"
+
+ missing_path = tmp_path / "missing.png"
+ monkeypatch.setattr(
+ gallery_jobs,
+ "safe_image_path",
+ lambda _filename: missing_path,
+ )
+ assert gallery_jobs._inspect_nodeimage_file(entry)["error"] == "Image file not found"
+
+ empty_path = tmp_path / "empty.png"
+ empty_path.write_bytes(b"")
+ monkeypatch.setattr(
+ gallery_jobs,
+ "safe_image_path",
+ lambda _filename: empty_path,
+ )
+ assert gallery_jobs._inspect_nodeimage_file(entry)["error"] == "Image file is empty"
+
+ class UnreadablePath:
+ def stat(self):
+ return SimpleNamespace(st_size=1)
+
+ def open(self, _mode):
+ raise OSError("unreadable")
+
+ monkeypatch.setattr(
+ gallery_jobs,
+ "safe_image_path",
+ lambda _filename: UnreadablePath(),
+ )
+ unreadable = gallery_jobs._inspect_nodeimage_file(entry)
+ assert unreadable["error"] == "Image file could not be read"
+
+ valid_path = tmp_path / "valid.png"
+ valid_path.write_bytes(PNG_BYTES)
+ original_limit = config.MAX_FILE_SIZE_MB
+ monkeypatch.setattr(config, "MAX_FILE_SIZE_MB", 0)
+ monkeypatch.setattr(
+ gallery_jobs,
+ "safe_image_path",
+ lambda _filename: valid_path,
+ )
+ oversized = gallery_jobs._inspect_nodeimage_file(entry)
+ assert "too large" in oversized["error"]
+
+ monkeypatch.setattr(config, "MAX_FILE_SIZE_MB", original_limit)
+ assert gallery_jobs._inspect_nodeimage_file(entry) == valid_path
+
+
def test_nodeimage_client_does_not_retry_ambiguous_network_errors(monkeypatch):
effective = nodeimage_client.NodeImageEffectiveSettings(True, "test-key")
diff --git a/backend/tests/test_secret_registry_review_regressions.py b/backend/tests/test_secret_registry_review_regressions.py
index 8cdcf41..aacfcb3 100644
--- a/backend/tests/test_secret_registry_review_regressions.py
+++ b/backend/tests/test_secret_registry_review_regressions.py
@@ -1,6 +1,10 @@
+import json
+
+import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
+from backend.app.api import presets
from backend.app.api.middleware import register_exception_handlers
from backend.app.core import secrets
from backend.app.core import settings as config
@@ -8,6 +12,100 @@ from backend.app.integrations.r2 import config as r2_config
from backend.app.repositories import db as db_repo
+def test_resolve_secret_reference_supports_all_setting_sources(monkeypatch):
+ target_url = "https://api.nodeimage.com"
+ options = {
+ "purpose": "nodeimage_api_key",
+ "target_url": target_url,
+ "host_allowlist": "api.nodeimage.com",
+ "field_name": "NodeImage API key",
+ }
+
+ assert secrets.resolve_secret_reference("", **options) == ""
+ assert secrets.resolve_secret_reference("literal-key", **options) == "literal-key"
+
+ monkeypatch.setenv("NODEIMAGE_REFERENCE_KEY", "env-key")
+ assert (
+ secrets.resolve_secret_reference("${NODEIMAGE_REFERENCE_KEY}", **options)
+ == "env-key"
+ )
+ monkeypatch.delenv("NODEIMAGE_REFERENCE_KEY")
+ with pytest.raises(
+ secrets.SecretRegistryError,
+ match="NodeImage API key environment variable NODEIMAGE_REFERENCE_KEY",
+ ):
+ secrets.resolve_secret_reference("${NODEIMAGE_REFERENCE_KEY}", **options)
+
+ monkeypatch.setenv("NODEIMAGE_REGISTRY_KEY", "registry-key")
+ secrets.configure_registry(
+ json.dumps(
+ {
+ "nodeimage-registry-key": {
+ "purpose": "nodeimage_api_key",
+ "origin": target_url,
+ "env": "NODEIMAGE_REGISTRY_KEY",
+ }
+ }
+ )
+ )
+ assert (
+ secrets.resolve_secret_reference("nodeimage-registry-key", **options)
+ == "registry-key"
+ )
+
+ with pytest.raises(secrets.SecretRegistryError, match="not permitted"):
+ secrets.resolve_secret_reference(
+ "nodeimage-registry-key",
+ **{**options, "purpose": "upstream_api"},
+ )
+
+
+def test_api_key_response_fields_masks_plaintext_for_all_consumers(monkeypatch):
+ monkeypatch.setenv("RESPONSE_REGISTRY_KEY", "registry-value")
+ secrets.configure_registry(
+ json.dumps(
+ {
+ "response-registry-key": {
+ "purpose": "upstream_api",
+ "origin": "https://api.example.com",
+ "env": "RESPONSE_REGISTRY_KEY",
+ }
+ }
+ )
+ )
+
+ fields = presets.api_key_response_fields("plain-secret-value")
+ assert fields == {
+ "api_key_masked": "plai***alue",
+ "has_api_key": True,
+ "api_key_source": "stored",
+ "api_key_env_var": None,
+ "api_key_secret_id": None,
+ }
+ assert "plain-secret-value" not in str(fields)
+
+ assert presets.api_key_response_fields("${RESPONSE_ENV_KEY}")["api_key_source"] == "env"
+ registry_fields = presets.api_key_response_fields("response-registry-key")
+ assert registry_fields["api_key_source"] == "registry"
+ assert registry_fields["api_key_secret_id"] == "response-registry-key"
+
+ r2 = presets.build_r2_backup_settings_response(
+ {
+ "enabled": True,
+ "endpoint_url": "https://account.r2.cloudflarestorage.com",
+ "bucket_name": "images",
+ "access_key_id": "plain-access-key",
+ "secret_access_key": "plain-secret-key",
+ }
+ ).model_dump()
+ assert r2["access_key_id_source"] == "stored"
+ assert r2["access_key_id_secret_id"] is None
+ assert r2["secret_access_key_source"] == "stored"
+ assert r2["secret_access_key_secret_id"] is None
+ assert "plain-access-key" not in str(r2)
+ assert "plain-secret-key" not in str(r2)
+
+
def test_default_settings_do_not_emit_missing_builtin_secret_ids(monkeypatch):
secrets.configure_registry("{}")
monkeypatch.setattr(config, "DEFAULT_API_KEY", "${OPENAI_API_KEY}")
diff --git a/backend/tests/test_security_import_contract.py b/backend/tests/test_security_import_contract.py
index 7711ea1..43d7b09 100644
--- a/backend/tests/test_security_import_contract.py
+++ b/backend/tests/test_security_import_contract.py
@@ -2,15 +2,15 @@ from backend.tests.support.contract import * # noqa: F403
def test_import_archive(client):
resp = _post_import_archive(client, _import_archive_bytes())
- assert resp.status_code == 200
- assert resp.json()["status"] == "success"
- assert resp.json()["imported"] == 1
+ assert resp.status_code == 202
+ finished = _wait_for_gallery_import_job(client, resp.json()["job_id"])
+ assert finished["status"] == "success"
+ assert finished["imported_count"] == 1
imported = gallery_queries.get_gallery_entry("import-1")
assert imported is not None
assert imported.bytes == len(PNG_BYTES)
- assert imported.thumbnail_filename is None
- assert imported.thumbnail_url == "/api/thumb/import-1.png"
+ assert imported.thumbnail_url.startswith("/api/thumb/")
def test_import_archive_truncates_long_image_filename(client):
@@ -22,7 +22,9 @@ def test_import_archive_truncates_long_image_filename(client):
_import_archive_bytes(image_name=long_name),
)
- assert resp.status_code == 200
+ assert resp.status_code == 202
+ finished = _wait_for_gallery_import_job(client, resp.json()["job_id"])
+ assert finished["status"] == "success"
imported = gallery_queries.get_gallery_entry("import-1")
assert imported is not None
assert imported.filename.endswith(".png")
@@ -283,8 +285,10 @@ def test_import_archive_skips_mismatched_png_content(client):
_import_archive_bytes(image_name="images/fake.png", image_bytes=b""),
)
- assert resp.status_code == 400
- assert resp.json()["detail"] == "No importable images found"
+ assert resp.status_code == 202
+ finished = _wait_for_gallery_import_job(client, resp.json()["job_id"])
+ assert finished["status"] == "error"
+ assert finished["error"] == "No importable images found"
def test_safe_image_paths_reject_traversal(client):
diff --git a/docker-compose.yml b/docker-compose.yml
index e02d4c6..b5a6bf5 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,6 +1,6 @@
services:
web:
- image: ${GPT_IMAGE_PANEL_IMAGE:-ghcr.io/z1rconium/gpt-image-linux:v1.4.1}
+ image: ${GPT_IMAGE_PANEL_IMAGE:-ghcr.io/z1rconium/gpt-image-linux:v1.4.2}
container_name: gpt-image-panel-web
ports:
- "127.0.0.1:9090:9090"
diff --git a/frontend/src/lib/api/types/common.ts b/frontend/src/lib/api/types/common.ts
index bf5cff0..a4bc00a 100644
--- a/frontend/src/lib/api/types/common.ts
+++ b/frontend/src/lib/api/types/common.ts
@@ -9,4 +9,5 @@ export type GenerateJobStatusValue = 'queued' | 'running' | 'success' | 'error'
export type GalleryExportJobStatusValue = 'queued' | 'running' | 'success' | 'error';
export type GallerySyncJobStatusValue = 'queued' | 'running' | 'success' | 'error';
export type GalleryImportJobStatusValue = 'queued' | 'running' | 'success' | 'error';
+export type NodeImageUploadJobStatusValue = 'queued' | 'running' | 'success' | 'partial_failure' | 'cancelled' | 'error';
export type AssistantGalleryBatchJobStatusValue = GalleryImportJobStatusValue;
diff --git a/frontend/src/lib/api/types/gallery.ts b/frontend/src/lib/api/types/gallery.ts
index 295f900..db8b3a8 100644
--- a/frontend/src/lib/api/types/gallery.ts
+++ b/frontend/src/lib/api/types/gallery.ts
@@ -1,4 +1,9 @@
-import type { GalleryExportJobStatusValue, GalleryImportJobStatusValue, GallerySyncJobStatusValue } from './common';
+import type {
+ GalleryExportJobStatusValue,
+ GalleryImportJobStatusValue,
+ GallerySyncJobStatusValue,
+ NodeImageUploadJobStatusValue
+} from './common';
export type GalleryEntry = {
id: string;
@@ -72,7 +77,7 @@ export type NodeImageUploadResponse = {
export type NodeImageBatchUploadItem = {
image_id: string;
filename?: string | null;
- status: 'ok' | 'error';
+ status: 'ok' | 'error' | 'cancelled';
url?: string | null;
markdown?: string | null;
error?: string | null;
@@ -85,6 +90,31 @@ export type NodeImageBatchUploadResponse = {
results: NodeImageBatchUploadItem[];
};
+export type NodeImageUploadJobStatus = {
+ job_id: string;
+ status: NodeImageUploadJobStatusValue;
+ stage?: string | null;
+ message?: string | null;
+ progress: number;
+ requested_count: number;
+ processed_count: number;
+ uploaded_count: number;
+ failed_count: number;
+ cancelled_count: number;
+ results: NodeImageBatchUploadItem[];
+ created_at?: string | null;
+ started_at?: string | null;
+ completed_at?: string | null;
+ updated_at?: string | null;
+ error?: string | null;
+ status_url?: string | null;
+ events_url?: string | null;
+ cancel_url?: string | null;
+};
+
+export type NodeImageBatchUploadJobStatus = NodeImageUploadJobStatus;
+export type NodeImageBatchUploadCreateResponse = NodeImageUploadJobStatus;
+
export type GallerySelectionTokenResponse = {
selection_token: string;
count: number;
diff --git a/frontend/src/lib/api/types/settings.ts b/frontend/src/lib/api/types/settings.ts
index b8a657b..a77b9ff 100644
--- a/frontend/src/lib/api/types/settings.ts
+++ b/frontend/src/lib/api/types/settings.ts
@@ -131,6 +131,7 @@ export type NodeImageSettings = {
enabled: boolean;
api_key_masked: string;
has_api_key: boolean;
+ api_key_resolvable: boolean;
api_key_source: ApiKeySource;
api_key_env_var?: string | null;
api_key_secret_id?: string | null;
diff --git a/frontend/src/lib/components/GalleryGrid.svelte b/frontend/src/lib/components/GalleryGrid.svelte
index d47f73d..37ef750 100644
--- a/frontend/src/lib/components/GalleryGrid.svelte
+++ b/frontend/src/lib/components/GalleryGrid.svelte
@@ -202,7 +202,20 @@
{operationStatus.label}
{operationStatus.detail}
- {operationStatus.progress === null ? $t.gallery.notInterruptible : `${operationStatus.progress}%`}
+
+
{operationStatus.progress === null ? $t.gallery.notInterruptible : `${operationStatus.progress}%`}
+ {#if operationStatus.cancel}
+
+ {/if}
+
{#if operationStatus.progress === null}
@@ -279,9 +292,9 @@
{image.prompt}
-
{displayImageSize(image)} / {image.model || '-'}
+
{displayImageSize(image)} / {image.model || '-'}
-
+