mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
This commit is contained in:
@@ -239,7 +239,7 @@ jobs:
|
|||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
build-args: |
|
build-args: |
|
||||||
SNAPOTTER_ANALYTICS=on
|
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 }}
|
SNAPOTTER_SENTRY_DSN=${{ secrets.SNAPOTTER_SENTRY_DSN }}
|
||||||
SENTRY_RELEASE=${{ needs.release.outputs.new_version }}
|
SENTRY_RELEASE=${{ needs.release.outputs.new_version }}
|
||||||
secrets: |
|
secrets: |
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type Redis from "ioredis";
|
import type Redis from "ioredis";
|
||||||
import { db, schema } from "../db/index.js";
|
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 { getQueue } from "./queues.js";
|
||||||
import { bullPrefix, POOLS } from "./types.js";
|
import { bullPrefix, POOLS } from "./types.js";
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ const CANCEL_CHANNEL = () => `${bullPrefix()}:cancel`;
|
|||||||
let subscriber: Redis | null = null;
|
let subscriber: Redis | null = null;
|
||||||
|
|
||||||
export async function startCancelListener(): Promise<void> {
|
export async function startCancelListener(): Promise<void> {
|
||||||
subscriber = createRedisConnection();
|
subscriber = createRedisSubscriberConnection();
|
||||||
subscriber.on("error", (err) => {
|
subscriber.on("error", (err) => {
|
||||||
console.error("Cancel listener subscriber error", err);
|
console.error("Cancel listener subscriber error", err);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
// ioredis 5.11 vs BullMQ's bundled 5.10 type mismatch
|
||||||
export function createBullMQConnection(): ConnectionOptions {
|
export function createBullMQConnection(): ConnectionOptions {
|
||||||
return createRedisConnection() as unknown as ConnectionOptions;
|
return createRedisConnection() as unknown as ConnectionOptions;
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ const CHANNEL = async () => {
|
|||||||
|
|
||||||
/** Subscribe so a setting change on any replica refreshes this process's cache. */
|
/** Subscribe so a setting change on any replica refreshes this process's cache. */
|
||||||
export async function startAnalyticsGateListener(): Promise<void> {
|
export async function startAnalyticsGateListener(): Promise<void> {
|
||||||
const { createRedisConnection } = await import("../jobs/connection.js");
|
const { createRedisSubscriberConnection } = await import("../jobs/connection.js");
|
||||||
gateSubscriber = createRedisConnection();
|
gateSubscriber = createRedisSubscriberConnection();
|
||||||
gateSubscriber.on("error", (err) => console.error("Analytics gate subscriber error", err));
|
gateSubscriber.on("error", (err) => console.error("Analytics gate subscriber error", err));
|
||||||
await gateSubscriber.subscribe(await CHANNEL());
|
await gateSubscriber.subscribe(await CHANNEL());
|
||||||
gateSubscriber.on("message", () => {
|
gateSubscriber.on("message", () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { db, schema } from "../db/index.js";
|
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 { bullPrefix } from "../jobs/types.js";
|
||||||
import { getSecurityHeaders } from "../lib/csp.js";
|
import { getSecurityHeaders } from "../lib/csp.js";
|
||||||
|
|
||||||
@@ -228,11 +228,11 @@ export function publishEphemeral(
|
|||||||
|
|
||||||
type FrameCallback = (json: string) => void;
|
type FrameCallback = (json: string) => void;
|
||||||
const sseListeners = new Map<string, Set<FrameCallback>>();
|
const sseListeners = new Map<string, Set<FrameCallback>>();
|
||||||
let sseSubscriber: ReturnType<typeof createRedisConnection> | null = null;
|
let sseSubscriber: ReturnType<typeof createRedisSubscriberConnection> | null = null;
|
||||||
|
|
||||||
function ensureSubscriber(): void {
|
function ensureSubscriber(): void {
|
||||||
if (sseSubscriber) return;
|
if (sseSubscriber) return;
|
||||||
sseSubscriber = createRedisConnection();
|
sseSubscriber = createRedisSubscriberConnection();
|
||||||
// ioredis auto-resubscribes after reconnects; the handler keeps connection
|
// ioredis auto-resubscribes after reconnects; the handler keeps connection
|
||||||
// errors observable without crashing (ioredis silentEmits, but be explicit).
|
// errors observable without crashing (ioredis silentEmits, but be explicit).
|
||||||
sseSubscriber.on("error", (err) => {
|
sseSubscriber.on("error", (err) => {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { InputValidationError } from "../modality/contract.js";
|
|||||||
import { inputHandlerFor } from "../modality/input-handler.js";
|
import { inputHandlerFor } from "../modality/input-handler.js";
|
||||||
import { MediaInputHandler, type MediaInputKind } from "../modality/media-input.js";
|
import { MediaInputHandler, type MediaInputKind } from "../modality/media-input.js";
|
||||||
import { requireToolAccess } from "../permissions.js";
|
import { requireToolAccess } from "../permissions.js";
|
||||||
|
import { buildAsyncAcceptedPayload } from "./async-response.js";
|
||||||
import { updateSingleFileProgress } from "./progress.js";
|
import { updateSingleFileProgress } from "./progress.js";
|
||||||
|
|
||||||
/** Context passed to tool process functions for cooperative cancellation, scratch storage, and progress. */
|
/** Context passed to tool process functions for cooperative cancellation, scratch storage, and progress. */
|
||||||
@@ -553,7 +554,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
|||||||
|
|
||||||
// Long tools never block the HTTP request (spec 4.5): straight to SSE.
|
// Long tools never block the HTTP request (spec 4.5): straight to SSE.
|
||||||
if (shouldSkipSyncWindow(toolMeta?.executionHint)) {
|
if (shouldSkipSyncWindow(toolMeta?.executionHint)) {
|
||||||
return reply.status(202).send({ jobId: clientJobId || jobId, async: true });
|
return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -590,7 +591,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
|||||||
...result.resultPayload,
|
...result.resultPayload,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return reply.status(202).send({ jobId: clientJobId || jobId, async: true });
|
return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Keep the full error (incl. raw ffmpeg/tool stderr) in server logs,
|
// Keep the full error (incl. raw ffmpeg/tool stderr) in server logs,
|
||||||
// but return only a user-safe detail to the client.
|
// but return only a user-safe detail to the client.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
|
|||||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||||
import { receiveUpload } from "../../lib/upload-stream.js";
|
import { receiveUpload } from "../../lib/upload-stream.js";
|
||||||
import { getAuthUser } from "../../plugins/auth.js";
|
import { getAuthUser } from "../../plugins/auth.js";
|
||||||
|
import { buildAsyncAcceptedPayload } from "../async-response.js";
|
||||||
import { registerToolProcessFn } from "../tool-factory.js";
|
import { registerToolProcessFn } from "../tool-factory.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
@@ -241,8 +242,6 @@ export function registerAiCanvasExpand(app: FastifyInstance) {
|
|||||||
await putObject(inputKey, fileBuffer);
|
await putObject(inputKey, fileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
await enqueueToolJob({
|
await enqueueToolJob({
|
||||||
jobId,
|
jobId,
|
||||||
toolId,
|
toolId,
|
||||||
@@ -256,7 +255,7 @@ export function registerAiCanvasExpand(app: FastifyInstance) {
|
|||||||
kind: "ai-tool",
|
kind: "ai-tool",
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { isToolInstalled } from "../../lib/feature-status.js";
|
|||||||
import { type TranscriptSegment, toSrt, toVtt } from "../../lib/subtitle-format.js";
|
import { type TranscriptSegment, toSrt, toVtt } from "../../lib/subtitle-format.js";
|
||||||
import { receiveUpload } from "../../lib/upload-stream.js";
|
import { receiveUpload } from "../../lib/upload-stream.js";
|
||||||
import { getAuthUser } from "../../plugins/auth.js";
|
import { getAuthUser } from "../../plugins/auth.js";
|
||||||
|
import { buildAsyncAcceptedPayload } from "../async-response.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
language: z
|
language: z
|
||||||
@@ -157,8 +158,6 @@ export function registerAutoSubtitles(app: FastifyInstance) {
|
|||||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
await enqueueToolJob({
|
await enqueueToolJob({
|
||||||
jobId,
|
jobId,
|
||||||
toolId,
|
toolId,
|
||||||
@@ -172,7 +171,7 @@ export function registerAutoSubtitles(app: FastifyInstance) {
|
|||||||
kind: "ai-tool",
|
kind: "ai-tool",
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.j
|
|||||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { receiveUpload } from "../../lib/upload-stream.js";
|
import { receiveUpload } from "../../lib/upload-stream.js";
|
||||||
import { getAuthUser } from "../../plugins/auth.js";
|
import { getAuthUser } from "../../plugins/auth.js";
|
||||||
|
import { buildAsyncAcceptedPayload } from "../async-response.js";
|
||||||
|
|
||||||
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
|
||||||
@@ -216,8 +217,6 @@ export function registerBackgroundReplace(app: FastifyInstance) {
|
|||||||
await putObject(inputKey, fileBuffer);
|
await putObject(inputKey, fileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
await enqueueToolJob({
|
await enqueueToolJob({
|
||||||
jobId,
|
jobId,
|
||||||
toolId,
|
toolId,
|
||||||
@@ -231,7 +230,7 @@ export function registerBackgroundReplace(app: FastifyInstance) {
|
|||||||
kind: "ai-tool",
|
kind: "ai-tool",
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.j
|
|||||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { receiveUpload } from "../../lib/upload-stream.js";
|
import { receiveUpload } from "../../lib/upload-stream.js";
|
||||||
import { getAuthUser } from "../../plugins/auth.js";
|
import { getAuthUser } from "../../plugins/auth.js";
|
||||||
|
import { buildAsyncAcceptedPayload } from "../async-response.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
intensity: z.number().int().min(1).max(100).default(50),
|
intensity: z.number().int().min(1).max(100).default(50),
|
||||||
@@ -186,8 +187,6 @@ export function registerBlurBackground(app: FastifyInstance) {
|
|||||||
await putObject(inputKey, fileBuffer);
|
await putObject(inputKey, fileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
await enqueueToolJob({
|
await enqueueToolJob({
|
||||||
jobId,
|
jobId,
|
||||||
toolId,
|
toolId,
|
||||||
@@ -201,7 +200,7 @@ export function registerBlurBackground(app: FastifyInstance) {
|
|||||||
kind: "ai-tool",
|
kind: "ai-tool",
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
|
|||||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||||
import { receiveUpload } from "../../lib/upload-stream.js";
|
import { receiveUpload } from "../../lib/upload-stream.js";
|
||||||
import { getAuthUser } from "../../plugins/auth.js";
|
import { getAuthUser } from "../../plugins/auth.js";
|
||||||
|
import { buildAsyncAcceptedPayload } from "../async-response.js";
|
||||||
import { registerToolProcessFn } from "../tool-factory.js";
|
import { registerToolProcessFn } from "../tool-factory.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
@@ -163,8 +164,6 @@ export function registerBlurFaces(app: FastifyInstance) {
|
|||||||
await putObject(inputKey, fileBuffer);
|
await putObject(inputKey, fileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
await enqueueToolJob({
|
await enqueueToolJob({
|
||||||
jobId,
|
jobId,
|
||||||
toolId,
|
toolId,
|
||||||
@@ -178,7 +177,7 @@ export function registerBlurFaces(app: FastifyInstance) {
|
|||||||
kind: "ai-tool",
|
kind: "ai-tool",
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
|
|||||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||||
import { receiveUpload } from "../../lib/upload-stream.js";
|
import { receiveUpload } from "../../lib/upload-stream.js";
|
||||||
import { getAuthUser } from "../../plugins/auth.js";
|
import { getAuthUser } from "../../plugins/auth.js";
|
||||||
|
import { buildAsyncAcceptedPayload } from "../async-response.js";
|
||||||
import { registerToolProcessFn } from "../tool-factory.js";
|
import { registerToolProcessFn } from "../tool-factory.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
@@ -162,8 +163,6 @@ export function registerColorize(app: FastifyInstance) {
|
|||||||
await putObject(inputKey, fileBuffer);
|
await putObject(inputKey, fileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
await enqueueToolJob({
|
await enqueueToolJob({
|
||||||
jobId,
|
jobId,
|
||||||
toolId,
|
toolId,
|
||||||
@@ -177,7 +176,7 @@ export function registerColorize(app: FastifyInstance) {
|
|||||||
kind: "ai-tool",
|
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
|
// Register in the pipeline/batch registry
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { decodeHeic } from "../../lib/heic-converter.js";
|
|||||||
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
|
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
|
||||||
import { receiveUpload } from "../../lib/upload-stream.js";
|
import { receiveUpload } from "../../lib/upload-stream.js";
|
||||||
import { getAuthUser } from "../../plugins/auth.js";
|
import { getAuthUser } from "../../plugins/auth.js";
|
||||||
|
import { buildAsyncAcceptedPayload } from "../async-response.js";
|
||||||
import { registerToolProcessFn } from "../tool-factory.js";
|
import { registerToolProcessFn } from "../tool-factory.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
@@ -156,8 +157,6 @@ export function registerEnhanceFaces(app: FastifyInstance) {
|
|||||||
await putObject(inputKey, fileBuffer);
|
await putObject(inputKey, fileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
await enqueueToolJob({
|
await enqueueToolJob({
|
||||||
jobId,
|
jobId,
|
||||||
toolId,
|
toolId,
|
||||||
@@ -171,7 +170,7 @@ export function registerEnhanceFaces(app: FastifyInstance) {
|
|||||||
kind: "ai-tool",
|
kind: "ai-tool",
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
|
|||||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||||
import { receiveUpload } from "../../lib/upload-stream.js";
|
import { receiveUpload } from "../../lib/upload-stream.js";
|
||||||
import { getAuthUser } from "../../plugins/auth.js";
|
import { getAuthUser } from "../../plugins/auth.js";
|
||||||
|
import { buildAsyncAcceptedPayload } from "../async-response.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
format: z
|
format: z
|
||||||
@@ -157,8 +158,6 @@ export function registerEraseObject(app: FastifyInstance) {
|
|||||||
await putObject(imageKey, imageBuffer);
|
await putObject(imageKey, imageBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
// Enqueue with both image and mask as inputRefs; the worker handler
|
// Enqueue with both image and mask as inputRefs; the worker handler
|
||||||
// reads them via getObjectBuffer.
|
// reads them via getObjectBuffer.
|
||||||
await enqueueToolJob({
|
await enqueueToolJob({
|
||||||
@@ -174,7 +173,7 @@ export function registerEraseObject(app: FastifyInstance) {
|
|||||||
kind: "ai-tool",
|
kind: "ai-tool",
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { decodeHeic } from "../../lib/heic-converter.js";
|
|||||||
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
|
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
|
||||||
import { receiveUpload } from "../../lib/upload-stream.js";
|
import { receiveUpload } from "../../lib/upload-stream.js";
|
||||||
import { getAuthUser } from "../../plugins/auth.js";
|
import { getAuthUser } from "../../plugins/auth.js";
|
||||||
|
import { buildAsyncAcceptedPayload } from "../async-response.js";
|
||||||
import { registerToolProcessFn } from "../tool-factory.js";
|
import { registerToolProcessFn } from "../tool-factory.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
@@ -167,8 +168,6 @@ export function registerNoiseRemoval(app: FastifyInstance) {
|
|||||||
await putObject(inputKey, fileBuffer);
|
await putObject(inputKey, fileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
await enqueueToolJob({
|
await enqueueToolJob({
|
||||||
jobId,
|
jobId,
|
||||||
toolId,
|
toolId,
|
||||||
@@ -182,7 +181,7 @@ export function registerNoiseRemoval(app: FastifyInstance) {
|
|||||||
kind: "ai-tool",
|
kind: "ai-tool",
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
|||||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||||
import { receiveUpload } from "../../lib/upload-stream.js";
|
import { receiveUpload } from "../../lib/upload-stream.js";
|
||||||
import { getAuthUser } from "../../plugins/auth.js";
|
import { getAuthUser } from "../../plugins/auth.js";
|
||||||
|
import { buildAsyncAcceptedPayload } from "../async-response.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
quality: z.enum(["fast", "balanced", "best"]).default("balanced"),
|
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" });
|
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
await enqueueToolJob({
|
await enqueueToolJob({
|
||||||
jobId,
|
jobId,
|
||||||
toolId,
|
toolId,
|
||||||
@@ -137,6 +136,6 @@ export function registerOcrPdf(app: FastifyInstance) {
|
|||||||
kind: "ai-tool",
|
kind: "ai-tool",
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { decodeHeic } from "../../lib/heic-converter.js";
|
|||||||
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
|
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
|
||||||
import { receiveUpload } from "../../lib/upload-stream.js";
|
import { receiveUpload } from "../../lib/upload-stream.js";
|
||||||
import { getAuthUser } from "../../plugins/auth.js";
|
import { getAuthUser } from "../../plugins/auth.js";
|
||||||
|
import { buildAsyncAcceptedPayload } from "../async-response.js";
|
||||||
import { registerToolProcessFn } from "../tool-factory.js";
|
import { registerToolProcessFn } from "../tool-factory.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
@@ -155,8 +156,6 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
|
|||||||
await putObject(inputKey, fileBuffer);
|
await putObject(inputKey, fileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
await enqueueToolJob({
|
await enqueueToolJob({
|
||||||
jobId,
|
jobId,
|
||||||
toolId,
|
toolId,
|
||||||
@@ -170,7 +169,7 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
|
|||||||
kind: "ai-tool",
|
kind: "ai-tool",
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { decodeHeic } from "../../lib/heic-converter.js";
|
|||||||
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
|
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
|
||||||
import { receiveUpload } from "../../lib/upload-stream.js";
|
import { receiveUpload } from "../../lib/upload-stream.js";
|
||||||
import { getAuthUser } from "../../plugins/auth.js";
|
import { getAuthUser } from "../../plugins/auth.js";
|
||||||
|
import { buildAsyncAcceptedPayload } from "../async-response.js";
|
||||||
import { registerToolProcessFn } from "../tool-factory.js";
|
import { registerToolProcessFn } from "../tool-factory.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
@@ -206,8 +207,6 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
|||||||
await putObject(inputKey, fileBuffer);
|
await putObject(inputKey, fileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
// Enqueue on the AI pool
|
// Enqueue on the AI pool
|
||||||
await enqueueToolJob({
|
await enqueueToolJob({
|
||||||
jobId,
|
jobId,
|
||||||
@@ -223,7 +222,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// AI tools always return 202 (no sync window)
|
// 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));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
|
|||||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||||
import { receiveUpload } from "../../lib/upload-stream.js";
|
import { receiveUpload } from "../../lib/upload-stream.js";
|
||||||
import { getAuthUser } from "../../plugins/auth.js";
|
import { getAuthUser } from "../../plugins/auth.js";
|
||||||
|
import { buildAsyncAcceptedPayload } from "../async-response.js";
|
||||||
import { registerToolProcessFn } from "../tool-factory.js";
|
import { registerToolProcessFn } from "../tool-factory.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
@@ -187,8 +188,6 @@ export function registerRestorePhoto(app: FastifyInstance) {
|
|||||||
await putObject(inputKey, fileBuffer);
|
await putObject(inputKey, fileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
await enqueueToolJob({
|
await enqueueToolJob({
|
||||||
jobId,
|
jobId,
|
||||||
toolId: "restore-photo",
|
toolId: "restore-photo",
|
||||||
@@ -202,7 +201,7 @@ export function registerRestorePhoto(app: FastifyInstance) {
|
|||||||
kind: "ai-tool",
|
kind: "ai-tool",
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { getObjectBuffer } from "../../lib/object-storage.js";
|
|||||||
import { receiveUpload } from "../../lib/upload-stream.js";
|
import { receiveUpload } from "../../lib/upload-stream.js";
|
||||||
import { inputHandlerFor } from "../../modality/input-handler.js";
|
import { inputHandlerFor } from "../../modality/input-handler.js";
|
||||||
import { getAuthUser } from "../../plugins/auth.js";
|
import { getAuthUser } from "../../plugins/auth.js";
|
||||||
|
import { buildAsyncAcceptedPayload } from "../async-response.js";
|
||||||
|
|
||||||
const TOOL_ID = "sign-pdf";
|
const TOOL_ID = "sign-pdf";
|
||||||
const MAX_PLACEMENTS = 100;
|
const MAX_PLACEMENTS = 100;
|
||||||
@@ -158,7 +159,7 @@ export function registerSignPdf(app: FastifyInstance) {
|
|||||||
savedFileId: result.savedFileId,
|
savedFileId: result.savedFileId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return reply.status(202).send({ jobId: clientJobId || jobId, async: true });
|
return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
request.log.error({ err, toolId: TOOL_ID }, "sign-pdf processing failed");
|
request.log.error({ err, toolId: TOOL_ID }, "sign-pdf processing failed");
|
||||||
return reply.status(422).send({
|
return reply.status(422).send({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { isToolInstalled } from "../../lib/feature-status.js";
|
|||||||
import { type TranscriptSegment, toSrt, toVtt } from "../../lib/subtitle-format.js";
|
import { type TranscriptSegment, toSrt, toVtt } from "../../lib/subtitle-format.js";
|
||||||
import { receiveUpload } from "../../lib/upload-stream.js";
|
import { receiveUpload } from "../../lib/upload-stream.js";
|
||||||
import { getAuthUser } from "../../plugins/auth.js";
|
import { getAuthUser } from "../../plugins/auth.js";
|
||||||
|
import { buildAsyncAcceptedPayload } from "../async-response.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
language: z
|
language: z
|
||||||
@@ -145,8 +146,6 @@ export function registerTranscribeAudio(app: FastifyInstance) {
|
|||||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
await enqueueToolJob({
|
await enqueueToolJob({
|
||||||
jobId,
|
jobId,
|
||||||
toolId,
|
toolId,
|
||||||
@@ -160,7 +159,7 @@ export function registerTranscribeAudio(app: FastifyInstance) {
|
|||||||
kind: "ai-tool",
|
kind: "ai-tool",
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { decodeHeic } from "../../lib/heic-converter.js";
|
|||||||
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
|
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
|
||||||
import { receiveUpload } from "../../lib/upload-stream.js";
|
import { receiveUpload } from "../../lib/upload-stream.js";
|
||||||
import { getAuthUser } from "../../plugins/auth.js";
|
import { getAuthUser } from "../../plugins/auth.js";
|
||||||
|
import { buildAsyncAcceptedPayload } from "../async-response.js";
|
||||||
import { registerToolProcessFn } from "../tool-factory.js";
|
import { registerToolProcessFn } from "../tool-factory.js";
|
||||||
|
|
||||||
const TOOL_ID = "transparency-fixer";
|
const TOOL_ID = "transparency-fixer";
|
||||||
@@ -252,8 +253,6 @@ export function registerTransparencyFixer(app: FastifyInstance) {
|
|||||||
await putObject(inputKey, fileBuffer);
|
await putObject(inputKey, fileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
await enqueueToolJob({
|
await enqueueToolJob({
|
||||||
jobId,
|
jobId,
|
||||||
toolId: TOOL_ID,
|
toolId: TOOL_ID,
|
||||||
@@ -267,7 +266,7 @@ export function registerTransparencyFixer(app: FastifyInstance) {
|
|||||||
kind: "ai-tool",
|
kind: "ai-tool",
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
return reply.status(202).send(buildAsyncAcceptedPayload(jobId, clientJobId));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
|
|||||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||||
import { receiveUpload } from "../../lib/upload-stream.js";
|
import { receiveUpload } from "../../lib/upload-stream.js";
|
||||||
import { getAuthUser } from "../../plugins/auth.js";
|
import { getAuthUser } from "../../plugins/auth.js";
|
||||||
|
import { buildAsyncAcceptedPayload } from "../async-response.js";
|
||||||
import { registerToolProcessFn } from "../tool-factory.js";
|
import { registerToolProcessFn } from "../tool-factory.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
@@ -214,8 +215,6 @@ export function registerUpscale(app: FastifyInstance) {
|
|||||||
await putObject(inputKey, fileBuffer);
|
await putObject(inputKey, fileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
await enqueueToolJob({
|
await enqueueToolJob({
|
||||||
jobId,
|
jobId,
|
||||||
toolId,
|
toolId,
|
||||||
@@ -229,7 +228,7 @@ export function registerUpscale(app: FastifyInstance) {
|
|||||||
kind: "ai-tool",
|
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
|
// Register in the pipeline/batch registry so this tool can be used
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||||
import { TOOLS, toolSection } from "@snapotter/shared";
|
import { TOOLS, toolSection } from "@snapotter/shared";
|
||||||
import * as lucideIcons from "lucide";
|
import * as lucideIcons from "lucide";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||||
const features = [
|
const features = [
|
||||||
{
|
{
|
||||||
tag: "Identity",
|
tag: "Identity",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||||
const aiTools = [
|
const aiTools = [
|
||||||
"Remove Background",
|
"Remove Background",
|
||||||
"Image Upscaling",
|
"Image Upscaling",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
|
|||||||
@@ -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 CategoryCards from "./CategoryCards.astro";
|
||||||
import HeroSearch from "./HeroSearch.astro";
|
import HeroSearch from "./HeroSearch.astro";
|
||||||
import TrustSignals from "./TrustSignals.astro";
|
import TrustSignals from "./TrustSignals.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 { TOOLS, toolSection } from "@snapotter/shared";
|
||||||
import * as lucideIcons from "lucide";
|
import * as lucideIcons from "lucide";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||||
interface Props {
|
interface Props {
|
||||||
data: Record<string, unknown> | Record<string, unknown>[];
|
data: Record<string, unknown> | Record<string, unknown>[];
|
||||||
}
|
}
|
||||||
@@ -8,5 +9,5 @@ const schemas = Array.isArray(data) ? data : [data];
|
|||||||
---
|
---
|
||||||
|
|
||||||
{schemas.map((schema) => (
|
{schemas.map((schema) => (
|
||||||
<script type="application/ld+json" set:html={JSON.stringify(schema)} />
|
<script is:inline type="application/ld+json" set:html={JSON.stringify(schema)} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||||
import { formatCompact, getStarCount } from "@/lib/stats";
|
import { formatCompact, getStarCount } from "@/lib/stats";
|
||||||
|
|
||||||
// Fetched at build time; refreshed by the scheduled landing rebuild.
|
// Fetched at build time; refreshed by the scheduled landing rebuild.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes component imports.
|
||||||
import SectionHeading from "./SectionHeading.astro";
|
import SectionHeading from "./SectionHeading.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 } from "@snapotter/shared";
|
import { TOOLS } from "@snapotter/shared";
|
||||||
import SectionHeading from "./SectionHeading.astro";
|
import SectionHeading from "./SectionHeading.astro";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
|
|||||||
@@ -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 { CATEGORIES, TOOLS, toolSection } from "@snapotter/shared";
|
import { CATEGORIES, TOOLS, toolSection } from "@snapotter/shared";
|
||||||
import * as lucideIcons from "lucide";
|
import * as lucideIcons from "lucide";
|
||||||
import SectionHeading from "./SectionHeading.astro";
|
import SectionHeading from "./SectionHeading.astro";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||||
import { formatCompact, getImagePulls, getStarCount } from "@/lib/stats";
|
import { formatCompact, getImagePulls, getStarCount } from "@/lib/stats";
|
||||||
|
|
||||||
// Fetched at build time; refreshed by the scheduled landing rebuild.
|
// Fetched at build time; refreshed by the scheduled landing rebuild.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||||
import "@/styles/globals.css";
|
import "@/styles/globals.css";
|
||||||
import { TOOLS } from "@snapotter/shared";
|
import { TOOLS } from "@snapotter/shared";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes component imports.
|
||||||
import Footer from "@/components/Footer.astro";
|
import Footer from "@/components/Footer.astro";
|
||||||
import Navbar from "@/components/Navbar.astro";
|
import Navbar from "@/components/Navbar.astro";
|
||||||
import Base from "@/layouts/Base.astro";
|
import Base from "@/layouts/Base.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 Footer from "@/components/Footer.astro";
|
import Footer from "@/components/Footer.astro";
|
||||||
import JsonLd from "@/components/JsonLd.astro";
|
import JsonLd from "@/components/JsonLd.astro";
|
||||||
import Navbar from "@/components/Navbar.astro";
|
import Navbar from "@/components/Navbar.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 Footer from "@/components/Footer.astro";
|
import Footer from "@/components/Footer.astro";
|
||||||
import JsonLd from "@/components/JsonLd.astro";
|
import JsonLd from "@/components/JsonLd.astro";
|
||||||
import Navbar from "@/components/Navbar.astro";
|
import Navbar from "@/components/Navbar.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 Footer from "@/components/Footer.astro";
|
import Footer from "@/components/Footer.astro";
|
||||||
import JsonLd from "@/components/JsonLd.astro";
|
import JsonLd from "@/components/JsonLd.astro";
|
||||||
import Navbar from "@/components/Navbar.astro";
|
import Navbar from "@/components/Navbar.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 } from "@snapotter/shared";
|
import { TOOLS } from "@snapotter/shared";
|
||||||
import Footer from "@/components/Footer.astro";
|
import Footer from "@/components/Footer.astro";
|
||||||
import JsonLd from "@/components/JsonLd.astro";
|
import JsonLd from "@/components/JsonLd.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 Footer from "@/components/Footer.astro";
|
import Footer from "@/components/Footer.astro";
|
||||||
import JsonLd from "@/components/JsonLd.astro";
|
import JsonLd from "@/components/JsonLd.astro";
|
||||||
import Navbar from "@/components/Navbar.astro";
|
import Navbar from "@/components/Navbar.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 } from "@snapotter/shared";
|
import { TOOLS } from "@snapotter/shared";
|
||||||
import EnterpriseSection from "@/components/EnterpriseSection.astro";
|
import EnterpriseSection from "@/components/EnterpriseSection.astro";
|
||||||
import FeatureHighlights from "@/components/FeatureHighlights.astro";
|
import FeatureHighlights from "@/components/FeatureHighlights.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 Footer from "@/components/Footer.astro";
|
import Footer from "@/components/Footer.astro";
|
||||||
import JsonLd from "@/components/JsonLd.astro";
|
import JsonLd from "@/components/JsonLd.astro";
|
||||||
import Navbar from "@/components/Navbar.astro";
|
import Navbar from "@/components/Navbar.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 Footer from "@/components/Footer.astro";
|
import Footer from "@/components/Footer.astro";
|
||||||
import JsonLd from "@/components/JsonLd.astro";
|
import JsonLd from "@/components/JsonLd.astro";
|
||||||
import Navbar from "@/components/Navbar.astro";
|
import Navbar from "@/components/Navbar.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 { CATEGORIES, PYTHON_SIDECAR_TOOLS, SECTIONS, TOOLS, toolSection } from "@snapotter/shared";
|
import { CATEGORIES, PYTHON_SIDECAR_TOOLS, SECTIONS, TOOLS, toolSection } from "@snapotter/shared";
|
||||||
import * as lucideIcons from "lucide";
|
import * as lucideIcons from "lucide";
|
||||||
import Footer from "@/components/Footer.astro";
|
import Footer from "@/components/Footer.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 { CATEGORIES, SECTIONS, TOOLS, toolSection } from "@snapotter/shared";
|
import { CATEGORIES, SECTIONS, TOOLS, toolSection } from "@snapotter/shared";
|
||||||
import * as lucideIcons from "lucide";
|
import * as lucideIcons from "lucide";
|
||||||
import Footer from "@/components/Footer.astro";
|
import Footer from "@/components/Footer.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 { CATEGORIES, TOOLS, toolSection } from "@snapotter/shared";
|
import { CATEGORIES, TOOLS, toolSection } from "@snapotter/shared";
|
||||||
import * as lucideIcons from "lucide";
|
import * as lucideIcons from "lucide";
|
||||||
import Footer from "@/components/Footer.astro";
|
import Footer from "@/components/Footer.astro";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AlertCircle, FileImage, FileUp, Upload } from "lucide-react";
|
import { AlertCircle, FileImage, FileUp, Upload } from "lucide-react";
|
||||||
import { type DragEvent, useCallback, useEffect, useState } from "react";
|
import { type DragEvent, type KeyboardEvent, useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useUrlImport } from "@/hooks/use-url-import";
|
import { useUrlImport } from "@/hooks/use-url-import";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -177,7 +177,7 @@ export function Dropzone({
|
|||||||
[onFiles, checkFile, acceptDescription, accept],
|
[onFiles, checkFile, acceptDescription, accept],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = useCallback(() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
@@ -193,7 +193,17 @@ export function Dropzone({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
};
|
}, [multiple, resolvedAccept, checkFile, onFiles, acceptDescription, accept]);
|
||||||
|
|
||||||
|
const handleDropzoneKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent<HTMLElement>) => {
|
||||||
|
if (e.target !== e.currentTarget) return;
|
||||||
|
if (e.key !== "Enter" && e.key !== " ") return;
|
||||||
|
e.preventDefault();
|
||||||
|
handleClick();
|
||||||
|
},
|
||||||
|
[handleClick],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePaste = (e: ClipboardEvent) => {
|
const handlePaste = (e: ClipboardEvent) => {
|
||||||
@@ -234,6 +244,7 @@ export function Dropzone({
|
|||||||
onDragLeave={handleDrag}
|
onDragLeave={handleDrag}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
onKeyDown={handleDropzoneKeyDown}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex flex-col items-center justify-center rounded-2xl border-2 border-dashed transition-all duration-200 mx-auto max-w-2xl w-full cursor-pointer",
|
"group flex flex-col items-center justify-center rounded-2xl border-2 border-dashed transition-all duration-200 mx-auto max-w-2xl w-full cursor-pointer",
|
||||||
compact ? "min-h-0 h-full" : "min-h-[400px]",
|
compact ? "min-h-0 h-full" : "min-h-[400px]",
|
||||||
|
|||||||
@@ -94,10 +94,7 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
|
|||||||
}, [handleClose]);
|
}, [handleClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Overlay */}
|
{/* Overlay */}
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|||||||
@@ -420,7 +420,7 @@ function GeneralSection() {
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
setTimeout(() => setSaveMsg(null), 3000);
|
setTimeout(() => setSaveMsg(null), 3000);
|
||||||
}
|
}
|
||||||
}, [defaultToolView]);
|
}, [defaultToolView, t.settings.general.saveSuccess, t.settings.general.saveFailed]);
|
||||||
|
|
||||||
const username = user?.username || "admin";
|
const username = user?.username || "admin";
|
||||||
const role = user?.role || "unknown";
|
const role = user?.role || "unknown";
|
||||||
@@ -940,7 +940,16 @@ function SecuritySection() {
|
|||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentPassword, newPassword, confirmPassword],
|
[
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
confirmPassword,
|
||||||
|
t.settings.security.changeFailed,
|
||||||
|
t.settings.security.currentPasswordIncorrect,
|
||||||
|
t.settings.security.changeSuccess,
|
||||||
|
t.settings.security.passwordsMismatch,
|
||||||
|
t.settings.security.passwordTooShort,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1468,7 +1477,17 @@ function PeopleSection() {
|
|||||||
setTimeout(() => setActionMsg(null), 3000);
|
setTimeout(() => setActionMsg(null), 3000);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[newUsername, newPassword, newRole, newTeam, maxUsers, loadUsers],
|
[
|
||||||
|
newUsername,
|
||||||
|
newPassword,
|
||||||
|
newRole,
|
||||||
|
newTeam,
|
||||||
|
maxUsers,
|
||||||
|
loadUsers,
|
||||||
|
t.settings.people.createFailed,
|
||||||
|
t.settings.people.createSuccess,
|
||||||
|
t.settings.people.userLimitReached,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteUser = useCallback(
|
const handleDeleteUser = useCallback(
|
||||||
@@ -1487,7 +1506,12 @@ function PeopleSection() {
|
|||||||
setOpenMenuId(null);
|
setOpenMenuId(null);
|
||||||
setTimeout(() => setActionMsg(null), 3000);
|
setTimeout(() => setActionMsg(null), 3000);
|
||||||
},
|
},
|
||||||
[loadUsers],
|
[
|
||||||
|
loadUsers,
|
||||||
|
t.settings.people.deleteSuccess,
|
||||||
|
t.settings.people.deleteFailed,
|
||||||
|
t.settings.people.deleteConfirm,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUpdateUser = useCallback(
|
const handleUpdateUser = useCallback(
|
||||||
@@ -1511,7 +1535,14 @@ function PeopleSection() {
|
|||||||
}
|
}
|
||||||
setTimeout(() => setActionMsg(null), 3000);
|
setTimeout(() => setActionMsg(null), 3000);
|
||||||
},
|
},
|
||||||
[editingUser, editRole, editTeam, loadUsers],
|
[
|
||||||
|
editingUser,
|
||||||
|
editRole,
|
||||||
|
editTeam,
|
||||||
|
loadUsers,
|
||||||
|
t.settings.people.cannotRemoveOwnAdmin,
|
||||||
|
t.settings.people.updateSuccess,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleResetPassword = useCallback(
|
const handleResetPassword = useCallback(
|
||||||
@@ -1531,7 +1562,7 @@ function PeopleSection() {
|
|||||||
}
|
}
|
||||||
setTimeout(() => setActionMsg(null), 3000);
|
setTimeout(() => setActionMsg(null), 3000);
|
||||||
},
|
},
|
||||||
[resetPasswordUser, resetPassword],
|
[resetPasswordUser, resetPassword, t.settings.people.resetSuccess],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -2357,7 +2388,7 @@ function TeamsSection() {
|
|||||||
setTimeout(() => setActionMsg(null), 3000);
|
setTimeout(() => setActionMsg(null), 3000);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[newTeamName, loadTeams],
|
[newTeamName, loadTeams, t.settings.teams.duplicateName, t.settings.teams.createSuccess],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRename = useCallback(
|
const handleRename = useCallback(
|
||||||
@@ -2375,7 +2406,7 @@ function TeamsSection() {
|
|||||||
}
|
}
|
||||||
setTimeout(() => setActionMsg(null), 3000);
|
setTimeout(() => setActionMsg(null), 3000);
|
||||||
},
|
},
|
||||||
[editingTeamName, loadTeams],
|
[editingTeamName, loadTeams, t.settings.teams.renameSuccess],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
@@ -2395,7 +2426,7 @@ function TeamsSection() {
|
|||||||
setOpenMenuId(null);
|
setOpenMenuId(null);
|
||||||
setTimeout(() => setActionMsg(null), 3000);
|
setTimeout(() => setActionMsg(null), 3000);
|
||||||
},
|
},
|
||||||
[loadTeams],
|
[loadTeams, t.settings.teams.deleteConfirm, t.settings.teams.cannotDeleteDefault],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleExpandTeam = useCallback(
|
const handleExpandTeam = useCallback(
|
||||||
@@ -2432,7 +2463,7 @@ function TeamsSection() {
|
|||||||
setTimeout(() => setActionMsg(null), 3000);
|
setTimeout(() => setActionMsg(null), 3000);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[quotaMb, retention, loadTeams],
|
[quotaMb, retention, loadTeams, t.settings.teams.quotaSaved],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -2787,7 +2818,14 @@ function RolesSection() {
|
|||||||
}
|
}
|
||||||
setTimeout(() => setActionMsg(null), 3000);
|
setTimeout(() => setActionMsg(null), 3000);
|
||||||
},
|
},
|
||||||
[newName, newDescription, newPermissions, loadRoles],
|
[
|
||||||
|
newName,
|
||||||
|
newDescription,
|
||||||
|
newPermissions,
|
||||||
|
loadRoles,
|
||||||
|
t.settings.roles.duplicateRoleError,
|
||||||
|
t.settings.roles.createSuccess,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUpdate = useCallback(
|
const handleUpdate = useCallback(
|
||||||
@@ -2809,7 +2847,14 @@ function RolesSection() {
|
|||||||
}
|
}
|
||||||
setTimeout(() => setActionMsg(null), 3000);
|
setTimeout(() => setActionMsg(null), 3000);
|
||||||
},
|
},
|
||||||
[editingRole, editName, editDescription, editPermissions, loadRoles],
|
[
|
||||||
|
editingRole,
|
||||||
|
editName,
|
||||||
|
editDescription,
|
||||||
|
editPermissions,
|
||||||
|
loadRoles,
|
||||||
|
t.settings.roles.updateSuccess,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
@@ -3213,8 +3258,11 @@ function AuditLogSection() {
|
|||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<Fragment key={entry.id}>
|
<Fragment key={entry.id}>
|
||||||
<div
|
<button
|
||||||
className="px-3 py-2.5 hover:bg-muted/20 cursor-pointer transition-colors"
|
type="button"
|
||||||
|
aria-expanded={expandedId === entry.id}
|
||||||
|
aria-controls={`audit-details-${entry.id}`}
|
||||||
|
className="w-full px-3 py-2.5 hover:bg-muted/20 cursor-pointer transition-colors text-start"
|
||||||
onClick={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
|
onClick={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
@@ -3239,9 +3287,9 @@ function AuditLogSection() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
{expandedId === entry.id && entry.details && (
|
{expandedId === entry.id && entry.details && (
|
||||||
<div className="px-3 py-2 bg-muted/10">
|
<div id={`audit-details-${entry.id}`} className="px-3 py-2 bg-muted/10">
|
||||||
<pre className="text-xs text-muted-foreground whitespace-pre-wrap font-mono overflow-x-auto">
|
<pre className="text-xs text-muted-foreground whitespace-pre-wrap font-mono overflow-x-auto">
|
||||||
{JSON.stringify(entry.details, null, 2)}
|
{JSON.stringify(entry.details, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
|
|||||||
@@ -42,14 +42,15 @@ export function DocumentView({ inputOnly = false }: { inputOnly?: boolean } = {}
|
|||||||
// F22: when processedUrl is available, use URL-based loading instead of
|
// F22: when processedUrl is available, use URL-based loading instead of
|
||||||
// entry.file (which is the original non-PDF input and would fail pdf.js)
|
// entry.file (which is the original non-PDF input and would fail pdf.js)
|
||||||
const file = hasProcessedUrl ? undefined : entry?.file;
|
const file = hasProcessedUrl ? undefined : entry?.file;
|
||||||
if (!file && !src) return;
|
const url = src;
|
||||||
|
if (!file && !url) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let doc: pdfjs.PDFDocumentProxy | undefined;
|
let doc: pdfjs.PDFDocumentProxy | undefined;
|
||||||
let renderTask: pdfjs.RenderTask | undefined;
|
let renderTask: pdfjs.RenderTask | undefined;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const source = file ? { data: new Uint8Array(await file.arrayBuffer()) } : { url: src! };
|
const source = file ? { data: new Uint8Array(await file.arrayBuffer()) } : { url };
|
||||||
doc = await pdfjs.getDocument(source).promise;
|
doc = await pdfjs.getDocument(source).promise;
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setPageCount(doc.numPages);
|
setPageCount(doc.numPages);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ArrowLeft, ChevronLeft, ChevronRight, Crown, Search } from "lucide-react";
|
import { ArrowLeft, ChevronLeft, ChevronRight, Crown, Search } from "lucide-react";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
|
||||||
import { formatFileSize } from "@/lib/download";
|
import { formatFileSize } from "@/lib/download";
|
||||||
import type { DuplicateFileInfo } from "@/stores/duplicate-store";
|
import type { DuplicateFileInfo } from "@/stores/duplicate-store";
|
||||||
import { useDuplicateStore } from "@/stores/duplicate-store";
|
import { useDuplicateStore } from "@/stores/duplicate-store";
|
||||||
@@ -251,7 +250,6 @@ function DetailComparison() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FindDuplicatesResults() {
|
export function FindDuplicatesResults() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { results, scanning, viewMode } = useDuplicateStore();
|
const { results, scanning, viewMode } = useDuplicateStore();
|
||||||
|
|
||||||
if (scanning) {
|
if (scanning) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Download, FolderArchive, Loader2 } from "lucide-react";
|
import { Download, FolderArchive, Loader2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { formatFileSize } from "@/lib/download";
|
import { formatFileSize } from "@/lib/download";
|
||||||
import type { DuplicateResult } from "@/stores/duplicate-store";
|
import type { DuplicateResult } from "@/stores/duplicate-store";
|
||||||
@@ -16,7 +15,6 @@ const PRESET_DESCRIPTIONS: Record<Preset, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function FindDuplicatesSettings() {
|
export function FindDuplicatesSettings() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const {
|
const {
|
||||||
results,
|
results,
|
||||||
@@ -217,7 +215,7 @@ export function FindDuplicatesSettings() {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}, [files, results]);
|
}, [files, results]);
|
||||||
|
|
||||||
const handleDownloadAll = useCallback(async () => {
|
const _handleDownloadAll = useCallback(async () => {
|
||||||
const { zipSync } = await import("fflate");
|
const { zipSync } = await import("fflate");
|
||||||
|
|
||||||
const zipData: Record<string, Uint8Array> = {};
|
const zipData: Record<string, Uint8Array> = {};
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export interface GifToolsControlsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GifToolsControls({ settings: initialSettings, onChange }: GifToolsControlsProps) {
|
export function GifToolsControls({ settings: initialSettings, onChange }: GifToolsControlsProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { info, loading: infoLoading } = useGifInfo();
|
const { info, loading: infoLoading } = useGifInfo();
|
||||||
const isAnimated = (info?.pages ?? 0) > 1;
|
const isAnimated = (info?.pages ?? 0) > 1;
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ export function HtmlToImageResults() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resultUrl = store.resultUrl;
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = store.resultUrl!;
|
a.href = resultUrl;
|
||||||
a.download = `screenshot.${store.format}`;
|
a.download = `screenshot.${store.format}`;
|
||||||
a.click();
|
a.click();
|
||||||
};
|
};
|
||||||
@@ -35,7 +37,7 @@ export function HtmlToImageResults() {
|
|||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex-1 overflow-auto p-4">
|
<div className="flex-1 overflow-auto p-4">
|
||||||
<img
|
<img
|
||||||
src={store.resultUrl}
|
src={resultUrl}
|
||||||
alt="Captured screenshot"
|
alt="Captured screenshot"
|
||||||
className="mx-auto max-w-full rounded-lg border border-border shadow-sm"
|
className="mx-auto max-w-full rounded-lg border border-border shadow-sm"
|
||||||
/>
|
/>
|
||||||
@@ -49,6 +51,7 @@ export function HtmlToImageResults() {
|
|||||||
: ""}
|
: ""}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -45,8 +45,11 @@ export function HtmlToImageSettings() {
|
|||||||
|
|
||||||
{store.mode === "url" && (
|
{store.mode === "url" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium">{ts.url}</label>
|
<label htmlFor="html-to-image-url" className="mb-1 block text-sm font-medium">
|
||||||
|
{ts.url}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="html-to-image-url"
|
||||||
type="url"
|
type="url"
|
||||||
value={store.url}
|
value={store.url}
|
||||||
onChange={(e) => store.setUrl(e.target.value)}
|
onChange={(e) => store.setUrl(e.target.value)}
|
||||||
@@ -58,8 +61,11 @@ export function HtmlToImageSettings() {
|
|||||||
|
|
||||||
{store.mode === "html" && (
|
{store.mode === "html" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium">{ts.htmlFile}</label>
|
<label htmlFor="html-to-image-file" className="mb-1 block text-sm font-medium">
|
||||||
|
{ts.htmlFile}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="html-to-image-file"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".html,.htm"
|
accept=".html,.htm"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -74,8 +80,11 @@ export function HtmlToImageSettings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium">{ts.format}</label>
|
<label htmlFor="html-to-image-format" className="mb-1 block text-sm font-medium">
|
||||||
|
{ts.format}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
|
id="html-to-image-format"
|
||||||
value={store.format}
|
value={store.format}
|
||||||
onChange={(e) => store.setFormat(e.target.value as "jpg" | "png" | "webp")}
|
onChange={(e) => store.setFormat(e.target.value as "jpg" | "png" | "webp")}
|
||||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||||
@@ -88,10 +97,11 @@ export function HtmlToImageSettings() {
|
|||||||
|
|
||||||
{store.format !== "png" && (
|
{store.format !== "png" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium">
|
<label htmlFor="html-to-image-quality" className="mb-1 block text-sm font-medium">
|
||||||
{ts.quality}: {store.quality}%
|
{ts.quality}: {store.quality}%
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="html-to-image-quality"
|
||||||
type="range"
|
type="range"
|
||||||
min={1}
|
min={1}
|
||||||
max={100}
|
max={100}
|
||||||
@@ -103,8 +113,11 @@ export function HtmlToImageSettings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium">{ts.devicePreset}</label>
|
<label htmlFor="html-to-image-device-preset" className="mb-1 block text-sm font-medium">
|
||||||
|
{ts.devicePreset}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
|
id="html-to-image-device-preset"
|
||||||
value={store.devicePreset}
|
value={store.devicePreset}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
store.setDevicePreset(e.target.value as "desktop" | "tablet" | "mobile" | "custom")
|
store.setDevicePreset(e.target.value as "desktop" | "tablet" | "mobile" | "custom")
|
||||||
@@ -121,8 +134,14 @@ export function HtmlToImageSettings() {
|
|||||||
{store.devicePreset === "custom" && (
|
{store.devicePreset === "custom" && (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium">{ts.viewportWidth}</label>
|
<label
|
||||||
|
htmlFor="html-to-image-viewport-width"
|
||||||
|
className="mb-1 block text-sm font-medium"
|
||||||
|
>
|
||||||
|
{ts.viewportWidth}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="html-to-image-viewport-width"
|
||||||
type="number"
|
type="number"
|
||||||
min={320}
|
min={320}
|
||||||
max={3840}
|
max={3840}
|
||||||
@@ -132,8 +151,14 @@ export function HtmlToImageSettings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium">{ts.viewportHeight}</label>
|
<label
|
||||||
|
htmlFor="html-to-image-viewport-height"
|
||||||
|
className="mb-1 block text-sm font-medium"
|
||||||
|
>
|
||||||
|
{ts.viewportHeight}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="html-to-image-viewport-height"
|
||||||
type="number"
|
type="number"
|
||||||
min={320}
|
min={320}
|
||||||
max={2160}
|
max={2160}
|
||||||
@@ -146,8 +171,11 @@ export function HtmlToImageSettings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-sm font-medium">{ts.fullPage}</label>
|
<label htmlFor="html-to-image-full-page" className="text-sm font-medium">
|
||||||
|
{ts.fullPage}
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
|
id="html-to-image-full-page"
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={store.fullPage}
|
aria-checked={store.fullPage}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
import { format } from "@/lib/format";
|
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
type EnhancementMode = "auto" | "portrait" | "landscape" | "low-light" | "food" | "document";
|
type EnhancementMode = "auto" | "portrait" | "landscape" | "low-light" | "food" | "document";
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Check, ClipboardCopy, Download, FileJson, FileText, Loader2 } from "lucide-react";
|
import { Check, ClipboardCopy, Download, FileJson, FileText, Loader2 } from "lucide-react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
|
||||||
import type { Base64Result } from "@/stores/base64-store";
|
import type { Base64Result } from "@/stores/base64-store";
|
||||||
import { useBase64Store } from "@/stores/base64-store";
|
import { useBase64Store } from "@/stores/base64-store";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
@@ -169,7 +168,6 @@ function FileResult({ result }: { result: Base64Result }) {
|
|||||||
// -- Main ResultsPanel ------------------------------------------------------
|
// -- Main ResultsPanel ------------------------------------------------------
|
||||||
|
|
||||||
export function ImageToBase64Results() {
|
export function ImageToBase64Results() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { results, errors, processing, progress } = useBase64Store();
|
const { results, errors, processing, progress } = useBase64Store();
|
||||||
const { entries, selectedIndex, originalBlobUrl, selectedFileName } = useFileStore();
|
const { entries, selectedIndex, originalBlobUrl, selectedFileName } = useFileStore();
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { format } from "@/lib/format";
|
|
||||||
import { useBase64Store } from "@/stores/base64-store";
|
import { useBase64Store } from "@/stores/base64-store";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
@@ -16,7 +14,6 @@ const OUTPUT_FORMATS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function ImageToBase64Settings() {
|
export function ImageToBase64Settings() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processing, setProcessing, setProgress, addResult, addError, reset } = useBase64Store();
|
const { processing, setProcessing, setProgress, addResult, addError, reset } = useBase64Store();
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export function InfoSettings() {
|
|||||||
cacheRef.current.clear();
|
cacheRef.current.clear();
|
||||||
autoFetchRef.current = false;
|
autoFetchRef.current = false;
|
||||||
setInfo(null);
|
setInfo(null);
|
||||||
}, [files.length]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoFetchRef.current || files.length === 0) return;
|
if (!autoFetchRef.current || files.length === 0) return;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
FONT_OPTIONS,
|
FONT_OPTIONS,
|
||||||
@@ -328,7 +327,6 @@ function ResultSettings() {
|
|||||||
// ── Main Settings Component ─────────────────────────────────────────
|
// ── Main Settings Component ─────────────────────────────────────────
|
||||||
|
|
||||||
export function MemeGeneratorSettings() {
|
export function MemeGeneratorSettings() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const phase = useMemeStore((s) => s.phase);
|
const phase = useMemeStore((s) => s.phase);
|
||||||
|
|
||||||
if (phase === "gallery") return <GallerySettings />;
|
if (phase === "gallery") return <GallerySettings />;
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
@@ -337,7 +336,6 @@ function CountryOption({
|
|||||||
// ── Settings panel (left side) ─────────────────────────────────────
|
// ── Settings panel (left side) ─────────────────────────────────────
|
||||||
|
|
||||||
export function PassportPhotoSettings() {
|
export function PassportPhotoSettings() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { error } = useToolProcessor("passport-photo");
|
const { error } = useToolProcessor("passport-photo");
|
||||||
|
|
||||||
@@ -896,7 +894,6 @@ export function PassportPhotoSettings() {
|
|||||||
// ── Preview panel (right side) ────────────────────────────────────
|
// ── Preview panel (right side) ────────────────────────────────────
|
||||||
|
|
||||||
export function PassportPhotoPreview() {
|
export function PassportPhotoPreview() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const {
|
const {
|
||||||
analyzeResult,
|
analyzeResult,
|
||||||
countryCode,
|
countryCode,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
import QRCodeStyling from "qr-code-styling";
|
import QRCodeStyling from "qr-code-styling";
|
||||||
import { useCallback, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import { CollapsibleSection } from "@/components/common/collapsible-section";
|
import { CollapsibleSection } from "@/components/common/collapsible-section";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
|
||||||
import {
|
import {
|
||||||
type ContentType,
|
type ContentType,
|
||||||
type CornerDotType,
|
type CornerDotType,
|
||||||
@@ -346,7 +345,6 @@ function PillButton({
|
|||||||
// ── Main settings component ──────────────────────────────────────────
|
// ── Main settings component ──────────────────────────────────────────
|
||||||
|
|
||||||
export function QrGenerateSettings() {
|
export function QrGenerateSettings() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const store = useQrStore();
|
const store = useQrStore();
|
||||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
|||||||
blurRadius,
|
blurRadius,
|
||||||
sobelThreshold,
|
sobelThreshold,
|
||||||
squareMode,
|
squareMode,
|
||||||
|
contentAware,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handlePreset = (preset: (typeof SOCIAL_MEDIA_PRESETS)[number]) => {
|
const handlePreset = (preset: (typeof SOCIAL_MEDIA_PRESETS)[number]) => {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export function RestorePhotoControls({
|
|||||||
settings: initialSettings,
|
settings: initialSettings,
|
||||||
onChange,
|
onChange,
|
||||||
}: RestorePhotoControlsProps) {
|
}: RestorePhotoControlsProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [scratchRemoval, setScratchRemoval] = useState(true);
|
const [scratchRemoval, setScratchRemoval] = useState(true);
|
||||||
const [faceEnhancement, setFaceEnhancement] = useState(true);
|
const [faceEnhancement, setFaceEnhancement] = useState(true);
|
||||||
const [fidelity, setFidelity] = useState(70);
|
const [fidelity, setFidelity] = useState(70);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
import { format } from "@/lib/format";
|
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
type CropMode = "subject" | "face" | "trim";
|
type CropMode = "subject" | "face" | "trim";
|
||||||
@@ -551,7 +550,6 @@ export function SmartCropControls({ settings: initialSettings, onChange }: Smart
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SmartCropSettings() {
|
export function SmartCropSettings() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processFiles, processAllFiles, processing, error, progress } =
|
const { processFiles, processAllFiles, processing, error, progress } =
|
||||||
useToolProcessor("smart-crop");
|
useToolProcessor("smart-crop");
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Download, Loader2, PackageOpen } from "lucide-react";
|
import { Download, Loader2, PackageOpen } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { CollapsibleSection } from "@/components/common/collapsible-section";
|
import { CollapsibleSection } from "@/components/common/collapsible-section";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import type { SplitMode } from "@/stores/split-store";
|
import type { SplitMode } from "@/stores/split-store";
|
||||||
@@ -36,7 +35,6 @@ const OUTPUT_FORMATS = [
|
|||||||
const LOSSY_FORMATS = new Set(["jpg", "webp", "avif", "jxl"]);
|
const LOSSY_FORMATS = new Set(["jpg", "webp", "avif", "jxl"]);
|
||||||
|
|
||||||
export function SplitSettings() {
|
export function SplitSettings() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { files, processing: fileStoreProcessing } = useFileStore();
|
const { files, processing: fileStoreProcessing } = useFileStore();
|
||||||
const {
|
const {
|
||||||
mode,
|
mode,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Download, Loader2 } from "lucide-react";
|
import { Download, Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
@@ -10,7 +9,6 @@ type Alignment = "start" | "center" | "end";
|
|||||||
type OutputFormat = "png" | "jpeg" | "webp" | "avif" | "jxl";
|
type OutputFormat = "png" | "jpeg" | "webp" | "avif" | "jxl";
|
||||||
|
|
||||||
export function StitchSettings() {
|
export function StitchSettings() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
||||||
useFileStore();
|
useFileStore();
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export function TransparencyFixerControls({
|
|||||||
settings: _settings,
|
settings: _settings,
|
||||||
onChange,
|
onChange,
|
||||||
}: TransparencyFixerControlsProps) {
|
}: TransparencyFixerControlsProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [defringe, setDefringe] = useState(30);
|
const [defringe, setDefringe] = useState(30);
|
||||||
const [outputFormat, setOutputFormat] = useState<OutputFormat>("png");
|
const [outputFormat, setOutputFormat] = useState<OutputFormat>("png");
|
||||||
const [removeWatermark, setRemoveWatermark] = useState(false);
|
const [removeWatermark, setRemoveWatermark] = useState(false);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
import { format } from "@/lib/format";
|
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right" | "tiled";
|
type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right" | "tiled";
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function useFocusTrap(containerRef: React.RefObject<HTMLElement | null>,
|
|||||||
return () => {
|
return () => {
|
||||||
container.removeEventListener("keydown", handleKeyDown);
|
container.removeEventListener("keydown", handleKeyDown);
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
if (returnFocusRef.current && returnFocusRef.current.isConnected) {
|
if (returnFocusRef.current?.isConnected) {
|
||||||
returnFocusRef.current.focus();
|
returnFocusRef.current.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -363,7 +363,14 @@ export function AutomatePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
}, [setSavedPipelines]);
|
}, [
|
||||||
|
setSavedPipelines,
|
||||||
|
t.automate.invalidPipelineFile,
|
||||||
|
t.automate.noSteps,
|
||||||
|
t.automate.newerVersion,
|
||||||
|
t.automate.missingName,
|
||||||
|
t.automate.couldNotRead,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!importError) return;
|
if (!importError) return;
|
||||||
@@ -418,13 +425,16 @@ export function AutomatePage() {
|
|||||||
*/
|
*/
|
||||||
function renderPipelinePreview(mode: "result" | "original") {
|
function renderPipelinePreview(mode: "result" | "original") {
|
||||||
const kind = currentEntry?.previewKind ?? "image";
|
const kind = currentEntry?.previewKind ?? "image";
|
||||||
|
const sourceUrl = originalBlobUrl;
|
||||||
|
if (!sourceUrl) return null;
|
||||||
|
|
||||||
if (mode === "result") {
|
if (mode === "result") {
|
||||||
|
if (!processedUrl) return null;
|
||||||
if (kind === "image") {
|
if (kind === "image") {
|
||||||
return (
|
return (
|
||||||
<BeforeAfterSlider
|
<BeforeAfterSlider
|
||||||
beforeSrc={originalBlobUrl!}
|
beforeSrc={sourceUrl}
|
||||||
afterSrc={processedUrl as string}
|
afterSrc={processedUrl}
|
||||||
beforeSize={originalSize ?? undefined}
|
beforeSize={originalSize ?? undefined}
|
||||||
afterSize={processedSize ?? undefined}
|
afterSize={processedSize ?? undefined}
|
||||||
/>
|
/>
|
||||||
@@ -440,7 +450,7 @@ export function AutomatePage() {
|
|||||||
if (kind === "audio") {
|
if (kind === "audio") {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
|
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
|
||||||
<WaveformPlayer src={processedUrl as string} />
|
<WaveformPlayer src={processedUrl} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -467,7 +477,7 @@ export function AutomatePage() {
|
|||||||
if (kind === "image") {
|
if (kind === "image") {
|
||||||
return (
|
return (
|
||||||
<ImageViewer
|
<ImageViewer
|
||||||
src={originalBlobUrl!}
|
src={sourceUrl}
|
||||||
filename={selectedFileName ?? files[0].name}
|
filename={selectedFileName ?? files[0].name}
|
||||||
fileSize={selectedFileSize ?? files[0].size}
|
fileSize={selectedFileSize ?? files[0].size}
|
||||||
/>
|
/>
|
||||||
@@ -483,7 +493,7 @@ export function AutomatePage() {
|
|||||||
if (kind === "audio") {
|
if (kind === "audio") {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
|
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
|
||||||
<WaveformPlayer src={originalBlobUrl!} />
|
<WaveformPlayer src={sourceUrl} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ export function LoginPage() {
|
|||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const _data = await res.json().catch(() => ({}));
|
||||||
setError(t.auth.invalidCredentials);
|
setError(t.auth.invalidCredentials);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ function FileSelectionInfo({
|
|||||||
const entry = fileEntries[i];
|
const entry = fileEntries[i];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${file.name}-${i}`}
|
key={entry?.blobUrl ?? `${file.name}-${file.size}-${file.lastModified}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(i)}
|
onClick={() => onSelect(i)}
|
||||||
className={`w-full flex items-center gap-1.5 text-xs rounded px-2 py-1.5 text-start transition-colors ${isSelected ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-muted"}`}
|
className={`w-full flex items-center gap-1.5 text-xs rounded px-2 py-1.5 text-start transition-colors ${isSelected ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-muted"}`}
|
||||||
|
|||||||
+62
-11
@@ -62,14 +62,14 @@ COPY apps/web/public ./apps/web/public
|
|||||||
# Bake analytics config into the shared package before building the frontend.
|
# Bake analytics config into the shared package before building the frontend.
|
||||||
# The published image ships with analytics ON; self-builders can override:
|
# The published image ships with analytics ON; self-builders can override:
|
||||||
# docker compose build --build-arg SNAPOTTER_ANALYTICS=off
|
# docker compose build --build-arg SNAPOTTER_ANALYTICS=off
|
||||||
# The real Sentry DSN + PostHog key are supplied as build args (public values,
|
# The real Sentry DSN + PostHog browser config are supplied as build args (public values,
|
||||||
# sourced from CI secrets in the official build); a build without them stays
|
# sourced from CI secrets in the official build); a build without them stays
|
||||||
# silent, so building from source never phones home.
|
# silent, so building from source never phones home.
|
||||||
ARG SNAPOTTER_ANALYTICS=on
|
ARG SNAPOTTER_ANALYTICS=on
|
||||||
ARG SNAPOTTER_POSTHOG_KEY=
|
ARG SNAPOTTER_POSTHOG_PROJECT_ID=
|
||||||
ARG SNAPOTTER_SENTRY_DSN=
|
ARG SNAPOTTER_SENTRY_DSN=
|
||||||
COPY scripts/bake-analytics.mjs ./scripts/
|
COPY scripts/bake-analytics.mjs ./scripts/
|
||||||
RUN SNAPOTTER_POSTHOG_KEY="${SNAPOTTER_POSTHOG_KEY}" \
|
RUN SNAPOTTER_POSTHOG_PROJECT_ID="${SNAPOTTER_POSTHOG_PROJECT_ID}" \
|
||||||
SNAPOTTER_SENTRY_DSN="${SNAPOTTER_SENTRY_DSN}" \
|
SNAPOTTER_SENTRY_DSN="${SNAPOTTER_SENTRY_DSN}" \
|
||||||
node scripts/bake-analytics.mjs ${SNAPOTTER_ANALYTICS}
|
node scripts/bake-analytics.mjs ${SNAPOTTER_ANALYTICS}
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ FROM node:22-bookworm@sha256:c601a46abb4d2ab80a9dc3da208d50d1122642d53f17a101926
|
|||||||
# driver gate enforced by nvidia-container-toolkit at container start: a 12.6 base
|
# driver gate enforced by nvidia-container-toolkit at container start: a 12.6 base
|
||||||
# needs driver R560+, vs 12.9 which needs R575+ and fails to start on common
|
# needs driver R560+, vs 12.9 which needs R575+ and fails to start on common
|
||||||
# production drivers (e.g. 570.x / CUDA 12.8). Keep this at 12.6.x.
|
# production drivers (e.g. 570.x / CUDA 12.8). Keep this at 12.6.x.
|
||||||
FROM nvidia/cuda:12.9.2-cudnn-runtime-ubuntu24.04@sha256:070f8f2672df1b05b84c0409a5fd1d54ddfd646e5b9d8dee7878131271b563fc AS base-linux-amd64
|
FROM nvidia/cuda:12.6.3-cudnn-runtime-ubuntu24.04@sha256:8aef630a54bc5c5146ae5ce68e6af5caa3df0fb690bb91544175c91f307e4356 AS base-linux-amd64
|
||||||
|
|
||||||
# Node.js donor: provides Node binaries for the CUDA amd64 image without
|
# Node.js donor: provides Node binaries for the CUDA amd64 image without
|
||||||
# relying on NodeSource apt repos or Ubuntu mirrors (which are flaky on CI).
|
# relying on NodeSource apt repos or Ubuntu mirrors (which are flaky on CI).
|
||||||
@@ -204,14 +204,13 @@ FROM node:22-bookworm@sha256:c601a46abb4d2ab80a9dc3da208d50d1122642d53f17a101926
|
|||||||
# ============================================
|
# ============================================
|
||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG PANDOC_VERSION=3.10
|
|
||||||
FROM base-${TARGETOS}-${TARGETARCH} AS production
|
FROM base-${TARGETOS}-${TARGETARCH} AS production
|
||||||
|
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG PANDOC_VERSION
|
ARG PANDOC_VERSION=3.10
|
||||||
|
|
||||||
# Pin corepack's cache to a system-wide path so all users share the same pnpm
|
# Pin corepack's cache during image build. It is removed after dependency and
|
||||||
# binary without downloading it on each container start.
|
# browser installation so pnpm is not part of the production runtime surface.
|
||||||
ENV COREPACK_HOME=/usr/local/share/corepack
|
ENV COREPACK_HOME=/usr/local/share/corepack
|
||||||
|
|
||||||
# Install Node.js on amd64 by copying from the official node image.
|
# Install Node.js on amd64 by copying from the official node image.
|
||||||
@@ -296,6 +295,7 @@ RUN install -d /usr/share/postgresql-common/pgdg \
|
|||||||
> /etc/apt/sources.list.d/pgdg.list \
|
> /etc/apt/sources.list.d/pgdg.list \
|
||||||
&& for i in 1 2 3; do apt-get -o Acquire::Retries=3 update && break || sleep $((i * 15)); done \
|
&& for i in 1 2 3; do apt-get -o Acquire::Retries=3 update && break || sleep $((i * 15)); done \
|
||||||
&& apt-get install -y --no-install-recommends postgresql-17 postgresql-client-17 redis-server \
|
&& apt-get install -y --no-install-recommends postgresql-17 postgresql-client-17 redis-server \
|
||||||
|
&& rm -f /etc/ssl/private/ssl-cert-snakeoil.key /etc/ssl/certs/ssl-cert-snakeoil.pem \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# s6-overlay supervises the embedded service tree (postgres + redis + app).
|
# s6-overlay supervises the embedded service tree (postgres + redis + app).
|
||||||
@@ -351,8 +351,9 @@ RUN ldconfig
|
|||||||
# Uses pre-built manylinux wheels where available; gcc/g++ above covers the rest.
|
# Uses pre-built manylinux wheels where available; gcc/g++ above covers the rest.
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
python3 -m venv /opt/venv && \
|
python3 -m venv /opt/venv && \
|
||||||
|
SITE_PACKAGES=$(/opt/venv/bin/python -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])') && \
|
||||||
/opt/venv/bin/pip install --upgrade "pip==26.1.2" && \
|
/opt/venv/bin/pip install --upgrade "pip==26.1.2" && \
|
||||||
/opt/venv/bin/pip install wheel setuptools && \
|
/opt/venv/bin/pip install --upgrade "wheel==0.47.0" "setuptools==78.1.1" "jaraco.context==6.1.0" && \
|
||||||
/opt/venv/bin/pip install \
|
/opt/venv/bin/pip install \
|
||||||
Pillow==12.2.0 \
|
Pillow==12.2.0 \
|
||||||
numpy==1.26.4 \
|
numpy==1.26.4 \
|
||||||
@@ -361,7 +362,19 @@ RUN --mount=type=cache,target=/root/.cache/pip \
|
|||||||
PyMuPDF==1.27.2.3 \
|
PyMuPDF==1.27.2.3 \
|
||||||
weasyprint==69.0 \
|
weasyprint==69.0 \
|
||||||
pdf2docx==0.5.13 \
|
pdf2docx==0.5.13 \
|
||||||
markdown==3.10.2
|
markdown==3.10.2 && \
|
||||||
|
# Trivy scans setuptools' vendored dist-info metadata, so replace the
|
||||||
|
# vulnerable vendored copies with the fixed packages pinned above.
|
||||||
|
rm -rf "$SITE_PACKAGES/setuptools/_vendor/wheel" \
|
||||||
|
"$SITE_PACKAGES"/setuptools/_vendor/wheel-*.dist-info \
|
||||||
|
"$SITE_PACKAGES/setuptools/_vendor/jaraco/context.py" \
|
||||||
|
"$SITE_PACKAGES/setuptools/_vendor/jaraco/context" \
|
||||||
|
"$SITE_PACKAGES"/setuptools/_vendor/jaraco.context-*.dist-info \
|
||||||
|
"$SITE_PACKAGES"/setuptools/_vendor/jaraco_context-*.dist-info && \
|
||||||
|
cp -a "$SITE_PACKAGES/wheel" "$SITE_PACKAGES/setuptools/_vendor/wheel" && \
|
||||||
|
cp -a "$SITE_PACKAGES"/wheel-0.47.0.dist-info "$SITE_PACKAGES/setuptools/_vendor/" && \
|
||||||
|
cp -a "$SITE_PACKAGES/jaraco/context" "$SITE_PACKAGES/setuptools/_vendor/jaraco/context" && \
|
||||||
|
cp -a "$SITE_PACKAGES"/jaraco_context-6.1.0.dist-info "$SITE_PACKAGES/setuptools/_vendor/"
|
||||||
|
|
||||||
# Stamp the venv so the entrypoint can detect base-package upgrades.
|
# Stamp the venv so the entrypoint can detect base-package upgrades.
|
||||||
# If the frozen package list changes, the hash changes, and containers
|
# If the frozen package list changes, the hash changes, and containers
|
||||||
@@ -412,6 +425,42 @@ RUN pnpm --filter @snapotter/api exec playwright install chromium --with-deps &&
|
|||||||
chmod -R a+rX /opt/playwright-browsers && \
|
chmod -R a+rX /opt/playwright-browsers && \
|
||||||
rm -rf /tmp/*
|
rm -rf /tmp/*
|
||||||
|
|
||||||
|
# Remove build-time package managers and native build headers from the runtime
|
||||||
|
# image after all dependency/browser installs are complete.
|
||||||
|
RUN apt-get purge -y --auto-remove \
|
||||||
|
autotools-dev \
|
||||||
|
dpkg-dev \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
python3-dev \
|
||||||
|
libraw-dev \
|
||||||
|
libopenexr-dev \
|
||||||
|
libcurl4-openssl-dev \
|
||||||
|
libdb-dev \
|
||||||
|
libdb5.3-dev \
|
||||||
|
libevent-dev \
|
||||||
|
libffi-dev \
|
||||||
|
libgcc-12-dev \
|
||||||
|
libgmp-dev \
|
||||||
|
liblzma-dev \
|
||||||
|
libmaxminddb-dev \
|
||||||
|
libwebp-dev \
|
||||||
|
libyaml-dev \
|
||||||
|
libc6-dev \
|
||||||
|
linux-libc-dev \
|
||||||
|
libpq-dev \
|
||||||
|
libssl-dev \
|
||||||
|
zlib1g-dev \
|
||||||
|
uuid-dev \
|
||||||
|
libcrypt-dev \
|
||||||
|
libnsl-dev \
|
||||||
|
libtirpc-dev \
|
||||||
|
rpcsvc-proto \
|
||||||
|
&& (corepack disable pnpm || true) \
|
||||||
|
&& rm -rf /usr/local/share/corepack /root/.cache/node/corepack /root/.cache/pip \
|
||||||
|
&& rm -f /usr/local/bin/pnpm /usr/local/bin/pnpx \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* /tmp/*
|
||||||
|
|
||||||
# Copy source code for API (tsx runs TS directly - no build step needed)
|
# Copy source code for API (tsx runs TS directly - no build step needed)
|
||||||
COPY apps/api/src ./apps/api/src
|
COPY apps/api/src ./apps/api/src
|
||||||
COPY apps/api/drizzle ./apps/api/drizzle
|
COPY apps/api/drizzle ./apps/api/drizzle
|
||||||
@@ -528,6 +577,8 @@ RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/embedded-postgres-boots
|
|||||||
/etc/s6-overlay/s6-rc.d/postgres-init/up /etc/s6-overlay/s6-rc.d/postgres-ready/up \
|
/etc/s6-overlay/s6-rc.d/postgres-init/up /etc/s6-overlay/s6-rc.d/postgres-ready/up \
|
||||||
/etc/s6-overlay/s6-rc.d/redis-ready/up
|
/etc/s6-overlay/s6-rc.d/redis-ready/up
|
||||||
|
|
||||||
|
WORKDIR /app/apps/api
|
||||||
|
|
||||||
EXPOSE 1349
|
EXPOSE 1349
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=3 \
|
||||||
@@ -538,4 +589,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=3 \
|
|||||||
# state as before), or s6-overlay's /init as PID 1 for embedded mode (which
|
# state as before), or s6-overlay's /init as PID 1 for embedded mode (which
|
||||||
# s6-overlay-suexec requires).
|
# s6-overlay-suexec requires).
|
||||||
ENTRYPOINT ["entrypoint.sh"]
|
ENTRYPOINT ["entrypoint.sh"]
|
||||||
CMD ["pnpm", "--filter", "@snapotter/api", "run", "start"]
|
CMD ["./node_modules/.bin/tsx", "--import", "./src/tracing.ts", "--import", "./src/instrument.ts", "src/index.ts"]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
/command/with-contenv sh -c "until pg_isready -h 127.0.0.1 -p 5432 -q; do sleep 1; done"
|
/command/with-contenv sh -c "until pg_isready -h 127.0.0.1 -p 5432 -U snapotter -d snapotter -q; do sleep 1; done"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
#!/command/with-contenv sh
|
#!/command/with-contenv sh
|
||||||
cd /app
|
cd /app/apps/api
|
||||||
exec s6-setuidgid snapotter pnpm --filter @snapotter/api run start
|
exec s6-setuidgid snapotter ./node_modules/.bin/tsx --import ./src/tracing.ts --import ./src/instrument.ts src/index.ts
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
DeleteObjectCommand,
|
DeleteObjectCommand,
|
||||||
DeleteObjectsCommand,
|
DeleteObjectsCommand,
|
||||||
GetObjectCommand,
|
GetObjectCommand,
|
||||||
|
type GetObjectCommandOutput,
|
||||||
HeadBucketCommand,
|
HeadBucketCommand,
|
||||||
HeadObjectCommand,
|
HeadObjectCommand,
|
||||||
ListObjectsV2Command,
|
ListObjectsV2Command,
|
||||||
@@ -62,6 +63,13 @@ function thumbKey(storedName: string): string {
|
|||||||
return `${prefix}thumbs/${storedName}.thumb.jpg`;
|
return `${prefix}thumbs/${storedName}.thumb.jpg`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireObjectBody(response: GetObjectCommandOutput, key: string) {
|
||||||
|
if (!response.Body) {
|
||||||
|
throw new Error(`S3 object response for ${key} did not include a body`);
|
||||||
|
}
|
||||||
|
return response.Body;
|
||||||
|
}
|
||||||
|
|
||||||
export async function checkConnection(): Promise<void> {
|
export async function checkConnection(): Promise<void> {
|
||||||
await getClient().send(new HeadBucketCommand({ Bucket: cfg().bucket }));
|
await getClient().send(new HeadBucketCommand({ Bucket: cfg().bucket }));
|
||||||
}
|
}
|
||||||
@@ -77,23 +85,25 @@ export async function putObject(storedName: string, buffer: Buffer): Promise<voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getObject(storedName: string): Promise<Buffer> {
|
export async function getObject(storedName: string): Promise<Buffer> {
|
||||||
|
const key = fileKey(storedName);
|
||||||
const response = await getClient().send(
|
const response = await getClient().send(
|
||||||
new GetObjectCommand({
|
new GetObjectCommand({
|
||||||
Bucket: cfg().bucket,
|
Bucket: cfg().bucket,
|
||||||
Key: fileKey(storedName),
|
Key: key,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return Buffer.from(await response.Body!.transformToByteArray());
|
return Buffer.from(await requireObjectBody(response, key).transformToByteArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getObjectStream(storedName: string): Promise<Readable> {
|
export async function getObjectStream(storedName: string): Promise<Readable> {
|
||||||
|
const key = fileKey(storedName);
|
||||||
const response = await getClient().send(
|
const response = await getClient().send(
|
||||||
new GetObjectCommand({
|
new GetObjectCommand({
|
||||||
Bucket: cfg().bucket,
|
Bucket: cfg().bucket,
|
||||||
Key: fileKey(storedName),
|
Key: key,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return response.Body as Readable;
|
return requireObjectBody(response, key) as Readable;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteObject(storedName: string): Promise<void> {
|
export async function deleteObject(storedName: string): Promise<void> {
|
||||||
@@ -111,13 +121,14 @@ export async function deleteObject(storedName: string): Promise<void> {
|
|||||||
|
|
||||||
export async function getThumbnail(storedName: string): Promise<Buffer | null> {
|
export async function getThumbnail(storedName: string): Promise<Buffer | null> {
|
||||||
try {
|
try {
|
||||||
|
const key = thumbKey(storedName);
|
||||||
const response = await getClient().send(
|
const response = await getClient().send(
|
||||||
new GetObjectCommand({
|
new GetObjectCommand({
|
||||||
Bucket: cfg().bucket,
|
Bucket: cfg().bucket,
|
||||||
Key: thumbKey(storedName),
|
Key: key,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return Buffer.from(await response.Body!.transformToByteArray());
|
return Buffer.from(await requireObjectBody(response, key).transformToByteArray());
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ const on = mode === "on";
|
|||||||
// a build from source stays silent. A Sentry DSN and a PostHog project key are
|
// a build from source stays silent. A Sentry DSN and a PostHog project key are
|
||||||
// public (they ship in the browser bundle), so this is about not making forks /
|
// public (they ship in the browser bundle), so this is about not making forks /
|
||||||
// source builds phone home by default, not about secrecy.
|
// source builds phone home by default, not about secrecy.
|
||||||
const posthogApiKey = on ? (process.env.SNAPOTTER_POSTHOG_KEY ?? "") : "";
|
const posthogApiKey = on
|
||||||
|
? (process.env.SNAPOTTER_POSTHOG_PROJECT_ID ?? process.env.SNAPOTTER_POSTHOG_KEY ?? "")
|
||||||
|
: "";
|
||||||
const sentryDsn = on ? (process.env.SNAPOTTER_SENTRY_DSN ?? "") : "";
|
const sentryDsn = on ? (process.env.SNAPOTTER_SENTRY_DSN ?? "") : "";
|
||||||
const posthogHost = posthogApiKey ? "https://us.i.posthog.com" : "";
|
const posthogHost = posthogApiKey ? "https://us.i.posthog.com" : "";
|
||||||
// Enabled only when turned on AND there is somewhere to report to, so a
|
// Enabled only when turned on AND there is somewhere to report to, so a
|
||||||
|
|||||||
@@ -72,6 +72,17 @@ function postOcrPdf(parts: Parameters<typeof createMultipartPayload>[0]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectAsyncAccepted(body: string, clientJobId: string) {
|
||||||
|
const artifactJobId = mocks.enqueueToolJob.mock.calls.at(-1)?.[0].jobId;
|
||||||
|
expect(artifactJobId).toBeDefined();
|
||||||
|
expect(JSON.parse(body)).toEqual({
|
||||||
|
jobId: clientJobId,
|
||||||
|
progressJobId: clientJobId,
|
||||||
|
artifactJobId,
|
||||||
|
async: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("ocr-pdf route coverage", () => {
|
describe("ocr-pdf route coverage", () => {
|
||||||
it("rejects requests without a PDF after the bundle gate passes", async () => {
|
it("rejects requests without a PDF after the bundle gate passes", async () => {
|
||||||
const res = await postOcrPdf([
|
const res = await postOcrPdf([
|
||||||
@@ -136,7 +147,7 @@ describe("ocr-pdf route coverage", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(res.statusCode).toBe(202);
|
expect(res.statusCode).toBe(202);
|
||||||
expect(JSON.parse(res.body)).toEqual({ jobId: clientJobId, async: true });
|
expectAsyncAccepted(res.body, clientJobId);
|
||||||
expect(mocks.enqueueToolJob).toHaveBeenCalledTimes(1);
|
expect(mocks.enqueueToolJob).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.enqueueToolJob).toHaveBeenCalledWith(
|
expect(mocks.enqueueToolJob).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -79,6 +79,17 @@ function postMultipart(url: string, fields: Parameters<typeof createMultipartPay
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectAsyncAccepted(body: string, clientJobId: string) {
|
||||||
|
const artifactJobId = mocks.enqueueToolJob.mock.calls.at(-1)?.[0].jobId;
|
||||||
|
expect(artifactJobId).toBeDefined();
|
||||||
|
expect(JSON.parse(body)).toEqual({
|
||||||
|
jobId: clientJobId,
|
||||||
|
progressJobId: clientJobId,
|
||||||
|
artifactJobId,
|
||||||
|
async: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("custom async AI image routes", () => {
|
describe("custom async AI image routes", () => {
|
||||||
it("upscale validates input, coerces settings, and enqueues an AI job", async () => {
|
it("upscale validates input, coerces settings, and enqueues an AI job", async () => {
|
||||||
const clientJobId = "22222222-2222-4222-8222-222222222222";
|
const clientJobId = "22222222-2222-4222-8222-222222222222";
|
||||||
@@ -93,7 +104,7 @@ describe("custom async AI image routes", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(res.statusCode).toBe(202);
|
expect(res.statusCode).toBe(202);
|
||||||
expect(JSON.parse(res.body)).toEqual({ jobId: clientJobId, async: true });
|
expectAsyncAccepted(res.body, clientJobId);
|
||||||
expect(mocks.enqueueToolJob).toHaveBeenCalledWith(
|
expect(mocks.enqueueToolJob).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
toolId: "upscale",
|
toolId: "upscale",
|
||||||
@@ -168,7 +179,7 @@ describe("custom async AI image routes", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(res.statusCode).toBe(202);
|
expect(res.statusCode).toBe(202);
|
||||||
expect(JSON.parse(res.body)).toEqual({ jobId: clientJobId, async: true });
|
expectAsyncAccepted(res.body, clientJobId);
|
||||||
expect(mocks.enqueueToolJob).toHaveBeenCalledWith(
|
expect(mocks.enqueueToolJob).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
toolId: "ai-canvas-expand",
|
toolId: "ai-canvas-expand",
|
||||||
@@ -262,7 +273,7 @@ describe("custom async AI image routes", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(res.statusCode).toBe(202);
|
expect(res.statusCode).toBe(202);
|
||||||
expect(JSON.parse(res.body)).toEqual({ jobId: clientJobId, async: true });
|
expectAsyncAccepted(res.body, clientJobId);
|
||||||
expect(mocks.enqueueToolJob).toHaveBeenCalledWith(
|
expect(mocks.enqueueToolJob).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
toolId: "erase-object",
|
toolId: "erase-object",
|
||||||
|
|||||||
@@ -110,6 +110,17 @@ function expectEnqueued(toolId: string, settings: Record<string, unknown>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectAsyncAccepted(body: string, clientJobId: string) {
|
||||||
|
const artifactJobId = mocks.enqueueToolJob.mock.calls.at(-1)?.[0].jobId;
|
||||||
|
expect(artifactJobId).toBeDefined();
|
||||||
|
expect(JSON.parse(body)).toEqual({
|
||||||
|
jobId: clientJobId,
|
||||||
|
progressJobId: clientJobId,
|
||||||
|
artifactJobId,
|
||||||
|
async: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("async AI photo routes", () => {
|
describe("async AI photo routes", () => {
|
||||||
it("colorize validates JSON settings and enqueues colorization jobs", async () => {
|
it("colorize validates JSON settings and enqueues colorization jobs", async () => {
|
||||||
const malformed = await postTool("colorize", [
|
const malformed = await postTool("colorize", [
|
||||||
@@ -122,8 +133,8 @@ describe("async AI photo routes", () => {
|
|||||||
const clientJobId = "55555555-5555-4555-8555-555555555555";
|
const clientJobId = "55555555-5555-4555-8555-555555555555";
|
||||||
const res = await postValid("colorize", { intensity: 0.45, model: "opencv" }, clientJobId);
|
const res = await postValid("colorize", { intensity: 0.45, model: "opencv" }, clientJobId);
|
||||||
expect(res.statusCode).toBe(202);
|
expect(res.statusCode).toBe(202);
|
||||||
expect(JSON.parse(res.body)).toEqual({ jobId: clientJobId, async: true });
|
|
||||||
expectEnqueued("colorize", { intensity: 0.45, model: "opencv" });
|
expectEnqueued("colorize", { intensity: 0.45, model: "opencv" });
|
||||||
|
expectAsyncAccepted(res.body, clientJobId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("noise-removal rejects invalid tiers and coerces numeric settings", async () => {
|
it("noise-removal rejects invalid tiers and coerces numeric settings", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildAsyncAcceptedPayload } from "../../../apps/api/src/routes/async-response.js";
|
||||||
|
|
||||||
|
describe("buildAsyncAcceptedPayload", () => {
|
||||||
|
it("keeps legacy async shape when the progress and artifact IDs match", () => {
|
||||||
|
expect(buildAsyncAcceptedPayload("job-123")).toEqual({
|
||||||
|
jobId: "job-123",
|
||||||
|
async: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes both progress and artifact IDs when a client progress ID is supplied", () => {
|
||||||
|
expect(buildAsyncAcceptedPayload("artifact-123", "progress-456")).toEqual({
|
||||||
|
jobId: "progress-456",
|
||||||
|
progressJobId: "progress-456",
|
||||||
|
artifactJobId: "artifact-123",
|
||||||
|
async: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { RedisMock } = vi.hoisted(() => ({
|
||||||
|
RedisMock: vi.fn(function RedisMock() {
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("ioredis", () => ({
|
||||||
|
default: RedisMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Redis connection factory", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
RedisMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps ready checks enabled for command connections", async () => {
|
||||||
|
const { createRedisConnection } = await import("../../../../apps/api/src/jobs/connection.js");
|
||||||
|
|
||||||
|
createRedisConnection();
|
||||||
|
|
||||||
|
expect(RedisMock).toHaveBeenCalledWith(expect.any(String), {
|
||||||
|
enableReadyCheck: true,
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables ready checks for pub/sub-only subscriber connections", async () => {
|
||||||
|
const { createRedisSubscriberConnection } = await import(
|
||||||
|
"../../../../apps/api/src/jobs/connection.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
createRedisSubscriberConnection();
|
||||||
|
|
||||||
|
expect(RedisMock).toHaveBeenCalledWith(expect.any(String), {
|
||||||
|
enableReadyCheck: false,
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -216,6 +216,7 @@ function createMockRequest(opts: {
|
|||||||
filename?: string;
|
filename?: string;
|
||||||
settings?: string;
|
settings?: string;
|
||||||
fileId?: string;
|
fileId?: string;
|
||||||
|
clientJobId?: string;
|
||||||
fileCount?: number;
|
fileCount?: number;
|
||||||
}) {
|
}) {
|
||||||
const parts: Array<{
|
const parts: Array<{
|
||||||
@@ -255,6 +256,15 @@ function createMockRequest(opts: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts.clientJobId) {
|
||||||
|
parts.push({
|
||||||
|
type: "field",
|
||||||
|
fieldname: "clientJobId",
|
||||||
|
value: opts.clientJobId,
|
||||||
|
file: (async function* () {})(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
parts: () => ({
|
parts: () => ({
|
||||||
[Symbol.asyncIterator]: async function* () {
|
[Symbol.asyncIterator]: async function* () {
|
||||||
@@ -271,6 +281,8 @@ function createMockRequest(opts: {
|
|||||||
describe("createToolRoute", () => {
|
describe("createToolRoute", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(isToolInstalled).mockReset();
|
||||||
|
vi.mocked(isToolInstalled).mockReturnValue(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("route registration", () => {
|
describe("route registration", () => {
|
||||||
@@ -505,6 +517,61 @@ describe("createToolRoute", () => {
|
|||||||
expect(reply.send).toHaveBeenCalledWith(expect.objectContaining({ async: true }));
|
expect(reply.send).toHaveBeenCalledWith(expect.objectContaining({ async: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns the artifact job ID when a client progress job ID is supplied", async () => {
|
||||||
|
vi.mocked(waitForJob).mockResolvedValueOnce(null);
|
||||||
|
const app = createMockApp();
|
||||||
|
const id = "resize";
|
||||||
|
createToolRoute(app as never, makeMockConfig(id));
|
||||||
|
const handler = app.routes[apiToolPath(id)];
|
||||||
|
const reply = createMockReply();
|
||||||
|
const clientJobId = "client-progress-id";
|
||||||
|
const req = createMockRequest({
|
||||||
|
fileBuffer: Buffer.from("png-data"),
|
||||||
|
settings: JSON.stringify({}),
|
||||||
|
clientJobId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, reply);
|
||||||
|
|
||||||
|
const artifactJobId = vi.mocked(enqueueToolJob).mock.calls[0][0].jobId;
|
||||||
|
expect(artifactJobId).not.toBe(clientJobId);
|
||||||
|
expect(reply.status).toHaveBeenCalledWith(202);
|
||||||
|
expect(reply.send).toHaveBeenCalledWith({
|
||||||
|
jobId: clientJobId,
|
||||||
|
progressJobId: clientJobId,
|
||||||
|
artifactJobId,
|
||||||
|
async: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the artifact job ID immediately for long tools with client progress IDs", async () => {
|
||||||
|
vi.mocked(isToolInstalled).mockReturnValue(true);
|
||||||
|
const app = createMockApp();
|
||||||
|
const id = "upscale";
|
||||||
|
createToolRoute(app as never, makeMockConfig(id));
|
||||||
|
const handler = app.routes[apiToolPath(id)];
|
||||||
|
const reply = createMockReply();
|
||||||
|
const clientJobId = "client-long-progress-id";
|
||||||
|
const req = createMockRequest({
|
||||||
|
fileBuffer: Buffer.from("png-data"),
|
||||||
|
settings: JSON.stringify({}),
|
||||||
|
clientJobId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, reply);
|
||||||
|
|
||||||
|
const artifactJobId = vi.mocked(enqueueToolJob).mock.calls[0][0].jobId;
|
||||||
|
expect(waitForJob).not.toHaveBeenCalled();
|
||||||
|
expect(artifactJobId).not.toBe(clientJobId);
|
||||||
|
expect(reply.status).toHaveBeenCalledWith(202);
|
||||||
|
expect(reply.send).toHaveBeenCalledWith({
|
||||||
|
jobId: clientJobId,
|
||||||
|
progressJobId: clientJobId,
|
||||||
|
artifactJobId,
|
||||||
|
async: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 422 when waitForJob rejects", async () => {
|
it("returns 422 when waitForJob rejects", async () => {
|
||||||
vi.mocked(waitForJob).mockRejectedValueOnce(new Error("Sharp exploded"));
|
vi.mocked(waitForJob).mockRejectedValueOnce(new Error("Sharp exploded"));
|
||||||
const app = createMockApp();
|
const app = createMockApp();
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const dockerfile = readFileSync(resolve(here, "../../../docker/Dockerfile"), "utf8");
|
||||||
|
const snapotterRun = readFileSync(
|
||||||
|
resolve(here, "../../../docker/s6/s6-rc.d/snapotter/run"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const postgresReady = readFileSync(
|
||||||
|
resolve(here, "../../../docker/s6/s6-rc.d/postgres-ready/up"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
function stageBody(stageName: string): string {
|
||||||
|
const lines = dockerfile.split(/\r?\n/);
|
||||||
|
const start = lines.findIndex((line) =>
|
||||||
|
new RegExp(`^FROM\\s+.*\\s+AS\\s+${stageName}$`).test(line),
|
||||||
|
);
|
||||||
|
expect(start).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
const next = lines.findIndex((line, index) => index > start && /^FROM\s+/.test(line));
|
||||||
|
return lines.slice(start, next === -1 ? undefined : next).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Dockerfile build args", () => {
|
||||||
|
it("keeps the Pandoc version default in the production stage", () => {
|
||||||
|
const production = stageBody("production");
|
||||||
|
const argMatch = production.match(/^ARG PANDOC_VERSION=(.+)$/m);
|
||||||
|
|
||||||
|
expect(argMatch?.[1]).toMatch(/^\d+\.\d+(?:\.\d+)?$/);
|
||||||
|
expect(production.indexOf("ARG PANDOC_VERSION=")).toBeLessThan(
|
||||||
|
production.indexOf("pandoc-${PANDOC_VERSION}"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the amd64 CUDA base on the cu126 runtime family", () => {
|
||||||
|
const baseLine = dockerfile
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.find((line) => line.includes(" AS base-linux-amd64"));
|
||||||
|
|
||||||
|
expect(baseLine).toContain("nvidia/cuda:12.6.");
|
||||||
|
expect(baseLine).toContain("cudnn-runtime-ubuntu24.04");
|
||||||
|
expect(baseLine).not.toContain("nvidia/cuda:12.9.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("avoids secret-scanner build arg names for public PostHog browser config", () => {
|
||||||
|
const dockerArgOrEnvNames = [...dockerfile.matchAll(/^(?:ARG|ENV)\s+([A-Za-z0-9_]+)/gm)].map(
|
||||||
|
(match) => match[1],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dockerArgOrEnvNames).not.toContain("SNAPOTTER_POSTHOG_KEY");
|
||||||
|
expect(dockerfile).toContain("SNAPOTTER_POSTHOG_PROJECT_ID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes distro-generated snakeoil TLS material after embedded database install", () => {
|
||||||
|
const production = stageBody("production");
|
||||||
|
const installIndex = production.indexOf("postgresql-17 postgresql-client-17 redis-server");
|
||||||
|
const removeIndex = production.indexOf("/etc/ssl/private/ssl-cert-snakeoil.key");
|
||||||
|
|
||||||
|
expect(installIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(removeIndex).toBeGreaterThan(installIndex);
|
||||||
|
expect(production).toContain("/etc/ssl/certs/ssl-cert-snakeoil.pem");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("purges build-only compiler and header packages before the final image", () => {
|
||||||
|
const production = stageBody("production");
|
||||||
|
const venvIndex = production.indexOf("python3 -m venv /opt/venv");
|
||||||
|
const purgeIndex = production.indexOf("apt-get purge -y --auto-remove");
|
||||||
|
|
||||||
|
expect(venvIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(purgeIndex).toBeGreaterThan(venvIndex);
|
||||||
|
expect(production.slice(purgeIndex)).toContain("python3-dev");
|
||||||
|
expect(production.slice(purgeIndex)).toContain("gcc");
|
||||||
|
expect(production.slice(purgeIndex)).toContain("g++");
|
||||||
|
expect(production.slice(purgeIndex)).toContain("libraw-dev");
|
||||||
|
expect(production.slice(purgeIndex)).toContain("libopenexr-dev");
|
||||||
|
expect(production.slice(purgeIndex)).toContain("libcurl4-openssl-dev");
|
||||||
|
expect(production.slice(purgeIndex)).toContain("libffi-dev");
|
||||||
|
expect(production.slice(purgeIndex)).toContain("libgcc-12-dev");
|
||||||
|
expect(production.slice(purgeIndex)).toContain("libwebp-dev");
|
||||||
|
expect(production.slice(purgeIndex)).toContain("dpkg-dev");
|
||||||
|
expect(production.slice(purgeIndex)).toContain("libc6-dev");
|
||||||
|
expect(production.slice(purgeIndex)).toContain("linux-libc-dev");
|
||||||
|
expect(production.slice(purgeIndex)).toContain("libpq-dev");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pins the Python venv setuptools package to the fixed CVE version", () => {
|
||||||
|
const production = stageBody("production");
|
||||||
|
|
||||||
|
expect(production).toContain('"setuptools==78.1.1"');
|
||||||
|
expect(production).toContain('"wheel==0.47.0"');
|
||||||
|
expect(production).toContain('"jaraco.context==6.1.0"');
|
||||||
|
expect(production).toContain("setuptools/_vendor/wheel-*.dist-info");
|
||||||
|
expect(production).toContain("jaraco_context-6.1.0.dist-info");
|
||||||
|
expect(production).not.toContain("pip install wheel setuptools");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not require pnpm or a root HOME at production runtime", () => {
|
||||||
|
const production = stageBody("production");
|
||||||
|
|
||||||
|
expect(production).toContain("corepack disable pnpm");
|
||||||
|
expect(production).not.toContain('CMD ["pnpm"');
|
||||||
|
expect(production).toContain('CMD ["./node_modules/.bin/tsx"');
|
||||||
|
expect(snapotterRun).not.toContain("pnpm");
|
||||||
|
expect(snapotterRun).toContain("exec s6-setuidgid snapotter ./node_modules/.bin/tsx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks embedded Postgres readiness with the app database role", () => {
|
||||||
|
expect(postgresReady).toContain("pg_isready");
|
||||||
|
expect(postgresReady).toContain("-U snapotter");
|
||||||
|
expect(postgresReady).toContain("-d snapotter");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user