@syngrisi/syngrisi
Version:
Syngrisi - Visual Testing Tool
1,855 lines (1,848 loc) • 51.2 kB
JavaScript
// 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"