underpost 3.1.3 → 3.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/.env.example +0 -2
  2. package/.github/workflows/ghpkg.ci.yml +4 -4
  3. package/.github/workflows/npmpkg.ci.yml +28 -11
  4. package/.github/workflows/publish.ci.yml +6 -0
  5. package/.github/workflows/pwa-microservices-template-page.cd.yml +4 -5
  6. package/.github/workflows/pwa-microservices-template-test.ci.yml +3 -3
  7. package/.github/workflows/release.cd.yml +13 -8
  8. package/CHANGELOG.md +396 -1
  9. package/CLI-HELP.md +53 -6
  10. package/Dockerfile +4 -2
  11. package/README.md +3 -2
  12. package/bin/build.js +18 -12
  13. package/bin/deploy.js +177 -124
  14. package/bin/file.js +3 -0
  15. package/conf.js +3 -2
  16. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -2
  17. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +5 -2
  18. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  19. package/manifests/deployment/dd-test-development/deployment.yaml +88 -74
  20. package/manifests/deployment/dd-test-development/proxy.yaml +13 -4
  21. package/manifests/deployment/playwright/deployment.yaml +1 -1
  22. package/nodemon.json +1 -1
  23. package/package.json +22 -15
  24. package/scripts/rhel-grpc-setup.sh +56 -0
  25. package/src/api/file/file.ref.json +18 -0
  26. package/src/api/user/user.service.js +8 -7
  27. package/src/cli/cluster.js +7 -7
  28. package/src/cli/db.js +726 -825
  29. package/src/cli/deploy.js +151 -93
  30. package/src/cli/env.js +19 -0
  31. package/src/cli/fs.js +5 -2
  32. package/src/cli/index.js +45 -2
  33. package/src/cli/kubectl.js +211 -0
  34. package/src/cli/release.js +284 -0
  35. package/src/cli/repository.js +434 -75
  36. package/src/cli/run.js +189 -34
  37. package/src/cli/secrets.js +73 -0
  38. package/src/cli/test.js +3 -3
  39. package/src/client/Default.index.js +3 -4
  40. package/src/client/components/core/AppStore.js +69 -0
  41. package/src/client/components/core/CalendarCore.js +2 -2
  42. package/src/client/components/core/DropDown.js +137 -17
  43. package/src/client/components/core/Keyboard.js +2 -2
  44. package/src/client/components/core/LogIn.js +2 -2
  45. package/src/client/components/core/LogOut.js +2 -2
  46. package/src/client/components/core/Modal.js +0 -1
  47. package/src/client/components/core/Panel.js +0 -1
  48. package/src/client/components/core/PanelForm.js +19 -19
  49. package/src/client/components/core/SocketIo.js +82 -29
  50. package/src/client/components/core/SocketIoHandler.js +75 -0
  51. package/src/client/components/core/Stream.js +143 -95
  52. package/src/client/components/core/Webhook.js +40 -7
  53. package/src/client/components/default/AppStoreDefault.js +5 -0
  54. package/src/client/components/default/LogInDefault.js +3 -3
  55. package/src/client/components/default/LogOutDefault.js +2 -2
  56. package/src/client/components/default/MenuDefault.js +5 -5
  57. package/src/client/components/default/SocketIoDefault.js +3 -51
  58. package/src/client/services/core/core.service.js +20 -8
  59. package/src/client/services/user/user.management.js +2 -2
  60. package/src/index.js +24 -1
  61. package/src/runtime/express/Dockerfile +4 -0
  62. package/src/runtime/express/Express.js +18 -1
  63. package/src/runtime/lampp/Dockerfile +13 -2
  64. package/src/runtime/lampp/Lampp.js +27 -4
  65. package/src/runtime/wp/Dockerfile +68 -0
  66. package/src/runtime/wp/Wp.js +639 -0
  67. package/src/server/auth.js +24 -1
  68. package/src/server/backup.js +57 -23
  69. package/src/server/client-build-docs.js +9 -2
  70. package/src/server/client-build.js +31 -31
  71. package/src/server/client-formatted.js +109 -57
  72. package/src/server/cron.js +23 -18
  73. package/src/server/ipfs-client.js +24 -1
  74. package/src/server/peer.js +8 -0
  75. package/src/server/runtime.js +25 -1
  76. package/src/server/start.js +3 -2
  77. package/src/ws/IoInterface.js +1 -10
  78. package/src/ws/IoServer.js +14 -33
  79. package/src/ws/core/channels/core.ws.chat.js +65 -20
  80. package/src/ws/core/channels/core.ws.mailer.js +113 -32
  81. package/src/ws/core/channels/core.ws.stream.js +90 -31
  82. package/src/ws/core/core.ws.connection.js +12 -33
  83. package/src/ws/core/core.ws.emit.js +10 -26
  84. package/src/ws/core/core.ws.server.js +25 -58
  85. package/src/ws/default/channels/default.ws.main.js +53 -12
  86. package/src/ws/default/default.ws.connection.js +26 -13
  87. package/src/ws/default/default.ws.server.js +30 -12
  88. package/src/client/components/default/ElementsDefault.js +0 -38
  89. package/src/ws/core/management/core.ws.chat.js +0 -8
  90. package/src/ws/core/management/core.ws.mailer.js +0 -16
  91. package/src/ws/core/management/core.ws.stream.js +0 -8
  92. package/src/ws/default/management/default.ws.main.js +0 -8
