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
MAX_MEGAPIXELS=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
WORKSPACE_PATH=./tmp/workspace
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(
"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"),
DEFAULT_USERNAME: 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"),
FILE_MAX_AGE_HOURS: z.coerce.number().default(24),
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 { env } from "../config.js";
const SAFE_STORAGE_EXTENSIONS = new Set([
".jpg",
".jpeg",
".png",
".webp",
".gif",
".bmp",
".tiff",
".tif",
".avif",
".svg",
".pdf",
]);
let storageReady = false;
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> {
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}`;
await writeFile(join(env.FILES_STORAGE_PATH, storedName), buffer);
return storedName;
+33 -1
View File
@@ -45,11 +45,17 @@ export interface ValidationError {
export async function validateImageBuffer(
buffer: Buffer,
): Promise<ValidationResult | ValidationError> {
// 1. Empty check
// 1. Empty / null-byte check
if (!buffer || buffer.length === 0) {
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
const detectedFormat = detectMagicBytes(buffer);
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 {
for (const entry of MAGIC_BYTES) {
if (buffer.length < entry.offset + entry.bytes.length) continue;
+36 -2
View File
@@ -1,8 +1,26 @@
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.
* Strips directory separators and ".." sequences, keeps only the base name.
* Sanitize a filename to prevent path traversal and double-extension attacks.
*
* 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 {
let name = basename(raw);
@@ -11,5 +29,21 @@ export function sanitizeFilename(raw: string): string {
if (!name || name === "." || name === "..") {
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;
}
+41 -3
View File
@@ -4,6 +4,7 @@ import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { auditLog } from "../lib/audit.js";
const scryptAsync = promisify(scrypt);
@@ -111,6 +112,7 @@ export async function ensureDefaultAdmin(): Promise<void> {
const id = randomUUID();
const passwordHash = await hashPassword(env.DEFAULT_PASSWORD);
const mustChange = !env.SKIP_MUST_CHANGE_PASSWORD;
const result = db
.insert(schema.users)
.values({
@@ -118,14 +120,16 @@ export async function ensureDefaultAdmin(): Promise<void> {
username: env.DEFAULT_USERNAME,
passwordHash,
role: "admin",
mustChangePassword: true,
mustChangePassword: mustChange,
})
.onConflictDoNothing()
.run();
if (result.changes > 0) {
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();
if (!user) {
auditLog(request.log, "LOGIN_FAILED", { username: body.username, reason: "unknown_user" });
return reply.status(401).send({ error: "Invalid credentials" });
}
const valid = await verifyPassword(body.password, user.passwordHash);
if (!valid) {
auditLog(request.log, "LOGIN_FAILED", { username: body.username, reason: "bad_password" });
return reply.status(401).send({ error: "Invalid credentials" });
}
@@ -188,6 +194,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
})
.run();
auditLog(request.log, "LOGIN_SUCCESS", { userId: user.id, username: user.username });
return reply.send({
token,
user: {
@@ -204,9 +212,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
// POST /api/auth/logout
app.post("/api/auth/logout", async (request: FastifyRequest, reply: FastifyReply) => {
const token = extractToken(request);
const user = getAuthUser(request);
if (token) {
db.delete(schema.sessions).where(eq(schema.sessions.id, token)).run();
}
auditLog(request.log, "LOGOUT", { userId: user?.id });
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
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 });
});
@@ -433,6 +445,13 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
})
.run();
auditLog(request.log, "USER_CREATED", {
adminId: admin.id,
newUserId: id,
newUsername: body.username,
role,
});
return reply.status(201).send({
id,
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();
auditLog(request.log, "USER_UPDATED", {
adminId: admin.id,
targetUserId: id,
changes: { role: updates.role, team: updates.team },
});
return reply.send({ ok: true });
},
);
@@ -534,6 +559,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
// Revoke all API keys
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 });
},
);
@@ -566,6 +597,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
// Delete the user (cascades to api_keys via FK)
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 });
},
);
@@ -705,7 +742,8 @@ export async function authMiddleware(app: FastifyInstance): Promise<void> {
};
// 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 = [
"/api/auth/change-password",
"/api/auth/logout",
+5
View File
@@ -9,6 +9,7 @@ import { randomBytes, randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db, schema } from "../db/index.js";
import { auditLog } from "../lib/audit.js";
import { computeKeyPrefix, hashPassword, requireAuth } from "../plugins/auth.js";
export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
@@ -43,6 +44,8 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
})
.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 reply.status(201).send({
id,
@@ -103,6 +106,8 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
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 });
},
);
+10
View File
@@ -16,6 +16,7 @@ import { and, desc, eq, like, sql } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { db, schema, sqlite } from "../db/index.js";
import { auditLog } from "../lib/audit.js";
import { deleteStoredFile, getStoredFilePath, saveFile } from "../lib/file-storage.js";
import { validateImageBuffer } from "../lib/file-validation.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" });
}
auditLog(request.log, "FILE_UPLOADED", {
userId,
count: created.length,
files: created.map((f) => f.originalName),
});
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 });
});
+6 -2
View File
@@ -1,6 +1,6 @@
# 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)
@@ -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.
### 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
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:
- **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.
Both run automatically. No manual steps needed after merging to `main`.
@@ -63,6 +63,7 @@ export function BarcodeReadSettings() {
<button
type="button"
data-testid="barcode-read-submit"
onClick={handleProcess}
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"
@@ -92,6 +92,7 @@ export function BlurFacesSettings() {
) : (
<button
type="button"
data-testid="blur-faces-submit"
onClick={handleProcess}
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"
@@ -105,6 +106,7 @@ export function BlurFacesSettings() {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -142,6 +142,7 @@ export function BorderSettings() {
) : (
<button
type="button"
data-testid="border-submit"
onClick={handleProcess}
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"
@@ -154,6 +155,7 @@ export function BorderSettings() {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -124,6 +124,7 @@ export function BulkRenameSettings() {
<button
type="button"
data-testid="bulk-rename-submit"
onClick={handleProcess}
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"
@@ -128,6 +128,7 @@ export function CollageSettings() {
<button
type="button"
data-testid="collage-submit"
onClick={handleProcess}
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"
@@ -140,6 +141,7 @@ export function CollageSettings() {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -58,6 +58,7 @@ export function ColorPaletteSettings() {
<div className="space-y-4">
<button
type="button"
data-testid="color-palette-submit"
onClick={handleProcess}
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"
@@ -260,6 +260,7 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
) : (
<button
type="submit"
data-testid={`${toolId}-submit`}
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"
>
@@ -272,6 +273,7 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -89,6 +89,7 @@ export function CompareSettings() {
<button
type="button"
data-testid="compare-submit"
onClick={handleProcess}
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"
@@ -101,6 +102,7 @@ export function CompareSettings() {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -164,6 +164,7 @@ export function ComposeSettings() {
<button
type="button"
data-testid="compose-submit"
onClick={handleProcess}
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"
@@ -176,6 +177,7 @@ export function ComposeSettings() {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -134,6 +134,7 @@ export function CompressSettings() {
) : (
<button
type="submit"
data-testid="compress-submit"
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"
>
@@ -146,6 +147,7 @@ export function CompressSettings() {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -130,6 +130,7 @@ export function ConvertSettings() {
) : (
<button
type="submit"
data-testid="convert-submit"
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"
>
@@ -142,6 +143,7 @@ export function ConvertSettings() {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -288,6 +288,7 @@ export function CropSettings({
) : (
<button
type="submit"
data-testid="crop-submit"
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"
>
@@ -300,6 +301,7 @@ export function CropSettings({
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -196,6 +196,7 @@ export function EraseObjectSettings({
) : (
<button
type="button"
data-testid="erase-object-submit"
onClick={handleProcess}
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"
@@ -209,6 +210,7 @@ export function EraseObjectSettings({
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -83,6 +83,7 @@ export function FaviconSettings() {
<button
type="button"
data-testid="favicon-submit"
onClick={handleProcess}
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"
@@ -63,6 +63,7 @@ export function FindDuplicatesSettings() {
<button
type="button"
data-testid="find-duplicates-submit"
onClick={handleProcess}
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"
@@ -134,6 +134,7 @@ export function GifToolsSettings() {
) : (
<button
type="button"
data-testid="gif-tools-submit"
onClick={handleProcess}
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"
@@ -146,6 +147,7 @@ export function GifToolsSettings() {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -229,6 +229,7 @@ export function ImageToPdfSettings() {
<button
type="button"
data-testid="image-to-pdf-submit"
onClick={handleProcess}
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"
@@ -241,6 +242,7 @@ export function ImageToPdfSettings() {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -80,6 +80,7 @@ export function InfoSettings() {
<div className="space-y-4">
<button
type="button"
data-testid="info-submit"
onClick={handleProcess}
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"
@@ -191,6 +191,7 @@ export function OcrSettings() {
) : (
<button
type="button"
data-testid="ocr-submit"
onClick={handleProcess}
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"
@@ -132,6 +132,7 @@ export function QrGenerateSettings() {
<button
type="button"
data-testid="qr-generate-submit"
onClick={handleGenerate}
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"
@@ -151,6 +152,7 @@ export function QrGenerateSettings() {
<a
href={downloadUrl ?? undefined}
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"
>
<Download className="h-4 w-4" />
@@ -200,6 +200,7 @@ export function RemoveBgSettings() {
) : (
<button
type="button"
data-testid="remove-background-submit"
onClick={handleProcess}
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"
@@ -213,6 +214,7 @@ export function RemoveBgSettings() {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -122,6 +122,7 @@ export function ReplaceColorSettings() {
) : (
<button
type="button"
data-testid="replace-color-submit"
onClick={handleProcess}
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"
@@ -134,6 +135,7 @@ export function ReplaceColorSettings() {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -260,6 +260,7 @@ export function ResizeSettings() {
) : (
<button
type="submit"
data-testid="resize-submit"
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"
>
@@ -272,6 +273,7 @@ export function ResizeSettings() {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -105,6 +105,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
<div className="flex items-center gap-2 mt-1">
<button
type="button"
data-testid="rotate-left"
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"
title="Rotate 90° counter-clockwise"
@@ -149,6 +150,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
</button>
<button
type="button"
data-testid="rotate-right"
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"
title="Rotate 90° clockwise"
@@ -193,6 +195,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
<div className="flex gap-2 mt-1">
<button
type="button"
data-testid="rotate-flip-h"
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 ${
flipH
@@ -205,6 +208,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
</button>
<button
type="button"
data-testid="rotate-flip-v"
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 ${
flipV
@@ -245,6 +249,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
) : (
<button
type="submit"
data-testid="rotate-submit"
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"
>
@@ -129,6 +129,7 @@ export function SmartCropSettings() {
) : (
<button
type="button"
data-testid="smart-crop-submit"
onClick={handleProcess}
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"
@@ -142,6 +143,7 @@ export function SmartCropSettings() {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -118,6 +118,7 @@ export function SplitSettings() {
<button
type="button"
data-testid="split-submit"
onClick={handleProcess}
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"
@@ -462,6 +462,7 @@ export function StripMetadataSettings() {
) : (
<button
type="submit"
data-testid="strip-metadata-submit"
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"
>
@@ -474,6 +475,7 @@ export function StripMetadataSettings() {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -148,6 +148,7 @@ export function SvgToRasterSettings() {
<button
type="button"
data-testid="svg-to-raster-submit"
onClick={handleProcess}
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"
@@ -160,6 +161,7 @@ export function SvgToRasterSettings() {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -154,6 +154,7 @@ export function TextOverlaySettings() {
) : (
<button
type="button"
data-testid="text-overlay-submit"
onClick={handleProcess}
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"
@@ -166,6 +167,7 @@ export function TextOverlaySettings() {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -77,6 +77,7 @@ export function UpscaleSettings() {
) : (
<button
type="button"
data-testid="upscale-submit"
onClick={handleProcess}
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"
@@ -90,6 +91,7 @@ export function UpscaleSettings() {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -122,6 +122,7 @@ export function VectorizeSettings() {
<button
type="button"
data-testid="vectorize-submit"
onClick={handleProcess}
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"
@@ -134,6 +135,7 @@ export function VectorizeSettings() {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -146,6 +146,7 @@ export function WatermarkImageSettings() {
<button
type="button"
data-testid="watermark-image-submit"
onClick={handleProcess}
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"
@@ -158,6 +159,7 @@ export function WatermarkImageSettings() {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
@@ -160,6 +160,7 @@ export function WatermarkTextSettings() {
) : (
<button
type="button"
data-testid="watermark-text-submit"
onClick={handleProcess}
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"
@@ -172,6 +173,7 @@ export function WatermarkTextSettings() {
<a
href={downloadUrl}
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"
>
<Download className="h-4 w-4" />
+2 -2
View File
@@ -7,7 +7,7 @@
# ============================================
# 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
@@ -38,7 +38,7 @@ RUN --mount=type=cache,id=turbo-cache,target=/app/.turbo \
# ============================================
# 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
+1
View File
@@ -49,6 +49,7 @@ export default defineConfig({
DEFAULT_USERNAME: "admin",
DEFAULT_PASSWORD: "admin",
RATE_LIMIT_PER_MIN: "50000",
SKIP_MUST_CHANGE_PASSWORD: "true",
},
timeout: 30_000,
},
+2
View File
@@ -467,6 +467,7 @@ describe("loadEnv", () => {
"CONCURRENT_JOBS",
"MAX_MEGAPIXELS",
"RATE_LIMIT_PER_MIN",
"SKIP_MUST_CHANGE_PASSWORD",
"DB_PATH",
"WORKSPACE_PATH",
"DEFAULT_THEME",
@@ -508,6 +509,7 @@ describe("loadEnv", () => {
expect(typeof env.CONCURRENT_JOBS).toBe("number");
expect(typeof env.MAX_MEGAPIXELS).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.WORKSPACE_PATH).toBe("string");
expect(["light", "dark"]).toContain(env.DEFAULT_THEME);
+1 -1
View File
@@ -71,7 +71,7 @@ function failResponse(status: number) {
return Promise.resolve({
ok: false,
status,
json: () => Promise.resolve({ error: "bad" }),
json: () => Promise.reject(new Error("no body")),
blob: () => Promise.resolve(new Blob()),
} as unknown as Response);
}