From f3342a1e571db5a12f5d8f659903ffe590fa2044 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 1 Jul 2026 12:32:33 +0800 Subject: [PATCH] fix: harden Docker image and async job responses Harden Docker runtime packaging, preserve async job response semantics, fix Redis subscriber startup connections, clear lint warnings, and harden enterprise S3 object body handling. --- .github/workflows/release.yml | 2 +- apps/api/src/jobs/cancel.ts | 4 +- apps/api/src/jobs/connection.ts | 12 ++ apps/api/src/lib/analytics-gate.ts | 4 +- apps/api/src/routes/async-response.ts | 24 ++++ apps/api/src/routes/progress.ts | 6 +- apps/api/src/routes/tool-factory.ts | 5 +- apps/api/src/routes/tools/ai-canvas-expand.ts | 5 +- apps/api/src/routes/tools/auto-subtitles.ts | 5 +- .../src/routes/tools/background-replace.ts | 5 +- apps/api/src/routes/tools/blur-background.ts | 5 +- apps/api/src/routes/tools/blur-faces.ts | 5 +- apps/api/src/routes/tools/colorize.ts | 5 +- apps/api/src/routes/tools/enhance-faces.ts | 5 +- apps/api/src/routes/tools/erase-object.ts | 5 +- apps/api/src/routes/tools/noise-removal.ts | 5 +- apps/api/src/routes/tools/ocr-pdf.ts | 5 +- apps/api/src/routes/tools/red-eye-removal.ts | 5 +- .../api/src/routes/tools/remove-background.ts | 5 +- apps/api/src/routes/tools/restore-photo.ts | 5 +- apps/api/src/routes/tools/sign-pdf.ts | 3 +- apps/api/src/routes/tools/transcribe-audio.ts | 5 +- .../src/routes/tools/transparency-fixer.ts | 5 +- apps/api/src/routes/tools/upscale.ts | 5 +- .../src/components/CategoryCards.astro | 1 + .../src/components/EnterpriseSection.astro | 1 + .../src/components/FeatureHighlights.astro | 1 + apps/landing/src/components/Footer.astro | 1 + apps/landing/src/components/Hero.astro | 2 + apps/landing/src/components/HeroSearch.astro | 2 + apps/landing/src/components/JsonLd.astro | 3 +- apps/landing/src/components/Navbar.astro | 1 + apps/landing/src/components/OpenSource.astro | 1 + apps/landing/src/components/Pricing.astro | 2 + .../src/components/SectionHeading.astro | 1 + apps/landing/src/components/ToolGrid.astro | 2 + .../landing/src/components/TrustSignals.astro | 1 + apps/landing/src/layouts/Base.astro | 1 + apps/landing/src/pages/404.astro | 1 + .../src/pages/alternatives/[slug].astro | 2 + .../src/pages/alternatives/index.astro | 2 + apps/landing/src/pages/contact.astro | 2 + apps/landing/src/pages/enterprise.astro | 2 + apps/landing/src/pages/faq.astro | 2 + apps/landing/src/pages/index.astro | 2 + apps/landing/src/pages/privacy.astro | 2 + apps/landing/src/pages/terms.astro | 2 + .../src/pages/tools/[section]/[tool].astro | 2 + .../src/pages/tools/[section]/index.astro | 2 + apps/landing/src/pages/tools/index.astro | 2 + apps/web/src/components/common/dropzone.tsx | 17 ++- .../components/common/url-import-modal.tsx | 5 +- .../components/settings/settings-dialog.tsx | 80 +++++++++--- .../src/components/tools/document-view.tsx | 5 +- .../tools/find-duplicates-results.tsx | 2 - .../tools/find-duplicates-settings.tsx | 4 +- .../components/tools/gif-tools-settings.tsx | 1 - .../tools/html-to-image-results.tsx | 7 +- .../tools/html-to-image-settings.tsx | 44 +++++-- .../tools/image-enhancement-settings.tsx | 1 - .../tools/image-to-base64-results.tsx | 2 - .../tools/image-to-base64-settings.tsx | 3 - .../src/components/tools/info-settings.tsx | 2 +- .../tools/meme-generator-settings.tsx | 2 - .../tools/passport-photo-settings.tsx | 3 - .../components/tools/qr-generate-settings.tsx | 2 - .../src/components/tools/resize-settings.tsx | 1 + .../tools/restore-photo-settings.tsx | 1 - .../components/tools/smart-crop-settings.tsx | 2 - .../src/components/tools/split-settings.tsx | 2 - .../src/components/tools/stitch-settings.tsx | 2 - .../tools/transparency-fixer-settings.tsx | 1 - .../tools/watermark-text-settings.tsx | 1 - apps/web/src/hooks/use-focus-trap.ts | 2 +- apps/web/src/pages/automate-page.tsx | 22 +++- apps/web/src/pages/login-page.tsx | 2 +- apps/web/src/pages/tool-page.tsx | 2 +- docker/Dockerfile | 73 +++++++++-- docker/s6/s6-rc.d/postgres-ready/up | 2 +- docker/s6/s6-rc.d/snapotter/run | 4 +- packages/enterprise/src/storage-s3.ts | 23 +++- scripts/bake-analytics.mjs | 4 +- .../document/ocr-pdf-route-coverage.test.ts | 13 +- .../image/ai-async-route-coverage.test.ts | 17 ++- .../image/ai-photo-route-coverage.test.ts | 13 +- tests/unit/api/async-job-response.test.ts | 20 +++ tests/unit/api/jobs/connection.test.ts | 41 +++++++ tests/unit/api/tool-factory-route.test.ts | 67 ++++++++++ .../security/dockerfile-build-args.test.ts | 116 ++++++++++++++++++ 89 files changed, 635 insertions(+), 159 deletions(-) create mode 100644 apps/api/src/routes/async-response.ts create mode 100644 tests/unit/api/async-job-response.test.ts create mode 100644 tests/unit/api/jobs/connection.test.ts create mode 100644 tests/unit/security/dockerfile-build-args.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53cb7009..74ecae24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -239,7 +239,7 @@ jobs: platforms: ${{ matrix.platform }} build-args: | SNAPOTTER_ANALYTICS=on - SNAPOTTER_POSTHOG_KEY=${{ secrets.SNAPOTTER_POSTHOG_KEY }} + SNAPOTTER_POSTHOG_PROJECT_ID=${{ secrets.SNAPOTTER_POSTHOG_KEY }} SNAPOTTER_SENTRY_DSN=${{ secrets.SNAPOTTER_SENTRY_DSN }} SENTRY_RELEASE=${{ needs.release.outputs.new_version }} secrets: | diff --git a/apps/api/src/jobs/cancel.ts b/apps/api/src/jobs/cancel.ts index e96a65ba..c746892f 100644 --- a/apps/api/src/jobs/cancel.ts +++ b/apps/api/src/jobs/cancel.ts @@ -14,7 +14,7 @@ import { eq } from "drizzle-orm"; import type Redis from "ioredis"; import { db, schema } from "../db/index.js"; -import { createRedisConnection, sharedRedis } from "./connection.js"; +import { createRedisSubscriberConnection, sharedRedis } from "./connection.js"; import { getQueue } from "./queues.js"; import { bullPrefix, POOLS } from "./types.js"; @@ -39,7 +39,7 @@ const CANCEL_CHANNEL = () => `${bullPrefix()}:cancel`; let subscriber: Redis | null = null; export async function startCancelListener(): Promise { - subscriber = createRedisConnection(); + subscriber = createRedisSubscriberConnection(); subscriber.on("error", (err) => { console.error("Cancel listener subscriber error", err); }); diff --git a/apps/api/src/jobs/connection.ts b/apps/api/src/jobs/connection.ts index 12597874..263cb240 100644 --- a/apps/api/src/jobs/connection.ts +++ b/apps/api/src/jobs/connection.ts @@ -20,6 +20,18 @@ export function createRedisConnection(): Redis { }); } +/** + * Create a Redis connection used only for pub/sub subscriptions. + * Subscriber sockets cannot run regular commands once subscribed, so disable + * ioredis ready checks that issue INFO during reconnects. + */ +export function createRedisSubscriberConnection(): Redis { + return new Redis(env.REDIS_URL, { + maxRetriesPerRequest: null, + enableReadyCheck: false, + }); +} + // ioredis 5.11 vs BullMQ's bundled 5.10 type mismatch export function createBullMQConnection(): ConnectionOptions { return createRedisConnection() as unknown as ConnectionOptions; diff --git a/apps/api/src/lib/analytics-gate.ts b/apps/api/src/lib/analytics-gate.ts index f4cd2037..cfab5b51 100644 --- a/apps/api/src/lib/analytics-gate.ts +++ b/apps/api/src/lib/analytics-gate.ts @@ -95,8 +95,8 @@ const CHANNEL = async () => { /** Subscribe so a setting change on any replica refreshes this process's cache. */ export async function startAnalyticsGateListener(): Promise { - const { createRedisConnection } = await import("../jobs/connection.js"); - gateSubscriber = createRedisConnection(); + const { createRedisSubscriberConnection } = await import("../jobs/connection.js"); + gateSubscriber = createRedisSubscriberConnection(); gateSubscriber.on("error", (err) => console.error("Analytics gate subscriber error", err)); await gateSubscriber.subscribe(await CHANNEL()); gateSubscriber.on("message", () => { diff --git a/apps/api/src/routes/async-response.ts b/apps/api/src/routes/async-response.ts new file mode 100644 index 00000000..4cff658e --- /dev/null +++ b/apps/api/src/routes/async-response.ts @@ -0,0 +1,24 @@ +export type AsyncAcceptedPayload = { + jobId: string; + async: true; + progressJobId?: string; + artifactJobId?: string; +}; + +export function buildAsyncAcceptedPayload( + artifactJobId: string, + clientJobId?: string | null, +): AsyncAcceptedPayload { + const progressJobId = clientJobId && clientJobId.length > 0 ? clientJobId : artifactJobId; + + if (progressJobId === artifactJobId) { + return { jobId: artifactJobId, async: true }; + } + + return { + jobId: progressJobId, + progressJobId, + artifactJobId, + async: true, + }; +} diff --git a/apps/api/src/routes/progress.ts b/apps/api/src/routes/progress.ts index 04e3e1ae..f049bac8 100644 --- a/apps/api/src/routes/progress.ts +++ b/apps/api/src/routes/progress.ts @@ -13,7 +13,7 @@ import { eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { db, schema } from "../db/index.js"; -import { createRedisConnection, sharedRedis } from "../jobs/connection.js"; +import { createRedisSubscriberConnection, sharedRedis } from "../jobs/connection.js"; import { bullPrefix } from "../jobs/types.js"; import { getSecurityHeaders } from "../lib/csp.js"; @@ -228,11 +228,11 @@ export function publishEphemeral( type FrameCallback = (json: string) => void; const sseListeners = new Map>(); -let sseSubscriber: ReturnType | null = null; +let sseSubscriber: ReturnType | null = null; function ensureSubscriber(): void { if (sseSubscriber) return; - sseSubscriber = createRedisConnection(); + sseSubscriber = createRedisSubscriberConnection(); // ioredis auto-resubscribes after reconnects; the handler keeps connection // errors observable without crashing (ioredis silentEmits, but be explicit). sseSubscriber.on("error", (err) => { diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index 48862b39..8a95983d 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -25,6 +25,7 @@ import { InputValidationError } from "../modality/contract.js"; import { inputHandlerFor } from "../modality/input-handler.js"; import { MediaInputHandler, type MediaInputKind } from "../modality/media-input.js"; import { requireToolAccess } from "../permissions.js"; +import { buildAsyncAcceptedPayload } from "./async-response.js"; import { updateSingleFileProgress } from "./progress.js"; /** Context passed to tool process functions for cooperative cancellation, scratch storage, and progress. */ @@ -553,7 +554,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig // Long tools never block the HTTP request (spec 4.5): straight to SSE. if (shouldSkipSyncWindow(toolMeta?.executionHint)) { - return reply.status(202).send({ jobId: clientJobId || jobId, async: true }); + return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId)); } try { @@ -590,7 +591,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig ...result.resultPayload, }); } - return reply.status(202).send({ jobId: clientJobId || jobId, async: true }); + return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId)); } catch (err) { // Keep the full error (incl. raw ffmpeg/tool stderr) in server logs, // but return only a user-safe detail to the client. diff --git a/apps/api/src/routes/tools/ai-canvas-expand.ts b/apps/api/src/routes/tools/ai-canvas-expand.ts index adc578e3..0a5daa7c 100644 --- a/apps/api/src/routes/tools/ai-canvas-expand.ts +++ b/apps/api/src/routes/tools/ai-canvas-expand.ts @@ -20,6 +20,7 @@ import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { resolveOutputFormat } from "../../lib/output-format.js"; import { receiveUpload } from "../../lib/upload-stream.js"; import { getAuthUser } from "../../plugins/auth.js"; +import { buildAsyncAcceptedPayload } from "../async-response.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -241,8 +242,6 @@ export function registerAiCanvasExpand(app: FastifyInstance) { await putObject(inputKey, fileBuffer); } - const progressJobId = clientJobId || jobId; - await enqueueToolJob({ jobId, toolId, @@ -256,7 +255,7 @@ export function registerAiCanvasExpand(app: FastifyInstance) { kind: "ai-tool", }); - return reply.status(202).send({ jobId: progressJobId, async: true }); + return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId)); }, ); diff --git a/apps/api/src/routes/tools/auto-subtitles.ts b/apps/api/src/routes/tools/auto-subtitles.ts index 477141a4..1569a060 100644 --- a/apps/api/src/routes/tools/auto-subtitles.ts +++ b/apps/api/src/routes/tools/auto-subtitles.ts @@ -13,6 +13,7 @@ import { isToolInstalled } from "../../lib/feature-status.js"; import { type TranscriptSegment, toSrt, toVtt } from "../../lib/subtitle-format.js"; import { receiveUpload } from "../../lib/upload-stream.js"; import { getAuthUser } from "../../plugins/auth.js"; +import { buildAsyncAcceptedPayload } from "../async-response.js"; const settingsSchema = z.object({ language: z @@ -157,8 +158,6 @@ export function registerAutoSubtitles(app: FastifyInstance) { return reply.status(400).send({ error: "Settings must be valid JSON" }); } - const progressJobId = clientJobId || jobId; - await enqueueToolJob({ jobId, toolId, @@ -172,7 +171,7 @@ export function registerAutoSubtitles(app: FastifyInstance) { kind: "ai-tool", }); - return reply.status(202).send({ jobId: progressJobId, async: true }); + return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId)); }, ); } diff --git a/apps/api/src/routes/tools/background-replace.ts b/apps/api/src/routes/tools/background-replace.ts index 8bd6c60b..2da5a6d4 100644 --- a/apps/api/src/routes/tools/background-replace.ts +++ b/apps/api/src/routes/tools/background-replace.ts @@ -15,6 +15,7 @@ import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.j import { decodeHeic } from "../../lib/heic-converter.js"; import { receiveUpload } from "../../lib/upload-stream.js"; import { getAuthUser } from "../../plugins/auth.js"; +import { buildAsyncAcceptedPayload } from "../async-response.js"; const HEX_RE = /^#[0-9a-fA-F]{6}$/; @@ -216,8 +217,6 @@ export function registerBackgroundReplace(app: FastifyInstance) { await putObject(inputKey, fileBuffer); } - const progressJobId = clientJobId || jobId; - await enqueueToolJob({ jobId, toolId, @@ -231,7 +230,7 @@ export function registerBackgroundReplace(app: FastifyInstance) { kind: "ai-tool", }); - return reply.status(202).send({ jobId: progressJobId, async: true }); + return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId)); }, ); } diff --git a/apps/api/src/routes/tools/blur-background.ts b/apps/api/src/routes/tools/blur-background.ts index e63862d1..148d43f5 100644 --- a/apps/api/src/routes/tools/blur-background.ts +++ b/apps/api/src/routes/tools/blur-background.ts @@ -15,6 +15,7 @@ import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.j import { decodeHeic } from "../../lib/heic-converter.js"; import { receiveUpload } from "../../lib/upload-stream.js"; import { getAuthUser } from "../../plugins/auth.js"; +import { buildAsyncAcceptedPayload } from "../async-response.js"; const settingsSchema = z.object({ intensity: z.number().int().min(1).max(100).default(50), @@ -186,8 +187,6 @@ export function registerBlurBackground(app: FastifyInstance) { await putObject(inputKey, fileBuffer); } - const progressJobId = clientJobId || jobId; - await enqueueToolJob({ jobId, toolId, @@ -201,7 +200,7 @@ export function registerBlurBackground(app: FastifyInstance) { kind: "ai-tool", }); - return reply.status(202).send({ jobId: progressJobId, async: true }); + return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId)); }, ); } diff --git a/apps/api/src/routes/tools/blur-faces.ts b/apps/api/src/routes/tools/blur-faces.ts index 358c7d31..256be892 100644 --- a/apps/api/src/routes/tools/blur-faces.ts +++ b/apps/api/src/routes/tools/blur-faces.ts @@ -19,6 +19,7 @@ import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { resolveOutputFormat } from "../../lib/output-format.js"; import { receiveUpload } from "../../lib/upload-stream.js"; import { getAuthUser } from "../../plugins/auth.js"; +import { buildAsyncAcceptedPayload } from "../async-response.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -163,8 +164,6 @@ export function registerBlurFaces(app: FastifyInstance) { await putObject(inputKey, fileBuffer); } - const progressJobId = clientJobId || jobId; - await enqueueToolJob({ jobId, toolId, @@ -178,7 +177,7 @@ export function registerBlurFaces(app: FastifyInstance) { kind: "ai-tool", }); - return reply.status(202).send({ jobId: progressJobId, async: true }); + return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId)); }, ); diff --git a/apps/api/src/routes/tools/colorize.ts b/apps/api/src/routes/tools/colorize.ts index f27d0fe9..e585c553 100644 --- a/apps/api/src/routes/tools/colorize.ts +++ b/apps/api/src/routes/tools/colorize.ts @@ -19,6 +19,7 @@ import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { resolveOutputFormat } from "../../lib/output-format.js"; import { receiveUpload } from "../../lib/upload-stream.js"; import { getAuthUser } from "../../plugins/auth.js"; +import { buildAsyncAcceptedPayload } from "../async-response.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -162,8 +163,6 @@ export function registerColorize(app: FastifyInstance) { await putObject(inputKey, fileBuffer); } - const progressJobId = clientJobId || jobId; - await enqueueToolJob({ jobId, toolId, @@ -177,7 +176,7 @@ export function registerColorize(app: FastifyInstance) { kind: "ai-tool", }); - return reply.status(202).send({ jobId: progressJobId, async: true }); + return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId)); }); // Register in the pipeline/batch registry diff --git a/apps/api/src/routes/tools/enhance-faces.ts b/apps/api/src/routes/tools/enhance-faces.ts index 62710847..91afb0aa 100644 --- a/apps/api/src/routes/tools/enhance-faces.ts +++ b/apps/api/src/routes/tools/enhance-faces.ts @@ -17,6 +17,7 @@ import { decodeHeic } from "../../lib/heic-converter.js"; import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { receiveUpload } from "../../lib/upload-stream.js"; import { getAuthUser } from "../../plugins/auth.js"; +import { buildAsyncAcceptedPayload } from "../async-response.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -156,8 +157,6 @@ export function registerEnhanceFaces(app: FastifyInstance) { await putObject(inputKey, fileBuffer); } - const progressJobId = clientJobId || jobId; - await enqueueToolJob({ jobId, toolId, @@ -171,7 +170,7 @@ export function registerEnhanceFaces(app: FastifyInstance) { kind: "ai-tool", }); - return reply.status(202).send({ jobId: progressJobId, async: true }); + return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId)); }, ); diff --git a/apps/api/src/routes/tools/erase-object.ts b/apps/api/src/routes/tools/erase-object.ts index f8130e7a..6d4bd214 100644 --- a/apps/api/src/routes/tools/erase-object.ts +++ b/apps/api/src/routes/tools/erase-object.ts @@ -16,6 +16,7 @@ import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { resolveOutputFormat } from "../../lib/output-format.js"; import { receiveUpload } from "../../lib/upload-stream.js"; import { getAuthUser } from "../../plugins/auth.js"; +import { buildAsyncAcceptedPayload } from "../async-response.js"; const settingsSchema = z.object({ format: z @@ -157,8 +158,6 @@ export function registerEraseObject(app: FastifyInstance) { await putObject(imageKey, imageBuffer); } - const progressJobId = clientJobId || jobId; - // Enqueue with both image and mask as inputRefs; the worker handler // reads them via getObjectBuffer. await enqueueToolJob({ @@ -174,7 +173,7 @@ export function registerEraseObject(app: FastifyInstance) { kind: "ai-tool", }); - return reply.status(202).send({ jobId: progressJobId, async: true }); + return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId)); }, ); } diff --git a/apps/api/src/routes/tools/noise-removal.ts b/apps/api/src/routes/tools/noise-removal.ts index 35ed3ea5..7359bebe 100644 --- a/apps/api/src/routes/tools/noise-removal.ts +++ b/apps/api/src/routes/tools/noise-removal.ts @@ -17,6 +17,7 @@ import { decodeHeic } from "../../lib/heic-converter.js"; import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { receiveUpload } from "../../lib/upload-stream.js"; import { getAuthUser } from "../../plugins/auth.js"; +import { buildAsyncAcceptedPayload } from "../async-response.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -167,8 +168,6 @@ export function registerNoiseRemoval(app: FastifyInstance) { await putObject(inputKey, fileBuffer); } - const progressJobId = clientJobId || jobId; - await enqueueToolJob({ jobId, toolId, @@ -182,7 +181,7 @@ export function registerNoiseRemoval(app: FastifyInstance) { kind: "ai-tool", }); - return reply.status(202).send({ jobId: progressJobId, async: true }); + return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId)); }, ); diff --git a/apps/api/src/routes/tools/ocr-pdf.ts b/apps/api/src/routes/tools/ocr-pdf.ts index e538b254..54a038b2 100644 --- a/apps/api/src/routes/tools/ocr-pdf.ts +++ b/apps/api/src/routes/tools/ocr-pdf.ts @@ -11,6 +11,7 @@ import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { receiveUpload } from "../../lib/upload-stream.js"; import { getAuthUser } from "../../plugins/auth.js"; +import { buildAsyncAcceptedPayload } from "../async-response.js"; const settingsSchema = z.object({ quality: z.enum(["fast", "balanced", "best"]).default("balanced"), @@ -122,8 +123,6 @@ export function registerOcrPdf(app: FastifyInstance) { return reply.status(400).send({ error: "Settings must be valid JSON" }); } - const progressJobId = clientJobId || jobId; - await enqueueToolJob({ jobId, toolId, @@ -137,6 +136,6 @@ export function registerOcrPdf(app: FastifyInstance) { kind: "ai-tool", }); - return reply.status(202).send({ jobId: progressJobId, async: true }); + return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId)); }); } diff --git a/apps/api/src/routes/tools/red-eye-removal.ts b/apps/api/src/routes/tools/red-eye-removal.ts index ea31f7d4..d53eef4e 100644 --- a/apps/api/src/routes/tools/red-eye-removal.ts +++ b/apps/api/src/routes/tools/red-eye-removal.ts @@ -17,6 +17,7 @@ import { decodeHeic } from "../../lib/heic-converter.js"; import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { receiveUpload } from "../../lib/upload-stream.js"; import { getAuthUser } from "../../plugins/auth.js"; +import { buildAsyncAcceptedPayload } from "../async-response.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -155,8 +156,6 @@ export function registerRedEyeRemoval(app: FastifyInstance) { await putObject(inputKey, fileBuffer); } - const progressJobId = clientJobId || jobId; - await enqueueToolJob({ jobId, toolId, @@ -170,7 +169,7 @@ export function registerRedEyeRemoval(app: FastifyInstance) { kind: "ai-tool", }); - return reply.status(202).send({ jobId: progressJobId, async: true }); + return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId)); }, ); diff --git a/apps/api/src/routes/tools/remove-background.ts b/apps/api/src/routes/tools/remove-background.ts index 337dcf7d..97ddd1b8 100644 --- a/apps/api/src/routes/tools/remove-background.ts +++ b/apps/api/src/routes/tools/remove-background.ts @@ -23,6 +23,7 @@ import { decodeHeic } from "../../lib/heic-converter.js"; import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { receiveUpload } from "../../lib/upload-stream.js"; import { getAuthUser } from "../../plugins/auth.js"; +import { buildAsyncAcceptedPayload } from "../async-response.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -206,8 +207,6 @@ export function registerRemoveBackground(app: FastifyInstance) { await putObject(inputKey, fileBuffer); } - const progressJobId = clientJobId || jobId; - // Enqueue on the AI pool await enqueueToolJob({ jobId, @@ -223,7 +222,7 @@ export function registerRemoveBackground(app: FastifyInstance) { }); // AI tools always return 202 (no sync window) - return reply.status(202).send({ jobId: progressJobId, async: true }); + return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId)); }, ); diff --git a/apps/api/src/routes/tools/restore-photo.ts b/apps/api/src/routes/tools/restore-photo.ts index 13ff86d4..4b1eebf0 100644 --- a/apps/api/src/routes/tools/restore-photo.ts +++ b/apps/api/src/routes/tools/restore-photo.ts @@ -19,6 +19,7 @@ import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { resolveOutputFormat } from "../../lib/output-format.js"; import { receiveUpload } from "../../lib/upload-stream.js"; import { getAuthUser } from "../../plugins/auth.js"; +import { buildAsyncAcceptedPayload } from "../async-response.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -187,8 +188,6 @@ export function registerRestorePhoto(app: FastifyInstance) { await putObject(inputKey, fileBuffer); } - const progressJobId = clientJobId || jobId; - await enqueueToolJob({ jobId, toolId: "restore-photo", @@ -202,7 +201,7 @@ export function registerRestorePhoto(app: FastifyInstance) { kind: "ai-tool", }); - return reply.status(202).send({ jobId: progressJobId, async: true }); + return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId)); }, ); diff --git a/apps/api/src/routes/tools/sign-pdf.ts b/apps/api/src/routes/tools/sign-pdf.ts index 9d12e86d..d3d0d5c8 100644 --- a/apps/api/src/routes/tools/sign-pdf.ts +++ b/apps/api/src/routes/tools/sign-pdf.ts @@ -14,6 +14,7 @@ import { getObjectBuffer } from "../../lib/object-storage.js"; import { receiveUpload } from "../../lib/upload-stream.js"; import { inputHandlerFor } from "../../modality/input-handler.js"; import { getAuthUser } from "../../plugins/auth.js"; +import { buildAsyncAcceptedPayload } from "../async-response.js"; const TOOL_ID = "sign-pdf"; const MAX_PLACEMENTS = 100; @@ -158,7 +159,7 @@ export function registerSignPdf(app: FastifyInstance) { savedFileId: result.savedFileId, }); } - return reply.status(202).send({ jobId: clientJobId || jobId, async: true }); + return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId)); } catch (err) { request.log.error({ err, toolId: TOOL_ID }, "sign-pdf processing failed"); return reply.status(422).send({ diff --git a/apps/api/src/routes/tools/transcribe-audio.ts b/apps/api/src/routes/tools/transcribe-audio.ts index 18163911..492ca48b 100644 --- a/apps/api/src/routes/tools/transcribe-audio.ts +++ b/apps/api/src/routes/tools/transcribe-audio.ts @@ -12,6 +12,7 @@ import { isToolInstalled } from "../../lib/feature-status.js"; import { type TranscriptSegment, toSrt, toVtt } from "../../lib/subtitle-format.js"; import { receiveUpload } from "../../lib/upload-stream.js"; import { getAuthUser } from "../../plugins/auth.js"; +import { buildAsyncAcceptedPayload } from "../async-response.js"; const settingsSchema = z.object({ language: z @@ -145,8 +146,6 @@ export function registerTranscribeAudio(app: FastifyInstance) { return reply.status(400).send({ error: "Settings must be valid JSON" }); } - const progressJobId = clientJobId || jobId; - await enqueueToolJob({ jobId, toolId, @@ -160,7 +159,7 @@ export function registerTranscribeAudio(app: FastifyInstance) { kind: "ai-tool", }); - return reply.status(202).send({ jobId: progressJobId, async: true }); + return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId)); }, ); } diff --git a/apps/api/src/routes/tools/transparency-fixer.ts b/apps/api/src/routes/tools/transparency-fixer.ts index ba289742..7654d784 100644 --- a/apps/api/src/routes/tools/transparency-fixer.ts +++ b/apps/api/src/routes/tools/transparency-fixer.ts @@ -18,6 +18,7 @@ import { decodeHeic } from "../../lib/heic-converter.js"; import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { receiveUpload } from "../../lib/upload-stream.js"; import { getAuthUser } from "../../plugins/auth.js"; +import { buildAsyncAcceptedPayload } from "../async-response.js"; import { registerToolProcessFn } from "../tool-factory.js"; const TOOL_ID = "transparency-fixer"; @@ -252,8 +253,6 @@ export function registerTransparencyFixer(app: FastifyInstance) { await putObject(inputKey, fileBuffer); } - const progressJobId = clientJobId || jobId; - await enqueueToolJob({ jobId, toolId: TOOL_ID, @@ -267,7 +266,7 @@ export function registerTransparencyFixer(app: FastifyInstance) { kind: "ai-tool", }); - return reply.status(202).send({ jobId: progressJobId, async: true }); + return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId)); }, ); diff --git a/apps/api/src/routes/tools/upscale.ts b/apps/api/src/routes/tools/upscale.ts index e47ecfc7..54383116 100644 --- a/apps/api/src/routes/tools/upscale.ts +++ b/apps/api/src/routes/tools/upscale.ts @@ -20,6 +20,7 @@ import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { resolveOutputFormat } from "../../lib/output-format.js"; import { receiveUpload } from "../../lib/upload-stream.js"; import { getAuthUser } from "../../plugins/auth.js"; +import { buildAsyncAcceptedPayload } from "../async-response.js"; import { registerToolProcessFn } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -214,8 +215,6 @@ export function registerUpscale(app: FastifyInstance) { await putObject(inputKey, fileBuffer); } - const progressJobId = clientJobId || jobId; - await enqueueToolJob({ jobId, toolId, @@ -229,7 +228,7 @@ export function registerUpscale(app: FastifyInstance) { kind: "ai-tool", }); - return reply.status(202).send({ jobId: progressJobId, async: true }); + return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId)); }); // Register in the pipeline/batch registry so this tool can be used diff --git a/apps/landing/src/components/CategoryCards.astro b/apps/landing/src/components/CategoryCards.astro index ade50c68..6b0f1ad2 100644 --- a/apps/landing/src/components/CategoryCards.astro +++ b/apps/landing/src/components/CategoryCards.astro @@ -1,4 +1,5 @@ --- +// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values. import { TOOLS, toolSection } from "@snapotter/shared"; import * as lucideIcons from "lucide"; diff --git a/apps/landing/src/components/EnterpriseSection.astro b/apps/landing/src/components/EnterpriseSection.astro index c2154a13..18ad134c 100644 --- a/apps/landing/src/components/EnterpriseSection.astro +++ b/apps/landing/src/components/EnterpriseSection.astro @@ -1,4 +1,5 @@ --- +// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values. const features = [ { tag: "Identity", diff --git a/apps/landing/src/components/FeatureHighlights.astro b/apps/landing/src/components/FeatureHighlights.astro index 60290f95..0ed9cc5d 100644 --- a/apps/landing/src/components/FeatureHighlights.astro +++ b/apps/landing/src/components/FeatureHighlights.astro @@ -1,4 +1,5 @@ --- +// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values. const aiTools = [ "Remove Background", "Image Upscaling", diff --git a/apps/landing/src/components/Footer.astro b/apps/landing/src/components/Footer.astro index 8f9ec729..030e5563 100644 --- a/apps/landing/src/components/Footer.astro +++ b/apps/landing/src/components/Footer.astro @@ -1,4 +1,5 @@ --- +// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values. const year = new Date().getFullYear(); const columns = [ diff --git a/apps/landing/src/components/Hero.astro b/apps/landing/src/components/Hero.astro index 55c12e49..db824f25 100644 --- a/apps/landing/src/components/Hero.astro +++ b/apps/landing/src/components/Hero.astro @@ -1,4 +1,6 @@ --- +// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes component imports. +// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values. import CategoryCards from "./CategoryCards.astro"; import HeroSearch from "./HeroSearch.astro"; import TrustSignals from "./TrustSignals.astro"; diff --git a/apps/landing/src/components/HeroSearch.astro b/apps/landing/src/components/HeroSearch.astro index 006b33b2..39ee7d71 100644 --- a/apps/landing/src/components/HeroSearch.astro +++ b/apps/landing/src/components/HeroSearch.astro @@ -1,4 +1,6 @@ --- +// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes component imports. +// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values. import { TOOLS, toolSection } from "@snapotter/shared"; import * as lucideIcons from "lucide"; diff --git a/apps/landing/src/components/JsonLd.astro b/apps/landing/src/components/JsonLd.astro index 23f0cf0c..b6443b4e 100644 --- a/apps/landing/src/components/JsonLd.astro +++ b/apps/landing/src/components/JsonLd.astro @@ -1,4 +1,5 @@ --- +// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values. interface Props { data: Record | Record[]; } @@ -8,5 +9,5 @@ const schemas = Array.isArray(data) ? data : [data]; --- {schemas.map((schema) => ( -