const fs = require('fs'); const path = require('path'); const { execFileSync } = require('child_process'); const vhostService = require('./vhostService'); const RECIPES_DIR = path.join(__dirname, '../recipes'); const INSTALLED_FILE = '/var/lib/sawa/installed.json'; // Ensure data directory exists if (!fs.existsSync(path.dirname(INSTALLED_FILE))) { try { execFileSync('mkdir', ['-p', path.dirname(INSTALLED_FILE)]); execFileSync('chown', ['root:root', path.dirname(INSTALLED_FILE)]); } catch (e) { console.error('Failed to prepare /var/lib/sawa:', e.message); } } /** * Validates identifiers for security (dbName, dbUser, recipeId). */ const validateIdentifier = (str) => { if (!/^[a-zA-Z0-9_-]+$/.test(str)) { throw new Error(`Invalid identifier: ${str}`); } }; /** * Validates password to prevent issues with quoting. */ const validatePassword = (str) => { // We escape single quotes now, but still good to avoid some patterns if necessary. // For now, removing the $$ restriction since we use stdin and single quote escaping. }; /** * Escapes single quotes for standard SQL. */ const escapeSql = (str) => str.replace(/'/g, "''"); /** * Loads all recipe.json files from the recipes directory. */ const loadRecipes = () => { try { if (!fs.existsSync(RECIPES_DIR)) return []; const folders = fs.readdirSync(RECIPES_DIR); return folders.map(folder => { const recipePath = path.join(RECIPES_DIR, folder, 'recipe.json'); if (fs.existsSync(recipePath)) { return JSON.parse(fs.readFileSync(recipePath, 'utf-8')); } return null; }).filter(Boolean); } catch (err) { console.error('Failed to load recipes:', err); return []; } }; /** * Reads the status of installed applications. */ const getInstalledApps = () => { try { if (!fs.existsSync(INSTALLED_FILE)) return {}; return JSON.parse(fs.readFileSync(INSTALLED_FILE, 'utf-8')); } catch (err) { return {}; } }; /** * Executes installation for a specific recipe. * Shared logic for both standard and streaming installs. */ const installApp = async (recipeId, options, onLog = null) => { validateIdentifier(recipeId); const logs = []; const log = (msg) => { const line = `[${new Date().toLocaleTimeString()}] ${msg}`; logs.push(line); if (onLog) onLog(line); console.log(`[Installer:${recipeId}] ${line}`); return line; }; try { const recipes = loadRecipes(); const recipe = recipes.find(r => r.id === recipeId); if (!recipe) throw new Error(`Recipe ${recipeId} not found`); log(`Starting installation of ${recipe.name} v${recipe.version}...`); // Clean start: remove existing installDir if any if (recipe.installDir) { log(`Cleaning up existing directory: ${recipe.installDir}...`); try { execFileSync('rm', ['-rf', recipe.installDir]); } catch (e) { } } // 0. Dependencies if (recipe.dependencies) { await _stepDependencies(recipe, log); } // 1. Fetch await _stepFetch(recipe, log); // 2. Database if (recipe.database) { validateIdentifier(recipe.database.dbName); validateIdentifier(recipe.database.dbUser); await _stepDatabase(recipe.database, options.dbPassword, log); } // 3. Custom Steps if (recipe.steps) { await _stepCustom(recipe, options, options.dbPassword, log); } // 4. Service (PM2) if (recipe.service) { await _stepService(recipe.service, recipe.installDir, log); } // 5. VHost if (recipe.vhost) { await _stepVHost(recipe, options, log); } // Finalize const installed = getInstalledApps(); installed[recipeId] = { version: recipe.version, installedAt: new Date().toISOString(), domain: options.domain }; fs.writeFileSync(INSTALLED_FILE, JSON.stringify(installed, null, 2)); log(`Successfully installed ${recipe.name}!`); return { success: true, logs }; } catch (err) { log(`FATAL ERROR: ${err.message}`); return { success: false, logs }; } }; /** * SSE-friendly wrapper for installation. */ const streamInstall = async (recipeId, options, onLog) => { return await installApp(recipeId, options, onLog); }; // --- PRIVATE STEP HELPERS --- const _stepDependencies = async (recipe, log) => { const { dependencies } = recipe; if (!dependencies || !Array.isArray(dependencies)) return; log(`Installing dependencies: ${dependencies.join(', ')}...`); for (const item of dependencies) { log(`Adding package: ${item}...`); execFileSync('apk', ['add', item]); } log('Dependencies installed.'); }; const _stepFetch = async (recipe, log) => { const { fetch, version, installDir } = recipe; log(`Preparing directory: ${installDir}...`); // Panel runs as root, no sudo needed execFileSync('mkdir', ['-p', installDir]); execFileSync('chown', ['root:root', installDir]); if (fetch.type === 'none') { log('No fetch required, skipping...'); return; } if (fetch.type === 'binary') { const url = fetch.url.replace(/{version}/g, version); const dest = path.join(installDir, fetch.filename); log(`Downloading binary...`); execFileSync('curl', ['-L', '-o', dest, url]); if (fetch.chmod) { execFileSync('chmod', [fetch.chmod, dest]); } } log('Fetch completed.'); }; const _stepDatabase = async (dbConfig, password, log) => { const { type, dbName, dbUser } = dbConfig; log(`Provisioning ${type} database: ${dbName}...`); if (type === 'postgres') { const escapedPassword = escapeSql(password); // Idempotent Cleanup const cleanupSql = `DROP DATABASE IF EXISTS ${dbName};\n` + `DROP USER IF EXISTS ${dbUser};\n`; const createSql = `CREATE USER ${dbUser} WITH PASSWORD '${escapedPassword}';\n` + `CREATE DATABASE ${dbName} OWNER ${dbUser};\n`; try { log(`Cleaning up existing database/user if any...`); execFileSync('sudo', ['-u', 'postgres', 'psql'], { input: cleanupSql, env: { ...process.env } }); log(`Creating database and user...`); execFileSync('sudo', ['-u', 'postgres', 'psql'], { input: createSql, env: { ...process.env } }); } catch (e) { log(`Database note: ${e.message.split('\n')[0]}`); } } else if (type === 'mariadb') { // MariaDB idempotency execFileSync('mysql', ['-e', `DROP DATABASE IF EXISTS ${dbName};`]); execFileSync('mysql', ['-e', `DROP USER IF EXISTS '${dbUser}'@'localhost';`]); execFileSync('mysql', ['-e', `CREATE DATABASE ${dbName};`]); execFileSync('mysql', ['-e', `CREATE USER '${dbUser}'@'localhost' IDENTIFIED BY '${password}';`]); execFileSync('mysql', ['-e', `GRANT ALL PRIVILEGES ON ${dbName}.* TO '${dbUser}'@'localhost';`]); execFileSync('mysql', ['-e', 'FLUSH PRIVILEGES;']); } log('Database provisioning completed.'); }; const _stepCustom = async (recipe, options, password, log) => { const { steps, installDir } = recipe; for (const step of steps) { log(`Executing: ${step.name}...`); execFileSync('sh', ['-c', step.cmd], { cwd: installDir, env: { ...process.env, SAWA_DB_PASSWORD: password, SAWA_DOMAIN: options.domain, SAWA_RECIPE_DIR: path.join(RECIPES_DIR, recipe.id) } }); } log('Custom steps completed.'); }; const _stepService = async (serviceConfig, installDir, log) => { const { type, name, command, user } = serviceConfig; log(`Registering ${type} service: ${name}...`); if (type === 'pm2') { try { execFileSync('pm2', ['delete', name]); } catch (e) { } const args = ['start', command, '--name', name]; if (user) { args.push('--user', user); } execFileSync('pm2', args, { cwd: installDir }); execFileSync('pm2', ['save']); } log('Service registration completed.'); }; const _stepVHost = async (recipe, options, log) => { const { vhost, installDir } = recipe; const { domain } = options; const port = options.port || vhost.proxyPort || 80; log(`Configuring Nginx vhost for ${domain}...`); const type = vhost.type === 'proxy' ? 'proxy' : 'static'; const rootOrUrl = type === 'proxy' ? `http://127.0.0.1:${port}` : installDir; // createVHost returns filename like "domain.conf.disabled" const filename = await vhostService.createVHost(domain, rootOrUrl, port, type); // Enable and reload await vhostService.toggleVHost(filename, true); await vhostService.reloadNginx(); log('Nginx vhost configured, enabled and reloaded.'); }; module.exports = { loadRecipes, getInstalledApps, installApp, streamInstall };