mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(db)!: SnapOtter 2.0 phase 1 foundation: postgres, migrator, compose stack (#216)
* feat(infra): add dev compose stack with postgres and redis
* fix(infra): comment dev env defaults until wired; harden dev compose restart and start_period
* chore(deps): add pg driver and testcontainers for postgres migration
* feat(db): translate schema to drizzle pg-core (timestamptz, boolean, pgEnum, jsonb)
Schema translation (apps/api/src/db/schema.ts):
- sqlite-core -> pg-core, all 10 tables preserved 1:1
- integer(mode:'timestamp') -> timestamp({ withTimezone: true })
- integer(mode:'boolean') -> boolean
- jobs.status text enum -> pgEnum('job_status') with same 4 values
- 7 columns changed from text to jsonb: jobs.inputFiles, jobs.settings,
pipelines.steps, apiKeys.permissions, roles.permissions,
auditLog.details, userFiles.toolChain
- settings.value stays text, jobs.error stays text, jobs.progress stays real
jsonb call-site sweep (removed JSON.stringify on writes, JSON.parse on reads):
- apps/api/src/routes/roles.ts: permissions read/write (3 sites)
- apps/api/src/routes/api-keys.ts: permissions write + read (2 sites)
- apps/api/src/routes/audit-log.ts: details read (1 site)
- apps/api/src/routes/pipeline.ts: steps write + read (2 sites)
- apps/api/src/routes/progress.ts: inputFiles write (2 sites)
- apps/api/src/routes/tool-factory.ts: toolChain read + write (2 sites)
- apps/api/src/routes/user-files.ts: toolChain read + write (4 sites)
- apps/api/src/permissions.ts: roles.permissions read (1 site)
- apps/api/src/lib/audit.ts: details write (1 site)
- apps/api/src/plugins/auth.ts: apiKeys.permissions read (1 site)
* refactor(db): type jsonb columns via $type and note raw CTE conversion requirements
* feat(db): archive sqlite migrations and generate postgres baseline
* chore(db): dockerignore legacy migrations, add archive breadcrumb, fix trailing newline
* feat(db): pg pool connection, advisory-locked boot migrations, DATABASE_URL config
* fix(db): friendly fatal on unreachable postgres, idempotent closeDb, lock-key convention note
* refactor(db): async drizzle calls in plugins, lib, permissions
* fix(api): analytics never throws, typed permission guard, single-query session invalidation
* refactor(db): async drizzle calls across all routes and bootstrap
Convert every route file and index.ts from sync SQLite drizzle
patterns to async node-postgres drizzle:
- .all() removed (bare await on select)
- .get() converted to destructured [row] = await ...
- .run() removed (bare await on insert/update/delete)
- .changes replaced with .rowCount (null-guarded) in progress.ts
- sqlite import removed from user-files.ts; raw CTEs converted to
await db.execute(sql`...`) with postgres-dialect recursive CTEs
- ChainRow types updated: tool_chain is parsed jsonb (string[] | null),
created_at is Date (timestamptz) with no * 1000 conversion
- All requirePermission() guard calls awaited (security: unawaited
async guard returns truthy Promise, bypassing permission check)
- All hasEffectivePermission() and getPermissions() calls awaited
- All auditLog() calls awaited (preserves write-before-response order)
- trackEvent() and captureException() left un-awaited (fire-and-forget
by design, guaranteed never-throw)
- ensureAnonymousUser(), startCleanupCron(), recoverStaleJobs() awaited
in bootstrap sequence
- ensureInstanceId() and ensureDefaultSettings() made async
Files converted: 14 (index.ts + 12 route files + tools/index.ts)
* fix(db): await async checkStorageQuota in user-files upload/save routes
* fix(db): await checkStorageQuota in save-result route (missed second call site)
* feat(db): sqlite-to-postgres migrator with CLI and first-boot import
* fix(db): migrator error context, honest force semantics, boot-hook fatal, null-variance tests
* test: run suite against per-file postgres databases via testcontainers
- Add tests/global-setup.ts: spins up a Postgres testcontainer,
creates a migrated template database once per vitest run.
- Rewrite tests/setup/per-fork-env.ts: each test file (forks pool)
clones the template into its own database via CREATE DATABASE ...
TEMPLATE, preserving the same per-file isolation granularity.
- Update vitest.config.ts: add globalSetup, pg alias, update comment.
- Fix tests/integration/test-server.ts: remove DB_PATH mkdir, async
runMigrations, async db operations, remove SQLite WAL checkpoint.
- Fix 21 unit test db/index mocks: add pool and closeDb exports.
- Fix 8 unit test files: add async/await for now-async permission,
audit, and analytics functions.
- Fix 18 integration test files: convert sync .run()/.all()/.get()
to async drizzle patterns, add async to callbacks.
- Production change: apps/api/src/routes/teams.ts: cast COUNT(*)
to ::int so Postgres returns a number instead of bigint string.
* fix(db): seed built-in roles, reject NUL bytes, cast COUNT, serialize job persists
- Seed built-in roles (admin, editor, user) at boot via ensureBuiltinRoles()
with onConflictDoNothing, restoring data that legacy SQLite migration 0007
provided via INSERT statements (the pg baseline is DDL-only).
- Reject NUL bytes in login credentials with 401 (postgres rejects \x00 in
text columns; valid usernames never contain NUL, matching 1.x behavior).
- Cast COUNT(*)::int in user-files, audit-log, and roles listing queries so
postgres returns a JS number instead of bigint-as-string.
- Serialize fire-and-forget job progress DB writes per jobId so the final
"completed" status is never overwritten by a late-arriving "processing"
write (race condition exposed by async postgres round-trips).
* test: fix teams race, seed roles in test server, poll for job status
- Add missing await to resetTeams() in teams PUT beforeEach (the async
delete raced with the subsequent insert under postgres).
- Call ensureBuiltinRoles() in test server bootstrap so integration tests
have the same built-in roles as production.
- Replace fixed 100ms flushPersist delay with a polling helper that waits
for terminal job status, eliminating timing-dependent failures caused by
postgres network round-trip latency.
* test: make heic temp-file cleanup assertion resilient to concurrent workers
Use a set-based diff instead of raw file count when checking that
decodeHeic cleans up temp files. Other concurrent test workers can
create heic-in-*/heic-out-* files in the shared tmpdir, inflating the
"after" count and causing spurious failures under full-suite load.
* fix(db): align builtin-role seed to post-0010 legacy state; test polish
* feat(docker): three-container compose (app, postgres, redis) with boot wait and migrations
* fix(docker): set TEST_DATABASE_URL so containerized tests skip testcontainers
* chore(docker): test compose project name, clearer 1.x upgrade comment, unref probe timer
* feat(enterprise): enforce D15 license boundary; move s3 storage into packages/enterprise
* fix(enterprise): restore lazy aws-sdk loading; community installs load no s3 code at boot
* fix(enterprise): boundary check catches dynamic imports; document getS3 concurrency
* feat(db)!: SnapOtter 2.0 phase 1 foundation: postgres, migrator, compose stack
BREAKING CHANGE: SQLite is no longer the runtime database. Deployments now
require Postgres (and Redis, used from phase 2). Existing installs migrate
with SQLITE_MIGRATE_PATH or 'pnpm --filter @snapotter/api migrate:sqlite'.
* fix(ci): postgres service + fresh e2e database per run; ignore unfixable torch CVE-2025-3000
This commit is contained in:
@@ -23,11 +23,10 @@ export async function analyticsRoutes(app: FastifyInstance): Promise<void> {
|
||||
};
|
||||
}
|
||||
|
||||
const row = db
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, "instance_id"))
|
||||
.get();
|
||||
.where(eq(schema.settings.key, "instance_id"));
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
@@ -56,28 +55,28 @@ export async function analyticsRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
if (body.remindLater) {
|
||||
const remindAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
db.update(schema.users)
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set({
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: now,
|
||||
analyticsConsentRemindAt: remindAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(schema.users.id, user.id))
|
||||
.run();
|
||||
.where(eq(schema.users.id, user.id));
|
||||
return reply.send({ ok: true, analyticsEnabled: null });
|
||||
}
|
||||
|
||||
const enabled = body.enabled === true;
|
||||
db.update(schema.users)
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set({
|
||||
analyticsEnabled: enabled,
|
||||
analyticsConsentShownAt: now,
|
||||
analyticsConsentRemindAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(schema.users.id, user.id))
|
||||
.run();
|
||||
.where(eq(schema.users.id, user.id));
|
||||
return reply.send({ ok: true, analyticsEnabled: enabled });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
let scopedPermissions: string[] | null = null;
|
||||
if (body.permissions && body.permissions.length > 0) {
|
||||
const userPerms = getPermissions(user.role);
|
||||
const userPerms = await getPermissions(user.role);
|
||||
const permSet = new Set<string>(userPerms);
|
||||
const invalid = body.permissions.filter((p) => !permSet.has(p));
|
||||
if (invalid.length > 0) {
|
||||
@@ -73,22 +73,20 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
|
||||
const id = randomUUID();
|
||||
|
||||
try {
|
||||
db.insert(schema.apiKeys)
|
||||
.values({
|
||||
id,
|
||||
userId: user.id,
|
||||
keyHash,
|
||||
keyPrefix,
|
||||
name,
|
||||
permissions: scopedPermissions ? JSON.stringify(scopedPermissions) : null,
|
||||
expiresAt,
|
||||
})
|
||||
.run();
|
||||
await db.insert(schema.apiKeys).values({
|
||||
id,
|
||||
userId: user.id,
|
||||
keyHash,
|
||||
keyPrefix,
|
||||
name,
|
||||
permissions: scopedPermissions,
|
||||
expiresAt,
|
||||
});
|
||||
} catch {
|
||||
return reply.status(409).send({ error: "Failed to create API key" });
|
||||
}
|
||||
|
||||
auditLog(request.log, "API_KEY_CREATED", { userId: user.id, keyId: id, keyName: name });
|
||||
await 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({
|
||||
@@ -114,19 +112,18 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
|
||||
lastUsedAt: schema.apiKeys.lastUsedAt,
|
||||
expiresAt: schema.apiKeys.expiresAt,
|
||||
};
|
||||
const keys = hasEffectivePermission(user, "apikeys:all")
|
||||
? db.select(selectFields).from(schema.apiKeys).all()
|
||||
: db
|
||||
const keys = (await hasEffectivePermission(user, "apikeys:all"))
|
||||
? await db.select(selectFields).from(schema.apiKeys)
|
||||
: await db
|
||||
.select(selectFields)
|
||||
.from(schema.apiKeys)
|
||||
.where(eq(schema.apiKeys.userId, user.id))
|
||||
.all();
|
||||
.where(eq(schema.apiKeys.userId, user.id));
|
||||
|
||||
return reply.send({
|
||||
apiKeys: keys.map((k) => ({
|
||||
id: k.id,
|
||||
name: k.name,
|
||||
permissions: k.permissions ? JSON.parse(k.permissions) : null,
|
||||
permissions: k.permissions ?? null,
|
||||
createdAt: k.createdAt.toISOString(),
|
||||
lastUsedAt: k.lastUsedAt?.toISOString() ?? null,
|
||||
expiresAt: k.expiresAt?.toISOString() ?? null,
|
||||
@@ -144,11 +141,10 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
|
||||
const { id } = request.params;
|
||||
|
||||
// Ensure the key belongs to the requesting user
|
||||
const existing = db
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(schema.apiKeys)
|
||||
.where(and(eq(schema.apiKeys.id, id), eq(schema.apiKeys.userId, user.id)))
|
||||
.get();
|
||||
.where(and(eq(schema.apiKeys.id, id), eq(schema.apiKeys.userId, user.id)));
|
||||
|
||||
if (!existing) {
|
||||
return reply.status(404).send({
|
||||
@@ -157,9 +153,9 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id)).run();
|
||||
await db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id));
|
||||
|
||||
auditLog(request.log, "API_KEY_DELETED", { userId: user.id, keyId: id });
|
||||
await auditLog(request.log, "API_KEY_DELETED", { userId: user.id, keyId: id });
|
||||
|
||||
return reply.send({ ok: true });
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function auditLogRoutes(app: FastifyInstance): Promise<void> {
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
const user = requirePermission("audit:read")(request, reply);
|
||||
const user = await requirePermission("audit:read")(request, reply);
|
||||
if (!user) return;
|
||||
|
||||
const page = Math.max(1, parseInt(request.query.page ?? "1", 10) || 1);
|
||||
@@ -45,20 +45,18 @@ export async function auditLogRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const entries = db
|
||||
const entries = await db
|
||||
.select()
|
||||
.from(schema.auditLog)
|
||||
.where(where)
|
||||
.orderBy(desc(schema.auditLog.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all();
|
||||
.offset(offset);
|
||||
|
||||
const countResult = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
const [countResult] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.auditLog)
|
||||
.where(where)
|
||||
.get();
|
||||
.where(where);
|
||||
|
||||
return reply.send({
|
||||
entries: entries.map((e) => ({
|
||||
@@ -68,7 +66,7 @@ export async function auditLogRoutes(app: FastifyInstance): Promise<void> {
|
||||
action: e.action,
|
||||
targetType: e.targetType,
|
||||
targetId: e.targetId,
|
||||
details: e.details ? JSON.parse(e.details) : null,
|
||||
details: e.details ?? null,
|
||||
ipAddress: e.ipAddress,
|
||||
createdAt: e.createdAt.toISOString(),
|
||||
})),
|
||||
|
||||
@@ -4,11 +4,10 @@ import { db, schema } from "../db/index.js";
|
||||
|
||||
export async function configRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/v1/config/locale", async (_request, reply) => {
|
||||
const row = db
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, "defaultLocale"))
|
||||
.get();
|
||||
.where(eq(schema.settings.key, "defaultLocale"));
|
||||
return reply.send({ defaultLocale: row?.value ?? "en" });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
|
||||
app.post(
|
||||
"/api/v1/admin/features/:bundleId/install",
|
||||
async (request: FastifyRequest<{ Params: BundleIdParams }>, reply: FastifyReply) => {
|
||||
const admin = requirePermission("features:manage")(request, reply);
|
||||
const admin = await requirePermission("features:manage")(request, reply);
|
||||
if (!admin) return;
|
||||
|
||||
const { bundleId } = request.params;
|
||||
@@ -280,7 +280,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
|
||||
app.post(
|
||||
"/api/v1/admin/features/:bundleId/uninstall",
|
||||
async (request: FastifyRequest<{ Params: BundleIdParams }>, reply: FastifyReply) => {
|
||||
const admin = requirePermission("features:manage")(request, reply);
|
||||
const admin = await requirePermission("features:manage")(request, reply);
|
||||
if (!admin) return;
|
||||
|
||||
const { bundleId } = request.params;
|
||||
@@ -355,7 +355,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
|
||||
app.get(
|
||||
"/api/v1/admin/features/disk-usage",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const admin = requirePermission("features:manage")(request, reply);
|
||||
const admin = await requirePermission("features:manage")(request, reply);
|
||||
if (!admin) return;
|
||||
|
||||
const totalBytes = getDirSize(getAiDir());
|
||||
|
||||
@@ -352,15 +352,13 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
const id = randomUUID();
|
||||
|
||||
try {
|
||||
db.insert(schema.pipelines)
|
||||
.values({
|
||||
id,
|
||||
userId: user.id,
|
||||
name,
|
||||
description: description ?? null,
|
||||
steps: JSON.stringify(steps),
|
||||
})
|
||||
.run();
|
||||
await db.insert(schema.pipelines).values({
|
||||
id,
|
||||
userId: user.id,
|
||||
name,
|
||||
description: description ?? null,
|
||||
steps,
|
||||
});
|
||||
} catch {
|
||||
return reply.status(409).send({ error: "Failed to save pipeline" });
|
||||
}
|
||||
@@ -384,8 +382,8 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
if (!user) return;
|
||||
|
||||
// Admins see all pipelines; regular users see their own + legacy (no owner)
|
||||
const allRows = db.select().from(schema.pipelines).all();
|
||||
const rows = hasEffectivePermission(user, "pipelines:all")
|
||||
const allRows = await db.select().from(schema.pipelines);
|
||||
const rows = (await hasEffectivePermission(user, "pipelines:all"))
|
||||
? allRows
|
||||
: allRows.filter((row) => !row.userId || row.userId === user.id);
|
||||
|
||||
@@ -393,7 +391,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
steps: JSON.parse(row.steps),
|
||||
steps: row.steps,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
@@ -413,7 +411,10 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
|
||||
const { id } = request.params;
|
||||
|
||||
const existing = db.select().from(schema.pipelines).where(eq(schema.pipelines.id, id)).get();
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(schema.pipelines)
|
||||
.where(eq(schema.pipelines.id, id));
|
||||
|
||||
if (!existing) {
|
||||
return reply.status(404).send({ error: "Pipeline not found" });
|
||||
@@ -423,12 +424,12 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
if (
|
||||
existing.userId &&
|
||||
existing.userId !== user.id &&
|
||||
!hasEffectivePermission(user, "pipelines:all")
|
||||
!(await hasEffectivePermission(user, "pipelines:all"))
|
||||
) {
|
||||
return reply.status(403).send({ error: "Not authorized to delete this pipeline" });
|
||||
}
|
||||
|
||||
db.delete(schema.pipelines).where(eq(schema.pipelines.id, id)).run();
|
||||
await db.delete(schema.pipelines).where(eq(schema.pipelines.id, id));
|
||||
|
||||
return reply.send({ ok: true });
|
||||
},
|
||||
|
||||
@@ -47,18 +47,43 @@ const listeners = new Map<string, Set<(data: JobProgress | SingleFileProgress) =
|
||||
|
||||
// ── DB persistence helpers ──────────────────────────────────────────
|
||||
|
||||
function persistJobProgress(progress: JobProgress): void {
|
||||
/**
|
||||
* Per-job serialization queues. Fire-and-forget persist calls for the same
|
||||
* jobId must run sequentially so that the final "completed" write is never
|
||||
* overwritten by a late-arriving "processing" write. Without this, the
|
||||
* async Postgres round-trips can re-order concurrent writes.
|
||||
*/
|
||||
// TODO(phase-2): delete when progress persistence moves to BullMQ job events.
|
||||
const persistQueues = new Map<string, Promise<void>>();
|
||||
|
||||
/** Await any pending persist writes for a specific job (used by tests). */
|
||||
export async function drainPersistQueue(jobId: string): Promise<void> {
|
||||
const pending = persistQueues.get(jobId);
|
||||
if (pending) await pending;
|
||||
}
|
||||
|
||||
function enqueuePersist(jobId: string, fn: () => Promise<void>): void {
|
||||
const prev = persistQueues.get(jobId) ?? Promise.resolve();
|
||||
const next = prev.then(fn, fn); // run even if prior rejected
|
||||
persistQueues.set(jobId, next);
|
||||
// Clean up the map entry once the queue drains
|
||||
next.then(() => {
|
||||
if (persistQueues.get(jobId) === next) persistQueues.delete(jobId);
|
||||
});
|
||||
}
|
||||
|
||||
async function persistJobProgress(progress: JobProgress): Promise<void> {
|
||||
try {
|
||||
const completionRatio =
|
||||
progress.totalFiles > 0 ? progress.completedFiles / progress.totalFiles : 0;
|
||||
const existing = db
|
||||
const [existing] = await db
|
||||
.select({ id: schema.jobs.id })
|
||||
.from(schema.jobs)
|
||||
.where(eq(schema.jobs.id, progress.jobId))
|
||||
.get();
|
||||
.where(eq(schema.jobs.id, progress.jobId));
|
||||
|
||||
if (existing) {
|
||||
db.update(schema.jobs)
|
||||
await db
|
||||
.update(schema.jobs)
|
||||
.set({
|
||||
status: progress.status,
|
||||
progress: completionRatio,
|
||||
@@ -66,26 +91,25 @@ function persistJobProgress(progress: JobProgress): void {
|
||||
completedAt:
|
||||
progress.status === "completed" || progress.status === "failed" ? new Date() : null,
|
||||
})
|
||||
.where(eq(schema.jobs.id, progress.jobId))
|
||||
.run();
|
||||
.where(eq(schema.jobs.id, progress.jobId));
|
||||
} else {
|
||||
db.insert(schema.jobs)
|
||||
.values({
|
||||
id: progress.jobId,
|
||||
type: "batch",
|
||||
status: progress.status,
|
||||
progress: completionRatio,
|
||||
inputFiles: JSON.stringify({ totalFiles: progress.totalFiles }),
|
||||
error: progress.errors.length > 0 ? JSON.stringify(progress.errors) : null,
|
||||
})
|
||||
.run();
|
||||
await db.insert(schema.jobs).values({
|
||||
id: progress.jobId,
|
||||
type: "batch",
|
||||
status: progress.status,
|
||||
progress: completionRatio,
|
||||
inputFiles: { totalFiles: progress.totalFiles },
|
||||
error: progress.errors.length > 0 ? JSON.stringify(progress.errors) : null,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// DB persistence is best-effort; don't break real-time SSE
|
||||
}
|
||||
}
|
||||
|
||||
function persistSingleFileProgress(progress: Omit<SingleFileProgress, "type">): void {
|
||||
async function persistSingleFileProgress(
|
||||
progress: Omit<SingleFileProgress, "type">,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const status =
|
||||
progress.phase === "complete"
|
||||
@@ -93,33 +117,30 @@ function persistSingleFileProgress(progress: Omit<SingleFileProgress, "type">):
|
||||
: progress.phase === "failed"
|
||||
? "failed"
|
||||
: "processing";
|
||||
const existing = db
|
||||
const [existing] = await db
|
||||
.select({ id: schema.jobs.id })
|
||||
.from(schema.jobs)
|
||||
.where(eq(schema.jobs.id, progress.jobId))
|
||||
.get();
|
||||
.where(eq(schema.jobs.id, progress.jobId));
|
||||
|
||||
if (existing) {
|
||||
db.update(schema.jobs)
|
||||
await db
|
||||
.update(schema.jobs)
|
||||
.set({
|
||||
status,
|
||||
progress: progress.percent / 100,
|
||||
error: progress.error ?? null,
|
||||
completedAt: status === "completed" || status === "failed" ? new Date() : null,
|
||||
})
|
||||
.where(eq(schema.jobs.id, progress.jobId))
|
||||
.run();
|
||||
.where(eq(schema.jobs.id, progress.jobId));
|
||||
} else {
|
||||
db.insert(schema.jobs)
|
||||
.values({
|
||||
id: progress.jobId,
|
||||
type: "single",
|
||||
status,
|
||||
progress: progress.percent / 100,
|
||||
inputFiles: "[]",
|
||||
error: progress.error ?? null,
|
||||
})
|
||||
.run();
|
||||
await db.insert(schema.jobs).values({
|
||||
id: progress.jobId,
|
||||
type: "single",
|
||||
status,
|
||||
progress: progress.percent / 100,
|
||||
inputFiles: [],
|
||||
error: progress.error ?? null,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Best-effort
|
||||
@@ -130,27 +151,25 @@ function persistSingleFileProgress(progress: Omit<SingleFileProgress, "type">):
|
||||
* Mark any jobs left in "processing" or "queued" state as failed.
|
||||
* Called once at startup to recover from unclean shutdown.
|
||||
*/
|
||||
export function recoverStaleJobs(): void {
|
||||
export async function recoverStaleJobs(): Promise<void> {
|
||||
try {
|
||||
const result = db
|
||||
const result = await db
|
||||
.update(schema.jobs)
|
||||
.set({
|
||||
status: "failed",
|
||||
error: "Server restarted while job was in progress",
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.jobs.status, "processing"))
|
||||
.run();
|
||||
const result2 = db
|
||||
.where(eq(schema.jobs.status, "processing"));
|
||||
const result2 = await db
|
||||
.update(schema.jobs)
|
||||
.set({
|
||||
status: "failed",
|
||||
error: "Server restarted while job was queued",
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.jobs.status, "queued"))
|
||||
.run();
|
||||
const total = result.changes + result2.changes;
|
||||
.where(eq(schema.jobs.status, "queued"));
|
||||
const total = (result.rowCount ?? 0) + (result2.rowCount ?? 0);
|
||||
if (total > 0) {
|
||||
console.log(`Recovered ${total} stale jobs from previous run`);
|
||||
}
|
||||
@@ -166,7 +185,7 @@ export function recoverStaleJobs(): void {
|
||||
*/
|
||||
export function updateJobProgress(progress: JobProgress): void {
|
||||
jobProgressStore.set(progress.jobId, progress);
|
||||
persistJobProgress(progress);
|
||||
enqueuePersist(progress.jobId, () => persistJobProgress(progress));
|
||||
// Notify all SSE listeners (add type: "batch" so the frontend can distinguish
|
||||
// batch events from single-file events in the shared SSE stream)
|
||||
const subs = listeners.get(progress.jobId);
|
||||
@@ -187,7 +206,7 @@ export function updateJobProgress(progress: JobProgress): void {
|
||||
|
||||
export function updateSingleFileProgress(progress: Omit<SingleFileProgress, "type">): void {
|
||||
const event: SingleFileProgress = { ...progress, type: "single" };
|
||||
persistSingleFileProgress(progress);
|
||||
enqueuePersist(progress.jobId, () => persistSingleFileProgress(progress));
|
||||
|
||||
if (progress.phase === "complete" || progress.phase === "failed") {
|
||||
if (singleFileCompletions.size >= 10_000) {
|
||||
|
||||
@@ -53,18 +53,17 @@ const updateRoleSchema = z.object({
|
||||
export async function rolesRoutes(app: FastifyInstance): Promise<void> {
|
||||
// GET /api/v1/roles — List all roles (requires audit:read to view)
|
||||
app.get("/api/v1/roles", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = requirePermission("audit:read")(request, reply);
|
||||
const user = await requirePermission("audit:read")(request, reply);
|
||||
if (!user) return;
|
||||
|
||||
const roles = db.select().from(schema.roles).all();
|
||||
const userCounts = db
|
||||
const roles = await db.select().from(schema.roles);
|
||||
const userCounts = await db
|
||||
.select({
|
||||
role: schema.users.role,
|
||||
count: sql<number>`COUNT(*)`,
|
||||
count: sql<number>`COUNT(*)::int`,
|
||||
})
|
||||
.from(schema.users)
|
||||
.groupBy(schema.users.role)
|
||||
.all();
|
||||
.groupBy(schema.users.role);
|
||||
const countMap = new Map(userCounts.map((r) => [r.role, r.count]));
|
||||
|
||||
return reply.send({
|
||||
@@ -72,7 +71,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
permissions: JSON.parse(r.permissions),
|
||||
permissions: r.permissions,
|
||||
isBuiltin: r.isBuiltin,
|
||||
userCount: countMap.get(r.name) ?? 0,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
@@ -83,7 +82,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
// POST /api/v1/roles — Create custom role
|
||||
app.post("/api/v1/roles", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = requirePermission("users:manage")(request, reply);
|
||||
const user = await requirePermission("users:manage")(request, reply);
|
||||
if (!user) return;
|
||||
|
||||
const parsed = createRoleSchema.safeParse(request.body);
|
||||
@@ -102,24 +101,22 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
|
||||
.send({ error: `Invalid permissions: ${invalid.join(", ")}`, code: "VALIDATION_ERROR" });
|
||||
}
|
||||
|
||||
const existing = db.select().from(schema.roles).where(eq(schema.roles.name, name)).get();
|
||||
const [existing] = await db.select().from(schema.roles).where(eq(schema.roles.name, name));
|
||||
if (existing) {
|
||||
return reply.status(409).send({ error: "Role name already exists", code: "CONFLICT" });
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
db.insert(schema.roles)
|
||||
.values({
|
||||
id,
|
||||
name,
|
||||
description: description?.trim() ?? "",
|
||||
permissions: JSON.stringify(permissions),
|
||||
isBuiltin: false,
|
||||
createdBy: user.id,
|
||||
})
|
||||
.run();
|
||||
await db.insert(schema.roles).values({
|
||||
id,
|
||||
name,
|
||||
description: description?.trim() ?? "",
|
||||
permissions,
|
||||
isBuiltin: false,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
auditLog(request.log, "ROLE_CREATED", { adminId: user.id, roleId: id, roleName: name });
|
||||
await auditLog(request.log, "ROLE_CREATED", { adminId: user.id, roleId: id, roleName: name });
|
||||
|
||||
return reply.status(201).send({
|
||||
id,
|
||||
@@ -134,11 +131,11 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.put(
|
||||
"/api/v1/roles/:id",
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||
const user = requirePermission("users:manage")(request, reply);
|
||||
const user = await requirePermission("users:manage")(request, reply);
|
||||
if (!user) return;
|
||||
|
||||
const { id } = request.params;
|
||||
const role = db.select().from(schema.roles).where(eq(schema.roles.id, id)).get();
|
||||
const [role] = await db.select().from(schema.roles).where(eq(schema.roles.id, id));
|
||||
if (!role) {
|
||||
return reply.status(404).send({ error: "Role not found", code: "NOT_FOUND" });
|
||||
}
|
||||
@@ -159,15 +156,15 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date() };
|
||||
|
||||
if (body.name) {
|
||||
const dup = db.select().from(schema.roles).where(eq(schema.roles.name, body.name)).get();
|
||||
const [dup] = await db.select().from(schema.roles).where(eq(schema.roles.name, body.name));
|
||||
if (dup && dup.id !== id) {
|
||||
return reply.status(409).send({ error: "Role name already exists", code: "CONFLICT" });
|
||||
}
|
||||
// Update users on old role name to new name
|
||||
db.update(schema.users)
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set({ role: body.name })
|
||||
.where(eq(schema.users.role, role.name))
|
||||
.run();
|
||||
.where(eq(schema.users.role, role.name));
|
||||
updates.name = body.name;
|
||||
}
|
||||
if (body.description !== undefined) {
|
||||
@@ -181,11 +178,11 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
|
||||
code: "VALIDATION_ERROR",
|
||||
});
|
||||
}
|
||||
updates.permissions = JSON.stringify(body.permissions);
|
||||
updates.permissions = body.permissions;
|
||||
}
|
||||
|
||||
db.update(schema.roles).set(updates).where(eq(schema.roles.id, id)).run();
|
||||
auditLog(request.log, "ROLE_UPDATED", { adminId: user.id, roleId: id });
|
||||
await db.update(schema.roles).set(updates).where(eq(schema.roles.id, id));
|
||||
await auditLog(request.log, "ROLE_UPDATED", { adminId: user.id, roleId: id });
|
||||
|
||||
return reply.send({ ok: true });
|
||||
},
|
||||
@@ -195,11 +192,11 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.delete(
|
||||
"/api/v1/roles/:id",
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||
const user = requirePermission("users:manage")(request, reply);
|
||||
const user = await requirePermission("users:manage")(request, reply);
|
||||
if (!user) return;
|
||||
|
||||
const { id } = request.params;
|
||||
const role = db.select().from(schema.roles).where(eq(schema.roles.id, id)).get();
|
||||
const [role] = await db.select().from(schema.roles).where(eq(schema.roles.id, id));
|
||||
if (!role) {
|
||||
return reply.status(404).send({ error: "Role not found", code: "NOT_FOUND" });
|
||||
}
|
||||
@@ -209,13 +206,13 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
|
||||
.send({ error: "Cannot delete built-in roles", code: "VALIDATION_ERROR" });
|
||||
}
|
||||
|
||||
db.update(schema.users)
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set({ role: "user", updatedAt: new Date() })
|
||||
.where(eq(schema.users.role, role.name))
|
||||
.run();
|
||||
.where(eq(schema.users.role, role.name));
|
||||
|
||||
db.delete(schema.roles).where(eq(schema.roles.id, id)).run();
|
||||
auditLog(request.log, "ROLE_DELETED", {
|
||||
await db.delete(schema.roles).where(eq(schema.roles.id, id));
|
||||
await auditLog(request.log, "ROLE_DELETED", {
|
||||
adminId: user.id,
|
||||
roleId: id,
|
||||
roleName: role.name,
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
if (!user) return;
|
||||
|
||||
const isAdmin = user.role === "admin";
|
||||
const rows = db.select().from(schema.settings).all();
|
||||
const rows = await db.select().from(schema.settings);
|
||||
|
||||
const settings: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
@@ -40,7 +40,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
// PUT /api/v1/settings — Save settings (admin only)
|
||||
app.put("/api/v1/settings", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const admin = requirePermission("settings:write")(request, reply);
|
||||
const admin = await requirePermission("settings:write")(request, reply);
|
||||
if (!admin) return;
|
||||
|
||||
const parsed = settingsBodySchema.safeParse(request.body);
|
||||
@@ -75,20 +75,23 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
for (const { key, strValue } of entries) {
|
||||
// Upsert: insert or update on conflict
|
||||
const existing = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get();
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, key));
|
||||
|
||||
if (existing) {
|
||||
db.update(schema.settings)
|
||||
await db
|
||||
.update(schema.settings)
|
||||
.set({ value: strValue, updatedAt: now })
|
||||
.where(eq(schema.settings.key, key))
|
||||
.run();
|
||||
.where(eq(schema.settings.key, key));
|
||||
} else {
|
||||
db.insert(schema.settings).values({ key, value: strValue }).run();
|
||||
await db.insert(schema.settings).values({ key, value: strValue });
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
auditLog(request.log, "SETTINGS_UPDATED", {
|
||||
await auditLog(request.log, "SETTINGS_UPDATED", {
|
||||
adminId: admin.id,
|
||||
username: admin.username,
|
||||
keys: entries.map((e) => e.key),
|
||||
@@ -111,7 +114,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.status(403).send({ error: "Forbidden", code: "FORBIDDEN" });
|
||||
}
|
||||
|
||||
const row = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get();
|
||||
const [row] = await db.select().from(schema.settings).where(eq(schema.settings.key, key));
|
||||
|
||||
if (!row) {
|
||||
return reply.status(404).send({
|
||||
|
||||
@@ -29,18 +29,17 @@ const teamNameSchema = z.object({
|
||||
export async function teamsRoutes(app: FastifyInstance): Promise<void> {
|
||||
// GET /api/v1/teams — List all teams with member count (admin only)
|
||||
app.get("/api/v1/teams", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = requirePermission("teams:manage")(request, reply);
|
||||
const user = await requirePermission("teams:manage")(request, reply);
|
||||
if (!user) return;
|
||||
|
||||
const teams = db
|
||||
const teams = await db
|
||||
.select({
|
||||
id: schema.teams.id,
|
||||
name: schema.teams.name,
|
||||
memberCount: sql<number>`(SELECT COUNT(*) FROM users WHERE users.team = ${schema.teams.id})`,
|
||||
memberCount: sql<number>`(SELECT COUNT(*)::int FROM users WHERE users.team = ${schema.teams.id})`,
|
||||
createdAt: schema.teams.createdAt,
|
||||
})
|
||||
.from(schema.teams)
|
||||
.all();
|
||||
.from(schema.teams);
|
||||
|
||||
return reply.send({
|
||||
teams: teams.map((t) => ({
|
||||
@@ -52,7 +51,7 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
// POST /api/v1/teams — Create team (admin only)
|
||||
app.post("/api/v1/teams", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const admin = requirePermission("teams:manage")(request, reply);
|
||||
const admin = await requirePermission("teams:manage")(request, reply);
|
||||
if (!admin) return;
|
||||
|
||||
const parsed = teamNameSchema.safeParse(request.body);
|
||||
@@ -65,11 +64,10 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
|
||||
const trimmedName = parsed.data.name;
|
||||
|
||||
// Check for duplicate name (case-insensitive)
|
||||
const existing = db
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(schema.teams)
|
||||
.where(sql`LOWER(${schema.teams.name}) = LOWER(${trimmedName})`)
|
||||
.get();
|
||||
.where(sql`LOWER(${schema.teams.name}) = LOWER(${trimmedName})`);
|
||||
|
||||
if (existing) {
|
||||
return reply.status(409).send({ error: "Team name already exists", code: "CONFLICT" });
|
||||
@@ -77,7 +75,7 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const id = randomUUID();
|
||||
|
||||
db.insert(schema.teams).values({ id, name: trimmedName }).run();
|
||||
await db.insert(schema.teams).values({ id, name: trimmedName });
|
||||
|
||||
return reply.status(201).send({ id, name: trimmedName });
|
||||
});
|
||||
@@ -86,12 +84,12 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.put(
|
||||
"/api/v1/teams/:id",
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||
const admin = requirePermission("teams:manage")(request, reply);
|
||||
const admin = await requirePermission("teams:manage")(request, reply);
|
||||
if (!admin) return;
|
||||
|
||||
const { id } = request.params;
|
||||
|
||||
const team = db.select().from(schema.teams).where(eq(schema.teams.id, id)).get();
|
||||
const [team] = await db.select().from(schema.teams).where(eq(schema.teams.id, id));
|
||||
if (!team) {
|
||||
return reply.status(404).send({ error: "Team not found", code: "NOT_FOUND" });
|
||||
}
|
||||
@@ -106,19 +104,18 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
|
||||
const trimmedName = parsed.data.name;
|
||||
|
||||
// Check for duplicate name (case-insensitive), excluding current team
|
||||
const duplicate = db
|
||||
const [duplicate] = await db
|
||||
.select()
|
||||
.from(schema.teams)
|
||||
.where(
|
||||
sql`LOWER(${schema.teams.name}) = LOWER(${trimmedName}) AND ${schema.teams.id} != ${id}`,
|
||||
)
|
||||
.get();
|
||||
);
|
||||
|
||||
if (duplicate) {
|
||||
return reply.status(409).send({ error: "Team name already exists", code: "CONFLICT" });
|
||||
}
|
||||
|
||||
db.update(schema.teams).set({ name: trimmedName }).where(eq(schema.teams.id, id)).run();
|
||||
await db.update(schema.teams).set({ name: trimmedName }).where(eq(schema.teams.id, id));
|
||||
|
||||
return reply.send({ ok: true });
|
||||
},
|
||||
@@ -128,12 +125,12 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.delete(
|
||||
"/api/v1/teams/:id",
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||
const admin = requirePermission("teams:manage")(request, reply);
|
||||
const admin = await requirePermission("teams:manage")(request, reply);
|
||||
if (!admin) return;
|
||||
|
||||
const { id } = request.params;
|
||||
|
||||
const team = db.select().from(schema.teams).where(eq(schema.teams.id, id)).get();
|
||||
const [team] = await db.select().from(schema.teams).where(eq(schema.teams.id, id));
|
||||
if (!team) {
|
||||
return reply.status(404).send({ error: "Team not found", code: "NOT_FOUND" });
|
||||
}
|
||||
@@ -147,11 +144,10 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
|
||||
// Cannot delete a team that has members
|
||||
const memberCount = db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
const [memberCount] = await db
|
||||
.select({ count: sql<number>`COUNT(*)::int` })
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.team, id))
|
||||
.get();
|
||||
.where(eq(schema.users.team, id));
|
||||
|
||||
if (memberCount && memberCount.count > 0) {
|
||||
return reply.status(400).send({
|
||||
@@ -160,7 +156,7 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
db.delete(schema.teams).where(eq(schema.teams.id, id)).run();
|
||||
await db.delete(schema.teams).where(eq(schema.teams.id, id));
|
||||
|
||||
return reply.send({ ok: true });
|
||||
},
|
||||
|
||||
@@ -457,14 +457,13 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
if (fileId) {
|
||||
try {
|
||||
const { saveFile } = await import("../lib/file-storage.js");
|
||||
const parent = db
|
||||
const [parent] = await db
|
||||
.select()
|
||||
.from(schema.userFiles)
|
||||
.where(eq(schema.userFiles.id, fileId))
|
||||
.get();
|
||||
.where(eq(schema.userFiles.id, fileId));
|
||||
if (parent) {
|
||||
const newVersion = parent.version + 1;
|
||||
const parentChain: string[] = parent.toolChain ? JSON.parse(parent.toolChain) : [];
|
||||
const parentChain: string[] = parent.toolChain ?? [];
|
||||
const newToolChain = [...parentChain, config.toolId];
|
||||
const storedName = await saveFile(result.buffer, result.filename);
|
||||
// Get image dimensions from the processed output
|
||||
@@ -478,21 +477,19 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
// dimensions are non-critical
|
||||
}
|
||||
const newId = randomUUID();
|
||||
db.insert(schema.userFiles)
|
||||
.values({
|
||||
id: newId,
|
||||
userId: parent.userId,
|
||||
originalName: result.filename,
|
||||
storedName,
|
||||
mimeType: result.contentType,
|
||||
size: result.buffer.length,
|
||||
width,
|
||||
height,
|
||||
version: newVersion,
|
||||
parentId: fileId,
|
||||
toolChain: JSON.stringify(newToolChain),
|
||||
})
|
||||
.run();
|
||||
await db.insert(schema.userFiles).values({
|
||||
id: newId,
|
||||
userId: parent.userId,
|
||||
originalName: result.filename,
|
||||
storedName,
|
||||
mimeType: result.contentType,
|
||||
size: result.buffer.length,
|
||||
width,
|
||||
height,
|
||||
version: newVersion,
|
||||
parentId: fileId,
|
||||
toolChain: newToolChain,
|
||||
});
|
||||
savedFileId = newId;
|
||||
}
|
||||
} catch (saveErr) {
|
||||
|
||||
@@ -65,19 +65,17 @@ import { registerWatermarkText } from "./watermark-text.js";
|
||||
*/
|
||||
export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Read disabled tools from settings
|
||||
const disabledRow = db
|
||||
const [disabledRow] = await db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, "disabledTools"))
|
||||
.get();
|
||||
.where(eq(schema.settings.key, "disabledTools"));
|
||||
const disabledTools: string[] = disabledRow ? JSON.parse(disabledRow.value) : [];
|
||||
|
||||
// Read experimental flag
|
||||
const expRow = db
|
||||
const [expRow] = await db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, "enableExperimentalTools"))
|
||||
.get();
|
||||
.where(eq(schema.settings.key, "enableExperimentalTools"));
|
||||
const enableExperimental = expRow?.value === "true";
|
||||
|
||||
// Get experimental tool IDs from shared constants
|
||||
|
||||
+119
-116
@@ -16,7 +16,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { env } from "../config.js";
|
||||
import { db, schema, sqlite } from "../db/index.js";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { auditLog } from "../lib/audit.js";
|
||||
import {
|
||||
deleteStoredFile,
|
||||
@@ -76,7 +76,7 @@ function serializeFile(row: typeof schema.userFiles.$inferSelect) {
|
||||
height: row.height,
|
||||
version: row.version,
|
||||
parentId: row.parentId,
|
||||
toolChain: row.toolChain ? JSON.parse(row.toolChain) : [],
|
||||
toolChain: row.toolChain ?? [],
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -85,14 +85,13 @@ function serializeFile(row: typeof schema.userFiles.$inferSelect) {
|
||||
* Check whether a user has exceeded their storage quota.
|
||||
* Returns the total bytes used, or throws if the quota is exceeded.
|
||||
*/
|
||||
function checkStorageQuota(userId: string | null): void {
|
||||
async function checkStorageQuota(userId: string | null): Promise<void> {
|
||||
if (!userId || env.MAX_STORAGE_PER_USER_MB <= 0) return;
|
||||
|
||||
const result = db
|
||||
const [result] = await db
|
||||
.select({ total: sql<number>`coalesce(sum(${schema.userFiles.size}), 0)` })
|
||||
.from(schema.userFiles)
|
||||
.where(eq(schema.userFiles.userId, userId))
|
||||
.get();
|
||||
.where(eq(schema.userFiles.userId, userId));
|
||||
|
||||
const usedBytes = result?.total ?? 0;
|
||||
const limitBytes = env.MAX_STORAGE_PER_USER_MB * 1024 * 1024;
|
||||
@@ -145,7 +144,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
const conditions = [latestCondition];
|
||||
|
||||
// Users without files:all only see their own files
|
||||
if (!hasEffectivePermission(user, "files:all")) {
|
||||
if (!(await hasEffectivePermission(user, "files:all"))) {
|
||||
conditions.push(eq(schema.userFiles.userId, user.id));
|
||||
}
|
||||
|
||||
@@ -154,21 +153,19 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
conditions.push(like(schema.userFiles.originalName, `%${escaped}%`));
|
||||
}
|
||||
|
||||
const rows = db
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.userFiles)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(schema.userFiles.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all();
|
||||
.offset(offset);
|
||||
|
||||
// Total count (for pagination)
|
||||
const countResult = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
const [countResult] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.userFiles)
|
||||
.where(and(...conditions))
|
||||
.get();
|
||||
.where(and(...conditions));
|
||||
|
||||
return reply.send({
|
||||
files: rows.map(serializeFile),
|
||||
@@ -194,7 +191,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
// Enforce per-user storage quota before accepting uploads
|
||||
try {
|
||||
checkStorageQuota(userId);
|
||||
await checkStorageQuota(userId);
|
||||
} catch (err) {
|
||||
const statusCode = (err as Error & { statusCode?: number }).statusCode ?? 413;
|
||||
return reply.status(statusCode).send({ error: (err as Error).message });
|
||||
@@ -236,26 +233,24 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Create DB record
|
||||
const id = randomUUID();
|
||||
try {
|
||||
db.insert(schema.userFiles)
|
||||
.values({
|
||||
id,
|
||||
userId,
|
||||
originalName: safeName,
|
||||
storedName,
|
||||
mimeType,
|
||||
size: safeBuffer.length,
|
||||
width: validation.width,
|
||||
height: validation.height,
|
||||
version: 1,
|
||||
parentId: null,
|
||||
toolChain: null,
|
||||
})
|
||||
.run();
|
||||
await db.insert(schema.userFiles).values({
|
||||
id,
|
||||
userId,
|
||||
originalName: safeName,
|
||||
storedName,
|
||||
mimeType,
|
||||
size: safeBuffer.length,
|
||||
width: validation.width,
|
||||
height: validation.height,
|
||||
version: 1,
|
||||
parentId: null,
|
||||
toolChain: null,
|
||||
});
|
||||
} catch {
|
||||
return reply.status(409).send({ error: "Failed to save file record" });
|
||||
}
|
||||
|
||||
const row = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
|
||||
const [row] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id));
|
||||
|
||||
if (row) created.push(serializeFile(row));
|
||||
}
|
||||
@@ -264,7 +259,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.status(400).send({ error: "No valid files uploaded" });
|
||||
}
|
||||
|
||||
auditLog(request.log, "FILE_UPLOADED", {
|
||||
await auditLog(request.log, "FILE_UPLOADED", {
|
||||
userId,
|
||||
count: created.length,
|
||||
files: created.map((f) => f.originalName),
|
||||
@@ -288,15 +283,22 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const { id } = request.params;
|
||||
|
||||
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
|
||||
const [file] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id));
|
||||
|
||||
if (!file || (file.userId !== user.id && !hasEffectivePermission(user, "files:all"))) {
|
||||
if (
|
||||
!file ||
|
||||
(file.userId !== user.id && !(await hasEffectivePermission(user, "files:all")))
|
||||
) {
|
||||
return reply.status(404).send({ error: "File not found" });
|
||||
}
|
||||
|
||||
// Walk the full version chain using a recursive CTE.
|
||||
// First find the root ancestor, then collect all descendants.
|
||||
interface ChainRow {
|
||||
//
|
||||
// node-postgres returns:
|
||||
// tool_chain as parsed jsonb (string[] | null) - do NOT JSON.parse
|
||||
// created_at as Date (timestamptz) - use directly, no * 1000
|
||||
type ChainRow = {
|
||||
id: string;
|
||||
original_name: string;
|
||||
mime_type: string;
|
||||
@@ -305,35 +307,34 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
height: number | null;
|
||||
version: number;
|
||||
parent_id: string | null;
|
||||
tool_chain: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
tool_chain: string[] | null;
|
||||
created_at: Date;
|
||||
};
|
||||
|
||||
const chainRows = sqlite
|
||||
.prepare(`
|
||||
WITH RECURSIVE
|
||||
ancestors(id, parent_id) AS (
|
||||
SELECT id, parent_id FROM user_files WHERE id = ?
|
||||
UNION ALL
|
||||
SELECT uf.id, uf.parent_id FROM user_files uf
|
||||
INNER JOIN ancestors a ON uf.id = a.parent_id
|
||||
),
|
||||
chain(id, original_name, mime_type, size, width, height,
|
||||
version, parent_id, tool_chain, created_at) AS (
|
||||
SELECT f.id, f.original_name, f.mime_type, f.size, f.width, f.height,
|
||||
f.version, f.parent_id, f.tool_chain, f.created_at
|
||||
FROM user_files f
|
||||
WHERE f.id = (SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT 1)
|
||||
UNION ALL
|
||||
SELECT child.id, child.original_name, child.mime_type, child.size,
|
||||
child.width, child.height, child.version, child.parent_id,
|
||||
child.tool_chain, child.created_at
|
||||
FROM user_files child
|
||||
INNER JOIN chain c ON child.parent_id = c.id
|
||||
)
|
||||
SELECT * FROM chain ORDER BY version ASC
|
||||
`)
|
||||
.all(id) as ChainRow[];
|
||||
const cteResult = await db.execute<ChainRow>(sql`
|
||||
WITH RECURSIVE
|
||||
ancestors(id, parent_id) AS (
|
||||
SELECT id, parent_id FROM user_files WHERE id = ${id}
|
||||
UNION ALL
|
||||
SELECT uf.id, uf.parent_id FROM user_files uf
|
||||
INNER JOIN ancestors a ON uf.id = a.parent_id
|
||||
),
|
||||
chain(id, original_name, mime_type, size, width, height,
|
||||
version, parent_id, tool_chain, created_at) AS (
|
||||
SELECT f.id, f.original_name, f.mime_type, f.size, f.width, f.height,
|
||||
f.version, f.parent_id, f.tool_chain, f.created_at
|
||||
FROM user_files f
|
||||
WHERE f.id = (SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT 1)
|
||||
UNION ALL
|
||||
SELECT child.id, child.original_name, child.mime_type, child.size,
|
||||
child.width, child.height, child.version, child.parent_id,
|
||||
child.tool_chain, child.created_at
|
||||
FROM user_files child
|
||||
INNER JOIN chain c ON child.parent_id = c.id
|
||||
)
|
||||
SELECT * FROM chain ORDER BY version ASC
|
||||
`);
|
||||
const chainRows = cteResult.rows;
|
||||
|
||||
const versions = chainRows.map((r) => ({
|
||||
id: r.id,
|
||||
@@ -344,8 +345,8 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
height: r.height,
|
||||
version: r.version,
|
||||
parentId: r.parent_id,
|
||||
toolChain: r.tool_chain ? JSON.parse(r.tool_chain) : [],
|
||||
createdAt: new Date(r.created_at * 1000).toISOString(),
|
||||
toolChain: r.tool_chain ?? [],
|
||||
createdAt: new Date(r.created_at).toISOString(),
|
||||
}));
|
||||
|
||||
return reply.send({
|
||||
@@ -368,9 +369,12 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const { id } = request.params;
|
||||
|
||||
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
|
||||
const [file] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id));
|
||||
|
||||
if (!file || (file.userId !== user.id && !hasEffectivePermission(user, "files:all"))) {
|
||||
if (
|
||||
!file ||
|
||||
(file.userId !== user.id && !(await hasEffectivePermission(user, "files:all")))
|
||||
) {
|
||||
return reply.status(404).send({ error: "File not found" });
|
||||
}
|
||||
|
||||
@@ -404,9 +408,12 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const { id } = request.params;
|
||||
|
||||
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
|
||||
const [file] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id));
|
||||
|
||||
if (!file || (file.userId !== user.id && !hasEffectivePermission(user, "files:all"))) {
|
||||
if (
|
||||
!file ||
|
||||
(file.userId !== user.id && !(await hasEffectivePermission(user, "files:all")))
|
||||
) {
|
||||
return reply.status(404).send({ error: "File not found" });
|
||||
}
|
||||
|
||||
@@ -476,49 +483,48 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
let deletedCount = 0;
|
||||
|
||||
interface DeleteChainRow {
|
||||
type DeleteChainRow = {
|
||||
id: string;
|
||||
stored_name: string;
|
||||
}
|
||||
};
|
||||
|
||||
for (const id of ids) {
|
||||
// Ownership check: non-admin users can only delete their own files
|
||||
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
|
||||
if (!file || (file.userId !== user.id && !hasEffectivePermission(user, "files:all")))
|
||||
const [file] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id));
|
||||
if (!file || (file.userId !== user.id && !(await hasEffectivePermission(user, "files:all"))))
|
||||
continue;
|
||||
// Collect all files in the chain using a recursive CTE
|
||||
const chainRows = sqlite
|
||||
.prepare(`
|
||||
WITH RECURSIVE chain(id, stored_name) AS (
|
||||
SELECT f.id, f.stored_name
|
||||
FROM user_files f
|
||||
WHERE f.id = (
|
||||
WITH RECURSIVE ancestors(id, parent_id) AS (
|
||||
SELECT id, parent_id FROM user_files WHERE id = ?
|
||||
UNION ALL
|
||||
SELECT uf.id, uf.parent_id FROM user_files uf
|
||||
INNER JOIN ancestors a ON uf.id = a.parent_id
|
||||
)
|
||||
SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT 1
|
||||
)
|
||||
const cteResult = await db.execute<DeleteChainRow>(sql`
|
||||
WITH RECURSIVE chain(id, stored_name) AS (
|
||||
SELECT f.id, f.stored_name
|
||||
FROM user_files f
|
||||
WHERE f.id = (
|
||||
WITH RECURSIVE ancestors(id, parent_id) AS (
|
||||
SELECT id, parent_id FROM user_files WHERE id = ${id}
|
||||
UNION ALL
|
||||
SELECT child.id, child.stored_name
|
||||
FROM user_files child
|
||||
INNER JOIN chain c ON child.parent_id = c.id
|
||||
SELECT uf.id, uf.parent_id FROM user_files uf
|
||||
INNER JOIN ancestors a ON uf.id = a.parent_id
|
||||
)
|
||||
SELECT id, stored_name FROM chain
|
||||
`)
|
||||
.all(id) as DeleteChainRow[];
|
||||
SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT 1
|
||||
)
|
||||
UNION ALL
|
||||
SELECT child.id, child.stored_name
|
||||
FROM user_files child
|
||||
INNER JOIN chain c ON child.parent_id = c.id
|
||||
)
|
||||
SELECT id, stored_name FROM chain
|
||||
`);
|
||||
const chainRows = cteResult.rows;
|
||||
|
||||
for (const row of chainRows) {
|
||||
await deleteStoredFile(row.stored_name);
|
||||
await deleteThumbnail(row.stored_name);
|
||||
db.delete(schema.userFiles).where(eq(schema.userFiles.id, row.id)).run();
|
||||
await db.delete(schema.userFiles).where(eq(schema.userFiles.id, row.id));
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
auditLog(request.log, "FILE_DELETED", { userId: user.id, count: deletedCount, ids });
|
||||
await auditLog(request.log, "FILE_DELETED", { userId: user.id, count: deletedCount, ids });
|
||||
|
||||
return reply.send({ deleted: deletedCount });
|
||||
});
|
||||
@@ -538,7 +544,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
// Enforce per-user storage quota before saving results
|
||||
try {
|
||||
checkStorageQuota(userId);
|
||||
await checkStorageQuota(userId);
|
||||
} catch (err) {
|
||||
const statusCode = (err as Error & { statusCode?: number }).statusCode ?? 413;
|
||||
return reply.status(statusCode).send({ error: (err as Error).message });
|
||||
@@ -582,11 +588,10 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
|
||||
// Look up the parent to compute the next version and carry forward the tool chain
|
||||
const parent = db
|
||||
const [parent] = await db
|
||||
.select()
|
||||
.from(schema.userFiles)
|
||||
.where(eq(schema.userFiles.id, parentId))
|
||||
.get();
|
||||
.where(eq(schema.userFiles.id, parentId));
|
||||
|
||||
if (!parent) {
|
||||
return reply.status(404).send({ error: "Parent file not found" });
|
||||
@@ -595,7 +600,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
const nextVersion = parent.version + 1;
|
||||
|
||||
// Build the tool chain: append the new toolId to the parent's chain
|
||||
const existingChain: string[] = parent.toolChain ? JSON.parse(parent.toolChain) : [];
|
||||
const existingChain: string[] = parent.toolChain ?? [];
|
||||
const newChain = toolId ? [...existingChain, toolId] : existingChain;
|
||||
|
||||
// Determine the original filename (preserve parent's name, update extension)
|
||||
@@ -614,26 +619,24 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Create DB record
|
||||
const id = randomUUID();
|
||||
try {
|
||||
db.insert(schema.userFiles)
|
||||
.values({
|
||||
id,
|
||||
userId,
|
||||
originalName: resultName,
|
||||
storedName,
|
||||
mimeType,
|
||||
size: safeResultBuffer.length,
|
||||
width: validation.width,
|
||||
height: validation.height,
|
||||
version: nextVersion,
|
||||
parentId,
|
||||
toolChain: JSON.stringify(newChain),
|
||||
})
|
||||
.run();
|
||||
await db.insert(schema.userFiles).values({
|
||||
id,
|
||||
userId,
|
||||
originalName: resultName,
|
||||
storedName,
|
||||
mimeType,
|
||||
size: safeResultBuffer.length,
|
||||
width: validation.width,
|
||||
height: validation.height,
|
||||
version: nextVersion,
|
||||
parentId,
|
||||
toolChain: newChain,
|
||||
});
|
||||
} catch {
|
||||
return reply.status(409).send({ error: "Failed to save result record" });
|
||||
}
|
||||
|
||||
const row = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
|
||||
const [row] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id));
|
||||
|
||||
return reply.status(201).send({ file: row ? serializeFile(row) : null });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user