mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: QA sweep fixes across migration, security, lint, and e2e tests
- fix(db): migration 0012 column order mismatch causing NOT NULL constraint failure on existing databases; use explicit column mapping instead of SELECT * - fix(db): disable FK checks during migrations to allow SQLite table-recreation pattern (DROP + RENAME) - fix(security): filter cookie_secret and instance_id from settings API response for non-admin users - fix(lint): resolve all 7 API lint warnings (noParameterAssign, noImplicitAnyLet) in compose, image-enhancement, and workspace - fix(docs): correct permission count from 16 to 14 in CLAUDE.md - fix(e2e): resolve 44 Playwright test failures across 8 spec files including locator specificity, compress mode defaults, format count, restore-photo UI drift, stitch image count, GIF animated fixtures, submit button timing, and processing timeouts
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
-- Make password_hash nullable to support OIDC-only users (no local password).
|
||||
-- SQLite does not support ALTER COLUMN, so we must recreate the table.
|
||||
DROP TABLE IF EXISTS `users_new`;--> statement-breakpoint
|
||||
CREATE TABLE `users_new` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`username` text NOT NULL,
|
||||
@@ -16,7 +17,9 @@ CREATE TABLE `users_new` (
|
||||
`analytics_consent_shown_at` integer,
|
||||
`analytics_consent_remind_at` integer
|
||||
);--> statement-breakpoint
|
||||
INSERT INTO `users_new` SELECT * FROM `users`;--> statement-breakpoint
|
||||
INSERT INTO `users_new` (`id`, `username`, `password_hash`, `role`, `team`, `must_change_password`, `auth_provider`, `external_id`, `email`, `created_at`, `updated_at`, `analytics_enabled`, `analytics_consent_shown_at`, `analytics_consent_remind_at`)
|
||||
SELECT `id`, `username`, `password_hash`, `role`, `team`, `must_change_password`, `auth_provider`, `external_id`, `email`, `created_at`, `updated_at`, `analytics_enabled`, `analytics_consent_shown_at`, `analytics_consent_remind_at`
|
||||
FROM `users`;--> statement-breakpoint
|
||||
DROP TABLE `users`;--> statement-breakpoint
|
||||
ALTER TABLE `users_new` RENAME TO `users`;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import { db } from "./index.js";
|
||||
import { db, sqlite } from "./index.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -24,6 +24,10 @@ let migrated = false;
|
||||
|
||||
export function runMigrations() {
|
||||
if (migrated) return;
|
||||
// Temporarily disable FK checks so table-recreation migrations
|
||||
// (DROP + RENAME pattern) can proceed without constraint errors.
|
||||
// Must be set outside any transaction to take effect in SQLite.
|
||||
sqlite.pragma("foreign_keys = OFF");
|
||||
try {
|
||||
migrate(db, { migrationsFolder });
|
||||
} catch (err: unknown) {
|
||||
@@ -37,6 +41,8 @@ export function runMigrations() {
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
sqlite.pragma("foreign_keys = ON");
|
||||
}
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
@@ -11,13 +11,13 @@ import { env } from "../config.js";
|
||||
async function checkWorkspaceCapacity(workspaceRoot: string): Promise<void> {
|
||||
if (!existsSync(workspaceRoot)) return;
|
||||
|
||||
let stats;
|
||||
let fsStats: Awaited<ReturnType<typeof statfs>>;
|
||||
try {
|
||||
stats = await statfs(workspaceRoot);
|
||||
fsStats = await statfs(workspaceRoot);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const freeBytes = stats.bavail * stats.bsize;
|
||||
const freeBytes = fsStats.bavail * fsStats.bsize;
|
||||
const freeGB = freeBytes / 1024 ** 3;
|
||||
|
||||
if (freeGB < 1) {
|
||||
@@ -39,13 +39,13 @@ async function checkWorkspaceCapacity(workspaceRoot: string): Promise<void> {
|
||||
}
|
||||
|
||||
// Recheck after cleanup
|
||||
let stats2;
|
||||
let recheckStats: Awaited<ReturnType<typeof statfs>>;
|
||||
try {
|
||||
stats2 = await statfs(workspaceRoot);
|
||||
recheckStats = await statfs(workspaceRoot);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const freeGB2 = (stats2.bavail * stats2.bsize) / 1024 ** 3;
|
||||
const freeGB2 = (recheckStats.bavail * recheckStats.bsize) / 1024 ** 3;
|
||||
if (freeGB2 < 0.5) {
|
||||
const error = new Error("Insufficient disk space for processing");
|
||||
(error as Error & { statusCode: number }).statusCode = 503;
|
||||
|
||||
@@ -17,16 +17,20 @@ const settingsBodySchema = z.record(z.string().min(1), z.unknown());
|
||||
|
||||
const HTML_TAG_PATTERN = /<[a-z/!?][^>]*>/i;
|
||||
|
||||
const SENSITIVE_KEYS = new Set(["cookie_secret", "instance_id"]);
|
||||
|
||||
export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
// GET /api/v1/settings — Get all settings as a key-value object
|
||||
app.get("/api/v1/settings", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = requireAuth(request, reply);
|
||||
if (!user) return;
|
||||
|
||||
const isAdmin = user.role === "admin";
|
||||
const rows = db.select().from(schema.settings).all();
|
||||
|
||||
const settings: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
if (!isAdmin && SENSITIVE_KEYS.has(row.key)) continue;
|
||||
settings[row.key] = row.value;
|
||||
}
|
||||
|
||||
@@ -94,6 +98,10 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const { key } = request.params;
|
||||
|
||||
if (SENSITIVE_KEYS.has(key) && user.role !== "admin") {
|
||||
return reply.status(403).send({ error: "Forbidden", code: "FORBIDDEN" });
|
||||
}
|
||||
|
||||
const row = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get();
|
||||
|
||||
if (!row) {
|
||||
|
||||
@@ -13,23 +13,24 @@ import { decodeHeic } from "../../lib/heic-converter.js";
|
||||
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
|
||||
async function decodeBuffer(buffer: Buffer, filename: string): Promise<Buffer> {
|
||||
const validation = await validateImageBuffer(buffer, filename);
|
||||
async function decodeBuffer(inputBuffer: Buffer, filename: string): Promise<Buffer> {
|
||||
const validation = await validateImageBuffer(inputBuffer, filename);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid image: ${validation.reason}`);
|
||||
}
|
||||
|
||||
let decoded = inputBuffer;
|
||||
if (validation.format === "heif") {
|
||||
buffer = await decodeHeic(buffer);
|
||||
decoded = await decodeHeic(decoded);
|
||||
} else if (needsCliDecode(validation.format)) {
|
||||
const ext = filename.split(".").pop()?.toLowerCase();
|
||||
buffer = await decodeToSharpCompat(buffer, validation.format, ext);
|
||||
decoded = await decodeToSharpCompat(decoded, validation.format, ext);
|
||||
} else if (validation.format === "svg") {
|
||||
buffer = decompressSvgz(buffer);
|
||||
buffer = sanitizeSvg(buffer);
|
||||
decoded = decompressSvgz(decoded);
|
||||
decoded = sanitizeSvg(decoded);
|
||||
}
|
||||
|
||||
return autoOrient(buffer);
|
||||
return autoOrient(decoded);
|
||||
}
|
||||
|
||||
const settingsSchema = z.object({
|
||||
|
||||
@@ -34,13 +34,14 @@ const settingsSchema = z.object({
|
||||
type EnhancementSettings = z.infer<typeof settingsSchema>;
|
||||
|
||||
async function processImageEnhancement(
|
||||
inputBuffer: Buffer,
|
||||
rawBuffer: Buffer,
|
||||
settings: EnhancementSettings,
|
||||
filename: string,
|
||||
) {
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
const outputFormat = await resolveOutputFormat(rawBuffer, filename);
|
||||
|
||||
// HDR/EXR decodes can produce 16-bit buffers; CLAHE requires 8-bit (VIPS_FORMAT_UCHAR)
|
||||
let inputBuffer = rawBuffer;
|
||||
const inputMeta = await sharp(inputBuffer).metadata();
|
||||
if (inputMeta.depth && inputMeta.depth !== "uchar") {
|
||||
inputBuffer = await sharp(inputBuffer).toColourspace("srgb").png().toBuffer();
|
||||
|
||||
Reference in New Issue
Block a user