@underpostnet/underpost
Version:
Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc
643 lines (575 loc) • 28.1 kB
JavaScript
/**
* WordPress runtime for the Underpost engine.
* Manages WordPress installations backed by LAMPP (Apache + PHP 8+) and MariaDB.
*
* Two provisioning modes:
* - **clone** — `conf.repository` is set: clones that GitHub repo as the wp root.
* - **fresh** — no `conf.repository`: downloads wordpress.org/latest.zip, creates
* the database if it does not exist, and writes wp-config.php.
*
* @module src/runtime/wp/Wp.js
* @namespace WpService
*/
import fs from 'fs-extra';
import path from 'path';
import { shellExec } from '../../server/process.js';
import { loggerFactory } from '../../server/logger.js';
import { Lampp } from '../lampp/Lampp.js';
import Underpost from '../../index.js';
const logger = loggerFactory(import.meta);
const WP_DOWNLOAD_URL = 'https://wordpress.org/latest.zip';
const WP_ZIP_PATH = '/tmp/wordpress-latest.zip';
const WP_BASE_DIR = '/opt/lampp/htdocs/wp';
// XAMPP ships its own PHP binary under /opt/lampp/bin which is not on the default
// PATH for non-login shells spawned by shellExec. Prepend it to every wp-cli call.
const LAMPP_BIN = '/opt/lampp/bin';
/**
* @class WpService
* @description Manages WordPress provisioning and Apache virtual-host wiring
* on top of the LAMPP service. Each server conf entry with `runtime: 'wp'`
* is handled here.
* @memberof WpService
*/
class WpService {
/**
* Ensures the base WordPress hosting directory exists.
*/
static ensureBaseDir() {
if (!fs.existsSync(WP_BASE_DIR)) fs.mkdirSync(WP_BASE_DIR, { recursive: true });
}
/**
* Returns the on-disk root path for a WordPress site.
* @param {string} host - Virtual-host name (used as folder name).
* @returns {string}
*/
static siteDir(host) {
return path.join(WP_BASE_DIR, host);
}
/**
* Ensures WP-CLI (`wp`) is available on PATH, downloading and installing it
* to `/usr/local/bin/wp` if it is not already present.
*/
static ensureWpCli() {
const existing = shellExec(`PATH="${LAMPP_BIN}:$PATH" which wp`, {
stdout: true,
silent: true,
disableLog: true,
silentOnError: true,
});
if (existing && existing.trim()) return;
logger.info('WP-CLI not found — installing to /usr/local/bin/wp');
shellExec(`curl -sL -o /tmp/wp-cli.phar https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar`);
shellExec(`chmod +x /tmp/wp-cli.phar`);
shellExec(`sudo mv /tmp/wp-cli.phar /usr/local/bin/wp`);
}
/**
* Ensures a no-op sendmail stub exists at /usr/sbin/sendmail so WP plugins
* that invoke sendmail directly do not crash when a real MTA is absent.
* Mirrors the Dockerfile `RUN printf '#!/bin/sh\ncat > /dev/null\n' > /usr/sbin/sendmail`
* line but guards against older container images that pre-date that layer.
*/
static ensureSendmail() {
const sendmailPath = '/usr/sbin/sendmail';
const existing = shellExec(`test -x "${sendmailPath}" && echo ok`, {
stdout: true,
silent: true,
disableLog: true,
silentOnError: true,
});
if (existing && existing.trim() === 'ok') return;
logger.info('sendmail stub missing — creating no-op at /usr/sbin/sendmail');
shellExec(
`printf '#!/bin/sh\\ncat > /dev/null\\n' | sudo tee ${sendmailPath} > /dev/null && sudo chmod +x ${sendmailPath}`,
);
}
/**
* Provisions a WordPress site and registers it with the LAMPP virtual-host router.
*
* Directory layout (e.g. host='test.nexodev.org', pathRoute='/wp'):
* - DocumentRoot (vhostDir): /opt/lampp/htdocs/wp/test.nexodev.org
* - WordPress files (wpDir): /opt/lampp/htdocs/wp/test.nexodev.org/wp
* - Root .htaccess rewrites / → /wp/ so Apache serves WordPress transparently.
*
* When pathRoute='/' the DocumentRoot and wpDir are the same directory.
*
* @param {object} opts
* @param {number} opts.port - Apache VirtualHost listen port.
* @param {string} opts.host - Server name / virtual host.
* @param {string} opts.pathRoute - URL path (e.g. '/wp' or '/').
* @param {string|null} [opts.repository] - GitHub HTTPS clone URL. When set the
* site root is cloned instead of downloading WordPress.
* @param {object|null} [opts.db] - MariaDB connection config:
* `{ host, name, user, password }`.
* Required for fresh installs; ignored when
* `repository` is set (wp-config.php is assumed
* to be part of the repo).
* @param {object|null} [opts.wp] - WordPress install config:
* `{ title, adminUser, adminPassword, adminEmail }`.
* @param {boolean} [opts.redirect] - If true, enables Apache RewriteEngine redirection.
* @param {string} [opts.redirectTarget] - Target URL for the redirect rule.
* @param {boolean} [opts.resetRouter] - Clear LAMPP router before appending.
* @returns {{ disabled: boolean }}
*/
static createApp({ port, host, pathRoute, repository, db, wp, redirect, redirectTarget, resetRouter }) {
if (!Lampp.enabled()) {
logger.warn(`LAMPP not installed — skipping ${host}`);
return { disabled: true };
}
WpService.ensureBaseDir();
// DocumentRoot for the Apache VirtualHost — always under WP_BASE_DIR/<host>
const vhostDir = WpService.siteDir(host);
// When pathRoute is a subdirectory (e.g. '/wp'), WordPress files live one level deeper.
// When pathRoute is '/' they share the same directory as the DocumentRoot.
const subDir = pathRoute && pathRoute !== '/' ? pathRoute.replace(/^\/+/, '').replace(/\/+$/, '') : '';
const wpDir = subDir ? path.join(vhostDir, subDir) : vhostDir;
let freshInstall = false;
if (repository) {
({ freshInstall } = WpService.provisionClone({ host, siteRoot: wpDir, repository, db, wp, subDir }));
} else {
({ freshInstall } = WpService.provisionFresh({ host, siteRoot: wpDir, db, wp, subDir }));
}
// Ensure git is initialized and linked to the backup repository.
// Mark the directory as safe before git operations — the site root is owned
// by daemon:daemon (Apache) and git 2.35+ refuses to run in directories owned
// by a different user unless explicitly declared safe.
if (repository) {
shellExec(`git config --global --add safe.directory "${wpDir}"`);
Underpost.repo.initLocalRepo({ path: wpDir, origin: repository });
}
// Write a root .htaccess that rewrites / → /subDir/ when running in subdirectory mode
if (subDir) {
WpService.ensureSubdirHtaccess({ vhostDir, subDir });
}
// Write security rules into the WordPress root .htaccess
WpService.ensureSecurityHtaccess({ dir: wpDir });
// Make the site writable by the XAMPP Apache process (runs as daemon:daemon).
// This is required for plugins like Wordfence WAF and Sucuri that write config/upload files.
shellExec(`sudo chown -R daemon:daemon "${vhostDir}"`);
shellExec(`sudo find "${vhostDir}" -type d -exec chmod 755 {} \\;`);
shellExec(`sudo find "${vhostDir}" -type f -exec chmod 644 {} \\;`);
// Wire up Apache VirtualHost via Lampp — DocumentRoot is always vhostDir;
// Lampp.createApp uses `directory` directly as the DocumentRoot.
const { disabled } = Lampp.createApp({
port,
host,
path: pathRoute,
directory: vhostDir,
redirect,
redirectTarget,
resetRouter,
});
// Immediately commit and push all generated files (wp-config.php, .htaccess,
// security rules, plugins, etc.) so that on rollout/restart the clone will
// have a complete working state and won't fall back to fresh install again.
if (repository && freshInstall) {
WpService.persistToRepo({ siteRoot: wpDir, repository, host });
}
return { disabled };
}
/**
* Clone mode — clones `repository` into `siteRoot` if not present, then
* verifies `wp-config.php` exists. If it is missing the site root is wiped
* and a fresh WordPress install is performed (requires `db` config).
* @param {object} opts
* @param {string} opts.host - Virtual-host name (for logging).
* @param {string} opts.siteRoot - Absolute path where the site should live.
* @param {string} opts.repository - HTTPS clone URL.
* @param {object|null} [opts.db] - MariaDB config used as fallback for fresh install.
*/
static provisionClone({ host, siteRoot, repository, db, wp, subDir = '' }) {
if (!process.env.GITHUB_TOKEN && repository && repository.startsWith('https://github.com/')) {
logger.warn(`${host}: GITHUB_TOKEN not set — git operations will fail for private repositories`);
}
// Step 0 — verify the remote repository is reachable; fall back to fresh install if not.
// repository.js isRemoteRepo handles token injection internally.
const repoAccessible = Underpost.repo.isRemoteRepo(repository);
logger.info(`${host}: remote accessible = ${repoAccessible} (${repository})`);
if (!repoAccessible) {
logger.warn(`${host}: remote repository not accessible (${repository}) — running fresh install`);
return WpService.provisionFresh({ host, siteRoot, db, wp, subDir });
}
// Step 1 — clone if the directory does not exist yet
if (!fs.existsSync(siteRoot)) {
logger.info(`${host}: cloning ${repository} → ${siteRoot}`);
const cloneUrl = Underpost.repo.resolveAuthUrl(repository);
const tmp = `${siteRoot}.tmp`;
if (fs.existsSync(tmp)) shellExec(`sudo rm -rf "${tmp}"`);
shellExec(`git clone "${cloneUrl}" "${tmp}"`);
shellExec(`sudo mv "${tmp}" "${siteRoot}"`);
shellExec(`sudo chmod -R 755 "${siteRoot}"`);
shellExec(`sudo chown -R daemon:daemon "${siteRoot}"`);
} else {
logger.info(`${host}: repo already present at ${siteRoot}`);
}
// Step 2 — verify wp-config.php; if missing, wipe and do a fresh install.
// initLocalRepo is NOT called here — createApp always calls it after provisionClone
// returns (whether clone or fallback path) so we avoid a double-init and ensure
// safe.directory is declared before git runs.
if (!fs.existsSync(path.join(siteRoot, 'wp-config.php'))) {
logger.warn(`${host}: wp-config.php not found — wiping site root and running fresh install`);
shellExec(`sudo rm -rf "${siteRoot}"`);
return WpService.provisionFresh({ host, siteRoot, db, wp, subDir });
}
return { freshInstall: false };
}
/**
* Fresh-install mode — downloads wordpress.org/latest.zip, extracts it,
* creates the MariaDB database, and writes wp-config.php.
* @param {object} opts
* @param {string} opts.host - Virtual-host name (for logging).
* @param {string} opts.siteRoot - Absolute path where WordPress should live.
* @param {object|null} opts.db - `{ host, name, user, password }`.
*/
static provisionFresh({ host, siteRoot, db, wp, subDir = '' }) {
// Validator: wp-config.php presence means installation is complete/valid
if (fs.existsSync(path.join(siteRoot, 'wp-config.php'))) {
logger.info(`${host}: wp-config.php found at ${siteRoot}, skipping fresh install`);
return { freshInstall: false };
}
logger.info(`${host}: fresh install → ${siteRoot}`);
// Download WordPress zip if not already cached
if (!fs.existsSync(WP_ZIP_PATH)) {
shellExec(`curl -Lo "${WP_ZIP_PATH}" "${WP_DOWNLOAD_URL}"`);
}
// Extract to /tmp/wordpress then move to siteRoot
const extractDir = '/tmp/wp-extract';
if (fs.existsSync(extractDir)) shellExec(`sudo rm -rf "${extractDir}"`);
fs.mkdirSync(extractDir, { recursive: true });
shellExec(`unzip -q "${WP_ZIP_PATH}" -d "${extractDir}"`);
// The zip always extracts to /tmp/wp-extract/wordpress/
const extracted = path.join(extractDir, 'wordpress');
if (fs.existsSync(siteRoot)) shellExec(`sudo rm -rf "${siteRoot}"`);
// Ensure parent directory exists (e.g. /opt/lampp/htdocs/wp/<host>/)
const parentDir = path.dirname(siteRoot);
if (!fs.existsSync(parentDir)) fs.mkdirSync(parentDir, { recursive: true });
shellExec(`sudo mv "${extracted}" "${siteRoot}"`);
shellExec(`sudo chmod -R 755 "${siteRoot}"`);
shellExec(`sudo chown -R daemon:daemon "${siteRoot}"`);
if (db) {
WpService.createDatabase(db);
WpService.writeWpConfig({ siteRoot, db, host, subDir, wp });
WpService.wpCliInstall({ siteRoot, db, host, wp, subDir });
} else {
logger.warn(`${host}: no db config provided — wp-config.php not written`);
}
return { freshInstall: true };
}
/**
* Runs WP-CLI to complete the WordPress installation non-interactively,
* then installs and activates the Wordfence security plugin.
* Safe to call on an already-installed site — wp-cli will detect it and skip.
* @param {object} opts
* @param {string} opts.siteRoot - Absolute path to the WordPress installation.
* @param {object} opts.db - MariaDB config (unused here but kept for signature consistency).
* @param {string} opts.host - Virtual-host name.
* @param {object|null} [opts.wp] - WordPress install config from server conf:
* `{ title, adminUser, adminPassword, adminEmail }`.
* @param {string} [opts.subDir] - Subdirectory name (e.g. 'wp').
*/
static wpCliInstall({ siteRoot, db, host, wp, subDir = '' }) {
WpService.ensureWpCli();
WpService.ensureSendmail();
const siteUrl = subDir ? `https://${host}/${subDir}` : `https://${host}`;
const adminUser = (wp && wp.adminUser) || process.env.WP_ADMIN_USER || 'admin';
const adminPassword =
(wp && wp.adminPassword) ||
process.env.WP_ADMIN_PASSWORD ||
'ChangeMe_' + Math.random().toString(36).slice(2, 10);
const adminEmail = (wp && wp.adminEmail) || process.env.WP_ADMIN_EMAIL || `admin@${host}`;
const siteTitle = (wp && wp.title) || process.env.WP_SITE_TITLE || host;
// Prepend XAMPP's bin dir so WP-CLI can find the bundled PHP binary.
const wpCli = (cmd) =>
shellExec(`PATH="${LAMPP_BIN}:$PATH" wp --allow-root --path="${siteRoot}" ${cmd}`, {
stdout: true,
silent: false,
});
// Step 1 — install WordPress core (skipped automatically by WP-CLI if already installed)
logger.info(`${host}: running wp core install`);
wpCli(
`core install` +
` --url="${siteUrl}"` +
` --title="${siteTitle}"` +
` --admin_user="${adminUser}"` +
` --admin_password="${adminPassword}"` +
` --admin_email="${adminEmail}"` +
` --skip-email`,
);
// Step 2 — install and activate Wordfence Security
logger.info(`${host}: installing Wordfence security plugin`);
wpCli(`plugin install wordfence --activate`);
wpCli(`plugin install all-in-one-wp-security-and-firewall --activate`);
wpCli(`plugin install sucuri-scanner --activate`);
wpCli(`plugin install cleantalk-spam-protect --activate`);
// Step 3 — enable auto-updates for the plugin
wpCli(`plugin auto-updates enable wordfence`);
wpCli(`plugin auto-updates enable all-in-one-wp-security-and-firewall`);
wpCli(`plugin auto-updates enable sucuri-scanner`);
wpCli(`plugin auto-updates enable cleantalk-spam-protect`);
// Step 4 — install and activate WP Mail SMTP when configured
if (wp && wp.wpMailSmtp) {
logger.info(`${host}: installing WP Mail SMTP plugin`);
wpCli(`plugin install wp-mail-smtp --activate`);
wpCli(`plugin auto-updates enable wp-mail-smtp`);
}
logger.info(`${host}: WP-CLI provisioning complete`, { siteUrl, adminUser, adminEmail });
}
/**
* Appends rewrite rules for a WordPress subdirectory to the root .htaccess.
* Each subdirectory gets its own scoped RewriteRule block so multiple
* WordPress installs under the same host do not overwrite each other.
* @param {{ vhostDir: string, subDir: string }} opts
*/
static ensureSubdirHtaccess({ vhostDir, subDir }) {
if (!fs.existsSync(vhostDir)) fs.mkdirSync(vhostDir, { recursive: true });
const htaccessPath = path.join(vhostDir, '.htaccess');
// Marker comments to identify each subDir block
const marker = `# -- wp-subdir: ${subDir} --`;
const block = `${marker}
RewriteCond %{REQUEST_URI} ^\\/${subDir}(\\/|$) [NC]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^${subDir}\\/?(.*)?$ \\/${subDir}\\/index.php [L]
${marker} end`;
let existing = '';
if (fs.existsSync(htaccessPath)) {
existing = fs.readFileSync(htaccessPath, 'utf8');
}
// If this subDir block already exists, replace it; otherwise append
const markerRegex = new RegExp(
`${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} end`,
);
if (markerRegex.test(existing)) {
existing = existing.replace(markerRegex, block);
} else {
// Ensure the RewriteEngine directive and IfModule wrapper exist
if (!existing.includes('RewriteEngine on')) {
existing = `<IfModule mod_rewrite.c>\nRewriteEngine on\n\n</IfModule>\n`;
}
// Insert the new block before the closing </IfModule>
existing = existing.replace('</IfModule>', `${block}\n</IfModule>`);
}
fs.writeFileSync(htaccessPath, existing, 'utf8');
logger.info(`subdirectory .htaccess updated`, { vhostDir, subDir });
}
/**
* Writes security rules into the WordPress site root `.htaccess`.
* Protects `.git` directories, sensitive config files, and SQL dumps
* from being served by Apache. Idempotent — uses marker comments to
* detect and replace existing blocks on re-runs.
* @param {{ dir: string }} opts
* @param {string} opts.dir - Absolute path to the WordPress root (where .htaccess lives).
*/
static ensureSecurityHtaccess({ dir }) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const htaccessPath = path.join(dir, '.htaccess');
const marker = '# -- wp-security --';
const block = `${marker}
# Block access to .git directories and files
RedirectMatch 404 /\\.git
# Block access to sensitive dotfiles
<FilesMatch "^\\.(env|htpasswd|htaccess\\.bak|DS_Store)">
Require all denied
</FilesMatch>
# Block access to WordPress config backups and SQL dumps
<FilesMatch "(wp-config\\.php\\.bak|wp-config-sample\\.php|\\.sql|\\.sql\\.gz)$">
Require all denied
</FilesMatch>
# Block direct access to PHP files in uploads
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^wp-content/uploads/.*\\.php$ - [F,L]
</IfModule>
# Block access to xmlrpc.php (common attack vector)
<Files "xmlrpc.php">
Require all denied
</Files>
# Block access to readme.html and license.txt (version disclosure)
<FilesMatch "^(readme\\.html|license\\.txt)$">
Require all denied
</FilesMatch>
${marker} end`;
let existing = '';
if (fs.existsSync(htaccessPath)) {
existing = fs.readFileSync(htaccessPath, 'utf8');
}
const markerRegex = new RegExp(
`${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} end`,
);
if (markerRegex.test(existing)) {
existing = existing.replace(markerRegex, block);
} else {
existing = existing ? `${existing}\n${block}\n` : `${block}\n`;
}
fs.writeFileSync(htaccessPath, existing, 'utf8');
logger.info(`security .htaccess updated`, { dir });
}
/**
* Ensures a WordPress-specific `.gitignore` exists in the site root so that
* large/transient files are excluded from the backup repository while
* wp-config.php and security .htaccess ARE tracked.
* Idempotent — only writes when the file is missing.
* @param {{ dir: string }} opts
*/
static ensureGitignore({ dir }) {
const gitignorePath = path.join(dir, '.gitignore');
if (fs.existsSync(gitignorePath)) return;
const content = `# WordPress .gitignore
# Cache and temp
wp-content/cache/
wp-content/upgrade/
wp-content/backup-db/
wp-content/backups/
wp-content/blogs.dir/
wp-content/advanced-cache.php
wp-content/wp-cache-config.php
wp-content/debug.log
# OS / editor
.DS_Store
Thumbs.db
*.swp
*.swo
*~
`;
fs.writeFileSync(gitignorePath, content, 'utf8');
logger.info(`.gitignore written`, { dir });
}
/**
* Commits all files in the WordPress site root and pushes to the remote
* repository. This persists wp-config.php, .htaccess security rules,
* installed plugins, and theme files so that on pod rollout/restart a
* `git clone` yields a fully working site without needing a fresh install.
*
* Safe to call repeatedly — `git commit` is a no-op when the working tree
* is clean (`silentOnError: true` swallows the non-zero exit gracefully).
*
* @param {object} opts
* @param {string} opts.siteRoot - Absolute path to the WordPress root.
* @param {string} opts.repository - Git remote URL.
* @param {string} opts.host - Virtual-host name (for logging/commit msg).
*/
static persistToRepo({ siteRoot, repository, host }) {
if (!fs.existsSync(path.join(siteRoot, '.git'))) {
logger.warn(`persistToRepo: .git missing at ${siteRoot} — skipping`);
return;
}
WpService.ensureGitignore({ dir: siteRoot });
const githubOrg = process.env.GITHUB_USERNAME || 'underpostnet';
const repoName = repository.split('/').pop().split('.')[0];
logger.info(`${host}: persisting site to repository`);
shellExec(
`cd "${siteRoot}" && git add -A && git commit -m "wp provision ${host} $(date -u +%Y-%m-%dT%H:%M:%SZ)"`,
{ silentOnError: true },
);
shellExec(`cd "${siteRoot}" && underpost push . ${githubOrg}/${repoName} -f`);
logger.info(`${host}: initial commit pushed to ${githubOrg}/${repoName}`);
}
/**
* Drops and recreates a MariaDB database to ensure a clean state for fresh installs.
* @param {{ host: string, name: string, user: string, password: string }} db
*/
static createDatabase({ host, name, user, password }) {
logger.info(`Dropping and recreating database "${name}" on ${host}`);
const mysql = `/opt/lampp/bin/mysql`;
const q = (s) => s.replace(/'/g, "\\'");
const exec = (sql) => shellExec(`${mysql} -h '${host}' -u '${q(user)}' -p'${q(password)}' -e "${sql}"`);
exec(`DROP DATABASE IF EXISTS \\\`${q(name)}\\\``);
exec(`CREATE DATABASE \\\`${q(name)}\\\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
}
/**
* Writes a minimal `wp-config.php` from `wp-config-sample.php`.
* When `wp.wpMailSmtp` is provided, injects WP Mail SMTP plugin constants
* (WPMS_ON, WPMS_SMTP_HOST, etc.) so the plugin is pre-configured on first boot.
* @param {{ siteRoot: string, db: { host: string, name: string, user: string, password: string }, host?: string, subDir?: string, wp?: object }} opts
*/
static writeWpConfig({ siteRoot, db, host = '', subDir = '', wp }) {
const sample = path.join(siteRoot, 'wp-config-sample.php');
const target = path.join(siteRoot, 'wp-config.php');
if (!fs.existsSync(sample)) {
logger.warn(`wp-config-sample.php not found at ${siteRoot}`);
return;
}
let cfg = fs.readFileSync(sample, 'utf8');
cfg = cfg
.replace("define( 'DB_NAME', 'database_name_here' );", `define( 'DB_NAME', '${db.name}' );`)
.replace("define( 'DB_USER', 'username_here' );", `define( 'DB_USER', '${db.user}' );`)
.replace("define( 'DB_PASSWORD', 'password_here' );", `define( 'DB_PASSWORD', '${db.password}' );`)
.replace("define( 'DB_HOST', 'localhost' );", `define( 'DB_HOST', '${db.host}' );`)
.replace("define( 'DB_CHARSET', 'utf8' );", `define( 'DB_CHARSET', 'utf8mb4' );`);
// When WordPress is installed in a subdirectory, WP_HOME and WP_SITEURL must be set
if (host && subDir) {
const wpSiteUrl = `https://${host}/${subDir}`;
cfg = cfg.replace(
'/** Absolute path to the WordPress directory. */',
`define( 'WP_HOME', '${wpSiteUrl}' );\ndefine( 'WP_SITEURL', '${wpSiteUrl}' );\n\n/** Absolute path to the WordPress directory. */`,
);
}
// Inject reverse-proxy HTTPS detection (needed behind Contour/envoy)
const httpsSnippet = `
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$_SERVER['HTTPS'] = 'on';
}
`;
cfg = cfg.replace('<?php', `<?php\n${httpsSnippet}`);
// Inject WP Mail SMTP constants when wpMailSmtp config is provided
const wpMailSmtp = wp && wp.wpMailSmtp;
if (wpMailSmtp) {
const smtp = wpMailSmtp.smtp || {};
const wpmsLines = [
`define( 'WPMS_ON', true );`,
wpMailSmtp.fromEmail ? `define( 'WPMS_MAIL_FROM', '${wpMailSmtp.fromEmail}' );` : null,
wpMailSmtp.fromName ? `define( 'WPMS_MAIL_FROM_NAME', '${wpMailSmtp.fromName}' );` : null,
`define( 'WPMS_MAIL_FROM_FORCE', true );`,
`define( 'WPMS_MAIL_FROM_NAME_FORCE', false );`,
wpMailSmtp.mailer ? `define( 'WPMS_MAILER', '${wpMailSmtp.mailer}' );` : null,
wpMailSmtp.returnPath !== undefined ? `define( 'WPMS_SET_RETURN_PATH', ${wpMailSmtp.returnPath} );` : null,
smtp.host ? `define( 'WPMS_SMTP_HOST', '${smtp.host}' );` : null,
smtp.port ? `define( 'WPMS_SMTP_PORT', ${smtp.port} );` : null,
smtp.encryption ? `define( 'WPMS_SSL', '${smtp.encryption}' );` : null,
smtp.auth !== undefined ? `define( 'WPMS_SMTP_AUTH', ${smtp.auth} );` : null,
`define( 'WPMS_SMTP_AUTOTLS', true );`,
smtp.user ? `define( 'WPMS_SMTP_USER', '${smtp.user}' );` : null,
smtp.pass ? `define( 'WPMS_SMTP_PASS', '${smtp.pass}' );` : null,
]
.filter(Boolean)
.join('\n');
cfg = cfg.replace(
'/** Absolute path to the WordPress directory. */',
`// WP Mail SMTP plugin constants\n${wpmsLines}\n\n/** Absolute path to the WordPress directory. */`,
);
logger.info(`${host}: WP Mail SMTP constants injected into wp-config.php`);
}
fs.writeFileSync(target, cfg, 'utf8');
logger.info(`wp-config.php written for ${db.name}`);
}
/**
* Backs up a WordPress site: commits all changes and pushes the
* site directory to its git remote.
* If no `.git` directory exists but `repository` is provided, git is
* initialized first so subsequent cron-triggered backups can push.
* @param {object} opts
* @param {string} opts.host - Virtual-host name.
* @param {string|null} [opts.repository] - Git remote URL; used to initialize git if missing.
*/
static backup({ host, repository }) {
const siteRoot = WpService.siteDir(host);
const githubOrg = process.env.GITHUB_USERNAME || 'underpostnet';
if (!fs.existsSync(siteRoot)) {
logger.warn(`backup: site root does not exist — ${siteRoot}`);
return;
}
logger.info(`backup: ${host}`);
// Ensure git is initialized when a repository is configured
if (repository && !fs.existsSync(path.join(siteRoot, '.git'))) {
Underpost.repo.initLocalRepo({ path: siteRoot, origin: repository });
}
// MariaDB export is handled by the shared db.js backup flow — no duplicate dump here.
if (fs.existsSync(path.join(siteRoot, '.git'))) {
shellExec(`cd "${siteRoot}" && git add -A && git commit -m "wp backup $(date -u +%Y-%m-%dT%H:%M:%SZ)"`, { silentOnError: true });
shellExec(`cd "${siteRoot}" && underpost push . ${githubOrg}/${repository.split('/').pop().split('.')[0]}`);
logger.info(`backup: git push done for ${siteRoot}`);
} else {
logger.warn(`backup: no .git and no repository configured for ${host} — skipping git push`);
}
}
}
export { WpService };