UNPKG

@syngrisi/syngrisi

Version:
1,855 lines (1,848 loc) 51.2 kB
// src/server/services/admin-data-job.service.ts import fs2 from "fs"; import { promises as fsp } from "fs"; import path2 from "path"; import { randomUUID } from "crypto"; import { createGzip, createGunzip } from "zlib"; import { promisify } from "util"; import { pipeline } from "stream"; import tar from "tar-stream"; import mongoose from "mongoose"; // src/server/config.ts import fs from "fs"; import dotenv2 from "dotenv"; // package.json var version = "3.5.0"; var gitHead = "12bfda406cbe5aaccf3f17fdab02a9bd1a9d6343"; // src/server/config.ts import crypto2 from "crypto"; import { execSync } from "child_process"; // src/server/envConfig.ts import { cleanEnv, host, num, port, str, bool } from "envalid"; import crypto from "crypto"; import path from "path"; import dotenv from "dotenv"; dotenv.config({ quiet: true }); if (!process.env.NODE_ENV) { process.env.NODE_ENV = "production"; } var env = cleanEnv(process.env, { NODE_ENV: str({ choices: ["development", "production", "test"] }), SYNGRISI_DB_URI: str({ default: "mongodb://127.0.0.1:27017/SyngrisiDb" }), SYNGRISI_APP_PORT: port({ default: 3e3 }), SYNGRISI_IMAGES_PATH: str({ default: path.join(process.cwd(), "./.snapshots-images") }), SYNGRISI_DOM_SNAPSHOTS_PATH: str({ default: "" }), // If empty, uses SYNGRISI_IMAGES_PATH SYNGRISI_TMP_DIR: str({ default: path.join(process.cwd(), ".tmp") }), SYNGRISI_ADMIN_DATA_JOBS_PATH: str({ default: path.join(process.cwd(), ".tmp", "admin-data-jobs") }), SYNGRISI_ADMIN_DATA_JOBS_TTL_MS: num({ default: 24 * 60 * 60 * 1e3 }), SYNGRISI_ADMIN_DATA_MAX_CONCURRENT_JOBS: num({ default: 1 }), SYNGRISI_ADMIN_DATA_UPLOAD_MAX_SIZE_MB: num({ default: 10240 }), SYNGRISI_HTTP_LOG: bool({ default: false }), SYNGRISI_COVERAGE: bool({ default: false }), SYNGRISI_HOSTNAME: host({ default: "localhost" }), SYNGRISI_AUTH: bool({ default: true }), SYNGRISI_TEST_MODE: bool({ default: false }), SYNGRISI_DISABLE_FIRST_RUN: bool({ default: false }), MONGODB_ROOT_USERNAME: str({ default: "" }), MONGODB_ROOT_PASSWORD: str({ default: "" }), LOGLEVEL: str({ choices: ["error", "warn", "info", "verbose", "debug", "silly"], default: "debug" }), // Legacy tests expect 20 rows per page; keep default aligned for e2e SYNGRISI_PAGINATION_SIZE: num({ default: 20 }), SYNGRISI_DISABLE_DEV_CORS: bool({ default: true, devDefault: true }), SYNGRISI_SESSION_STORE_KEY: str({ default: crypto.randomBytes(64).toString("hex") }), SYNGRISI_LOG_LEVEL: str({ default: "debug" }), SYNGRISI_DISABLE_LOGS: bool({ default: false }), SYNGRISI_AUTO_REMOVE_CHECKS_POLL_INTERVAL_MS: num({ default: 10 * 60 * 1e3 }), // 10 minutes SYNGRISI_AUTO_REMOVE_CHECKS_MIN_INTERVAL_MS: num({ default: 24 * 60 * 60 * 1e3 }), SYNGRISI_ENABLE_SCHEDULERS_IN_TEST_MODE: bool({ default: false }), // RCA SYNGRISI_RCA: bool({ default: false }), // trunk features SYNGRISI_TRUNK_FEATURE_AI_SEVERITY: bool({ default: false }), SYNGRISI_AI_KEY: str({ default: "" }), OPENAI_API_BASE_URL: str({ default: "https://api.openai.com/v1" }), OPENAI_API_KEY: str({ default: "" }), SYNGRISI_V8_COVERAGE_ON_EXIT: bool({ default: false }), // Rate Limiting SYNGRISI_RATE_LIMIT_WINDOW_MS: num({ default: 15 * 60 * 1e3 }), // 15 minutes SYNGRISI_RATE_LIMIT_MAX: num({ default: 5e4 }), SYNGRISI_AUTH_RATE_LIMIT_WINDOW_MS: num({ default: 15 * 60 * 1e3 }), // 15 minutes SYNGRISI_AUTH_RATE_LIMIT_MAX: num({ default: 200 }), // Mongo tuneables for tests/CI flake reduction SYNGRISI_MONGO_SOCKET_TIMEOUT_MS: num({ default: 6e4 }), SYNGRISI_MONGO_MAX_POOL_SIZE: num({ default: 20 }), SYNGRISI_MONGO_MIN_POOL_SIZE: num({ default: 2 }), SYNGRISI_MONGO_MAX_IDLE_TIME_MS: num({ default: 3e4 }), SYNGRISI_MONGO_WAIT_QUEUE_TIMEOUT_MS: num({ default: 3e4 }), SYNGRISI_MONGO_SERVER_SELECTION_TIMEOUT_MS: num({ default: 1e4 }), SYNGRISI_MONGO_CONNECT_TIMEOUT_MS: num({ default: 3e4 }), // SSO Configuration SSO_ENABLED: bool({ default: false }), SSO_PROTOCOL: str({ choices: ["", "oauth2", "saml"], default: "" }), SSO_CLIENT_ID: str({ default: "" }), SSO_CLIENT_SECRET: str({ default: "" }), SSO_AUTHORIZATION_URL: str({ default: "" }), SSO_TOKEN_URL: str({ default: "" }), SSO_USERINFO_URL: str({ default: "" }), SSO_CALLBACK_URL: str({ default: "/v1/auth/sso/oauth/callback" }), // SAML specific SSO_ENTRY_POINT: str({ default: "" }), SSO_ISSUER: str({ default: "" }), SSO_CERT: str({ default: "" }), SSO_IDP_ISSUER: str({ default: "" }), SSO_IDP_METADATA_URL: str({ default: "" }), // URL to fetch IdP metadata XML (alternative to manual SSO_ENTRY_POINT/SSO_CERT) // SSO user settings SSO_DEFAULT_ROLE: str({ choices: ["", "user", "admin", "reviewer"], default: "reviewer" }), SSO_AUTO_CREATE_USERS: bool({ default: true }), SSO_ALLOW_ACCOUNT_LINKING: bool({ default: true }), // Plugin System SYNGRISI_PLUGINS_ENABLED: str({ default: "" }), // Comma-separated list of enabled plugins SYNGRISI_PLUGINS_DIR: str({ default: "" }), // Directory for external plugins // Okta Auth Plugin // Deprecated: Use SYNGRISI_PLUGIN_JWT_AUTH_* variables instead OKTA_JWKS_URL: str({ default: "" }), OKTA_ISSUER: str({ default: "" }), OKTA_SERVICE_USER_ROLE: str({ default: "" }), OKTA_AUTH_HEADER: str({ default: "" }), // Custom Check Validator Plugin CHECK_MISMATCH_THRESHOLD: str({ default: "0" }), // Mismatch % below which checks pass CHECK_VALIDATOR_SCRIPT: str({ default: "" }) // Path to custom validation script }); // src/server/data/devices.json var devices_default = [ { os: "ios", os_version: "16", device: "iPhone 14 Pro Max", realMobile: true }, { os: "ios", os_version: "16", device: "iPhone 14 Pro", realMobile: true }, { os: "ios", os_version: "16", device: "iPhone 14 Plus", realMobile: true }, { os: "ios", os_version: "16", device: "iPhone 14", realMobile: true }, { os: "ios", os_version: "16", device: "iPhone 12 Pro Max", realMobile: true }, { os: "ios", os_version: "16", device: "iPhone 12 Pro", realMobile: true }, { os: "ios", os_version: "16", device: "iPhone 12 Mini", realMobile: true }, { os: "ios", os_version: "16", device: "iPhone 11 Pro Max", realMobile: true }, { os: "ios", os_version: "15", device: "iPhone XS", realMobile: true }, { os: "ios", os_version: "15", device: "iPhone 13 Pro Max", realMobile: true }, { os: "ios", os_version: "15", device: "iPhone 13 Pro", realMobile: true }, { os: "ios", os_version: "15", device: "iPhone 13 Mini", realMobile: true }, { os: "ios", os_version: "15", device: "iPhone 13", realMobile: true }, { os: "ios", os_version: "15", device: "iPhone 11 Pro", realMobile: true }, { os: "ios", os_version: "15", device: "iPhone 11", realMobile: true }, { os: "ios", os_version: "14", device: "iPhone XS", realMobile: true }, { os: "ios", os_version: "14", device: "iPhone 12 Pro Max", realMobile: true }, { os: "ios", os_version: "14", device: "iPhone 12 Pro", realMobile: true }, { os: "ios", os_version: "14", device: "iPhone 12 Mini", realMobile: true }, { os: "ios", os_version: "14", device: "iPhone 12", realMobile: true }, { os: "ios", os_version: "14", device: "iPhone 11 Pro Max", realMobile: true }, { os: "ios", os_version: "14", device: "iPhone 11", realMobile: true }, { os: "ios", os_version: "13", device: "iPhone XS", realMobile: true }, { os: "ios", os_version: "13", device: "iPhone 11 Pro Max", realMobile: true }, { os: "ios", os_version: "13", device: "iPhone 11 Pro", realMobile: true }, { os: "ios", os_version: "13", device: "iPhone 11", realMobile: true }, { os: "ios", os_version: "12", device: "iPhone XS", realMobile: true }, { os: "ios", os_version: "12", device: "iPhone XS Max", realMobile: true }, { os: "ios", os_version: "15", device: "iPhone XR", realMobile: true }, { os: "ios", os_version: "12", device: "iPhone XR", realMobile: true }, { os: "ios", os_version: "11", device: "iPhone X", realMobile: true }, { os: "ios", os_version: "15", device: "iPhone 8", realMobile: true }, { os: "ios", os_version: "13", device: "iPhone 8", realMobile: true }, { os: "ios", os_version: "12", device: "iPhone 8", realMobile: true }, { os: "ios", os_version: "11", device: "iPhone 8", realMobile: true }, { os: "ios", os_version: "12", device: "iPhone 8 Plus", realMobile: true }, { os: "ios", os_version: "11", device: "iPhone 8 Plus", realMobile: true }, { os: "ios", os_version: "12", device: "iPhone 7", realMobile: true }, { os: "ios", os_version: "10", device: "iPhone 7", realMobile: true }, { os: "ios", os_version: "12", device: "iPhone 6S", realMobile: true }, { os: "ios", os_version: "11", device: "iPhone 6S", realMobile: true }, { os: "ios", os_version: "11", device: "iPhone 6S Plus", realMobile: true }, { os: "ios", os_version: "11", device: "iPhone 6", realMobile: true }, { os: "ios", os_version: "15", device: "iPhone SE 2022", realMobile: true }, { os: "ios", os_version: "13", device: "iPhone SE 2020", realMobile: true }, { os: "ios", os_version: "11", device: "iPhone SE", realMobile: true }, { os: "ios", os_version: "14", device: "iPad Air 4", realMobile: true }, { os: "ios", os_version: "15", device: "iPad 9th", realMobile: true }, { os: "ios", os_version: "16", device: "iPad Pro 12.9 2022", realMobile: true }, { os: "ios", os_version: "16", device: "iPad Pro 12.9 2020", realMobile: true }, { os: "ios", os_version: "16", device: "iPad Pro 11 2022", realMobile: true }, { os: "ios", os_version: "16", device: "iPad 10th", realMobile: true }, { os: "ios", os_version: "15", device: "iPad Air 5", realMobile: true }, { os: "ios", os_version: "14", device: "iPad Pro 12.9 2021", realMobile: true }, { os: "ios", os_version: "14", device: "iPad Pro 12.9 2020", realMobile: true }, { os: "ios", os_version: "14", device: "iPad Pro 11 2021", realMobile: true }, { os: "ios", os_version: "13", device: "iPad Pro 12.9 2020", realMobile: true }, { os: "ios", os_version: "16", device: "iPad 8th", realMobile: true }, { os: "ios", os_version: "15", device: "iPad Pro 12.9 2018", realMobile: true }, { os: "ios", os_version: "15", device: "iPad Mini 2021", realMobile: true }, { os: "ios", os_version: "14", device: "iPad 8th", realMobile: true }, { os: "ios", os_version: "13", device: "iPad Pro 12.9 2018", realMobile: true }, { os: "ios", os_version: "13", device: "iPad Pro 11 2020", realMobile: true }, { os: "ios", os_version: "13", device: "iPad Mini 2019", realMobile: true }, { os: "ios", os_version: "13", device: "iPad Air 2019", realMobile: true }, { os: "ios", os_version: "13", device: "iPad 7th", realMobile: true }, { os: "ios", os_version: "12", device: "iPad Pro 12.9 2018", realMobile: true }, { os: "ios", os_version: "12", device: "iPad Pro 11 2018", realMobile: true }, { os: "ios", os_version: "12", device: "iPad Mini 2019", realMobile: true }, { os: "ios", os_version: "12", device: "iPad Air 2019", realMobile: true }, { os: "ios", os_version: "11", device: "iPad Pro 9.7 2016", realMobile: true }, { os: "ios", os_version: "11", device: "iPad Pro 12.9 2017", realMobile: true }, { os: "ios", os_version: "11", device: "iPad Mini 4", realMobile: true }, { os: "ios", os_version: "11", device: "iPad 6th", realMobile: true }, { os: "ios", os_version: "11", device: "iPad 5th", realMobile: true }, { os: "android", os_version: "12.0", device: "Samsung Galaxy S22 Ultra", realMobile: true }, { os: "android", os_version: "12.0", device: "Samsung Galaxy S22 Plus", realMobile: true }, { os: "android", os_version: "12.0", device: "Samsung Galaxy S22", realMobile: true }, { os: "android", os_version: "12.0", device: "Samsung Galaxy S21", realMobile: true }, { os: "android", os_version: "11.0", device: "Samsung Galaxy S21 Ultra", realMobile: true }, { os: "android", os_version: "11.0", device: "Samsung Galaxy S21", realMobile: true }, { os: "android", os_version: "11.0", device: "Samsung Galaxy S21 Plus", realMobile: true }, { os: "android", os_version: "10.0", device: "Samsung Galaxy S20", realMobile: true }, { os: "android", os_version: "10.0", device: "Samsung Galaxy S20 Plus", realMobile: true }, { os: "android", os_version: "10.0", device: "Samsung Galaxy S20 Ultra", realMobile: true }, { os: "android", os_version: "11.0", device: "Samsung Galaxy M52", realMobile: true }, { os: "android", os_version: "11.0", device: "Samsung Galaxy M32", realMobile: true }, { os: "android", os_version: "11.0", device: "Samsung Galaxy A52", realMobile: true }, { os: "android", os_version: "10.0", device: "Samsung Galaxy Note 20 Ultra", realMobile: true }, { os: "android", os_version: "10.0", device: "Samsung Galaxy Note 20", realMobile: true }, { os: "android", os_version: "10.0", device: "Samsung Galaxy A51", realMobile: true }, { os: "android", os_version: "10.0", device: "Samsung Galaxy A11", realMobile: true }, { os: "android", os_version: "9.0", device: "Samsung Galaxy S9 Plus", realMobile: true }, { os: "android", os_version: "9.0", device: "Samsung Galaxy S10e", realMobile: true }, { os: "android", os_version: "9.0", device: "Samsung Galaxy S10 Plus", realMobile: true }, { os: "android", os_version: "9.0", device: "Samsung Galaxy S10", realMobile: true }, { os: "android", os_version: "9.0", device: "Samsung Galaxy Note 10 Plus", realMobile: true }, { os: "android", os_version: "9.0", device: "Samsung Galaxy Note 10", realMobile: true }, { os: "android", os_version: "9.0", device: "Samsung Galaxy A10", realMobile: true }, { os: "android", os_version: "8.1", device: "Samsung Galaxy Note 9", realMobile: true }, { os: "android", os_version: "8.1", device: "Samsung Galaxy J7 Prime", realMobile: true }, { os: "android", os_version: "8.0", device: "Samsung Galaxy S9 Plus", realMobile: true }, { os: "android", os_version: "8.0", device: "Samsung Galaxy S9", realMobile: true }, { os: "android", os_version: "7.1", device: "Samsung Galaxy Note 8", realMobile: true }, { os: "android", os_version: "7.1", device: "Samsung Galaxy A8", realMobile: true }, { os: "android", os_version: "7.0", device: "Samsung Galaxy S8 Plus", realMobile: true }, { os: "android", os_version: "7.0", device: "Samsung Galaxy S8", realMobile: true }, { os: "android", os_version: "6.0", device: "Samsung Galaxy S7", realMobile: true }, { os: "android", os_version: "5.0", device: "Samsung Galaxy S6", realMobile: true }, { os: "android", os_version: "13.0", device: "Google Pixel 7 Pro", realMobile: true }, { os: "android", os_version: "13.0", device: "Google Pixel 7", realMobile: true }, { os: "android", os_version: "13.0", device: "Google Pixel 6 Pro", realMobile: true }, { os: "android", os_version: "12.0", device: "Google Pixel 6 Pro", realMobile: true }, { os: "android", os_version: "12.0", device: "Google Pixel 6", realMobile: true }, { os: "android", os_version: "12.0", device: "Google Pixel 5", realMobile: true }, { os: "android", os_version: "11.0", device: "Google Pixel 5", realMobile: true }, { os: "android", os_version: "11.0", device: "Google Pixel 4", realMobile: true }, { os: "android", os_version: "10.0", device: "Google Pixel 4 XL", realMobile: true }, { os: "android", os_version: "10.0", device: "Google Pixel 4", realMobile: true }, { os: "android", os_version: "10.0", device: "Google Pixel 3", realMobile: true }, { os: "android", os_version: "9.0", device: "Google Pixel 3a XL", realMobile: true }, { os: "android", os_version: "9.0", device: "Google Pixel 3a", realMobile: true }, { os: "android", os_version: "9.0", device: "Google Pixel 3 XL", realMobile: true }, { os: "android", os_version: "9.0", device: "Google Pixel 3", realMobile: true }, { os: "android", os_version: "9.0", device: "Google Pixel 2", realMobile: true }, { os: "android", os_version: "8.0", device: "Google Pixel 2", realMobile: true }, { os: "android", os_version: "7.1", device: "Google Pixel", realMobile: true }, { os: "android", os_version: "6.0", device: "Google Nexus 6", realMobile: true }, { os: "android", os_version: "4.4", device: "Google Nexus 5", realMobile: true }, { os: "android", os_version: "11.0", device: "OnePlus 9", realMobile: true }, { os: "android", os_version: "10.0", device: "OnePlus 8", realMobile: true }, { os: "android", os_version: "10.0", device: "OnePlus 7T", realMobile: true }, { os: "android", os_version: "9.0", device: "OnePlus 7", realMobile: true }, { os: "android", os_version: "9.0", device: "OnePlus 6T", realMobile: true }, { os: "android", os_version: "11.0", device: "Xiaomi Redmi Note 11", realMobile: true }, { os: "android", os_version: "10.0", device: "Xiaomi Redmi Note 9", realMobile: true }, { os: "android", os_version: "9.0", device: "Xiaomi Redmi Note 8", realMobile: true }, { os: "android", os_version: "9.0", device: "Xiaomi Redmi Note 7", realMobile: true }, { os: "android", os_version: "11.0", device: "Vivo Y21", realMobile: true }, { os: "android", os_version: "11.0", device: "Vivo V21", realMobile: true }, { os: "android", os_version: "10.0", device: "Vivo Y50", realMobile: true }, { os: "android", os_version: "11.0", device: "Oppo Reno 6", realMobile: true }, { os: "android", os_version: "11.0", device: "Oppo A96", realMobile: true }, { os: "android", os_version: "10.0", device: "Oppo Reno 3 Pro", realMobile: true }, { os: "android", os_version: "11.0", device: "Motorola Moto G71 5G", realMobile: true }, { os: "android", os_version: "10.0", device: "Motorola Moto G9 Play", realMobile: true }, { os: "android", os_version: "9.0", device: "Motorola Moto G7 Play", realMobile: true }, { os: "android", os_version: "9.0", device: "Huawei P30", realMobile: true }, { os: "android", os_version: "12.0", device: "Samsung Galaxy Tab S8", realMobile: true }, { os: "android", os_version: "11.0", device: "Samsung Galaxy Tab S7", realMobile: true }, { os: "android", os_version: "10.0", device: "Samsung Galaxy Tab S7", realMobile: true }, { os: "android", os_version: "9.0", device: "Samsung Galaxy Tab S6", realMobile: true }, { os: "android", os_version: "9.0", device: "Samsung Galaxy Tab S5e", realMobile: true }, { os: "android", os_version: "8.1", device: "Samsung Galaxy Tab S4", realMobile: true } ]; // src/server/config.ts var getCommitHash = () => { if (gitHead) { return gitHead.substring(0, 7); } try { return execSync("git rev-parse --short HEAD", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); } catch { return ""; } }; var customDevicesPath = "./server/data/custom_devices.json"; var logsFolder = "./logs"; dotenv2.config(); var CURRENT_VERSION = version; var [major, minor] = CURRENT_VERSION.split(".").map(Number); var minSupportedMinor = Math.max(0, minor - 2); var MIN_SUPPORTED_SDK_VERSION = `${major}.${minSupportedMinor}.0`; var config = { version, commitHash: getCommitHash(), minSupportedSdkVersion: MIN_SUPPORTED_SDK_VERSION, apiVersion: "1", // this isn't used getDevices: async () => { if (fs.existsSync(customDevicesPath)) { return [...devices_default, ...(await import(customDevicesPath)).default]; } return devices_default; }, defaultImagesPath: env.SYNGRISI_IMAGES_PATH, domSnapshotsPath: env.SYNGRISI_DOM_SNAPSHOTS_PATH || env.SYNGRISI_IMAGES_PATH, connectionString: env.SYNGRISI_DB_URI || "mongodb://127.0.0.1:27017/SyngrisiDb", host: env.SYNGRISI_HOSTNAME, port: env.SYNGRISI_APP_PORT || 3e3, backupsFolder: "./backups", enableHttpLogger: env.SYNGRISI_HTTP_LOG, httpLoggerFilePath: `${logsFolder}/http.log`, storeSessionKey: env.SYNGRISI_SESSION_STORE_KEY || crypto2.randomBytes(64).toString("hex"), codeCoverage: env.SYNGRISI_COVERAGE, disableCors: env.SYNGRISI_DISABLE_DEV_CORS, fileUploadMaxSize: 50 * 1024 * 1024, adminDataJobsPath: env.SYNGRISI_ADMIN_DATA_JOBS_PATH, adminDataJobsTtlMs: env.SYNGRISI_ADMIN_DATA_JOBS_TTL_MS, adminDataMaxConcurrentJobs: env.SYNGRISI_ADMIN_DATA_MAX_CONCURRENT_JOBS, adminDataUploadMaxSize: env.SYNGRISI_ADMIN_DATA_UPLOAD_MAX_SIZE_MB * 1024 * 1024, testMode: env.SYNGRISI_TEST_MODE, jsonLimit: "50mb", tmpDir: env.SYNGRISI_TMP_DIR, helmet: { crossOriginEmbedderPolicy: false, crossOriginResourcePolicy: false, crossOriginOpenerPolicy: false, contentSecurityPolicy: { useDefaults: false, directives: { defaultSrc: ["'self'", "*", "'unsafe-inline'", "'unsafe-eval'", "data:", "blob:"], frameAncestors: ["'self'", "*"], frameSrc: ["'self'", "*"], scriptSrc: ["'self'", "*", "'unsafe-inline'", "'unsafe-eval'"], styleSrc: ["'self'", "*", "'unsafe-inline'"], imgSrc: ["'self'", "*", "data:", "blob:"], fontSrc: ["'self'", "*", "data:"], connectSrc: ["'self'", "*"], baseUri: ["'self'"], formAction: ["'self'"], objectSrc: ["'none'"], scriptSrcAttr: ["'none'"] } }, hsts: false }, rateLimit: { windowMs: env.SYNGRISI_RATE_LIMIT_WINDOW_MS, max: env.SYNGRISI_RATE_LIMIT_MAX, standardHeaders: true, legacyHeaders: false }, authRateLimit: { windowMs: env.SYNGRISI_AUTH_RATE_LIMIT_WINDOW_MS, max: env.SYNGRISI_AUTH_RATE_LIMIT_MAX, standardHeaders: true, legacyHeaders: false } }; if (!fs.existsSync(config.defaultImagesPath)) { fs.mkdirSync(config.defaultImagesPath, { recursive: true }); } if (config.domSnapshotsPath !== config.defaultImagesPath && !fs.existsSync(config.domSnapshotsPath)) { fs.mkdirSync(config.domSnapshotsPath, { recursive: true }); } if (!fs.existsSync(config.adminDataJobsPath)) { fs.mkdirSync(config.adminDataJobsPath, { recursive: true }); } if (!fs.existsSync(logsFolder)) { fs.mkdirSync(logsFolder, { recursive: true }); } // src/server/services/admin-data-job.service.ts var pipelineAsync = promisify(pipeline); var { BSON } = mongoose.mongo; var activeTasks = /* @__PURE__ */ new Map(); var META_FILENAME = "job.json"; var LOG_FILENAME = "job.log"; var DB_EXPORT_DIRNAME = "db-export"; var isActiveStatus = (status) => status === "pending" || status === "running"; var getJobDir = (jobId) => path2.join(config.adminDataJobsPath, jobId); var getJobMetaPath = (jobId) => path2.join(getJobDir(jobId), META_FILENAME); var getJobLogPath = (jobId) => path2.join(getJobDir(jobId), LOG_FILENAME); async function ensureDir(dirPath) { await fsp.mkdir(dirPath, { recursive: true }); } async function removeDirSafe(dirPath) { if (!dirPath) return; await fsp.rm(dirPath, { recursive: true, force: true }); } async function fileExists(filePath) { try { await fsp.access(filePath); return true; } catch { return false; } } async function writeJob(job) { await ensureDir(job.workDir); await fsp.writeFile(getJobMetaPath(job.id), JSON.stringify(job, null, 2)); } async function appendLog(jobId, message) { const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] ${message} `; await fsp.appendFile(getJobLogPath(jobId), line); } async function readJob(jobId) { try { const raw = await fsp.readFile(getJobMetaPath(jobId), "utf8"); return JSON.parse(raw); } catch { return null; } } async function listJobsInternal() { await ensureDir(config.adminDataJobsPath); const entries = await fsp.readdir(config.adminDataJobsPath, { withFileTypes: true }); const jobs = await Promise.all(entries.filter((entry) => entry.isDirectory()).map((entry) => readJob(entry.name))); return jobs.filter((job) => Boolean(job)).sort((a, b) => b.createdAt.localeCompare(a.createdAt)); } async function updateJob(jobId, patch) { const current = await readJob(jobId); if (!current) { throw new Error(`Job not found: ${jobId}`); } const nextJob = { ...current, ...patch }; await writeJob(nextJob); return nextJob; } async function updateProgress(jobId, progress, message, statsPatch) { const current = await readJob(jobId); if (!current) return; const nextProgress = { ...current.progress, ...progress }; if (typeof nextProgress.current === "number" && typeof nextProgress.total === "number" && nextProgress.total > 0) { nextProgress.percent = Math.min(100, Math.round(nextProgress.current / nextProgress.total * 100)); } await writeJob({ ...current, progress: nextProgress, message: message ?? current.message, stats: { ...current.stats, ...statsPatch } }); } async function finalizeJob(jobId, status, patch = {}) { const current = await readJob(jobId); if (!current) return null; const job = { ...current, ...patch, status, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }; await writeJob(job); return job; } function normalizeArchiveName(type) { const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-"); switch (type) { case "db_backup": return `syngrisi-db-backup-${stamp}.tar.gz`; case "screenshots_backup": return `syngrisi-screenshots-backup-${stamp}.tar.gz`; default: return `${type}-${stamp}.tar.gz`; } } async function getRunningJobs() { const jobs = await listJobsInternal(); return jobs.filter((job) => isActiveStatus(job.status)); } async function hasActiveDatabaseRestoreJob() { const jobs = await getRunningJobs(); return jobs.some((job) => job.type === "db_restore"); } async function assertCanStartJob() { const runningJobs = await getRunningJobs(); if (runningJobs.length >= config.adminDataMaxConcurrentJobs) { throw new Error(`Another data job is already active: ${runningJobs[0].id}`); } } async function countFilesRecursive(rootDir) { let count = 0; const stack = [rootDir]; while (stack.length > 0) { const currentDir = stack.pop(); if (!currentDir) continue; const dir = await fsp.opendir(currentDir); for await (const entry of dir) { const entryPath = path2.join(currentDir, entry.name); if (entry.isDirectory()) { stack.push(entryPath); } else if (entry.isFile()) { count += 1; } } } return count; } async function streamToFile(sourcePath, targetPath) { await ensureDir(path2.dirname(targetPath)); await pipelineAsync(fs2.createReadStream(sourcePath), fs2.createWriteStream(targetPath)); } function getActiveState(jobId) { return activeTasks.get(jobId); } function assertNotCancelled(jobId) { if (getActiveState(jobId)?.cancelRequested) { throw new Error("Job cancelled"); } } async function createJob(type, params) { await assertCanStartJob(); const id = randomUUID(); const workDir = getJobDir(id); await ensureDir(workDir); const job = { id, type, status: "pending", params, progress: { stage: "queued", percent: 0 }, message: "Queued", stats: {}, downloadAvailable: false, workDir, logFilePath: getJobLogPath(id), createdAt: (/* @__PURE__ */ new Date()).toISOString() }; await writeJob(job); await appendLog(id, `Job created: ${type}`); return job; } async function addFileToTar(pack, filePath, entryName) { const stat = await fsp.stat(filePath); await new Promise((resolve, reject) => { const entry = pack.entry({ name: entryName, size: stat.size, mode: stat.mode }, (error) => { if (error) { reject(error); return; } resolve(); }); fs2.createReadStream(filePath).on("error", reject).pipe(entry).on("error", reject); }); } async function createTarGzArchive(outputPath, items) { await ensureDir(path2.dirname(outputPath)); const pack = tar.pack(); const gzip = createGzip(); const output = fs2.createWriteStream(outputPath); const pipelinePromise = pipelineAsync(pack, gzip, output); for (const item of items) { await addFileToTar(pack, item.path, item.name); } pack.finalize(); await pipelinePromise; } async function extractTarGzArchive(archivePath, destinationDir) { await ensureDir(destinationDir); const extract = tar.extract(); await new Promise((resolve, reject) => { extract.on("entry", (header, stream, next) => { const outputPath = path2.join(destinationDir, header.name); const finishEntry = (error) => { if (error) { reject(error); return; } next(); }; if (header.type === "directory") { void ensureDir(outputPath).then(() => { stream.resume(); finishEntry(); }).catch((error) => finishEntry(error)); return; } void ensureDir(path2.dirname(outputPath)).then(() => pipelineAsync(stream, fs2.createWriteStream(outputPath))).then(() => finishEntry()).catch((error) => finishEntry(error)); }); extract.on("finish", () => resolve()); extract.on("error", reject); fs2.createReadStream(archivePath).on("error", reject).pipe(createGunzip()).on("error", reject).pipe(extract).on("error", reject); }); } async function countFilesInTarGzArchive(archivePath) { const extract = tar.extract(); let totalFiles = 0; await new Promise((resolve, reject) => { extract.on("entry", (_header, stream, next) => { if (_header.type === "file") { totalFiles += 1; } stream.resume(); next(); }); extract.on("finish", () => resolve()); extract.on("error", reject); fs2.createReadStream(archivePath).on("error", reject).pipe(createGunzip()).on("error", reject).pipe(extract).on("error", reject); }); return totalFiles; } async function writeCollectionDump(jobId, collectionName, outputPath) { const db = mongoose.connection.db; if (!db) { throw new Error("MongoDB connection is not available"); } let documentCount = 0; await ensureDir(path2.dirname(outputPath)); const gzip = createGzip(); const output = fs2.createWriteStream(outputPath); gzip.pipe(output); const cursor = db.collection(collectionName).find({}, { timeout: false }); for await (const doc of cursor) { assertNotCancelled(jobId); gzip.write(BSON.serialize(doc)); documentCount += 1; if (documentCount % 1e3 === 0) { await appendLog(jobId, `Exported ${documentCount} documents from ${collectionName}`); } } gzip.end(); await new Promise((resolve, reject) => { output.on("finish", () => resolve()); output.on("error", reject); gzip.on("error", reject); }); return documentCount; } async function walkFiles(rootDir, onFile) { const stack = [rootDir]; while (stack.length > 0) { const currentDir = stack.pop(); if (!currentDir) continue; const dir = await fsp.opendir(currentDir); for await (const entry of dir) { const fullPath = path2.join(currentDir, entry.name); const relativePath = path2.relative(rootDir, fullPath); if (entry.isDirectory()) { stack.push(fullPath); } else if (entry.isFile()) { await onFile(fullPath, relativePath); } } } } async function runDbBackup(job) { const db = mongoose.connection.db; if (!db) { throw new Error("MongoDB connection is not available"); } const archiveName = normalizeArchiveName(job.type); const archivePath = path2.join(job.workDir, archiveName); const exportDir = path2.join(job.workDir, DB_EXPORT_DIRNAME); await ensureDir(exportDir); await updateProgress(job.id, { stage: "indexing", percent: 5 }, "Inspecting database"); const collectionInfos = await db.listCollections({}, { nameOnly: true }).toArray(); const collections = collectionInfos.map((item) => item.name).filter((name) => !name.startsWith("system.")); const manifest = { format: "syngrisi-db-backup-v1", exportedAt: (/* @__PURE__ */ new Date()).toISOString(), databaseName: db.databaseName, collections: [] }; let processedCollections = 0; for (const collectionName of collections) { assertNotCancelled(job.id); const dumpFileName = `${collectionName}.bson.gz`; const dumpPath = path2.join(exportDir, dumpFileName); await updateProgress(job.id, { stage: "dumping", current: processedCollections, total: collections.length }, `Exporting ${collectionName}`); const documentCount = await writeCollectionDump(job.id, collectionName, dumpPath); const indexes = await db.collection(collectionName).indexes(); manifest.collections.push({ name: collectionName, dumpFile: dumpFileName, documentCount, indexes: indexes.map((index) => JSON.parse(JSON.stringify(index))) }); processedCollections += 1; await updateProgress(job.id, { stage: "dumping", current: processedCollections, total: collections.length }, `Exported ${collectionName}`); } const manifestPath = path2.join(exportDir, "manifest.json"); await fsp.writeFile(manifestPath, JSON.stringify(manifest, null, 2)); await updateProgress(job.id, { stage: "archiving", percent: 80 }, "Creating archive"); const tarItems = [ { path: manifestPath, name: "manifest.json" }, ...manifest.collections.map((collection) => ({ path: path2.join(exportDir, collection.dumpFile), name: `collections/${collection.dumpFile}` })) ]; await createTarGzArchive(archivePath, tarItems); const stat = await fsp.stat(archivePath); await finalizeJob(job.id, "completed", { message: "Database backup completed", archivePath, archiveName, downloadAvailable: true, stats: { archiveSizeBytes: stat.size, processedFiles: manifest.collections.reduce((acc, item) => acc + item.documentCount, 0), totalFiles: manifest.collections.length }, progress: { stage: "completed", percent: 100, current: manifest.collections.length, total: manifest.collections.length } }); } async function recreateIndexes(collectionName, indexes) { const db = mongoose.connection.db; if (!db) { throw new Error("MongoDB connection is not available"); } const filtered = indexes.filter((index) => index.name !== "_id_"); if (filtered.length === 0) { return; } const definitions = filtered.map((index) => { const { key, ...options } = index; return { key, ...options }; }); await db.collection(collectionName).createIndexes(definitions); } async function importCollectionDump(jobId, collectionName, dumpPath) { const db = mongoose.connection.db; if (!db) { throw new Error("MongoDB connection is not available"); } const collection = db.collection(collectionName); const batch = []; let inserted = 0; const flush = async () => { if (batch.length === 0) return; await collection.insertMany(batch, { ordered: false }); inserted += batch.length; batch.length = 0; }; const input = fs2.createReadStream(dumpPath).pipe(createGunzip()); let pending = Buffer.alloc(0); for await (const chunk of input) { assertNotCancelled(jobId); pending = Buffer.concat([pending, chunk]); while (pending.length >= 4) { const documentSize = pending.readInt32LE(0); if (documentSize <= 0) { throw new Error(`Invalid BSON document size ${documentSize} in ${collectionName}`); } if (pending.length < documentSize) { break; } const documentBuffer = pending.subarray(0, documentSize); pending = pending.subarray(documentSize); batch.push(BSON.deserialize(documentBuffer)); if (batch.length >= 1e3) { await flush(); await appendLog(jobId, `Imported ${inserted} documents into ${collectionName}`); } } } if (pending.length > 0) { throw new Error(`Unexpected trailing BSON bytes in ${collectionName}`); } await flush(); return inserted; } async function runDbRestore(job) { const db = mongoose.connection.db; if (!db) { throw new Error("MongoDB connection is not available"); } const uploadPath = String(job.uploadPath || ""); if (!uploadPath || !await fileExists(uploadPath)) { throw new Error("Uploaded archive is missing"); } const extractDir = path2.join(job.workDir, "extracted-db"); await updateProgress(job.id, { stage: "extracting", percent: 10 }, "Extracting database archive"); await extractTarGzArchive(uploadPath, extractDir); const manifestPath = path2.join(extractDir, "manifest.json"); if (!await fileExists(manifestPath)) { throw new Error("manifest.json is missing from database archive"); } const manifest = JSON.parse(await fsp.readFile(manifestPath, "utf8")); if (manifest.format !== "syngrisi-db-backup-v1") { throw new Error("Unsupported database backup format"); } await updateProgress(job.id, { stage: "dropping_db", percent: 30 }, "Dropping current database"); await db.dropDatabase(); let importedCollections = 0; for (const collectionInfo of manifest.collections) { assertNotCancelled(job.id); const dumpPath = path2.join(extractDir, "collections", collectionInfo.dumpFile); await updateProgress( job.id, { stage: "restoring", current: importedCollections, total: manifest.collections.length }, `Restoring ${collectionInfo.name}` ); await importCollectionDump(job.id, collectionInfo.name, dumpPath); await recreateIndexes(collectionInfo.name, collectionInfo.indexes); importedCollections += 1; await updateProgress( job.id, { stage: "restoring", current: importedCollections, total: manifest.collections.length }, `Restored ${collectionInfo.name}` ); } await finalizeJob(job.id, "completed", { message: "Database restore completed", progress: { stage: "completed", percent: 100, current: importedCollections, total: manifest.collections.length }, stats: { processedFiles: manifest.collections.reduce((acc, item) => acc + item.documentCount, 0), totalFiles: manifest.collections.length } }); } async function runScreenshotsBackup(job) { const archiveName = normalizeArchiveName(job.type); const archivePath = path2.join(job.workDir, archiveName); const totalFiles = await countFilesRecursive(config.defaultImagesPath); let processedFiles = 0; await ensureDir(path2.dirname(archivePath)); const pack = tar.pack(); const gzip = createGzip(); const output = fs2.createWriteStream(archivePath); const archivePipeline = pipelineAsync(pack, gzip, output); await updateProgress(job.id, { stage: "archiving", current: 0, total: totalFiles }, "Archiving screenshots", { totalFiles }); await walkFiles(config.defaultImagesPath, async (fullPath, relativePath) => { assertNotCancelled(job.id); await addFileToTar(pack, fullPath, relativePath); processedFiles += 1; if (processedFiles % 200 === 0 || processedFiles === totalFiles) { await updateProgress(job.id, { stage: "archiving", current: processedFiles, total: totalFiles }, "Archiving screenshots", { processedFiles, totalFiles }); } }); pack.finalize(); await archivePipeline; const stat = await fsp.stat(archivePath); await finalizeJob(job.id, "completed", { message: "Screenshots backup completed", archivePath, archiveName, downloadAvailable: true, stats: { archiveSizeBytes: stat.size, processedFiles, totalFiles }, progress: { stage: "completed", percent: 100, current: processedFiles, total: totalFiles } }); } async function runScreenshotsRestore(job) { const uploadPath = String(job.uploadPath || ""); const skipExisting = Boolean(job.params.skipExisting); if (!uploadPath || !await fileExists(uploadPath)) { throw new Error("Uploaded archive is missing"); } const totalFiles = await countFilesInTarGzArchive(uploadPath); let processedFiles = 0; let importedFiles = 0; let skippedFiles = 0; let errorFiles = 0; await updateProgress(job.id, { stage: "importing", current: 0, total: totalFiles }, "Importing screenshots", { totalFiles }); const extract = tar.extract(); await new Promise((resolve, reject) => { extract.on("entry", (header, stream, next) => { const finish = (error) => { if (error) { reject(error); return; } next(); }; if (header.type !== "file") { stream.resume(); finish(); return; } const relativePath = header.name; const targetPath = path2.join(config.defaultImagesPath, relativePath); void (async () => { assertNotCancelled(job.id); const exists = await fileExists(targetPath); if (exists && skipExisting) { skippedFiles += 1; stream.resume(); } else { await ensureDir(path2.dirname(targetPath)); if (exists) { await fsp.rm(targetPath, { force: true }); } await pipelineAsync(stream, fs2.createWriteStream(targetPath)); importedFiles += 1; } processedFiles += 1; if (processedFiles % 200 === 0 || processedFiles === totalFiles) { await updateProgress( job.id, { stage: "importing", current: processedFiles, total: totalFiles }, "Importing screenshots", { processedFiles, importedFiles, skippedFiles, errorFiles, totalFiles } ); } })().then(() => finish()).catch(async (error) => { errorFiles += 1; await appendLog(job.id, `Failed to import ${relativePath}: ${error.message}`); stream.resume(); processedFiles += 1; finish(); }); }); extract.on("finish", () => resolve()); extract.on("error", reject); fs2.createReadStream(uploadPath).on("error", reject).pipe(createGunzip()).on("error", reject).pipe(extract).on("error", reject); }); await finalizeJob(job.id, "completed", { message: "Screenshots restore completed", progress: { stage: "completed", percent: 100, current: processedFiles, total: totalFiles }, stats: { totalFiles, processedFiles, importedFiles, skippedFiles, errorFiles } }); } async function cleanupExpiredJobs() { const jobs = await listJobsInternal(); const now = Date.now(); await Promise.all(jobs.map(async (job) => { if (isActiveStatus(job.status)) return; const finishedAt = job.finishedAt ? new Date(job.finishedAt).getTime() : new Date(job.createdAt).getTime(); if (now - finishedAt > config.adminDataJobsTtlMs) { await removeDirSafe(job.workDir); } })); } async function cleanupJobWorkdirs(jobId) { const job = await readJob(jobId); if (!job) return; await removeDirSafe(path2.join(job.workDir, "staging")); await removeDirSafe(path2.join(job.workDir, "extracted")); await removeDirSafe(path2.join(job.workDir, "extracted-db")); await removeDirSafe(path2.join(job.workDir, DB_EXPORT_DIRNAME)); if (job.status === "completed" && job.downloadAvailable) { if (job.uploadPath) { await fsp.rm(job.uploadPath, { force: true }); } return; } if (job.uploadPath) { await fsp.rm(job.uploadPath, { force: true }); } } async function markStaleJobsFailed() { const jobs = await listJobsInternal(); await Promise.all(jobs.map(async (job) => { if (isActiveStatus(job.status)) { await finalizeJob(job.id, "failed", { message: "Marked as failed after server restart", error: "Server restarted while job was running", downloadAvailable: false }); } })); } async function initialize() { await ensureDir(config.adminDataJobsPath); await markStaleJobsFailed(); await cleanupExpiredJobs(); } async function startJob(job) { setImmediate(() => { void runJob(job.id); }); } async function runJob(jobId) { const currentJob = await readJob(jobId); if (!currentJob || currentJob.status === "cancelled") { return; } activeTasks.set(jobId, { cancelRequested: false }); const job = await updateJob(jobId, { status: "running", startedAt: (/* @__PURE__ */ new Date()).toISOString(), progress: { stage: "preparing", percent: 0 }, message: "Preparing" }); try { switch (job.type) { case "db_backup": await runDbBackup(job); break; case "db_restore": await runDbRestore(job); break; case "screenshots_backup": await runScreenshotsBackup(job); break; case "screenshots_restore": await runScreenshotsRestore(job); break; default: throw new Error(`Unsupported job type: ${job.type}`); } } catch (error) { const message = error instanceof Error ? error.message : String(error); const cancelled = message === "Job cancelled"; await appendLog(jobId, `Job ${cancelled ? "cancelled" : "failed"}: ${message}`); await finalizeJob(jobId, cancelled ? "cancelled" : "failed", { error: cancelled ? void 0 : message, message: cancelled ? "Cancelled" : message, downloadAvailable: false }); } finally { activeTasks.delete(jobId); await cleanupJobWorkdirs(jobId); } } async function persistUploadToJobDir(file, jobDir, fileName) { const targetPath = path2.join(jobDir, fileName); await ensureDir(jobDir); if (file.tempFilePath) { await fsp.rename(file.tempFilePath, targetPath).catch(async () => { await streamToFile(file.tempFilePath, targetPath); await fsp.rm(file.tempFilePath, { force: true }); }); } else { await fsp.writeFile(targetPath, file.data); } return targetPath; } async function createDbBackupJob() { const job = await createJob("db_backup", {}); await startJob(job); return readJob(job.id); } async function createScreenshotsBackupJob() { const job = await createJob("screenshots_backup", {}); await startJob(job); return readJob(job.id); } async function createDbRestoreJob(file) { const job = await createJob("db_restore", {}); const uploadPath = await persistUploadToJobDir(file, path2.join(job.workDir, "staging"), file.name || "db-restore.tar.gz"); await updateJob(job.id, { uploadPath, message: "Upload stored, waiting for restore" }); const updated = await readJob(job.id); if (!updated) throw new Error("Failed to read created job"); await startJob(updated); return readJob(updated.id); } async function createScreenshotsRestoreJob(file, skipExisting) { const job = await createJob("screenshots_restore", { skipExisting }); const uploadPath = await persistUploadToJobDir(file, path2.join(job.workDir, "staging"), file.name || "screenshots-restore.tar.gz"); await updateJob(job.id, { uploadPath, message: "Upload stored, waiting for restore" }); const updated = await readJob(job.id); if (!updated) throw new Error("Failed to read created job"); await startJob(updated); return readJob(updated.id); } async function getJob(jobId) { return readJob(jobId); } async function getJobLog(jobId) { try { return await fsp.readFile(getJobLogPath(jobId), "utf8"); } catch { return ""; } } async function cancelJob(jobId) { const job = await readJob(jobId); if (!job) { throw new Error(`Job not found: ${jobId}`); } const active = activeTasks.get(jobId); if (!active) { return finalizeJob(jobId, job.status === "pending" ? "cancelled" : job.status, { message: job.status === "pending" ? "Cancelled" : job.message }); } active.cancelRequested = true; await appendLog(jobId, "Cancellation requested"); return updateJob(jobId, { message: "Cancellation requested"