feat: multi-arch Docker support, security hardening, and test improvements

Remove hardcoded --platform=linux/amd64 from Dockerfile so buildx produces
native arm64 images for Apple Silicon and Raspberry Pi. Add audit logging
for auth events, harden file storage with extension whitelists and
double-extension attack prevention, reject null-byte buffers in validation,
add data-testid attributes to all tool settings components, update
deployment docs with architecture notes and correct CI workflow references,
and fix unit test mock to match throwWithMessage error extraction.
This commit is contained in:
Siddharth Kumar Sah
2026-03-28 11:19:09 +08:00
parent 8f09c0678b
commit 6cfa3b0c38
48 changed files with 254 additions and 13 deletions
+2
View File
@@ -12,6 +12,8 @@ MAX_BATCH_SIZE=200
CONCURRENT_JOBS=3 CONCURRENT_JOBS=3
MAX_MEGAPIXELS=100 MAX_MEGAPIXELS=100
RATE_LIMIT_PER_MIN=100 RATE_LIMIT_PER_MIN=100
# Set to true in CI/dev to skip the forced password-change on the default admin
# SKIP_MUST_CHANGE_PASSWORD=false
DB_PATH=./data/stirling.db DB_PATH=./data/stirling.db
WORKSPACE_PATH=./tmp/workspace WORKSPACE_PATH=./tmp/workspace
FILES_STORAGE_PATH=./data/files FILES_STORAGE_PATH=./data/files
+1 -1
View File
@@ -51,7 +51,7 @@ app.addHook("onSend", async (_request, reply) => {
reply.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); reply.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
reply.header( reply.header(
"Content-Security-Policy", "Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; connect-src 'self'; font-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; connect-src 'self'; font-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
); );
} }
}); });
+29
View File
@@ -0,0 +1,29 @@
import type { FastifyBaseLogger } from "fastify";
type AuditEvent =
| "LOGIN_SUCCESS"
| "LOGIN_FAILED"
| "LOGOUT"
| "PASSWORD_CHANGED"
| "PASSWORD_RESET"
| "USER_CREATED"
| "USER_DELETED"
| "USER_UPDATED"
| "FILE_UPLOADED"
| "FILE_DELETED"
| "API_KEY_CREATED"
| "API_KEY_DELETED";
/**
* Emit a structured audit log entry for security-relevant events.
*
* Logs are written at INFO level with `audit: true` so they can be
* filtered by log aggregators (e.g. `jq 'select(.audit)'`).
*/
export function auditLog(
logger: FastifyBaseLogger,
event: AuditEvent,
details: Record<string, unknown> = {},
): void {
logger.info({ audit: true, event, ...details }, `[AUDIT] ${event}`);
}
+4
View File
@@ -8,6 +8,10 @@ const envSchema = z.object({
.transform((v) => v === "true"), .transform((v) => v === "true"),
DEFAULT_USERNAME: z.string().default("admin"), DEFAULT_USERNAME: z.string().default("admin"),
DEFAULT_PASSWORD: z.string().default("admin"), DEFAULT_PASSWORD: z.string().default("admin"),
SKIP_MUST_CHANGE_PASSWORD: z
.enum(["true", "false"])
.default("false")
.transform((v) => v === "true"),
STORAGE_MODE: z.enum(["local", "s3"]).default("local"), STORAGE_MODE: z.enum(["local", "s3"]).default("local"),
FILE_MAX_AGE_HOURS: z.coerce.number().default(24), FILE_MAX_AGE_HOURS: z.coerce.number().default(24),
CLEANUP_INTERVAL_MINUTES: z.coerce.number().default(30), CLEANUP_INTERVAL_MINUTES: z.coerce.number().default(30),
+20 -1
View File
@@ -3,6 +3,20 @@ import { mkdir, unlink, writeFile } from "node:fs/promises";
import { extname, join } from "node:path"; import { extname, join } from "node:path";
import { env } from "../config.js"; import { env } from "../config.js";
const SAFE_STORAGE_EXTENSIONS = new Set([
".jpg",
".jpeg",
".png",
".webp",
".gif",
".bmp",
".tiff",
".tif",
".avif",
".svg",
".pdf",
]);
let storageReady = false; let storageReady = false;
export async function ensureStorageDir(): Promise<void> { export async function ensureStorageDir(): Promise<void> {
@@ -13,7 +27,12 @@ export async function ensureStorageDir(): Promise<void> {
export async function saveFile(buffer: Buffer, originalName: string): Promise<string> { export async function saveFile(buffer: Buffer, originalName: string): Promise<string> {
await ensureStorageDir(); await ensureStorageDir();
const ext = extname(originalName).toLowerCase() || ".bin"; let ext = extname(originalName).toLowerCase() || ".bin";
// Only allow known image extensions to be stored — reject dangerous extensions
// even if they somehow pass upstream sanitization.
if (!SAFE_STORAGE_EXTENSIONS.has(ext)) {
ext = ".bin";
}
const storedName = `${randomUUID()}${ext}`; const storedName = `${randomUUID()}${ext}`;
await writeFile(join(env.FILES_STORAGE_PATH, storedName), buffer); await writeFile(join(env.FILES_STORAGE_PATH, storedName), buffer);
return storedName; return storedName;
+33 -1
View File
@@ -45,11 +45,17 @@ export interface ValidationError {
export async function validateImageBuffer( export async function validateImageBuffer(
buffer: Buffer, buffer: Buffer,
): Promise<ValidationResult | ValidationError> { ): Promise<ValidationResult | ValidationError> {
// 1. Empty check // 1. Empty / null-byte check
if (!buffer || buffer.length === 0) { if (!buffer || buffer.length === 0) {
return { valid: false, reason: "File is empty" }; return { valid: false, reason: "File is empty" };
} }
// Reject buffers that are entirely null bytes — they are not valid images
// and would pass the length check but crash Sharp.
if (isNullByteBuffer(buffer)) {
return { valid: false, reason: "File contains no image data" };
}
// 2. Magic byte detection // 2. Magic byte detection
const detectedFormat = detectMagicBytes(buffer); const detectedFormat = detectMagicBytes(buffer);
if (!detectedFormat) { if (!detectedFormat) {
@@ -84,6 +90,32 @@ export async function validateImageBuffer(
} }
} }
/**
* Fast check whether a buffer is entirely null bytes.
* Samples the first 64 bytes + a few random positions to avoid
* a full scan on large buffers.
*/
function isNullByteBuffer(buffer: Buffer): boolean {
// Check the first 64 bytes (covers all magic byte positions)
const checkLen = Math.min(buffer.length, 64);
for (let i = 0; i < checkLen; i++) {
if (buffer[i] !== 0) return false;
}
// For larger buffers, spot-check a few additional positions
if (buffer.length > 64) {
const positions = [
Math.floor(buffer.length / 4),
Math.floor(buffer.length / 2),
Math.floor((buffer.length * 3) / 4),
buffer.length - 1,
];
for (const pos of positions) {
if (buffer[pos] !== 0) return false;
}
}
return true;
}
function detectMagicBytes(buffer: Buffer): string | null { function detectMagicBytes(buffer: Buffer): string | null {
for (const entry of MAGIC_BYTES) { for (const entry of MAGIC_BYTES) {
if (buffer.length < entry.offset + entry.bytes.length) continue; if (buffer.length < entry.offset + entry.bytes.length) continue;
+36 -2
View File
@@ -1,8 +1,26 @@
import { basename } from "node:path"; import { basename } from "node:path";
const SAFE_IMAGE_EXTENSIONS = new Set([
".jpg",
".jpeg",
".png",
".webp",
".gif",
".bmp",
".tiff",
".tif",
".avif",
".svg",
".pdf",
]);
/** /**
* Sanitize a filename to prevent path traversal attacks. * Sanitize a filename to prevent path traversal and double-extension attacks.
* Strips directory separators and ".." sequences, keeps only the base name. *
* 1. Strips directory separators (basename only).
* 2. Removes ".." sequences and null bytes.
* 3. Truncates after the first recognised image extension so that
* "photo.png.php" becomes "photo.png".
*/ */
export function sanitizeFilename(raw: string): string { export function sanitizeFilename(raw: string): string {
let name = basename(raw); let name = basename(raw);
@@ -11,5 +29,21 @@ export function sanitizeFilename(raw: string): string {
if (!name || name === "." || name === "..") { if (!name || name === "." || name === "..") {
name = "upload"; name = "upload";
} }
// Guard against double-extension attacks (e.g. "image.png.php").
// Walk the dot-separated parts and truncate after the first safe image extension.
const dotIndex = name.indexOf(".");
if (dotIndex !== -1) {
const parts = name.split(".");
for (let i = 1; i < parts.length; i++) {
const ext = `.${parts[i].toLowerCase()}`;
if (SAFE_IMAGE_EXTENSIONS.has(ext)) {
// Keep everything up to and including this extension, drop the rest
name = parts.slice(0, i + 1).join(".");
break;
}
}
}
return name; return name;
} }
+41 -3
View File
@@ -4,6 +4,7 @@ import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { env } from "../config.js"; import { env } from "../config.js";
import { db, schema } from "../db/index.js"; import { db, schema } from "../db/index.js";
import { auditLog } from "../lib/audit.js";
const scryptAsync = promisify(scrypt); const scryptAsync = promisify(scrypt);
@@ -111,6 +112,7 @@ export async function ensureDefaultAdmin(): Promise<void> {
const id = randomUUID(); const id = randomUUID();
const passwordHash = await hashPassword(env.DEFAULT_PASSWORD); const passwordHash = await hashPassword(env.DEFAULT_PASSWORD);
const mustChange = !env.SKIP_MUST_CHANGE_PASSWORD;
const result = db const result = db
.insert(schema.users) .insert(schema.users)
.values({ .values({
@@ -118,14 +120,16 @@ export async function ensureDefaultAdmin(): Promise<void> {
username: env.DEFAULT_USERNAME, username: env.DEFAULT_USERNAME,
passwordHash, passwordHash,
role: "admin", role: "admin",
mustChangePassword: true, mustChangePassword: mustChange,
}) })
.onConflictDoNothing() .onConflictDoNothing()
.run(); .run();
if (result.changes > 0) { if (result.changes > 0) {
console.log( console.log(
`Default admin user '${env.DEFAULT_USERNAME}' created — password change required on first login`, mustChange
? `Default admin user '${env.DEFAULT_USERNAME}' created — password change required on first login`
: `Default admin user '${env.DEFAULT_USERNAME}' created (password change skipped via env)`,
); );
} }
} }
@@ -168,11 +172,13 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
.get(); .get();
if (!user) { if (!user) {
auditLog(request.log, "LOGIN_FAILED", { username: body.username, reason: "unknown_user" });
return reply.status(401).send({ error: "Invalid credentials" }); return reply.status(401).send({ error: "Invalid credentials" });
} }
const valid = await verifyPassword(body.password, user.passwordHash); const valid = await verifyPassword(body.password, user.passwordHash);
if (!valid) { if (!valid) {
auditLog(request.log, "LOGIN_FAILED", { username: body.username, reason: "bad_password" });
return reply.status(401).send({ error: "Invalid credentials" }); return reply.status(401).send({ error: "Invalid credentials" });
} }
@@ -188,6 +194,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
}) })
.run(); .run();
auditLog(request.log, "LOGIN_SUCCESS", { userId: user.id, username: user.username });
return reply.send({ return reply.send({
token, token,
user: { user: {
@@ -204,9 +212,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
// POST /api/auth/logout // POST /api/auth/logout
app.post("/api/auth/logout", async (request: FastifyRequest, reply: FastifyReply) => { app.post("/api/auth/logout", async (request: FastifyRequest, reply: FastifyReply) => {
const token = extractToken(request); const token = extractToken(request);
const user = getAuthUser(request);
if (token) { if (token) {
db.delete(schema.sessions).where(eq(schema.sessions.id, token)).run(); db.delete(schema.sessions).where(eq(schema.sessions.id, token)).run();
} }
auditLog(request.log, "LOGOUT", { userId: user?.id });
return reply.send({ ok: true }); return reply.send({ ok: true });
}); });
@@ -305,6 +315,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
// Revoke all API keys — if credentials were compromised, keys must be rotated too // Revoke all API keys — if credentials were compromised, keys must be rotated too
db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, authUser.id)).run(); db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, authUser.id)).run();
auditLog(request.log, "PASSWORD_CHANGED", { userId: authUser.id, username: authUser.username });
return reply.send({ ok: true }); return reply.send({ ok: true });
}); });
@@ -433,6 +445,13 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
}) })
.run(); .run();
auditLog(request.log, "USER_CREATED", {
adminId: admin.id,
newUserId: id,
newUsername: body.username,
role,
});
return reply.status(201).send({ return reply.status(201).send({
id, id,
username: body.username, username: body.username,
@@ -486,6 +505,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
db.update(schema.users).set(updates).where(eq(schema.users.id, id)).run(); db.update(schema.users).set(updates).where(eq(schema.users.id, id)).run();
auditLog(request.log, "USER_UPDATED", {
adminId: admin.id,
targetUserId: id,
changes: { role: updates.role, team: updates.team },
});
return reply.send({ ok: true }); return reply.send({ ok: true });
}, },
); );
@@ -534,6 +559,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
// Revoke all API keys // Revoke all API keys
db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, id)).run(); db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, id)).run();
auditLog(request.log, "PASSWORD_RESET", {
adminId: admin.id,
targetUserId: id,
targetUsername: user.username,
});
return reply.send({ ok: true }); return reply.send({ ok: true });
}, },
); );
@@ -566,6 +597,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
// Delete the user (cascades to api_keys via FK) // Delete the user (cascades to api_keys via FK)
db.delete(schema.users).where(eq(schema.users.id, id)).run(); db.delete(schema.users).where(eq(schema.users.id, id)).run();
auditLog(request.log, "USER_DELETED", {
adminId: admin.id,
deletedUserId: id,
deletedUsername: user.username,
});
return reply.send({ ok: true }); return reply.send({ ok: true });
}, },
); );
@@ -705,7 +742,8 @@ export async function authMiddleware(app: FastifyInstance): Promise<void> {
}; };
// Enforce mustChangePassword — block non-auth API calls // Enforce mustChangePassword — block non-auth API calls
if (user.mustChangePassword) { // (skipped when SKIP_MUST_CHANGE_PASSWORD=true for CI/dev environments)
if (user.mustChangePassword && !env.SKIP_MUST_CHANGE_PASSWORD) {
const allowed = [ const allowed = [
"/api/auth/change-password", "/api/auth/change-password",
"/api/auth/logout", "/api/auth/logout",
+5
View File
@@ -9,6 +9,7 @@ import { randomBytes, randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm"; import { and, 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 { auditLog } from "../lib/audit.js";
import { computeKeyPrefix, hashPassword, requireAuth } from "../plugins/auth.js"; import { computeKeyPrefix, hashPassword, requireAuth } from "../plugins/auth.js";
export async function apiKeyRoutes(app: FastifyInstance): Promise<void> { export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
@@ -43,6 +44,8 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
}) })
.run(); .run();
auditLog(request.log, "API_KEY_CREATED", { userId: user.id, keyId: id, keyName: name });
// Return the raw key ONCE — it cannot be retrieved again // Return the raw key ONCE — it cannot be retrieved again
return reply.status(201).send({ return reply.status(201).send({
id, id,
@@ -103,6 +106,8 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id)).run(); db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id)).run();
auditLog(request.log, "API_KEY_DELETED", { userId: user.id, keyId: id });
return reply.send({ ok: true }); return reply.send({ ok: true });
}, },
); );
+10
View File
@@ -16,6 +16,7 @@ import { and, desc, eq, like, sql } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp"; import sharp from "sharp";
import { db, schema, sqlite } from "../db/index.js"; import { db, schema, sqlite } from "../db/index.js";
import { auditLog } from "../lib/audit.js";
import { deleteStoredFile, getStoredFilePath, saveFile } from "../lib/file-storage.js"; import { deleteStoredFile, getStoredFilePath, saveFile } from "../lib/file-storage.js";
import { validateImageBuffer } from "../lib/file-validation.js"; import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js"; import { sanitizeFilename } from "../lib/filename.js";
@@ -205,6 +206,12 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
return reply.status(400).send({ error: "No valid files uploaded" }); return reply.status(400).send({ error: "No valid files uploaded" });
} }
auditLog(request.log, "FILE_UPLOADED", {
userId,
count: created.length,
files: created.map((f) => f.originalName),
});
return reply.status(201).send({ files: created }); return reply.status(201).send({ files: created });
}); });
@@ -410,6 +417,9 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
} }
} }
const user = getAuthUser(request);
auditLog(request.log, "FILE_DELETED", { userId: user?.id, count: deletedCount, ids });
return reply.send({ deleted: deletedCount }); return reply.send({ deleted: deletedCount });
}); });
+6 -2
View File
@@ -1,6 +1,6 @@
# Deployment # Deployment
Stirling Image ships as a single Docker container. The frontend, API, and Python AI runtime all run inside one image. Stirling Image ships as a single Docker container. The frontend, API, and Python AI runtime all run inside one image. The image supports **linux/amd64** and **linux/arm64**, so it runs natively on Intel/AMD servers, Apple Silicon Macs, and ARM devices like the Raspberry Pi 4/5.
## Docker Compose (recommended) ## Docker Compose (recommended)
@@ -59,6 +59,10 @@ Everything runs from a single process. The Fastify server handles API requests a
Model weights are downloaded at build time, so the container works fully offline. Model weights are downloaded at build time, so the container works fully offline.
### Architecture notes
All core image tools (resize, crop, compress, convert, watermark, etc.) work on both amd64 and arm64. Some ML packages (PaddleOCR, MediaPipe, LaMa Cleaner) have limited arm64 support and may be unavailable on ARM systems. The container logs a warning for any package that could not be installed and falls back gracefully — Tesseract handles OCR and Lanczos handles upscaling when the ML alternatives are missing.
## Volumes ## Volumes
Mount these to persist data: Mount these to persist data:
@@ -106,7 +110,7 @@ Set `client_max_body_size` to match your `MAX_UPLOAD_SIZE_MB` value.
The GitHub repository has two workflows: The GitHub repository has two workflows:
- **docker-publish.yml** -- Builds and pushes the Docker image to Docker Hub on every push to `main` and on version tags. The image is published as `siddharth123sk/stirling-image`. - **release.yml** -- On release, builds multi-arch Docker images (amd64 + arm64) and pushes to both Docker Hub (`siddharth123sk/stirling-image`) and GitHub Container Registry (`ghcr.io/siddharthksah/stirling-image`).
- **deploy-docs.yml** -- Builds this documentation site and deploys it to GitHub Pages. - **deploy-docs.yml** -- Builds this documentation site and deploys it to GitHub Pages.
Both run automatically. No manual steps needed after merging to `main`. Both run automatically. No manual steps needed after merging to `main`.
@@ -63,6 +63,7 @@ export function BarcodeReadSettings() {
<button <button
type="button" type="button"
data-testid="barcode-read-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || processing} disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -92,6 +92,7 @@ export function BlurFacesSettings() {
) : ( ) : (
<button <button
type="button" type="button"
data-testid="blur-faces-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || processing} disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -105,6 +106,7 @@ export function BlurFacesSettings() {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="blur-faces-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -142,6 +142,7 @@ export function BorderSettings() {
) : ( ) : (
<button <button
type="button" type="button"
data-testid="border-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || processing} disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -154,6 +155,7 @@ export function BorderSettings() {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="border-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -124,6 +124,7 @@ export function BulkRenameSettings() {
<button <button
type="button" type="button"
data-testid="bulk-rename-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFiles || processing || !pattern} disabled={!hasFiles || processing || !pattern}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -128,6 +128,7 @@ export function CollageSettings() {
<button <button
type="button" type="button"
data-testid="collage-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFiles || processing} disabled={!hasFiles || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -140,6 +141,7 @@ export function CollageSettings() {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="collage-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -58,6 +58,7 @@ export function ColorPaletteSettings() {
<div className="space-y-4"> <div className="space-y-4">
<button <button
type="button" type="button"
data-testid="color-palette-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || processing} disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -260,6 +260,7 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
) : ( ) : (
<button <button
type="submit" type="submit"
data-testid={`${toolId}-submit`}
disabled={!hasFile || !hasChanges || processing} disabled={!hasFile || !hasChanges || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
@@ -272,6 +273,7 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid={`${toolId}-download`}
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -89,6 +89,7 @@ export function CompareSettings() {
<button <button
type="button" type="button"
data-testid="compare-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || !secondFile || processing} disabled={!hasFile || !secondFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -101,6 +102,7 @@ export function CompareSettings() {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="compare-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -164,6 +164,7 @@ export function ComposeSettings() {
<button <button
type="button" type="button"
data-testid="compose-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || !overlayFile || processing} disabled={!hasFile || !overlayFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -176,6 +177,7 @@ export function ComposeSettings() {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="compose-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -134,6 +134,7 @@ export function CompressSettings() {
) : ( ) : (
<button <button
type="submit" type="submit"
data-testid="compress-submit"
disabled={!hasFile || !canProcess || processing} disabled={!hasFile || !canProcess || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
@@ -146,6 +147,7 @@ export function CompressSettings() {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="compress-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -130,6 +130,7 @@ export function ConvertSettings() {
) : ( ) : (
<button <button
type="submit" type="submit"
data-testid="convert-submit"
disabled={!hasFile || processing} disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
@@ -142,6 +143,7 @@ export function ConvertSettings() {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="convert-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -288,6 +288,7 @@ export function CropSettings({
) : ( ) : (
<button <button
type="submit" type="submit"
data-testid="crop-submit"
disabled={!hasFile || !hasSize || processing} disabled={!hasFile || !hasSize || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
@@ -300,6 +301,7 @@ export function CropSettings({
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="crop-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -196,6 +196,7 @@ export function EraseObjectSettings({
) : ( ) : (
<button <button
type="button" type="button"
data-testid="erase-object-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || !hasStrokes || processing} disabled={!hasFile || !hasStrokes || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -209,6 +210,7 @@ export function EraseObjectSettings({
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="erase-object-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -83,6 +83,7 @@ export function FaviconSettings() {
<button <button
type="button" type="button"
data-testid="favicon-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || processing} disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -63,6 +63,7 @@ export function FindDuplicatesSettings() {
<button <button
type="button" type="button"
data-testid="find-duplicates-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFiles || processing} disabled={!hasFiles || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -134,6 +134,7 @@ export function GifToolsSettings() {
) : ( ) : (
<button <button
type="button" type="button"
data-testid="gif-tools-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || processing} disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -146,6 +147,7 @@ export function GifToolsSettings() {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="gif-tools-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -229,6 +229,7 @@ export function ImageToPdfSettings() {
<button <button
type="button" type="button"
data-testid="image-to-pdf-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFiles || processing} disabled={!hasFiles || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -241,6 +242,7 @@ export function ImageToPdfSettings() {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="image-to-pdf-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -80,6 +80,7 @@ export function InfoSettings() {
<div className="space-y-4"> <div className="space-y-4">
<button <button
type="button" type="button"
data-testid="info-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || processing} disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -191,6 +191,7 @@ export function OcrSettings() {
) : ( ) : (
<button <button
type="button" type="button"
data-testid="ocr-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || processing} disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -132,6 +132,7 @@ export function QrGenerateSettings() {
<button <button
type="button" type="button"
data-testid="qr-generate-submit"
onClick={handleGenerate} onClick={handleGenerate}
disabled={!text || processing} disabled={!text || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -151,6 +152,7 @@ export function QrGenerateSettings() {
<a <a
href={downloadUrl ?? undefined} href={downloadUrl ?? undefined}
download download
data-testid="qr-generate-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -200,6 +200,7 @@ export function RemoveBgSettings() {
) : ( ) : (
<button <button
type="button" type="button"
data-testid="remove-background-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || processing} disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -213,6 +214,7 @@ export function RemoveBgSettings() {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="remove-background-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -122,6 +122,7 @@ export function ReplaceColorSettings() {
) : ( ) : (
<button <button
type="button" type="button"
data-testid="replace-color-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || processing} disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -134,6 +135,7 @@ export function ReplaceColorSettings() {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="replace-color-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -260,6 +260,7 @@ export function ResizeSettings() {
) : ( ) : (
<button <button
type="submit" type="submit"
data-testid="resize-submit"
disabled={!canProcess} disabled={!canProcess}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
@@ -272,6 +273,7 @@ export function ResizeSettings() {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="resize-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -105,6 +105,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<button <button
type="button" type="button"
data-testid="rotate-left"
onClick={rotateLeft} onClick={rotateLeft}
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm font-medium" className="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm font-medium"
title="Rotate 90° counter-clockwise" title="Rotate 90° counter-clockwise"
@@ -149,6 +150,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
</button> </button>
<button <button
type="button" type="button"
data-testid="rotate-right"
onClick={rotateRight} onClick={rotateRight}
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm font-medium" className="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm font-medium"
title="Rotate 90° clockwise" title="Rotate 90° clockwise"
@@ -193,6 +195,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
<div className="flex gap-2 mt-1"> <div className="flex gap-2 mt-1">
<button <button
type="button" type="button"
data-testid="rotate-flip-h"
onClick={() => setFlipH(!flipH)} onClick={() => setFlipH(!flipH)}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg text-sm font-medium transition-colors ${ className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg text-sm font-medium transition-colors ${
flipH flipH
@@ -205,6 +208,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
</button> </button>
<button <button
type="button" type="button"
data-testid="rotate-flip-v"
onClick={() => setFlipV(!flipV)} onClick={() => setFlipV(!flipV)}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg text-sm font-medium transition-colors ${ className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg text-sm font-medium transition-colors ${
flipV flipV
@@ -245,6 +249,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
) : ( ) : (
<button <button
type="submit" type="submit"
data-testid="rotate-submit"
disabled={!hasFile || !hasChanges || processing} disabled={!hasFile || !hasChanges || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
@@ -129,6 +129,7 @@ export function SmartCropSettings() {
) : ( ) : (
<button <button
type="button" type="button"
data-testid="smart-crop-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || !canProcess || processing} disabled={!hasFile || !canProcess || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -142,6 +143,7 @@ export function SmartCropSettings() {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="smart-crop-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -118,6 +118,7 @@ export function SplitSettings() {
<button <button
type="button" type="button"
data-testid="split-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || processing} disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -462,6 +462,7 @@ export function StripMetadataSettings() {
) : ( ) : (
<button <button
type="submit" type="submit"
data-testid="strip-metadata-submit"
disabled={!hasFile || processing} disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
@@ -474,6 +475,7 @@ export function StripMetadataSettings() {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="strip-metadata-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -148,6 +148,7 @@ export function SvgToRasterSettings() {
<button <button
type="button" type="button"
data-testid="svg-to-raster-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || processing} disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -160,6 +161,7 @@ export function SvgToRasterSettings() {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="svg-to-raster-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -154,6 +154,7 @@ export function TextOverlaySettings() {
) : ( ) : (
<button <button
type="button" type="button"
data-testid="text-overlay-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || processing || !text} disabled={!hasFile || processing || !text}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -166,6 +167,7 @@ export function TextOverlaySettings() {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="text-overlay-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -77,6 +77,7 @@ export function UpscaleSettings() {
) : ( ) : (
<button <button
type="button" type="button"
data-testid="upscale-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || processing} disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -90,6 +91,7 @@ export function UpscaleSettings() {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="upscale-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -122,6 +122,7 @@ export function VectorizeSettings() {
<button <button
type="button" type="button"
data-testid="vectorize-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || processing} disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -134,6 +135,7 @@ export function VectorizeSettings() {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="vectorize-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -146,6 +146,7 @@ export function WatermarkImageSettings() {
<button <button
type="button" type="button"
data-testid="watermark-image-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || !watermarkFile || processing} disabled={!hasFile || !watermarkFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -158,6 +159,7 @@ export function WatermarkImageSettings() {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="watermark-image-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@@ -160,6 +160,7 @@ export function WatermarkTextSettings() {
) : ( ) : (
<button <button
type="button" type="button"
data-testid="watermark-text-submit"
onClick={handleProcess} onClick={handleProcess}
disabled={!hasFile || processing || !text} disabled={!hasFile || processing || !text}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -172,6 +173,7 @@ export function WatermarkTextSettings() {
<a <a
href={downloadUrl} href={downloadUrl}
download download
data-testid="watermark-text-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
+2 -2
View File
@@ -7,7 +7,7 @@
# ============================================ # ============================================
# Stage 1: Build the frontend (Vite + React) # Stage 1: Build the frontend (Vite + React)
# ============================================ # ============================================
FROM --platform=linux/amd64 node:22-bookworm AS builder FROM node:22-bookworm AS builder
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
@@ -38,7 +38,7 @@ RUN --mount=type=cache,id=turbo-cache,target=/app/.turbo \
# ============================================ # ============================================
# Stage 2: Production runtime # Stage 2: Production runtime
# ============================================ # ============================================
FROM --platform=linux/amd64 node:22-bookworm AS production FROM node:22-bookworm AS production
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
+1
View File
@@ -49,6 +49,7 @@ export default defineConfig({
DEFAULT_USERNAME: "admin", DEFAULT_USERNAME: "admin",
DEFAULT_PASSWORD: "admin", DEFAULT_PASSWORD: "admin",
RATE_LIMIT_PER_MIN: "50000", RATE_LIMIT_PER_MIN: "50000",
SKIP_MUST_CHANGE_PASSWORD: "true",
}, },
timeout: 30_000, timeout: 30_000,
}, },
+2
View File
@@ -467,6 +467,7 @@ describe("loadEnv", () => {
"CONCURRENT_JOBS", "CONCURRENT_JOBS",
"MAX_MEGAPIXELS", "MAX_MEGAPIXELS",
"RATE_LIMIT_PER_MIN", "RATE_LIMIT_PER_MIN",
"SKIP_MUST_CHANGE_PASSWORD",
"DB_PATH", "DB_PATH",
"WORKSPACE_PATH", "WORKSPACE_PATH",
"DEFAULT_THEME", "DEFAULT_THEME",
@@ -508,6 +509,7 @@ describe("loadEnv", () => {
expect(typeof env.CONCURRENT_JOBS).toBe("number"); expect(typeof env.CONCURRENT_JOBS).toBe("number");
expect(typeof env.MAX_MEGAPIXELS).toBe("number"); expect(typeof env.MAX_MEGAPIXELS).toBe("number");
expect(typeof env.RATE_LIMIT_PER_MIN).toBe("number"); expect(typeof env.RATE_LIMIT_PER_MIN).toBe("number");
expect(typeof env.SKIP_MUST_CHANGE_PASSWORD).toBe("boolean");
expect(typeof env.DB_PATH).toBe("string"); expect(typeof env.DB_PATH).toBe("string");
expect(typeof env.WORKSPACE_PATH).toBe("string"); expect(typeof env.WORKSPACE_PATH).toBe("string");
expect(["light", "dark"]).toContain(env.DEFAULT_THEME); expect(["light", "dark"]).toContain(env.DEFAULT_THEME);
+1 -1
View File
@@ -71,7 +71,7 @@ function failResponse(status: number) {
return Promise.resolve({ return Promise.resolve({
ok: false, ok: false,
status, status,
json: () => Promise.resolve({ error: "bad" }), json: () => Promise.reject(new Error("no body")),
blob: () => Promise.resolve(new Blob()), blob: () => Promise.resolve(new Blob()),
} as unknown as Response); } as unknown as Response);
} }