2026-04-20 21:35:15 +08:00
|
|
|
import { availableParallelism } from "node:os";
|
2026-03-22 02:46:34 +08:00
|
|
|
import { z } from "zod";
|
|
|
|
|
|
2026-05-13 18:49:29 +08:00
|
|
|
const envSchema = z
|
|
|
|
|
.object({
|
|
|
|
|
PORT: z.coerce.number().default(1349),
|
|
|
|
|
AUTH_ENABLED: z
|
|
|
|
|
.enum(["true", "false"])
|
|
|
|
|
.default("true")
|
|
|
|
|
.transform((v) => v === "true"),
|
|
|
|
|
DEFAULT_USERNAME: z.string().default("admin"),
|
|
|
|
|
DEFAULT_PASSWORD: z.string().default("admin"),
|
|
|
|
|
SKIP_MUST_CHANGE_PASSWORD: z
|
|
|
|
|
.enum(["true", "false"])
|
|
|
|
|
.default("false")
|
|
|
|
|
.transform((v) => v === "true"),
|
|
|
|
|
STORAGE_MODE: z.enum(["local", "s3"]).default("local"),
|
2026-06-06 20:17:49 +08:00
|
|
|
S3_BUCKET: z.string().default(""),
|
|
|
|
|
S3_REGION: z.string().default("us-east-1"),
|
|
|
|
|
S3_ENDPOINT: z.string().default(""),
|
|
|
|
|
S3_ACCESS_KEY_ID: z.string().default(""),
|
|
|
|
|
S3_SECRET_ACCESS_KEY: z.string().default(""),
|
|
|
|
|
S3_FORCE_PATH_STYLE: z
|
|
|
|
|
.enum(["true", "false"])
|
|
|
|
|
.default("false")
|
|
|
|
|
.transform((v) => v === "true"),
|
|
|
|
|
S3_PREFIX: z.string().default(""),
|
|
|
|
|
SNAPOTTER_LICENSE_KEY: z.string().default(""),
|
2026-05-13 18:49:29 +08:00
|
|
|
FILE_MAX_AGE_HOURS: z.coerce.number().default(72),
|
|
|
|
|
CLEANUP_INTERVAL_MINUTES: z.coerce.number().default(60),
|
2026-06-29 17:54:12 +08:00
|
|
|
MAX_UPLOAD_SIZE_MB: z.coerce.number().default(100),
|
|
|
|
|
MAX_BATCH_SIZE: z.coerce.number().default(100),
|
2026-05-13 18:49:29 +08:00
|
|
|
CONCURRENT_JOBS: z.coerce.number().default(0),
|
|
|
|
|
MAX_MEGAPIXELS: z.coerce.number().default(0),
|
2026-06-20 00:53:11 +08:00
|
|
|
RATE_LIMIT_PER_MIN: z.coerce.number().default(300),
|
2026-06-28 18:57:53 +08:00
|
|
|
API_KEYS_RATE_LIMIT_PER_MIN: z.coerce.number().default(30),
|
2026-06-13 10:15:23 +08:00
|
|
|
DATABASE_URL: z.string().default("postgres://snapotter:snapotter@localhost:5432/snapotter"),
|
|
|
|
|
SQLITE_MIGRATE_PATH: z.string().default(""),
|
2026-05-13 18:49:29 +08:00
|
|
|
FILES_STORAGE_PATH: z.string().default("./data/files"),
|
|
|
|
|
WORKSPACE_PATH: z.string().default("./tmp/workspace"),
|
|
|
|
|
DEFAULT_THEME: z.enum(["light", "dark", "system"]).default("light"),
|
|
|
|
|
DEFAULT_LOCALE: z.string().default("en"),
|
2026-05-16 11:37:28 +08:00
|
|
|
DEFAULT_TOOL_VIEW: z.enum(["sidebar", "fullscreen"]).default("sidebar"),
|
2026-05-13 18:49:29 +08:00
|
|
|
CORS_ORIGIN: z.string().default(""),
|
|
|
|
|
MAX_USERS: z.coerce.number().default(0),
|
|
|
|
|
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
|
|
|
|
|
MAX_WORKER_THREADS: z.coerce.number().default(0),
|
|
|
|
|
PROCESSING_TIMEOUT_S: z.coerce.number().default(0),
|
2026-06-07 21:54:27 +08:00
|
|
|
MAX_PIPELINE_STEPS: z.coerce.number().default(20),
|
2026-06-20 10:50:20 +08:00
|
|
|
MAX_PIPELINE_STEP_PIXELS: z.coerce.number().default(67_108_864),
|
2026-05-13 18:49:29 +08:00
|
|
|
MAX_CANVAS_PIXELS: z.coerce.number().default(0),
|
2026-06-07 21:54:27 +08:00
|
|
|
MAX_SVG_SIZE_MB: z.coerce.number().default(50),
|
2026-05-13 18:49:29 +08:00
|
|
|
MAX_SPLIT_GRID: z.coerce.number().default(100),
|
2026-05-14 22:31:26 +08:00
|
|
|
MAX_STORAGE_PER_USER_MB: z.coerce.number().default(5000),
|
|
|
|
|
MAX_WORKSPACE_SIZE_GB: z.coerce.number().default(10),
|
2026-05-13 18:49:29 +08:00
|
|
|
MAX_PDF_PAGES: z.coerce.number().default(0),
|
2026-06-13 10:18:39 +08:00
|
|
|
MAX_VIDEO_DURATION_S: z.coerce.number().default(0),
|
|
|
|
|
MAX_AUDIO_DURATION_S: z.coerce.number().default(0),
|
|
|
|
|
MAX_VIDEO_BITRATE_KBPS: z.coerce.number().default(0),
|
|
|
|
|
LIBREOFFICE_TIMEOUT_S: z.coerce.number().default(120),
|
2026-05-13 18:49:29 +08:00
|
|
|
SESSION_DURATION_HOURS: z.coerce.number().default(168),
|
2026-06-20 00:53:11 +08:00
|
|
|
LOGIN_ATTEMPT_LIMIT: z.coerce.number().default(10),
|
2026-06-13 16:25:58 +08:00
|
|
|
TRUST_PROXY: z.string().default("false"),
|
2026-05-13 18:49:29 +08:00
|
|
|
OIDC_ENABLED: z
|
|
|
|
|
.enum(["true", "false"])
|
|
|
|
|
.default("false")
|
|
|
|
|
.transform((v) => v === "true"),
|
|
|
|
|
OIDC_ISSUER_URL: z.string().default(""),
|
|
|
|
|
OIDC_CLIENT_ID: z.string().default(""),
|
|
|
|
|
OIDC_CLIENT_SECRET: z.string().default(""),
|
|
|
|
|
OIDC_SCOPES: z.string().default("openid profile email"),
|
|
|
|
|
OIDC_AUTO_CREATE_USERS: z
|
|
|
|
|
.enum(["true", "false"])
|
|
|
|
|
.default("true")
|
|
|
|
|
.transform((v) => v === "true"),
|
|
|
|
|
OIDC_DEFAULT_ROLE: z.string().default("user"),
|
|
|
|
|
OIDC_AUTO_LINK_USERS: z
|
|
|
|
|
.enum(["true", "false"])
|
|
|
|
|
.default("false")
|
|
|
|
|
.transform((v) => v === "true"),
|
|
|
|
|
OIDC_PROVIDER_NAME: z.string().default(""),
|
|
|
|
|
OIDC_CLOCK_TOLERANCE: z.coerce.number().min(0).max(300).default(30),
|
|
|
|
|
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"),
|
2026-06-13 22:27:56 +08:00
|
|
|
SAML_ENABLED: z
|
|
|
|
|
.enum(["true", "false"])
|
|
|
|
|
.default("false")
|
|
|
|
|
.transform((v) => v === "true"),
|
|
|
|
|
SAML_ENTITY_ID: z.string().default(""),
|
|
|
|
|
SAML_CALLBACK_URL: z.string().default(""),
|
|
|
|
|
SAML_IDP_SSO_URL: z.string().default(""),
|
|
|
|
|
SAML_IDP_CERTIFICATE: z.string().default(""),
|
|
|
|
|
SAML_AUTO_CREATE_USERS: z
|
|
|
|
|
.enum(["true", "false"])
|
|
|
|
|
.default("true")
|
|
|
|
|
.transform((v) => v === "true"),
|
|
|
|
|
SAML_AUTO_LINK_USERS: z
|
|
|
|
|
.enum(["true", "false"])
|
|
|
|
|
.default("false")
|
|
|
|
|
.transform((v) => v === "true"),
|
|
|
|
|
SAML_DEFAULT_ROLE: z.string().default("user"),
|
|
|
|
|
SAML_PROVIDER_NAME: z.string().default(""),
|
|
|
|
|
SAML_USERNAME_ATTRIBUTE: z.string().default(""),
|
|
|
|
|
SAML_EMAIL_ATTRIBUTE: z.string().default("email"),
|
2026-05-13 18:49:29 +08:00
|
|
|
EXTERNAL_URL: z.string().default(""),
|
|
|
|
|
COOKIE_SECRET: z.string().default(""),
|
2026-06-13 10:17:13 +08:00
|
|
|
REDIS_URL: z.string().default("redis://localhost:6379"),
|
|
|
|
|
SYNC_WAIT_MS: z.coerce.number().default(8000),
|
|
|
|
|
JOB_TIMEOUT_FAST_S: z.coerce.number().default(120),
|
|
|
|
|
JOB_TIMEOUT_LONG_S: z.coerce.number().default(7200),
|
|
|
|
|
JOBS_RETENTION_DAYS: z.coerce.number().default(30),
|
|
|
|
|
AUDIT_RETENTION_DAYS: z.coerce.number().default(0),
|
|
|
|
|
LOG_DIR: z.string().default("./data/logs"),
|
|
|
|
|
SCRATCH_PATH: z.string().default(""),
|
2026-06-13 16:35:28 +08:00
|
|
|
DATA_ENCRYPTION_KEY: z.string().default(""),
|
|
|
|
|
DATA_ENCRYPTION_KEY_PREVIOUS: z.string().default(""),
|
2026-05-13 18:49:29 +08:00
|
|
|
})
|
|
|
|
|
.superRefine((data, ctx) => {
|
2026-06-06 20:17:49 +08:00
|
|
|
if (data.STORAGE_MODE === "s3") {
|
|
|
|
|
if (!data.S3_BUCKET) {
|
|
|
|
|
ctx.addIssue({
|
|
|
|
|
code: z.ZodIssueCode.custom,
|
|
|
|
|
message: "S3_BUCKET is required when STORAGE_MODE=s3",
|
|
|
|
|
path: ["S3_BUCKET"],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (!data.S3_ACCESS_KEY_ID) {
|
|
|
|
|
ctx.addIssue({
|
|
|
|
|
code: z.ZodIssueCode.custom,
|
|
|
|
|
message: "S3_ACCESS_KEY_ID is required when STORAGE_MODE=s3",
|
|
|
|
|
path: ["S3_ACCESS_KEY_ID"],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (!data.S3_SECRET_ACCESS_KEY) {
|
|
|
|
|
ctx.addIssue({
|
|
|
|
|
code: z.ZodIssueCode.custom,
|
|
|
|
|
message: "S3_SECRET_ACCESS_KEY is required when STORAGE_MODE=s3",
|
|
|
|
|
path: ["S3_SECRET_ACCESS_KEY"],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-13 18:49:29 +08:00
|
|
|
if (data.OIDC_ENABLED) {
|
|
|
|
|
if (!data.OIDC_ISSUER_URL) {
|
|
|
|
|
ctx.addIssue({
|
|
|
|
|
code: z.ZodIssueCode.custom,
|
|
|
|
|
message: "OIDC_ISSUER_URL is required when OIDC_ENABLED=true",
|
|
|
|
|
path: ["OIDC_ISSUER_URL"],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (!data.OIDC_CLIENT_ID) {
|
|
|
|
|
ctx.addIssue({
|
|
|
|
|
code: z.ZodIssueCode.custom,
|
|
|
|
|
message: "OIDC_CLIENT_ID is required when OIDC_ENABLED=true",
|
|
|
|
|
path: ["OIDC_CLIENT_ID"],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (!data.OIDC_CLIENT_SECRET) {
|
|
|
|
|
ctx.addIssue({
|
|
|
|
|
code: z.ZodIssueCode.custom,
|
|
|
|
|
message: "OIDC_CLIENT_SECRET is required when OIDC_ENABLED=true",
|
|
|
|
|
path: ["OIDC_CLIENT_SECRET"],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (!data.EXTERNAL_URL) {
|
|
|
|
|
ctx.addIssue({
|
|
|
|
|
code: z.ZodIssueCode.custom,
|
|
|
|
|
message: "EXTERNAL_URL is required when OIDC_ENABLED=true",
|
|
|
|
|
path: ["EXTERNAL_URL"],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-13 22:27:56 +08:00
|
|
|
if (data.SAML_ENABLED) {
|
|
|
|
|
if (!data.SAML_IDP_SSO_URL) {
|
|
|
|
|
ctx.addIssue({
|
|
|
|
|
code: z.ZodIssueCode.custom,
|
|
|
|
|
message: "SAML_IDP_SSO_URL is required when SAML_ENABLED=true",
|
|
|
|
|
path: ["SAML_IDP_SSO_URL"],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (!data.SAML_IDP_CERTIFICATE) {
|
|
|
|
|
ctx.addIssue({
|
|
|
|
|
code: z.ZodIssueCode.custom,
|
|
|
|
|
message: "SAML_IDP_CERTIFICATE is required when SAML_ENABLED=true",
|
|
|
|
|
path: ["SAML_IDP_CERTIFICATE"],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (!data.EXTERNAL_URL) {
|
|
|
|
|
ctx.addIssue({
|
|
|
|
|
code: z.ZodIssueCode.custom,
|
|
|
|
|
message: "EXTERNAL_URL is required when SAML_ENABLED=true",
|
|
|
|
|
path: ["EXTERNAL_URL"],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-14 14:03:47 +08:00
|
|
|
if (data.DATA_ENCRYPTION_KEY) {
|
|
|
|
|
if (!/^[0-9a-fA-F]{64}$/.test(data.DATA_ENCRYPTION_KEY)) {
|
|
|
|
|
ctx.addIssue({
|
|
|
|
|
code: z.ZodIssueCode.custom,
|
|
|
|
|
path: ["DATA_ENCRYPTION_KEY"],
|
|
|
|
|
message: "DATA_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (data.DATA_ENCRYPTION_KEY_PREVIOUS) {
|
|
|
|
|
if (!/^[0-9a-fA-F]{64}$/.test(data.DATA_ENCRYPTION_KEY_PREVIOUS)) {
|
|
|
|
|
ctx.addIssue({
|
|
|
|
|
code: z.ZodIssueCode.custom,
|
|
|
|
|
path: ["DATA_ENCRYPTION_KEY_PREVIOUS"],
|
|
|
|
|
message: "DATA_ENCRYPTION_KEY_PREVIOUS must be a 64-character hex string (32 bytes)",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-13 18:49:29 +08:00
|
|
|
});
|
2026-03-22 02:46:34 +08:00
|
|
|
|
|
|
|
|
export type Env = z.infer<typeof envSchema>;
|
|
|
|
|
|
|
|
|
|
export function loadEnv(): Env {
|
|
|
|
|
return envSchema.parse(process.env);
|
|
|
|
|
}
|
2026-04-20 21:35:15 +08:00
|
|
|
|
|
|
|
|
export function resolveConcurrency(env: Env): number {
|
|
|
|
|
if (env.CONCURRENT_JOBS > 0) return env.CONCURRENT_JOBS;
|
|
|
|
|
return Math.max(2, availableParallelism() - 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function resolveWorkerThreads(env: Env): number {
|
|
|
|
|
if (env.MAX_WORKER_THREADS > 0) return env.MAX_WORKER_THREADS;
|
|
|
|
|
return Math.max(2, availableParallelism() - 1);
|
|
|
|
|
}
|