mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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'",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user