2026-04-22 19:00:15 +08:00
import { randomUUID } from "node:crypto" ;
2026-06-14 12:01:29 +08:00
import { statfs } from "node:fs/promises" ;
2026-05-13 19:01:30 +08:00
import cookie from "@fastify/cookie" ;
2026-04-14 22:01:04 +08:00
import cors from "@fastify/cors" ;
import rateLimit from "@fastify/rate-limit" ;
2026-06-15 12:53:06 +08:00
import { trace } from "@opentelemetry/api" ;
2026-04-30 18:49:52 +08:00
import { getDispatcherStatus , initDispatcher , isGpuAvailable } from "@snapotter/ai" ;
2026-07-13 14:23:16 +08:00
import { ANALYTICS_EVENTS , APP_VERSION , SafeError } from "@snapotter/shared" ;
2026-06-13 10:15:23 +08:00
import { eq , sql } from "drizzle-orm" ;
2026-03-25 09:24:37 +08:00
import Fastify from "fastify" ;
import { env } from "./config.js" ;
2026-06-13 10:15:23 +08:00
import { closeDb , db , schema } from "./db/index.js" ;
2026-03-22 02:51:57 +08:00
import { runMigrations } from "./db/migrate.js" ;
2026-06-13 10:17:13 +08:00
import { startCancelListener , stopCancelListener } from "./jobs/cancel.js" ;
2026-07-10 21:41:49 +08:00
import { assertRedisCompatible , closeRedis , pingRedis } from "./jobs/connection.js" ;
2026-06-21 23:22:59 +08:00
import { closeFlowProducer , closeQueueEvents , warmQueueEvents } from "./jobs/enqueue.js" ;
2026-06-14 12:01:29 +08:00
import { closeQueues , perPoolHealth , queueCounts } from "./jobs/queues.js" ;
2026-06-13 10:17:13 +08:00
import { enqueueSystemJob , SYSTEM_JOBS , scheduleSystemJobs } from "./jobs/system-jobs.js" ;
import { closeWorkers , startWorkers } from "./jobs/worker.js" ;
2026-07-13 14:23:16 +08:00
import { initAnalytics , shutdownAnalytics , trackEvent } from "./lib/analytics.js" ;
2026-06-13 10:17:13 +08:00
import { shouldRunStartupCleanup } from "./lib/cleanup.js" ;
2026-05-05 17:16:19 +08:00
import { buildCsp } from "./lib/csp.js" ;
2026-07-10 21:41:49 +08:00
import { reportError } from "./lib/error-report.js" ;
2026-06-20 00:53:11 +08:00
import { stripInternalPaths } from "./lib/errors.js" ;
2026-04-18 02:34:21 +08:00
import { ensureAiDirs , recoverInterruptedInstalls } from "./lib/feature-status.js" ;
2026-06-15 12:53:06 +08:00
import { logger } from "./lib/logger.js" ;
2026-06-14 12:06:06 +08:00
import { requestDuration } from "./lib/metrics.js" ;
2026-06-13 22:32:16 +08:00
import { getSettingString } from "./lib/settings-helpers.js" ;
2026-06-22 16:58:59 +08:00
import { assertStorageWritable } from "./lib/storage-writable.js" ;
2026-07-13 14:23:16 +08:00
import { gatherSystemProperties } from "./lib/system-info.js" ;
2026-04-22 18:10:04 +08:00
import { requirePermission } from "./permissions.js" ;
2026-05-16 12:36:06 +08:00
import {
authMiddleware ,
authRoutes ,
ensureAnonymousUser ,
2026-06-13 10:15:23 +08:00
ensureBuiltinRoles ,
2026-05-16 12:36:06 +08:00
ensureDefaultAdmin ,
2026-06-28 18:57:53 +08:00
ensureDefaultTeam ,
2026-06-15 12:53:06 +08:00
getAuthUser ,
2026-05-16 12:36:06 +08:00
} from "./plugins/auth.js" ;
2026-06-13 22:49:00 +08:00
import { registerMfa } from "./plugins/mfa.js" ;
2026-05-13 19:01:30 +08:00
import { oidcRoutes } from "./plugins/oidc.js" ;
2026-06-13 22:27:56 +08:00
import { registerSaml } from "./plugins/saml.js" ;
2026-03-25 09:24:37 +08:00
import { registerStatic } from "./plugins/static.js" ;
import { registerUpload } from "./plugins/upload.js" ;
2026-06-13 10:17:13 +08:00
import { adminOpsRoutes } from "./routes/admin-ops.js" ;
2026-04-22 19:03:23 +08:00
import { analyticsRoutes } from "./routes/analytics.js" ;
2026-03-25 09:24:37 +08:00
import { apiKeyRoutes } from "./routes/api-keys.js" ;
2026-04-22 18:10:04 +08:00
import { auditLogRoutes } from "./routes/audit-log.js" ;
2026-03-22 04:03:38 +08:00
import { registerBatchRoutes } from "./routes/batch.js" ;
2026-05-15 17:02:49 +08:00
import { configRoutes } from "./routes/config.js" ;
2026-03-27 12:28:35 +08:00
import { docsRoutes } from "./routes/docs.js" ;
2026-06-13 22:27:56 +08:00
import { registerEnterpriseRoutes } from "./routes/enterprise/index.js" ;
2026-04-18 02:34:21 +08:00
import { registerFeatureRoutes } from "./routes/features.js" ;
2026-06-29 18:16:33 +08:00
import { feedbackRoutes } from "./routes/feedback.js" ;
2026-05-11 21:24:56 +08:00
import { registerFetchUrlsRoute } from "./routes/fetch-urls.js" ;
2026-06-15 11:33:45 +08:00
import { filePreviewRoutes } from "./routes/file-preview.js" ;
2026-03-25 09:24:37 +08:00
import { fileRoutes } from "./routes/files.js" ;
2026-05-08 16:05:17 +08:00
import { registerMemeTemplates } from "./routes/meme-templates.js" ;
2026-03-22 04:41:51 +08:00
import { registerPipelineRoutes } from "./routes/pipeline.js" ;
2026-06-28 18:57:53 +08:00
import { preferencesRoutes } from "./routes/preferences.js" ;
2026-06-13 10:17:13 +08:00
import { registerProgressRoutes } from "./routes/progress.js" ;
2026-04-22 18:10:04 +08:00
import { rolesRoutes } from "./routes/roles.js" ;
2026-03-22 19:28:57 +08:00
import { settingsRoutes } from "./routes/settings.js" ;
2026-03-25 09:24:37 +08:00
import { teamsRoutes } from "./routes/teams.js" ;
import { registerToolRoutes } from "./routes/tools/index.js" ;
2026-03-25 01:16:52 +08:00
import { userFileRoutes } from "./routes/user-files.js" ;
2026-06-15 12:53:06 +08:00
import { shutdownTracing } from "./tracing.js" ;
2026-03-22 02:51:57 +08:00
// Run before anything else
2026-06-13 10:15:23 +08:00
try {
await runMigrations ();
} catch ( err ) {
const safeUrl = env . DATABASE_URL . replace ( /:\/\/[^@]*@/ , "://***@" );
console . error (
`FATAL: Cannot connect to Postgres at ${ safeUrl } . Is the database running? (docker compose up, or set DATABASE_URL)` ,
);
console . error ( err );
process . exit ( 1 );
}
2026-03-22 02:51:57 +08:00
console . log ( "Database initialized" );
2026-03-22 02:46:34 +08:00
2026-06-13 10:17:13 +08:00
// Verify Redis is reachable (required for BullMQ job queues)
try {
await pingRedis ();
} catch ( err ) {
const safeUrl = env . REDIS_URL . replace ( /:\/\/[^@]*@/ , "://***@" );
console . error (
`FATAL: Cannot connect to Redis at ${ safeUrl } . Is Redis running? (docker compose up, or set REDIS_URL)` ,
);
console . error ( err );
process . exit ( 1 );
}
2026-07-10 21:41:49 +08:00
// BullMQ v5 requires Redis >= 6.2. Fail fast with an actionable message instead
// of crash-looping later on ReplyErrors from an incompatible server.
try {
await assertRedisCompatible ();
} catch ( err ) {
const detected = err instanceof SafeError && err . code ? ` (detected ${ err . code } )` : "" ;
console . error ( `FATAL: ${ ( err as Error ). message }${ detected } ` );
process . exit ( 1 );
}
2026-06-13 10:17:13 +08:00
console . log ( "Redis connected" );
2026-06-22 16:58:59 +08:00
// Verify the local storage directories are writable before serving. A non-root
// container launched against a volume it cannot write (TrueNAS, Kubernetes
// runAsUser / OpenShift, or a bind mount owned by another user) would otherwise
// boot "healthy" and fail with a cryptic EACCES on the first file operation.
try {
await assertStorageWritable ();
console . log ( "Storage directories writable" );
} catch ( err ) {
console . error ( `FATAL: ${ ( err as Error ). message } ` );
process . exit ( 1 );
}
2026-07-04 23:15:39 +08:00
// Auto-import / detect a 1.x SQLite database on boot (before default user creation).
// The orchestrator owns detection (explicit path, "off" sentinel, or DATA_DIR probe),
// the four boot states, and the persisted marker. See db/sqlite-import.ts.
{
const { runBootImport } = await import ( "./db/sqlite-import.js" );
await runBootImport ({
SQLITE_MIGRATE_PATH : env.SQLITE_MIGRATE_PATH ,
DATA_DIR : env.DATA_DIR ,
FILES_STORAGE_PATH : env.FILES_STORAGE_PATH ,
});
2026-06-13 10:15:23 +08:00
}
// Seed built-in roles (admin, editor, user) that legacy SQLite migrations
// inserted via data statements. The pg baseline is DDL-only, so roles are
// seeded here at boot time. onConflictDoNothing makes this idempotent.
await ensureBuiltinRoles ();
2026-06-28 18:57:53 +08:00
await ensureDefaultTeam ();
2026-06-13 10:15:23 +08:00
2026-04-23 14:45:04 +08:00
if ( env . AUTH_ENABLED ) {
await ensureDefaultAdmin ();
2026-05-16 12:36:06 +08:00
} else {
2026-06-13 10:15:23 +08:00
await ensureAnonymousUser ();
2026-04-23 14:45:04 +08:00
}
2026-03-22 02:55:10 +08:00
2026-06-13 10:15:23 +08:00
async function ensureInstanceId() {
const [ existing ] = await db
2026-04-22 19:00:15 +08:00
. select ()
. from ( schema . settings )
2026-06-13 10:15:23 +08:00
. where ( eq ( schema . settings . key , "instance_id" ));
2026-04-22 19:00:15 +08:00
if ( ! existing ) {
2026-06-13 10:15:23 +08:00
await db . insert ( schema . settings ). values ({ key : "instance_id" , value : randomUUID () });
2026-04-22 19:00:15 +08:00
}
}
2026-06-13 10:15:23 +08:00
await ensureInstanceId ();
2026-04-25 22:39:18 +08:00
2026-06-13 10:15:23 +08:00
async function ensureDefaultSettings() {
2026-04-25 22:39:18 +08:00
const defaults : Record < string , string > = {
defaultTheme : env.DEFAULT_THEME ,
defaultLocale : env.DEFAULT_LOCALE ,
2026-05-16 11:37:28 +08:00
defaultToolView : env.DEFAULT_TOOL_VIEW ,
2026-04-25 22:39:18 +08:00
};
for ( const [ key , value ] of Object . entries ( defaults )) {
2026-06-13 10:15:23 +08:00
const [ existing ] = await db . select (). from ( schema . settings ). where ( eq ( schema . settings . key , key ));
2026-04-25 22:39:18 +08:00
if ( ! existing ) {
2026-06-13 10:15:23 +08:00
await db . insert ( schema . settings ). values ({ key , value });
2026-04-25 22:39:18 +08:00
}
}
}
2026-06-13 10:15:23 +08:00
await ensureDefaultSettings ();
2026-05-13 19:01:30 +08:00
if ( ! env . COOKIE_SECRET ) {
2026-06-13 10:15:23 +08:00
const [ existing ] = await db
2026-05-13 19:01:30 +08:00
. select ()
. from ( schema . settings )
2026-06-13 10:15:23 +08:00
. where ( eq ( schema . settings . key , "cookie_secret" ));
2026-05-13 19:01:30 +08:00
if ( existing ) {
( env as Record < string , unknown >). COOKIE_SECRET = existing . value ;
} else {
const generated = randomUUID () + randomUUID ();
2026-06-13 10:15:23 +08:00
await db . insert ( schema . settings ). values ({ key : "cookie_secret" , value : generated });
2026-05-13 19:01:30 +08:00
( env as Record < string , unknown >). COOKIE_SECRET = generated ;
}
}
2026-04-23 21:45:10 +08:00
await initAnalytics ();
2026-06-28 18:57:53 +08:00
const { primeAnalyticsGate } = await import ( "./lib/analytics-gate.js" );
await primeAnalyticsGate ();
2026-07-13 14:23:16 +08:00
// ignoreSampleRate: this once-per-boot census must not be thinned by the
// volume sample rate that exists to throttle high-frequency usage events.
await trackEvent ( ANALYTICS_EVENTS . INSTANCE_STARTED , { ... gatherSystemProperties () }, undefined , {
ignoreSampleRate : true ,
});
2026-04-22 19:00:15 +08:00
2026-06-06 20:17:49 +08:00
// Enterprise features (license-gated)
let enterpriseLicense : { org : string ; plan : string } | null = null ;
try {
const { initEnterprise } = await import ( "@snapotter/enterprise" );
const result = initEnterprise ( env . SNAPOTTER_LICENSE_KEY || undefined );
if ( result . valid && result . license ) {
enterpriseLicense = result . license ;
} else if ( env . SNAPOTTER_LICENSE_KEY ) {
console . warn ( "[WARN] Invalid or expired enterprise license key" );
}
} catch {
// Enterprise package not available
}
2026-06-24 17:27:59 +08:00
// S3 storage is a licensed feature (s3_storage). packages/enterprise now ships in
// every image, so STORAGE_MODE=s3 would otherwise function without any license check.
// Enforce the gate at boot so an unlicensed deploy fails fast rather than silently
// writing data to S3 it isn't entitled to use.
if ( env . STORAGE_MODE === "s3" ) {
let s3Licensed = false ;
try {
const { isFeatureEnabled } = await import ( "@snapotter/enterprise" );
s3Licensed = isFeatureEnabled ( "s3_storage" );
} catch {
s3Licensed = false ;
}
if ( ! s3Licensed ) {
console . error (
"[FATAL] STORAGE_MODE=s3 requires a license that includes the s3_storage feature. " +
"Set a valid SNAPOTTER_LICENSE_KEY (team or enterprise plan) or use STORAGE_MODE=local." ,
);
process . exit ( 1 );
}
}
2026-06-13 10:17:13 +08:00
// Start the cooperative cancellation listener (Redis pub/sub)
await startCancelListener ();
2026-06-28 18:57:53 +08:00
const { startAnalyticsGateListener } = await import ( "./lib/analytics-gate.js" );
await startAnalyticsGateListener ();
2026-03-29 17:23:41 +08:00
2026-06-22 23:25:22 +08:00
// Set up AI feature directories and recover from interrupted installs. Both are
// best-effort and must never block boot: ensureAiDirs swallows its own errors,
// and recovery (clearing stale locks and partial downloads) is wrapped here so a
// malformed installed.json or unreadable models dir degrades to a warning rather
// than a fatal startup crash (Sentry NODE-12).
2026-04-18 02:34:21 +08:00
ensureAiDirs ();
2026-06-22 23:25:22 +08:00
try {
recoverInterruptedInstalls ();
} catch ( err ) {
console . warn (
`[feature-status] Interrupted-install recovery failed (continuing): ${ ( err as Error ). message } ` ,
);
}
2026-04-18 02:34:21 +08:00
2026-06-13 16:25:58 +08:00
function parseTrustProxy ( value : string ) : boolean | number | string {
if ( value === "true" ) return true ;
if ( value === "false" ) return false ;
const asNum = Number ( value );
if ( ! Number . isNaN ( asNum )) return asNum ;
return value ; // CIDR list
}
2026-03-22 02:46:34 +08:00
const app = Fastify ({
2026-06-13 17:04:27 +08:00
genReqId : ( req ) => ( req . headers [ "x-request-id" ] as string ) ?? randomUUID (),
2026-06-15 12:53:06 +08:00
loggerInstance : logger ,
2026-04-20 21:50:17 +08:00
bodyLimit : env.MAX_UPLOAD_SIZE_MB > 0 ? env . MAX_UPLOAD_SIZE_MB * 1024 * 1024 : 1073741824 ,
2026-06-13 16:25:58 +08:00
trustProxy : parseTrustProxy ( env . TRUST_PROXY ),
2026-04-21 10:19:08 +08:00
routerOptions : { maxParamLength : 500 },
2026-06-22 23:25:22 +08:00
// Self-hosted boots can be slow: venv bootstrap, AI-model verification, and
// SPA static serving all touch disk, and some deployments sit on slow or
// contended volumes. avvio's default 10s pluginTimeout fataled boot at
// '@fastify/static' on those hosts (Sentry NODE-14). 60s tolerates slow
// startup I/O while still surfacing a genuinely deadlocked plugin.
pluginTimeout : 60_000 ,
2026-03-22 02:46:34 +08:00
});
2026-05-13 14:19:51 +08:00
// Image processing (especially AI batch) can run for tens of minutes.
// Node.js defaults to a 5-minute requestTimeout which kills long-running
// connections. Set a generous default; per-route overrides disable it entirely.
app . server . requestTimeout = 30 * 60 * 1000 ;
app . server . headersTimeout = 60 * 1000 ;
2026-05-01 18:11:49 +08:00
app . removeContentTypeParser ( "application/json" );
app . addContentTypeParser ( "application/json" , { parseAs : "string" }, ( _request , body , done ) => {
try {
const str = typeof body === "string" ? body : ( body as Buffer ). toString ();
done ( null , str . length > 0 ? JSON . parse ( str ) : {});
2026-06-07 21:54:27 +08:00
} catch {
const parseErr = new Error ( "Malformed JSON in request body" ) as Error & { statusCode : number };
parseErr . statusCode = 400 ;
done ( parseErr , undefined );
2026-05-01 18:11:49 +08:00
}
});
2026-04-17 14:15:27 +08:00
app . setErrorHandler (( error : Error & { statusCode? : number }, request , reply ) => {
const statusCode = error . statusCode ?? 500 ;
2026-06-07 10:43:52 +08:00
if ( statusCode === 429 ) {
request . log . warn ({ url : request.url , method : request.method }, "Rate limit exceeded" );
} else if ( statusCode >= 500 ) {
request . log . error (
{ err : error , url : request.url , method : request.method },
"Unhandled request error" ,
);
2026-07-10 21:41:49 +08:00
void reportError ( error , {
source : "http" ,
route : request.routeOptions?.url ?? undefined ,
method : request.method ,
statusCode ,
});
2026-06-07 10:43:52 +08:00
} else {
request . log . warn ({ err : error , url : request.url , method : request.method }, "Request error" );
2026-05-01 19:01:05 +08:00
}
2026-04-17 14:15:27 +08:00
reply . status ( statusCode ). send ({
2026-06-20 00:53:11 +08:00
error : statusCode >= 500 ? "Internal server error" : stripInternalPaths ( error . message ),
...( statusCode < 500 && { details : stripInternalPaths ( error . message ) }),
2026-04-17 14:15:27 +08:00
});
});
2026-03-22 02:46:34 +08:00
// Plugins
2026-03-23 11:46:45 +08:00
await app . register ( cors , {
origin : env.CORS_ORIGIN
? env . CORS_ORIGIN . split ( "," ). map (( s ) => s . trim ())
2026-06-20 10:50:20 +08:00
: process . env . NODE_ENV === "production"
? false
: [ /^http:\/\/localhost:\d+$/ ],
2026-03-23 11:46:45 +08:00
});
2026-05-13 21:33:50 +08:00
// Security headers -- applied in all environments. HSTS is ignored over plain
// HTTP so it is safe (and desirable) to send it in dev/staging too. CSP catches
// injection issues early when applied during development.
2026-03-23 11:46:45 +08:00
app . addHook ( "onSend" , async ( _request , reply ) => {
2026-06-13 17:04:27 +08:00
reply . header ( "x-request-id" , _request . id );
2026-03-23 11:46:45 +08:00
reply . header ( "X-Content-Type-Options" , "nosniff" );
reply . header ( "X-Frame-Options" , "DENY" );
reply . header ( "X-XSS-Protection" , "0" );
reply . header ( "Referrer-Policy" , "strict-origin-when-cross-origin" );
2026-03-24 21:38:06 +08:00
reply . header ( "Permissions-Policy" , "camera=(), microphone=(), geolocation=()" );
2026-05-13 21:33:50 +08:00
reply . header ( "Strict-Transport-Security" , "max-age=31536000; includeSubDomains" );
reply . header ( "Content-Security-Policy" , buildCsp ( _request . url . startsWith ( "/api/docs" )));
2026-03-23 11:46:45 +08:00
});
2026-06-14 12:06:06 +08:00
// Record HTTP request duration for Prometheus (bounded cardinality: 5 route groups * 5 status classes)
app . addHook ( "onResponse" , ( request , reply , done ) => {
const duration = reply . elapsedTime / 1000 ;
const url = request . url ;
let routeGroup = "other" ;
if ( url . startsWith ( "/api/v1/tools/" ) || url . startsWith ( "/api/v1/jobs/" )) routeGroup = "tools" ;
else if ( url . startsWith ( "/api/auth/" ) || url . startsWith ( "/api/v1/enterprise/" ))
routeGroup = "auth" ;
else if ( url . startsWith ( "/api/v1/admin/" ) || url . startsWith ( "/api/v1/settings" ))
routeGroup = "admin" ;
else if ( url . startsWith ( "/api/v1/files" )) routeGroup = "files" ;
else if ( url . startsWith ( "/api/v1/scim/" )) routeGroup = "scim" ;
const statusClass = ` ${ Math . floor ( reply . statusCode / 100 ) } xx` ;
requestDuration . observe ({ route_group : routeGroup , status_class : statusClass }, duration );
done ();
});
2026-04-21 10:19:08 +08:00
// Always register rate-limit plugin so per-route limits (login brute-force protection) work.
2026-06-08 14:05:47 +08:00
// max=0 means "unlimited" (50k/min) -- @fastify/rate-limit treats literal 0 as "block all".
2026-04-21 10:19:08 +08:00
await app . register ( rateLimit , {
2026-06-08 14:05:47 +08:00
max : env.RATE_LIMIT_PER_MIN > 0 ? env.RATE_LIMIT_PER_MIN : 50_000 ,
2026-04-21 10:19:08 +08:00
timeWindow : "1 minute" ,
allowList : ( request ) => ! request . url . startsWith ( "/api/" ),
});
2026-03-22 02:46:34 +08:00
2026-06-07 21:54:27 +08:00
// Block TRACE method (returns 401 instead of 405 without this)
app . addHook ( "onRequest" , async ( request , reply ) => {
if ( request . method === "TRACE" ) {
2026-06-20 10:50:20 +08:00
reply . header ( "Allow" , "GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD" );
2026-06-07 21:54:27 +08:00
return reply . status ( 405 ). send ({ error : "Method not allowed" });
}
});
2026-03-22 03:47:54 +08:00
// Multipart upload support
await registerUpload ( app );
2026-05-13 19:01:30 +08:00
// Cookie support (required for OIDC state and session cookies)
await app . register ( cookie , {
secret : env.COOKIE_SECRET ,
hook : "onRequest" ,
});
2026-06-13 22:54:06 +08:00
// IP allowlist (enterprise -- must run before auth to reject early)
import { registerIpAllowlist } from "./plugins/ip-allowlist.js" ;
2026-06-14 12:09:25 +08:00
import { registerPerUserRateLimit } from "./plugins/per-user-rate-limit.js" ;
2026-06-13 22:54:06 +08:00
await registerIpAllowlist ( app );
2026-05-15 17:02:49 +08:00
// Public config routes (no auth required)
await configRoutes ( app );
2026-06-28 18:57:53 +08:00
// Per-user preferences (any authenticated user)
await preferencesRoutes ( app );
2026-03-22 02:55:10 +08:00
// Auth middleware (must be registered before routes it protects)
await authMiddleware ( app );
2026-06-14 12:09:25 +08:00
// Per-user rate limiting (after auth so request.user is populated)
await registerPerUserRateLimit ( app );
2026-06-15 12:53:06 +08:00
// Enrich active OTel span with tool_id and user_id when available
app . addHook ( "preHandler" , ( request , _reply , done ) => {
const span = trace . getActiveSpan ();
if ( span ) {
const params = request . params as Record < string , string > | undefined ;
if ( params ? . toolId ) span . setAttribute ( "snapotter.tool_id" , params . toolId );
const user = getAuthUser ( request );
if ( user ) span . setAttribute ( "snapotter.user_id" , user . id );
}
done ();
});
2026-03-22 02:55:10 +08:00
// Auth routes
await authRoutes ( app );
2026-05-13 19:01:30 +08:00
// OIDC routes
await oidcRoutes ( app );
2026-06-13 22:27:56 +08:00
// SAML routes
await registerSaml ( app );
2026-06-13 22:49:00 +08:00
// MFA routes (TOTP enrollment, verification, disable)
await registerMfa ( app );
2026-03-22 03:47:54 +08:00
// File upload/download routes
await fileRoutes ( app );
2026-03-25 01:16:52 +08:00
// User file library routes (persistent file management with versioning)
await userFileRoutes ( app );
2026-06-15 11:33:45 +08:00
// File preview routes (server-side video/audio preview generation)
await filePreviewRoutes ( app );
2026-05-08 16:05:17 +08:00
// Meme template listing and static serving (before tool routes which have catch-all)
await registerMemeTemplates ( app );
2026-03-22 03:48:11 +08:00
// Tool routes (generic factory-based)
await registerToolRoutes ( app );
2026-03-22 04:03:38 +08:00
// Batch processing routes (must be after tool routes so the registry is populated)
await registerBatchRoutes ( app );
2026-05-11 21:24:56 +08:00
// URL fetch routes (server-side image fetching with SSRF protection)
await registerFetchUrlsRoute ( app );
2026-03-22 04:41:51 +08:00
// Pipeline routes (must be after tool routes so the registry is populated)
await registerPipelineRoutes ( app );
2026-03-22 04:03:38 +08:00
// Progress SSE routes
await registerProgressRoutes ( app );
2026-03-22 19:28:57 +08:00
// API key management routes
await apiKeyRoutes ( app );
// Settings routes
await settingsRoutes ( app );
2026-04-22 19:03:23 +08:00
// Analytics config and consent routes
await analyticsRoutes ( app );
2026-06-29 18:16:33 +08:00
// Explicit customer feedback capture (respects the analytics gate)
await feedbackRoutes ( app );
2026-04-18 02:34:21 +08:00
// Feature management routes (AI feature bundle install/uninstall)
await registerFeatureRoutes ( app );
2026-03-25 09:24:37 +08:00
// Teams routes
await teamsRoutes ( app );
2026-04-22 18:10:04 +08:00
// Audit log routes
await auditLogRoutes ( app );
// Roles management routes
await rolesRoutes ( app );
2026-06-13 10:17:13 +08:00
// Admin ops routes (runtime log level, Prometheus metrics)
await adminOpsRoutes ( app );
2026-06-13 16:45:11 +08:00
// Enterprise routes (license-gated features)
await registerEnterpriseRoutes ( app );
2026-03-27 12:28:35 +08:00
// API docs (Scalar)
await docsRoutes ( app );
2026-06-14 12:01:29 +08:00
// Disk space check for readiness probe (local storage mode only)
async function checkDiskSpace ( path : string , minBytes : number ) : Promise < boolean > {
try {
const stats = await statfs ( path );
return stats . bfree * stats . bsize > minBytes ;
} catch {
return true ; // Path doesn't exist or not applicable -- skip check
}
}
2026-04-10 13:21:06 +08:00
// Public health check (checks core dependencies)
app . get ( "/api/v1/health" , async ( _request , reply ) => {
let dbOk = false ;
try {
2026-06-13 10:15:23 +08:00
await db . select (). from ( schema . settings ). limit ( 1 );
2026-04-10 13:21:06 +08:00
dbOk = true ;
} catch {
/* db unreachable */
}
const status = dbOk ? "healthy" : "unhealthy" ;
const code = dbOk ? 200 : 503 ;
return reply . code ( code ). send ({
status ,
version : APP_VERSION ,
});
});
2026-03-28 19:00:50 +08:00
// Admin health check (full diagnostics)
app . get ( "/api/v1/admin/health" , async ( request , reply ) => {
2026-06-13 10:15:23 +08:00
const admin = await requirePermission ( "system:health" )( request , reply );
2026-03-28 19:00:50 +08:00
if ( ! admin ) return ;
2026-03-23 11:46:45 +08:00
let dbOk = false ;
try {
2026-06-13 10:15:23 +08:00
await db . select (). from ( schema . settings ). limit ( 1 );
2026-03-23 11:46:45 +08:00
dbOk = true ;
2026-03-25 09:24:37 +08:00
} catch {
/* db unreachable */
}
2026-06-13 10:17:13 +08:00
let queueStats = { active : 0 , pending : 0 };
2026-06-14 12:01:29 +08:00
let pools : Record < string , unknown > = {};
2026-06-13 10:17:13 +08:00
try {
const counts = await queueCounts ();
queueStats = { active : counts.active , pending : counts.waiting };
2026-06-14 12:01:29 +08:00
pools = await perPoolHealth ();
2026-06-13 10:17:13 +08:00
} catch {
/* redis unreachable */
}
2026-06-14 12:01:29 +08:00
// Storage total across all users
let libraryStorage = "0" ;
try {
const storageResult = await db
. select ({
totalBytes : sql < string > `coalesce(sum( ${ schema . users . storageUsed } ), 0)::text` ,
})
. from ( schema . users );
libraryStorage = storageResult [ 0 ] ? . totalBytes ?? "0" ;
} catch {
/* db error */
}
// Backup recency
let lastBackup : string | null = null ;
try {
const backupResult = await db
. select ({ value : schema.settings.value })
. from ( schema . settings )
. where ( eq ( schema . settings . key , "backup_last_completed" ))
. limit ( 1 );
lastBackup = backupResult . length > 0 ? backupResult [ 0 ]. value : null ;
} catch {
/* db error */
}
2026-03-23 11:46:45 +08:00
return {
status : dbOk ? "healthy" : "degraded" ,
version : APP_VERSION ,
2026-03-25 09:24:37 +08:00
uptime : ` ${ process . uptime (). toFixed ( 0 ) } s` ,
2026-03-23 11:46:45 +08:00
storage : { mode : env.STORAGE_MODE , available : "N/A" },
database : dbOk ? "ok" : "error" ,
2026-06-13 10:17:13 +08:00
queue : queueStats ,
2026-06-14 12:01:29 +08:00
pools ,
libraryStorage ,
lastBackup ,
2026-04-26 03:22:26 +08:00
ai : { gpu : isGpuAvailable (), dispatcher : getDispatcherStatus () },
2026-06-06 20:17:49 +08:00
enterprise : enterpriseLicense
? { active : true , org : enterpriseLicense.org , plan : enterpriseLicense.plan }
: { active : false },
2026-03-23 11:46:45 +08:00
};
});
2026-03-22 02:46:34 +08:00
2026-03-22 11:04:20 +08:00
// Public config endpoint (for frontend to know if auth is required)
2026-05-13 19:01:30 +08:00
app . get ( "/api/v1/config/auth" , async () => {
const config : Record < string , unknown > = {
authEnabled : env.AUTH_ENABLED ,
};
if ( env . OIDC_ENABLED ) {
config . oidcEnabled = true ;
config . oidcProviderName = env . OIDC_PROVIDER_NAME || null ;
config . oidcLoginUrl = "/api/auth/oidc/login" ;
}
2026-06-13 22:27:56 +08:00
// SAML SSO requires both env flag and enterprise license
let samlLicensed = false ;
if ( env . SAML_ENABLED ) {
try {
const { isFeatureEnabled } = await import ( "@snapotter/enterprise" );
samlLicensed = isFeatureEnabled ( "saml_sso" );
} catch {
// Enterprise package not available
}
}
if ( env . SAML_ENABLED && samlLicensed ) {
config . samlEnabled = true ;
config . samlProviderName = env . SAML_PROVIDER_NAME || "SSO" ;
config . samlLoginUrl = "/api/auth/saml/login" ;
}
2026-06-13 22:32:16 +08:00
config . ssoEnforced = ( await getSettingString ( "ssoEnforcement" , "false" )) === "true" ;
2026-05-13 19:01:30 +08:00
return config ;
});
2026-03-22 11:04:20 +08:00
2026-06-13 10:17:13 +08:00
// Readiness probe (no auth -- used by load balancers / k8s)
app . get ( "/api/v1/readyz" , async ( _request , reply ) => {
let postgres = false ;
let redis = false ;
try {
await db . select (). from ( schema . settings ). limit ( 1 );
postgres = true ;
} catch {
/* db unreachable */
}
try {
redis = await pingRedis ();
} catch {
/* redis unreachable */
}
2026-06-14 12:01:29 +08:00
// Disk space: fail readiness if below 500 MB on storage paths (local mode only)
const diskOk =
env . STORAGE_MODE !== "s3"
? ( await checkDiskSpace ( env . WORKSPACE_PATH , 500 * 1024 * 1024 )) &&
( await checkDiskSpace ( env . FILES_STORAGE_PATH , 500 * 1024 * 1024 ))
: true ;
// S3 reachability (S3 mode only)
let s3Ok = true ;
if ( env . STORAGE_MODE === "s3" ) {
try {
const { loadS3Storage } = await import ( "@snapotter/enterprise" );
const s3 = await loadS3Storage ();
await s3 . checkConnection ();
} catch {
s3Ok = false ;
}
}
const ok = postgres && redis && diskOk && s3Ok ;
return reply . code ( ok ? 200 : 503 ). send ({ ok , postgres , redis , disk : diskOk , s3 : s3Ok });
2026-06-13 10:17:13 +08:00
});
// Cancel a job (authenticated)
app . post (
"/api/v1/jobs/:jobId/cancel" ,
2026-06-21 11:49:02 +08:00
{ config : { rateLimit : { max : 300 , timeWindow : "1 minute" } } },
2026-06-13 10:17:13 +08:00
async (
request : import ( "fastify" ). FastifyRequest < { Params : { jobId : string } } > ,
reply : import ( "fastify" ). FastifyReply ,
) => {
const { requireAuth } = await import ( "./plugins/auth.js" );
const user = requireAuth ( request , reply );
if ( ! user ) return ;
const { requestCancel } = await import ( "./jobs/cancel.js" );
const { jobId } = request . params ;
const canceled = await requestCancel ( jobId );
return reply . send ({ canceled });
},
);
2026-03-22 03:09:33 +08:00
// Serve SPA in production
if ( process . env . NODE_ENV === "production" ) {
await registerStatic ( app );
}
2026-06-13 10:17:13 +08:00
// Schedule repeatable system jobs (storage TTL, session purge, retention)
await scheduleSystemJobs ();
if ( await shouldRunStartupCleanup ()) {
await enqueueSystemJob ( SYSTEM_JOBS . storageTtl );
}
// Start BullMQ worker pools (after route registration so the tool registry is full)
startWorkers ();
2026-03-22 03:09:33 +08:00
2026-06-24 17:27:59 +08:00
// Reconcile orphaned job rows. A jobs row created without a tool_id/pool (e.g. an
// SSE-progress placeholder for a clientJobId whose client then disconnected) is
// never enqueued to BullMQ, so unlike a genuinely interrupted job (which BullMQ's
// stalled-detection requeues) it would sit in 'processing'/'queued' forever --
// inflating the per-user concurrent-job count and the upgrade-check in-flight gate.
// Only rows with an empty tool_id are touched, so real jobs are never affected.
void db
. execute (
sql `UPDATE jobs SET status = 'failed', error = '{"message":"Orphaned job reconciled at startup"}'::jsonb, completed_at = now() WHERE status IN ('processing','queued') AND (tool_id IS NULL OR tool_id = '')` ,
)
. then (( r ) => {
const n = ( r as { rowCount? : number }). rowCount ?? 0 ;
if ( n > 0 ) app . log . info ({ count : n }, "Reconciled orphaned job rows at startup" );
})
. catch (( err ) => app . log . warn ({ err }, "Orphaned-job reconciliation failed" ));
2026-06-21 23:22:59 +08:00
// Warm the per-pool QueueEvents consumers so the first synchronous tool request
// after boot does not pay the lazy-connect cost (and cannot miss a fast job's
// completion event). Non-blocking: a slow/unreachable Redis must not stall boot;
// the consumers fall back to lazy creation on first use if this has not finished.
void warmQueueEvents (). catch (( err ) => {
app . log . warn ({ err }, "QueueEvents warm-up failed; consumers will connect lazily" );
});
2026-03-22 02:46:34 +08:00
// Start
try {
await app . listen ({ port : env.PORT , host : "0.0.0.0" });
2026-04-30 18:49:52 +08:00
const dispatcherResult = await initDispatcher ();
const gpuLine = dispatcherResult . ready
? dispatcherResult . gpu
? "[INFO] GPU detected -- AI tools will use CUDA acceleration"
: "[WARN] No GPU detected -- AI tools will use CPU (slower)"
: "[WARN] AI sidecar did not start -- AI tools will use per-request Python (slower)" ;
2026-04-21 10:19:08 +08:00
console . log (
[
2026-04-24 18:02:21 +08:00
`SnapOtter v ${ APP_VERSION } running on port ${ env . PORT } ` ,
2026-04-27 01:13:32 +08:00
gpuLine ,
2026-04-21 10:19:08 +08:00
`[INFO] Rate limit: ${ env . RATE_LIMIT_PER_MIN > 0 ? ` ${ env . RATE_LIMIT_PER_MIN } /min` : "disabled" } ` ,
`[INFO] Upload limit: ${ env . MAX_UPLOAD_SIZE_MB > 0 ? ` ${ env . MAX_UPLOAD_SIZE_MB } MB` : "unlimited" } ` ,
`[INFO] Trust proxy: ${ env . TRUST_PROXY } ` ,
2026-06-06 20:17:49 +08:00
`[INFO] Storage: ${ env . STORAGE_MODE }${ env . STORAGE_MODE === "s3" ? ` ( ${ env . S3_BUCKET } )` : "" } ` ,
enterpriseLicense
? `[INFO] Enterprise license: ${ enterpriseLicense . org } ( ${ enterpriseLicense . plan } )`
: "[INFO] Edition: Community" ,
2026-04-21 10:19:08 +08:00
]. join ( "\n" ),
);
2026-03-22 02:46:34 +08:00
} catch ( err ) {
app . log . error ( err );
process . exit ( 1 );
}
2026-03-29 17:23:41 +08:00
// Graceful shutdown
2026-04-20 21:50:17 +08:00
const SHUTDOWN_TIMEOUT_MS = 30000 ;
2026-03-29 17:23:41 +08:00
let shuttingDown = false ;
async function shutdown ( signal : string ) {
if ( shuttingDown ) return ;
shuttingDown = true ;
console . log ( ` \ n ${ signal } received, shutting down gracefully...` );
2026-04-10 13:21:06 +08:00
const forceExit = setTimeout (() => {
console . error ( "Shutdown timed out, forcing exit" );
process . exit ( 1 );
}, SHUTDOWN_TIMEOUT_MS );
forceExit . unref ();
2026-03-29 17:23:41 +08:00
try {
await app . close ();
console . log ( "HTTP server closed" );
} catch ( err ) {
console . error ( "Error closing HTTP server:" , err );
}
try {
2026-06-13 10:18:39 +08:00
const { shutdownDispatcher , shutdownDocsDispatcher } = await import ( "@snapotter/ai" );
2026-03-29 17:23:41 +08:00
shutdownDispatcher ();
2026-06-13 10:18:39 +08:00
await shutdownDocsDispatcher ();
console . log ( "Python dispatchers shut down" );
2026-03-29 17:23:41 +08:00
} catch {
// AI package may not be available
}
2026-06-06 20:53:39 +08:00
try {
const { shutdownBrowser } = await import ( "./lib/browser-service.js" );
await shutdownBrowser ();
console . log ( "Browser service shut down" );
} catch {
// Browser service may not have been initialized
}
2026-04-22 19:03:23 +08:00
try {
await shutdownAnalytics ();
console . log ( "Analytics flushed" );
} catch {
// analytics shutdown is best-effort
}
2026-06-13 10:17:13 +08:00
// Close BullMQ resources before database (workers first so no new jobs start)
try {
await closeWorkers ();
2026-06-15 12:53:06 +08:00
} catch ( err ) {
console . error ( "Error closing workers:" , err );
}
try {
await shutdownTracing ();
} catch {
// tracing shutdown is best-effort
}
try {
2026-06-13 10:17:13 +08:00
await closeFlowProducer ();
await closeQueueEvents ();
await closeQueues ();
await stopCancelListener ();
2026-06-28 18:57:53 +08:00
const { stopAnalyticsGateListener } = await import ( "./lib/analytics-gate.js" );
await stopAnalyticsGateListener ();
2026-06-13 10:17:13 +08:00
await closeRedis ();
console . log ( "Redis connections closed" );
} catch ( err ) {
console . error ( "Error closing Redis connections:" , err );
}
2026-03-29 17:23:41 +08:00
try {
2026-06-13 10:15:23 +08:00
await closeDb ();
2026-03-29 17:23:41 +08:00
console . log ( "Database connection closed" );
} catch ( err ) {
console . error ( "Error closing database:" , err );
}
2026-04-10 13:21:06 +08:00
clearTimeout ( forceExit );
2026-03-29 17:23:41 +08:00
process . exit ( 0 );
}
process . on ( "SIGTERM" , () => shutdown ( "SIGTERM" ));
process . on ( "SIGINT" , () => shutdown ( "SIGINT" ));