@@ -0,0 +1,639 @@
1
+ /**
2
+ * WordPress runtime for the Underpost engine.
3
+ * Manages WordPress installations backed by LAMPP (Apache + PHP 8+) and MariaDB.
4
+ *
5
+ * Two provisioning modes:
6
+ * - **clone** — `conf.repository` is set: clones that GitHub repo as the wp root.
7
+ * - **fresh** — no `conf.repository`: downloads wordpress.org/latest.zip, creates
8
+ * the database if it does not exist, and writes wp-config.php.
9
+ *
10
+ * @module src/runtime/wp/Wp.js
11
+ * @namespace WpService
12
+ */
13
+
14
+ import fs from 'fs-extra';
15
+ import path from 'path';
16
+ import { shellExec } from '../../server/process.js';
17
+ import { loggerFactory } from '../../server/logger.js';
18
+ import { Lampp } from '../lampp/Lampp.js';
19
+ import Underpost from '../../index.js';
20
+
21
+ const logger = loggerFactory(import.meta);
22
+
23
+ const WP_DOWNLOAD_URL = 'https://wordpress.org/latest.zip';
24
+ const WP_ZIP_PATH = '/tmp/wordpress-latest.zip';
25
+ const WP_BASE_DIR = '/opt/lampp/htdocs/wp';
26
+ // XAMPP ships its own PHP binary under /opt/lampp/bin which is not on the default
27
+ // PATH for non-login shells spawned by shellExec. Prepend it to every wp-cli call.
28
+ const LAMPP_BIN = '/opt/lampp/bin';
29
+
30
+ /**
31
+ * @class WpService
32
+ * @description Manages WordPress provisioning and Apache virtual-host wiring
33
+ * on top of the LAMPP service. Each server conf entry with `runtime: 'wp'`
34
+ * is handled here.
35
+ * @memberof WpService
36
+ */
37
+ class WpService {
38
+ /**
39
+ * Ensures the base WordPress hosting directory exists.
40
+ */
41
+ static ensureBaseDir() {
42
+ if (!fs.existsSync(WP_BASE_DIR)) fs.mkdirSync(WP_BASE_DIR, { recursive: true });
43
+ }
44
+
45
+ /**
46
+ * Returns the on-disk root path for a WordPress site.
47
+ * @param {string} host - Virtual-host name (used as folder name).
48
+ * @returns {string}
49
+ */
50
+ static siteDir(host) {
51
+ return path.join(WP_BASE_DIR, host);
52
+ }
53
+
54
+ /**
55
+ * Ensures WP-CLI (`wp`) is available on PATH, downloading and installing it
56
+ * to `/usr/local/bin/wp` if it is not already present.
57
+ */
58
+ static ensureWpCli() {
59
+ const existing = shellExec(`PATH="${LAMPP_BIN}:$PATH" which wp 2>/dev/null || true`, {
60
+ stdout: true,
61
+ silent: true,
62
+ disableLog: true,
63
+ });
64
+ if (existing && existing.trim()) return;
65
+ logger.info('WP-CLI not found — installing to /usr/local/bin/wp');
66
+ shellExec(`curl -sL -o /tmp/wp-cli.phar https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar`);
67
+ shellExec(`chmod +x /tmp/wp-cli.phar`);
68
+ shellExec(`sudo mv /tmp/wp-cli.phar /usr/local/bin/wp`);
69
+ }
70
+
71
+ /**
72
+ * Ensures a no-op sendmail stub exists at /usr/sbin/sendmail so WP plugins
73
+ * that invoke sendmail directly do not crash when a real MTA is absent.
74
+ * Mirrors the Dockerfile `RUN printf '#!/bin/sh\ncat > /dev/null\n' > /usr/sbin/sendmail`
75
+ * line but guards against older container images that pre-date that layer.
76
+ */
77
+ static ensureSendmail() {
78
+ const sendmailPath = '/usr/sbin/sendmail';
79
+ const existing = shellExec(`test -x "${sendmailPath}" && echo ok || true`, {
80
+ stdout: true,
81
+ silent: true,
82
+ disableLog: true,
83
+ });
84
+ if (existing && existing.trim() === 'ok') return;
85
+ logger.info('sendmail stub missing — creating no-op at /usr/sbin/sendmail');
86
+ shellExec(
87
+ `printf '#!/bin/sh\\ncat > /dev/null\\n' | sudo tee ${sendmailPath} > /dev/null && sudo chmod +x ${sendmailPath}`,
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Provisions a WordPress site and registers it with the LAMPP virtual-host router.
93
+ *
94
+ * Directory layout (e.g. host='test.nexodev.org', pathRoute='/wp'):
95
+ * - DocumentRoot (vhostDir): /opt/lampp/htdocs/wp/test.nexodev.org
96
+ * - WordPress files (wpDir): /opt/lampp/htdocs/wp/test.nexodev.org/wp
97
+ * - Root .htaccess rewrites / → /wp/ so Apache serves WordPress transparently.
98
+ *
99
+ * When pathRoute='/' the DocumentRoot and wpDir are the same directory.
100
+ *
101
+ * @param {object} opts
102
+ * @param {number} opts.port - Apache VirtualHost listen port.
103
+ * @param {string} opts.host - Server name / virtual host.
104
+ * @param {string} opts.pathRoute - URL path (e.g. '/wp' or '/').
105
+ * @param {string|null} [opts.repository] - GitHub HTTPS clone URL. When set the
106
+ * site root is cloned instead of downloading WordPress.
107
+ * @param {object|null} [opts.db] - MariaDB connection config:
108
+ * `{ host, name, user, password }`.
109
+ * Required for fresh installs; ignored when
110
+ * `repository` is set (wp-config.php is assumed
111
+ * to be part of the repo).
112
+ * @param {object|null} [opts.wp] - WordPress install config:
113
+ * `{ title, adminUser, adminPassword, adminEmail }`.
114
+ * @param {boolean} [opts.redirect] - If true, enables Apache RewriteEngine redirection.
115
+ * @param {string} [opts.redirectTarget] - Target URL for the redirect rule.
116
+ * @param {boolean} [opts.resetRouter] - Clear LAMPP router before appending.
117
+ * @returns {{ disabled: boolean }}
118
+ */
119
+ static createApp({ port, host, pathRoute, repository, db, wp, redirect, redirectTarget, resetRouter }) {
120
+ if (!Lampp.enabled()) {
121
+ logger.warn(`LAMPP not installed — skipping ${host}`);
122
+ return { disabled: true };
123
+ }
124
+
125
+ WpService.ensureBaseDir();
126
+
127
+ // DocumentRoot for the Apache VirtualHost — always under WP_BASE_DIR/<host>
128
+ const vhostDir = WpService.siteDir(host);
129
+
130
+ // When pathRoute is a subdirectory (e.g. '/wp'), WordPress files live one level deeper.
131
+ // When pathRoute is '/' they share the same directory as the DocumentRoot.
132
+ const subDir = pathRoute && pathRoute !== '/' ? pathRoute.replace(/^\/+/, '').replace(/\/+$/, '') : '';
133
+ const wpDir = subDir ? path.join(vhostDir, subDir) : vhostDir;
134
+
135
+ let freshInstall = false;
136
+ if (repository) {
137
+ ({ freshInstall } = WpService.provisionClone({ host, siteRoot: wpDir, repository, db, wp, subDir }));
138
+ } else {
139
+ ({ freshInstall } = WpService.provisionFresh({ host, siteRoot: wpDir, db, wp, subDir }));
140
+ }
141
+
142
+ // Ensure git is initialized and linked to the backup repository.
143
+ // Mark the directory as safe before git operations — the site root is owned
144
+ // by daemon:daemon (Apache) and git 2.35+ refuses to run in directories owned
145
+ // by a different user unless explicitly declared safe.
146
+ if (repository) {
147
+ shellExec(`git config --global --add safe.directory "${wpDir}"`);
148
+ Underpost.repo.initLocalRepo({ path: wpDir, origin: repository });
149
+ }
150
+
151
+ // Write a root .htaccess that rewrites / → /subDir/ when running in subdirectory mode
152
+ if (subDir) {
153
+ WpService.ensureSubdirHtaccess({ vhostDir, subDir });
154
+ }
155
+
156
+ // Write security rules into the WordPress root .htaccess
157
+ WpService.ensureSecurityHtaccess({ dir: wpDir });
158
+
159
+ // Make the site writable by the XAMPP Apache process (runs as daemon:daemon).
160
+ // This is required for plugins like Wordfence WAF and Sucuri that write config/upload files.
161
+ shellExec(`sudo chown -R daemon:daemon "${vhostDir}"`);
162
+ shellExec(`sudo find "${vhostDir}" -type d -exec chmod 755 {} \\;`);
163
+ shellExec(`sudo find "${vhostDir}" -type f -exec chmod 644 {} \\;`);
164
+
165
+ // Wire up Apache VirtualHost via Lampp — DocumentRoot is always vhostDir;
166
+ // Lampp.createApp uses `directory` directly as the DocumentRoot.
167
+ const { disabled } = Lampp.createApp({
168
+ port,
169
+ host,
170
+ path: pathRoute,
171
+ directory: vhostDir,
172
+ redirect,
173
+ redirectTarget,
174
+ resetRouter,
175
+ });
176
+
177
+ // Immediately commit and push all generated files (wp-config.php, .htaccess,
178
+ // security rules, plugins, etc.) so that on rollout/restart the clone will
179
+ // have a complete working state and won't fall back to fresh install again.
180
+ if (repository && freshInstall) {
181
+ WpService.persistToRepo({ siteRoot: wpDir, repository, host });
182
+ }
183
+
184
+ return { disabled };
185
+ }
186
+
187
+ /**
188
+ * Clone mode — clones `repository` into `siteRoot` if not present, then
189
+ * verifies `wp-config.php` exists. If it is missing the site root is wiped
190
+ * and a fresh WordPress install is performed (requires `db` config).
191
+ * @param {object} opts
192
+ * @param {string} opts.host - Virtual-host name (for logging).
193
+ * @param {string} opts.siteRoot - Absolute path where the site should live.
194
+ * @param {string} opts.repository - HTTPS clone URL.
195
+ * @param {object|null} [opts.db] - MariaDB config used as fallback for fresh install.
196
+ */
197
+ static provisionClone({ host, siteRoot, repository, db, wp, subDir = '' }) {
198
+ if (!process.env.GITHUB_TOKEN && repository && repository.startsWith('https://github.com/')) {
199
+ logger.warn(`${host}: GITHUB_TOKEN not set — git operations will fail for private repositories`);
200
+ }
201
+
202
+ // Step 0 — verify the remote repository is reachable; fall back to fresh install if not.
203
+ // repository.js isRemoteRepo handles token injection internally.
204
+ const repoAccessible = Underpost.repo.isRemoteRepo(repository);
205
+ logger.info(`${host}: remote accessible = ${repoAccessible} (${repository})`);
206
+ if (!repoAccessible) {
207
+ logger.warn(`${host}: remote repository not accessible (${repository}) — running fresh install`);
208
+ return WpService.provisionFresh({ host, siteRoot, db, wp, subDir });
209
+ }
210
+
211
+ // Step 1 — clone if the directory does not exist yet
212
+ if (!fs.existsSync(siteRoot)) {
213
+ logger.info(`${host}: cloning ${repository} → ${siteRoot}`);
214
+ const cloneUrl = Underpost.repo.resolveAuthUrl(repository);
215
+ const tmp = `${siteRoot}.tmp`;
216
+ if (fs.existsSync(tmp)) shellExec(`sudo rm -rf "${tmp}"`);
217
+ shellExec(`git clone "${cloneUrl}" "${tmp}"`);
218
+ shellExec(`sudo mv "${tmp}" "${siteRoot}"`);
219
+ shellExec(`sudo chmod -R 755 "${siteRoot}"`);
220
+ shellExec(`sudo chown -R daemon:daemon "${siteRoot}"`);
221
+ } else {
222
+ logger.info(`${host}: repo already present at ${siteRoot}`);
223
+ }
224
+
225
+ // Step 2 — verify wp-config.php; if missing, wipe and do a fresh install.
226
+ // initLocalRepo is NOT called here — createApp always calls it after provisionClone
227
+ // returns (whether clone or fallback path) so we avoid a double-init and ensure
228
+ // safe.directory is declared before git runs.
229
+ if (!fs.existsSync(path.join(siteRoot, 'wp-config.php'))) {
230
+ logger.warn(`${host}: wp-config.php not found — wiping site root and running fresh install`);
231
+ shellExec(`sudo rm -rf "${siteRoot}"`);
232
+ return WpService.provisionFresh({ host, siteRoot, db, wp, subDir });
233
+ }
234
+
235
+ return { freshInstall: false };
236
+ }
237
+
238
+ /**
239
+ * Fresh-install mode — downloads wordpress.org/latest.zip, extracts it,
240
+ * creates the MariaDB database, and writes wp-config.php.
241
+ * @param {object} opts
242
+ * @param {string} opts.host - Virtual-host name (for logging).
243
+ * @param {string} opts.siteRoot - Absolute path where WordPress should live.
244
+ * @param {object|null} opts.db - `{ host, name, user, password }`.
245
+ */
246
+ static provisionFresh({ host, siteRoot, db, wp, subDir = '' }) {
247
+ // Validator: wp-config.php presence means installation is complete/valid
248
+ if (fs.existsSync(path.join(siteRoot, 'wp-config.php'))) {
249
+ logger.info(`${host}: wp-config.php found at ${siteRoot}, skipping fresh install`);
250
+ return { freshInstall: false };
251
+ }
252
+
253
+ logger.info(`${host}: fresh install → ${siteRoot}`);
254
+
255
+ // Download WordPress zip if not already cached
256
+ if (!fs.existsSync(WP_ZIP_PATH)) {
257
+ shellExec(`curl -Lo "${WP_ZIP_PATH}" "${WP_DOWNLOAD_URL}"`);
258
+ }
259
+
260
+ // Extract to /tmp/wordpress then move to siteRoot
261
+ const extractDir = '/tmp/wp-extract';
262
+ if (fs.existsSync(extractDir)) shellExec(`sudo rm -rf "${extractDir}"`);
263
+ fs.mkdirSync(extractDir, { recursive: true });
264
+ shellExec(`unzip -q "${WP_ZIP_PATH}" -d "${extractDir}"`);
265
+ // The zip always extracts to /tmp/wp-extract/wordpress/
266
+ const extracted = path.join(extractDir, 'wordpress');
267
+ if (fs.existsSync(siteRoot)) shellExec(`sudo rm -rf "${siteRoot}"`);
268
+ // Ensure parent directory exists (e.g. /opt/lampp/htdocs/wp/<host>/)
269
+ const parentDir = path.dirname(siteRoot);
270
+ if (!fs.existsSync(parentDir)) fs.mkdirSync(parentDir, { recursive: true });
271
+ shellExec(`sudo mv "${extracted}" "${siteRoot}"`);
272
+ shellExec(`sudo chmod -R 755 "${siteRoot}"`);
273
+ shellExec(`sudo chown -R daemon:daemon "${siteRoot}"`);
274
+
275
+ if (db) {
276
+ WpService.createDatabase(db);
277
+ WpService.writeWpConfig({ siteRoot, db, host, subDir, wp });
278
+ WpService.wpCliInstall({ siteRoot, db, host, wp, subDir });
279
+ } else {
280
+ logger.warn(`${host}: no db config provided — wp-config.php not written`);
281
+ }
282
+
283
+ return { freshInstall: true };
284
+ }
285
+
286
+ /**
287
+ * Runs WP-CLI to complete the WordPress installation non-interactively,
288
+ * then installs and activates the Wordfence security plugin.
289
+ * Safe to call on an already-installed site — wp-cli will detect it and skip.
290
+ * @param {object} opts
291
+ * @param {string} opts.siteRoot - Absolute path to the WordPress installation.
292
+ * @param {object} opts.db - MariaDB config (unused here but kept for signature consistency).
293
+ * @param {string} opts.host - Virtual-host name.
294
+ * @param {object|null} [opts.wp] - WordPress install config from server conf:
295
+ * `{ title, adminUser, adminPassword, adminEmail }`.
296
+ * @param {string} [opts.subDir] - Subdirectory name (e.g. 'wp').
297
+ */
298
+ static wpCliInstall({ siteRoot, db, host, wp, subDir = '' }) {
299
+ WpService.ensureWpCli();
300
+ WpService.ensureSendmail();
301
+ const siteUrl = subDir ? `https://${host}/${subDir}` : `https://${host}`;
302
+ const adminUser = (wp && wp.adminUser) || process.env.WP_ADMIN_USER || 'admin';
303
+ const adminPassword =
304
+ (wp && wp.adminPassword) ||
305
+ process.env.WP_ADMIN_PASSWORD ||
306
+ 'ChangeMe_' + Math.random().toString(36).slice(2, 10);
307
+ const adminEmail = (wp && wp.adminEmail) || process.env.WP_ADMIN_EMAIL || `admin@${host}`;
308
+ const siteTitle = (wp && wp.title) || process.env.WP_SITE_TITLE || host;
309
+ // Prepend XAMPP's bin dir so WP-CLI can find the bundled PHP binary.
310
+ const wpCli = (cmd) =>
311
+ shellExec(`PATH="${LAMPP_BIN}:$PATH" wp --allow-root --path="${siteRoot}" ${cmd}`, {
312
+ stdout: true,
313
+ silent: false,
314
+ });
315
+
316
+ // Step 1 — install WordPress core (skipped automatically by WP-CLI if already installed)
317
+ logger.info(`${host}: running wp core install`);
318
+ wpCli(
319
+ `core install` +
320
+ ` --url="${siteUrl}"` +
321
+ ` --title="${siteTitle}"` +
322
+ ` --admin_user="${adminUser}"` +
323
+ ` --admin_password="${adminPassword}"` +
324
+ ` --admin_email="${adminEmail}"` +
325
+ ` --skip-email`,
326
+ );
327
+
328
+ // Step 2 — install and activate Wordfence Security
329
+ logger.info(`${host}: installing Wordfence security plugin`);
330
+ wpCli(`plugin install wordfence --activate`);
331
+ wpCli(`plugin install all-in-one-wp-security-and-firewall --activate`);
332
+ wpCli(`plugin install sucuri-scanner --activate`);
333
+ wpCli(`plugin install cleantalk-spam-protect --activate`);
334
+
335
+ // Step 3 — enable auto-updates for the plugin
336
+ wpCli(`plugin auto-updates enable wordfence`);
337
+ wpCli(`plugin auto-updates enable all-in-one-wp-security-and-firewall`);
338
+ wpCli(`plugin auto-updates enable sucuri-scanner`);
339
+ wpCli(`plugin auto-updates enable cleantalk-spam-protect`);
340
+
341
+ // Step 4 — install and activate WP Mail SMTP when configured
342
+ if (wp && wp.wpMailSmtp) {
343
+ logger.info(`${host}: installing WP Mail SMTP plugin`);
344
+ wpCli(`plugin install wp-mail-smtp --activate`);
345
+ wpCli(`plugin auto-updates enable wp-mail-smtp`);
346
+ }
347
+
348
+ logger.info(`${host}: WP-CLI provisioning complete`, { siteUrl, adminUser, adminEmail });
349
+ }
350
+
351
+ /**
352
+ * Appends rewrite rules for a WordPress subdirectory to the root .htaccess.
353
+ * Each subdirectory gets its own scoped RewriteRule block so multiple
354
+ * WordPress installs under the same host do not overwrite each other.
355
+ * @param {{ vhostDir: string, subDir: string }} opts
356
+ */
357
+ static ensureSubdirHtaccess({ vhostDir, subDir }) {
358
+ if (!fs.existsSync(vhostDir)) fs.mkdirSync(vhostDir, { recursive: true });
359
+ const htaccessPath = path.join(vhostDir, '.htaccess');
360
+
361
+ // Marker comments to identify each subDir block
362
+ const marker = `# -- wp-subdir: ${subDir} --`;
363
+ const block = `${marker}
364
+ RewriteCond %{REQUEST_URI} ^\\/${subDir}(\\/|$) [NC]
365
+ RewriteCond %{REQUEST_FILENAME} !-f
366
+ RewriteCond %{REQUEST_FILENAME} !-d
367
+ RewriteRule ^${subDir}\\/?(.*)?$ \\/${subDir}\\/index.php [L]
368
+ ${marker} end`;
369
+
370
+ let existing = '';
371
+ if (fs.existsSync(htaccessPath)) {
372
+ existing = fs.readFileSync(htaccessPath, 'utf8');
373
+ }
374
+
375
+ // If this subDir block already exists, replace it; otherwise append
376
+ const markerRegex = new RegExp(
377
+ `${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} end`,
378
+ );
379
+
380
+ if (markerRegex.test(existing)) {
381
+ existing = existing.replace(markerRegex, block);
382
+ } else {
383
+ // Ensure the RewriteEngine directive and IfModule wrapper exist
384
+ if (!existing.includes('RewriteEngine on')) {
385
+ existing = `<IfModule mod_rewrite.c>\nRewriteEngine on\n\n</IfModule>\n`;
386
+ }
387
+ // Insert the new block before the closing </IfModule>
388
+ existing = existing.replace('</IfModule>', `${block}\n</IfModule>`);
389
+ }
390
+
391
+ fs.writeFileSync(htaccessPath, existing, 'utf8');
392
+ logger.info(`subdirectory .htaccess updated`, { vhostDir, subDir });
393
+ }
394
+
395
+ /**
396
+ * Writes security rules into the WordPress site root `.htaccess`.
397
+ * Protects `.git` directories, sensitive config files, and SQL dumps
398
+ * from being served by Apache. Idempotent — uses marker comments to
399
+ * detect and replace existing blocks on re-runs.
400
+ * @param {{ dir: string }} opts
401
+ * @param {string} opts.dir - Absolute path to the WordPress root (where .htaccess lives).
402
+ */
403
+ static ensureSecurityHtaccess({ dir }) {
404
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
405
+ const htaccessPath = path.join(dir, '.htaccess');
406
+
407
+ const marker = '# -- wp-security --';
408
+ const block = `${marker}
409
+ # Block access to .git directories and files
410
+ RedirectMatch 404 /\\.git
411
+
412
+ # Block access to sensitive dotfiles
413
+ <FilesMatch "^\\.(env|htpasswd|htaccess\\.bak|DS_Store)">
414
+ Require all denied
415
+ </FilesMatch>
416
+
417
+ # Block access to WordPress config backups and SQL dumps
418
+ <FilesMatch "(wp-config\\.php\\.bak|wp-config-sample\\.php|\\.sql|\\.sql\\.gz)$">
419
+ Require all denied
420
+ </FilesMatch>
421
+
422
+ # Block direct access to PHP files in uploads
423
+ <IfModule mod_rewrite.c>
424
+ RewriteEngine On
425
+ RewriteRule ^wp-content/uploads/.*\\.php$ - [F,L]
426
+ </IfModule>
427
+
428
+ # Block access to xmlrpc.php (common attack vector)
429
+ <Files "xmlrpc.php">
430
+ Require all denied
431
+ </Files>
432
+
433
+ # Block access to readme.html and license.txt (version disclosure)
434
+ <FilesMatch "^(readme\\.html|license\\.txt)$">
435
+ Require all denied
436
+ </FilesMatch>
437
+ ${marker} end`;
438
+
439
+ let existing = '';
440
+ if (fs.existsSync(htaccessPath)) {
441
+ existing = fs.readFileSync(htaccessPath, 'utf8');
442
+ }
443
+
444
+ const markerRegex = new RegExp(
445
+ `${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} end`,
446
+ );
447
+
448
+ if (markerRegex.test(existing)) {
449
+ existing = existing.replace(markerRegex, block);
450
+ } else {
451
+ existing = existing ? `${existing}\n${block}\n` : `${block}\n`;
452
+ }
453
+
454
+ fs.writeFileSync(htaccessPath, existing, 'utf8');
455
+ logger.info(`security .htaccess updated`, { dir });
456
+ }
457
+
458
+ /**
459
+ * Ensures a WordPress-specific `.gitignore` exists in the site root so that
460
+ * large/transient files are excluded from the backup repository while
461
+ * wp-config.php and security .htaccess ARE tracked.
462
+ * Idempotent — only writes when the file is missing.
463
+ * @param {{ dir: string }} opts
464
+ */
465
+ static ensureGitignore({ dir }) {
466
+ const gitignorePath = path.join(dir, '.gitignore');
467
+ if (fs.existsSync(gitignorePath)) return;
468
+ const content = `# WordPress .gitignore
469
+ # Cache and temp
470
+ wp-content/cache/
471
+ wp-content/upgrade/
472
+ wp-content/backup-db/
473
+ wp-content/backups/
474
+ wp-content/blogs.dir/
475
+ wp-content/advanced-cache.php
476
+ wp-content/wp-cache-config.php
477
+ wp-content/debug.log
478
+
479
+ # OS / editor
480
+ .DS_Store
481
+ Thumbs.db
482
+ *.swp
483
+ *.swo
484
+ *~
485
+ `;
486
+ fs.writeFileSync(gitignorePath, content, 'utf8');
487
+ logger.info(`.gitignore written`, { dir });
488
+ }
489
+
490
+ /**
491
+ * Commits all files in the WordPress site root and pushes to the remote
492
+ * repository. This persists wp-config.php, .htaccess security rules,
493
+ * installed plugins, and theme files so that on pod rollout/restart a
494
+ * `git clone` yields a fully working site without needing a fresh install.
495
+ *
496
+ * Safe to call repeatedly — `git commit` is a no-op when the working tree
497
+ * is clean (`|| true` prevents non-zero exit).
498
+ *
499
+ * @param {object} opts
500
+ * @param {string} opts.siteRoot - Absolute path to the WordPress root.
501
+ * @param {string} opts.repository - Git remote URL.
502
+ * @param {string} opts.host - Virtual-host name (for logging/commit msg).
503
+ */
504
+ static persistToRepo({ siteRoot, repository, host }) {
505
+ if (!fs.existsSync(path.join(siteRoot, '.git'))) {
506
+ logger.warn(`persistToRepo: .git missing at ${siteRoot} — skipping`);
507
+ return;
508
+ }
509
+
510
+ WpService.ensureGitignore({ dir: siteRoot });
511
+
512
+ const githubOrg = process.env.GITHUB_USERNAME || 'underpostnet';
513
+ const repoName = repository.split('/').pop().split('.')[0];
514
+
515
+ logger.info(`${host}: persisting site to repository`);
516
+ shellExec(
517
+ `cd "${siteRoot}" && git add -A && git commit -m "wp provision ${host} $(date -u +%Y-%m-%dT%H:%M:%SZ)" || true`,
518
+ );
519
+ shellExec(`cd "${siteRoot}" && underpost push . ${githubOrg}/${repoName} -f`);
520
+ logger.info(`${host}: initial commit pushed to ${githubOrg}/${repoName}`);
521
+ }
522
+
523
+ /**
524
+ * Drops and recreates a MariaDB database to ensure a clean state for fresh installs.
525
+ * @param {{ host: string, name: string, user: string, password: string }} db
526
+ */
527
+ static createDatabase({ host, name, user, password }) {
528
+ logger.info(`Dropping and recreating database "${name}" on ${host}`);
529
+ const mysql = `/opt/lampp/bin/mysql`;
530
+ const q = (s) => s.replace(/'/g, "\\'");
531
+ const exec = (sql) => shellExec(`${mysql} -h '${host}' -u '${q(user)}' -p'${q(password)}' -e "${sql}"`);
532
+ exec(`DROP DATABASE IF EXISTS \\\`${q(name)}\\\``);
533
+ exec(`CREATE DATABASE \\\`${q(name)}\\\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
534
+ }
535
+
536
+ /**
537
+ * Writes a minimal `wp-config.php` from `wp-config-sample.php`.
538
+ * When `wp.wpMailSmtp` is provided, injects WP Mail SMTP plugin constants
539
+ * (WPMS_ON, WPMS_SMTP_HOST, etc.) so the plugin is pre-configured on first boot.
540
+ * @param {{ siteRoot: string, db: { host: string, name: string, user: string, password: string }, host?: string, subDir?: string, wp?: object }} opts
541
+ */
542
+ static writeWpConfig({ siteRoot, db, host = '', subDir = '', wp }) {
543
+ const sample = path.join(siteRoot, 'wp-config-sample.php');
544
+ const target = path.join(siteRoot, 'wp-config.php');
545
+ if (!fs.existsSync(sample)) {
546
+ logger.warn(`wp-config-sample.php not found at ${siteRoot}`);
547
+ return;
548
+ }
549
+ let cfg = fs.readFileSync(sample, 'utf8');
550
+ cfg = cfg
551
+ .replace("define( 'DB_NAME', 'database_name_here' );", `define( 'DB_NAME', '${db.name}' );`)
552
+ .replace("define( 'DB_USER', 'username_here' );", `define( 'DB_USER', '${db.user}' );`)
553
+ .replace("define( 'DB_PASSWORD', 'password_here' );", `define( 'DB_PASSWORD', '${db.password}' );`)
554
+ .replace("define( 'DB_HOST', 'localhost' );", `define( 'DB_HOST', '${db.host}' );`)
555
+ .replace("define( 'DB_CHARSET', 'utf8' );", `define( 'DB_CHARSET', 'utf8mb4' );`);
556
+ // When WordPress is installed in a subdirectory, WP_HOME and WP_SITEURL must be set
557
+ if (host && subDir) {
558
+ const wpSiteUrl = `https://${host}/${subDir}`;
559
+ cfg = cfg.replace(
560
+ '/** Absolute path to the WordPress directory. */',
561
+ `define( 'WP_HOME', '${wpSiteUrl}' );\ndefine( 'WP_SITEURL', '${wpSiteUrl}' );\n\n/** Absolute path to the WordPress directory. */`,
562
+ );
563
+ }
564
+ // Inject reverse-proxy HTTPS detection (needed behind Contour/envoy)
565
+ const httpsSnippet = `
566
+ if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
567
+ $_SERVER['HTTPS'] = 'on';
568
+ }
569
+ `;
570
+ cfg = cfg.replace('<?php', `<?php\n${httpsSnippet}`);
571
+
572
+ // Inject WP Mail SMTP constants when wpMailSmtp config is provided
573
+ const wpMailSmtp = wp && wp.wpMailSmtp;
574
+ if (wpMailSmtp) {
575
+ const smtp = wpMailSmtp.smtp || {};
576
+ const wpmsLines = [
577
+ `define( 'WPMS_ON', true );`,
578
+ wpMailSmtp.fromEmail ? `define( 'WPMS_MAIL_FROM', '${wpMailSmtp.fromEmail}' );` : null,
579
+ wpMailSmtp.fromName ? `define( 'WPMS_MAIL_FROM_NAME', '${wpMailSmtp.fromName}' );` : null,
580
+ `define( 'WPMS_MAIL_FROM_FORCE', true );`,
581
+ `define( 'WPMS_MAIL_FROM_NAME_FORCE', false );`,
582
+ wpMailSmtp.mailer ? `define( 'WPMS_MAILER', '${wpMailSmtp.mailer}' );` : null,
583
+ wpMailSmtp.returnPath !== undefined ? `define( 'WPMS_SET_RETURN_PATH', ${wpMailSmtp.returnPath} );` : null,
584
+ smtp.host ? `define( 'WPMS_SMTP_HOST', '${smtp.host}' );` : null,
585
+ smtp.port ? `define( 'WPMS_SMTP_PORT', ${smtp.port} );` : null,
586
+ smtp.encryption ? `define( 'WPMS_SSL', '${smtp.encryption}' );` : null,
587
+ smtp.auth !== undefined ? `define( 'WPMS_SMTP_AUTH', ${smtp.auth} );` : null,
588
+ `define( 'WPMS_SMTP_AUTOTLS', true );`,
589
+ smtp.user ? `define( 'WPMS_SMTP_USER', '${smtp.user}' );` : null,
590
+ smtp.pass ? `define( 'WPMS_SMTP_PASS', '${smtp.pass}' );` : null,
591
+ ]
592
+ .filter(Boolean)
593
+ .join('\n');
594
+ cfg = cfg.replace(
595
+ '/** Absolute path to the WordPress directory. */',
596
+ `// WP Mail SMTP plugin constants\n${wpmsLines}\n\n/** Absolute path to the WordPress directory. */`,
597
+ );
598
+ logger.info(`${host}: WP Mail SMTP constants injected into wp-config.php`);
599
+ }
600
+
601
+ fs.writeFileSync(target, cfg, 'utf8');
602
+ logger.info(`wp-config.php written for ${db.name}`);
603
+ }
604
+
605
+ /**
606
+ * Backs up a WordPress site: commits all changes and pushes the
607
+ * site directory to its git remote.
608
+ * If no `.git` directory exists but `repository` is provided, git is
609
+ * initialized first so subsequent cron-triggered backups can push.
610
+ * @param {object} opts
611
+ * @param {string} opts.host - Virtual-host name.
612
+ * @param {string|null} [opts.repository] - Git remote URL; used to initialize git if missing.
613
+ */
614
+ static backup({ host, repository }) {
615
+ const siteRoot = WpService.siteDir(host);
616
+ const githubOrg = process.env.GITHUB_USERNAME || 'underpostnet';
617
+ if (!fs.existsSync(siteRoot)) {
618
+ logger.warn(`backup: site root does not exist — ${siteRoot}`);
619
+ return;
620
+ }
621
+ logger.info(`backup: ${host}`);
622
+
623
+ // Ensure git is initialized when a repository is configured
624
+ if (repository && !fs.existsSync(path.join(siteRoot, '.git'))) {
625
+ Underpost.repo.initLocalRepo({ path: siteRoot, origin: repository });
626
+ }
627
+
628
+ // MariaDB export is handled by the shared db.js backup flow — no duplicate dump here.
629
+ if (fs.existsSync(path.join(siteRoot, '.git'))) {
630
+ shellExec(`cd "${siteRoot}" && git add -A && git commit -m "wp backup $(date -u +%Y-%m-%dT%H:%M:%SZ)" || true`);
631
+ shellExec(`cd "${siteRoot}" && underpost push . ${githubOrg}/${repository.split('/').pop().split('.')[0]}`);
632
+ logger.info(`backup: git push done for ${siteRoot}`);
633
+ } else {
634
+ logger.warn(`backup: no .git and no repository configured for ${host} — skipping git push`);
635
+ }
636
+ }
637
+ }
638
+
639
+ export { WpService };
@@ -9,7 +9,12 @@ import { loggerFactory } from './logger.js';
9
9
  import crypto from 'crypto';
10
10
  import { promisify } from 'util';
11
11
  import { UserDto } from '../api/user/user.model.js';
12
- import { commonAdminGuard, commonModeratorGuard, validatePassword } from '../client/components/core/CommonJs.js';
12
+ import {
13
+ commonAdminGuard,
14
+ commonModeratorGuard,
15
+ commonUserGuard,
16
+ validatePassword,
17
+ } from '../client/components/core/CommonJs.js';
13
18
  import helmet from 'helmet';
14
19
  import rateLimit from 'express-rate-limit';
15
20
  import slowDown from 'express-slow-down';
@@ -303,6 +308,23 @@ const moderatorGuard = (req, res, next) => {
303
308
  return res.status(400).json({ status: 'error', message: 'bad request' });
304
309
  }
305
310
  };
311
+ /**
312
+ * Express middleware to guard routes for authenticated users (any non-guest role).
313
+ * @param {import('express').Request} req The Express request object.
314
+ * @param {import('express').Response} res The Express response object.
315
+ * @param {import('express').NextFunction} next The next middleware function.
316
+ * @memberof Auth
317
+ */
318
+ const userGuard = (req, res, next) => {
319
+ try {
320
+ if (!req.auth || !commonUserGuard(req.auth.user.role))
321
+ return res.status(403).json({ status: 'error', message: 'Insufficient permission' });
322
+ return next();
323
+ } catch (err) {
324
+ logger.error(err);
325
+ return res.status(400).json({ status: 'error', message: 'bad request' });
326
+ }
327
+ };
306
328
 
307
329
  // ---------- Password validation middleware (server-side) ----------
308
330
  /**
@@ -678,6 +700,7 @@ export {
678
700
  jwtVerify as verifyJWT,
679
701
  adminGuard,
680
702
  moderatorGuard,
703
+ userGuard,
681
704
  validatePasswordMiddleware,
682
705
  getBearerToken,
683
706
  createSessionAndUserToken,