underpost 3.1.2 → 3.2.0

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 (98) hide show
  1. package/.env.example +0 -2
  2. package/.github/workflows/ghpkg.ci.yml +4 -4
  3. package/.github/workflows/npmpkg.ci.yml +38 -7
  4. package/.github/workflows/pwa-microservices-template-page.cd.yml +3 -4
  5. package/.github/workflows/pwa-microservices-template-test.ci.yml +3 -3
  6. package/.github/workflows/release.cd.yml +4 -4
  7. package/CHANGELOG.md +365 -1
  8. package/CLI-HELP.md +55 -3
  9. package/README.md +7 -3
  10. package/bin/build.js +18 -12
  11. package/bin/deploy.js +205 -225
  12. package/bin/file.js +3 -0
  13. package/conf.js +4 -10
  14. package/jsdoc.json +1 -1
  15. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  16. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  17. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  18. package/manifests/deployment/dd-test-development/deployment.yaml +72 -50
  19. package/manifests/deployment/dd-test-development/proxy.yaml +13 -4
  20. package/manifests/deployment/playwright/deployment.yaml +1 -1
  21. package/nodemon.json +1 -1
  22. package/package.json +21 -14
  23. package/scripts/ports-ls.sh +2 -0
  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 +76 -242
  29. package/src/cli/deploy.js +104 -65
  30. package/src/cli/env.js +1 -0
  31. package/src/cli/fs.js +2 -1
  32. package/src/cli/index.js +50 -1
  33. package/src/cli/kubectl.js +211 -0
  34. package/src/cli/release.js +284 -0
  35. package/src/cli/repository.js +328 -112
  36. package/src/cli/run.js +283 -69
  37. package/src/cli/test.js +3 -3
  38. package/src/client/Default.index.js +3 -4
  39. package/src/client/components/core/Alert.js +2 -2
  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/Docs.js +9 -2
  43. package/src/client/components/core/DropDown.js +129 -17
  44. package/src/client/components/core/Keyboard.js +2 -2
  45. package/src/client/components/core/LogIn.js +2 -2
  46. package/src/client/components/core/LogOut.js +2 -2
  47. package/src/client/components/core/Modal.js +0 -1
  48. package/src/client/components/core/Panel.js +0 -1
  49. package/src/client/components/core/PanelForm.js +19 -19
  50. package/src/client/components/core/RichText.js +1 -2
  51. package/src/client/components/core/SocketIo.js +82 -29
  52. package/src/client/components/core/SocketIoHandler.js +75 -0
  53. package/src/client/components/core/Stream.js +143 -95
  54. package/src/client/components/core/Webhook.js +40 -7
  55. package/src/client/components/default/AppStoreDefault.js +5 -0
  56. package/src/client/components/default/LogInDefault.js +3 -3
  57. package/src/client/components/default/LogOutDefault.js +2 -2
  58. package/src/client/components/default/MenuDefault.js +5 -5
  59. package/src/client/components/default/SocketIoDefault.js +3 -51
  60. package/src/client/services/core/core.service.js +20 -8
  61. package/src/client/services/user/user.management.js +2 -2
  62. package/src/client/ssr/body/404.js +15 -11
  63. package/src/client/ssr/body/500.js +15 -11
  64. package/src/client/ssr/body/SwaggerDarkMode.js +285 -0
  65. package/src/client/ssr/offline/NoNetworkConnection.js +11 -10
  66. package/src/client/ssr/pages/Test.js +11 -10
  67. package/src/index.js +24 -1
  68. package/src/runtime/express/Express.js +26 -9
  69. package/src/runtime/lampp/Dockerfile +9 -2
  70. package/src/runtime/lampp/Lampp.js +4 -3
  71. package/src/runtime/wp/Dockerfile +64 -0
  72. package/src/runtime/wp/Wp.js +497 -0
  73. package/src/server/auth.js +30 -6
  74. package/src/server/backup.js +19 -1
  75. package/src/server/client-build-docs.js +51 -110
  76. package/src/server/client-build.js +55 -64
  77. package/src/server/client-formatted.js +109 -57
  78. package/src/server/conf.js +19 -15
  79. package/src/server/ipfs-client.js +24 -1
  80. package/src/server/peer.js +8 -0
  81. package/src/server/runtime.js +25 -1
  82. package/src/server/start.js +21 -8
  83. package/src/ws/IoInterface.js +1 -10
  84. package/src/ws/IoServer.js +14 -33
  85. package/src/ws/core/channels/core.ws.chat.js +65 -20
  86. package/src/ws/core/channels/core.ws.mailer.js +113 -32
  87. package/src/ws/core/channels/core.ws.stream.js +90 -31
  88. package/src/ws/core/core.ws.connection.js +12 -33
  89. package/src/ws/core/core.ws.emit.js +10 -26
  90. package/src/ws/core/core.ws.server.js +25 -58
  91. package/src/ws/default/channels/default.ws.main.js +53 -12
  92. package/src/ws/default/default.ws.connection.js +26 -13
  93. package/src/ws/default/default.ws.server.js +30 -12
  94. package/src/client/components/default/ElementsDefault.js +0 -38
  95. package/src/ws/core/management/core.ws.chat.js +0 -8
  96. package/src/ws/core/management/core.ws.mailer.js +0 -16
  97. package/src/ws/core/management/core.ws.stream.js +0 -8
  98. package/src/ws/default/management/default.ws.main.js +0 -8
@@ -0,0 +1,497 @@
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
+ if (repository) {
136
+ WpService.provisionClone({ host, siteRoot: wpDir, repository, db, wp, subDir });
137
+ } else {
138
+ WpService.provisionFresh({ host, siteRoot: wpDir, db, wp, subDir });
139
+ }
140
+
141
+ // Ensure git is initialized and linked to the backup repository.
142
+ // Mark the directory as safe before git operations — the site root is owned
143
+ // by daemon:daemon (Apache) and git 2.35+ refuses to run in directories owned
144
+ // by a different user unless explicitly declared safe.
145
+ if (repository) {
146
+ shellExec(`git config --global --add safe.directory "${wpDir}"`);
147
+ Underpost.repo.initLocalRepo({ path: wpDir, origin: repository });
148
+ }
149
+
150
+ // Write a root .htaccess that rewrites / → /subDir/ when running in subdirectory mode
151
+ if (subDir) {
152
+ WpService.ensureSubdirHtaccess({ vhostDir, subDir });
153
+ }
154
+
155
+ // Make the site writable by the XAMPP Apache process (runs as daemon:daemon).
156
+ // This is required for plugins like Wordfence WAF and Sucuri that write config/upload files.
157
+ shellExec(`sudo chown -R daemon:daemon "${vhostDir}"`);
158
+ shellExec(`sudo find "${vhostDir}" -type d -exec chmod 755 {} \\;`);
159
+ shellExec(`sudo find "${vhostDir}" -type f -exec chmod 644 {} \\;`);
160
+
161
+ // Wire up Apache VirtualHost via Lampp — DocumentRoot is always vhostDir;
162
+ // Lampp.createApp uses `directory` directly as the DocumentRoot.
163
+ const { disabled } = Lampp.createApp({
164
+ port,
165
+ host,
166
+ path: pathRoute,
167
+ directory: vhostDir,
168
+ redirect,
169
+ redirectTarget,
170
+ resetRouter,
171
+ });
172
+
173
+ return { disabled };
174
+ }
175
+
176
+ /**
177
+ * Clone mode — clones `repository` into `siteRoot` if not present, then
178
+ * verifies `wp-config.php` exists. If it is missing the site root is wiped
179
+ * and a fresh WordPress install is performed (requires `db` config).
180
+ * @param {object} opts
181
+ * @param {string} opts.host - Virtual-host name (for logging).
182
+ * @param {string} opts.siteRoot - Absolute path where the site should live.
183
+ * @param {string} opts.repository - HTTPS clone URL.
184
+ * @param {object|null} [opts.db] - MariaDB config used as fallback for fresh install.
185
+ */
186
+ static provisionClone({ host, siteRoot, repository, db, wp, subDir = '' }) {
187
+ if (!process.env.GITHUB_TOKEN && repository && repository.startsWith('https://github.com/')) {
188
+ logger.warn(`${host}: GITHUB_TOKEN not set — git operations will fail for private repositories`);
189
+ }
190
+
191
+ // Step 0 — verify the remote repository is reachable; fall back to fresh install if not.
192
+ // repository.js isRemoteRepo handles token injection internally.
193
+ const repoAccessible = Underpost.repo.isRemoteRepo(repository);
194
+ logger.info(`${host}: remote accessible = ${repoAccessible} (${repository})`);
195
+ if (!repoAccessible) {
196
+ logger.warn(`${host}: remote repository not accessible (${repository}) — running fresh install`);
197
+ WpService.provisionFresh({ host, siteRoot, db, wp, subDir });
198
+ return;
199
+ }
200
+
201
+ // Step 1 — clone if the directory does not exist yet
202
+ if (!fs.existsSync(siteRoot)) {
203
+ logger.info(`${host}: cloning ${repository} → ${siteRoot}`);
204
+ const cloneUrl = Underpost.repo.resolveAuthUrl(repository);
205
+ const tmp = `${siteRoot}.tmp`;
206
+ if (fs.existsSync(tmp)) shellExec(`sudo rm -rf "${tmp}"`);
207
+ shellExec(`git clone "${cloneUrl}" "${tmp}"`);
208
+ shellExec(`sudo mv "${tmp}" "${siteRoot}"`);
209
+ shellExec(`sudo chmod -R 755 "${siteRoot}"`);
210
+ shellExec(`sudo chown -R daemon:daemon "${siteRoot}"`);
211
+ } else {
212
+ logger.info(`${host}: repo already present at ${siteRoot}`);
213
+ }
214
+
215
+ // Step 2 — verify wp-config.php; if missing, wipe and do a fresh install.
216
+ // initLocalRepo is NOT called here — createApp always calls it after provisionClone
217
+ // returns (whether clone or fallback path) so we avoid a double-init and ensure
218
+ // safe.directory is declared before git runs.
219
+ if (!fs.existsSync(path.join(siteRoot, 'wp-config.php'))) {
220
+ logger.warn(`${host}: wp-config.php not found — wiping site root and running fresh install`);
221
+ shellExec(`sudo rm -rf "${siteRoot}"`);
222
+ WpService.provisionFresh({ host, siteRoot, db, wp, subDir });
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Fresh-install mode — downloads wordpress.org/latest.zip, extracts it,
228
+ * creates the MariaDB database, and writes wp-config.php.
229
+ * @param {object} opts
230
+ * @param {string} opts.host - Virtual-host name (for logging).
231
+ * @param {string} opts.siteRoot - Absolute path where WordPress should live.
232
+ * @param {object|null} opts.db - `{ host, name, user, password }`.
233
+ */
234
+ static provisionFresh({ host, siteRoot, db, wp, subDir = '' }) {
235
+ // Validator: wp-config.php presence means installation is complete/valid
236
+ if (fs.existsSync(path.join(siteRoot, 'wp-config.php'))) {
237
+ logger.info(`${host}: wp-config.php found at ${siteRoot}, skipping fresh install`);
238
+ return;
239
+ }
240
+
241
+ logger.info(`${host}: fresh install → ${siteRoot}`);
242
+
243
+ // Download WordPress zip if not already cached
244
+ if (!fs.existsSync(WP_ZIP_PATH)) {
245
+ shellExec(`curl -Lo "${WP_ZIP_PATH}" "${WP_DOWNLOAD_URL}"`);
246
+ }
247
+
248
+ // Extract to /tmp/wordpress then move to siteRoot
249
+ const extractDir = '/tmp/wp-extract';
250
+ if (fs.existsSync(extractDir)) shellExec(`sudo rm -rf "${extractDir}"`);
251
+ fs.mkdirSync(extractDir, { recursive: true });
252
+ shellExec(`unzip -q "${WP_ZIP_PATH}" -d "${extractDir}"`);
253
+ // The zip always extracts to /tmp/wp-extract/wordpress/
254
+ const extracted = path.join(extractDir, 'wordpress');
255
+ if (fs.existsSync(siteRoot)) shellExec(`sudo rm -rf "${siteRoot}"`);
256
+ // Ensure parent directory exists (e.g. /opt/lampp/htdocs/wp/<host>/)
257
+ const parentDir = path.dirname(siteRoot);
258
+ if (!fs.existsSync(parentDir)) fs.mkdirSync(parentDir, { recursive: true });
259
+ shellExec(`sudo mv "${extracted}" "${siteRoot}"`);
260
+ shellExec(`sudo chmod -R 755 "${siteRoot}"`);
261
+ shellExec(`sudo chown -R daemon:daemon "${siteRoot}"`);
262
+
263
+ if (db) {
264
+ WpService.createDatabase(db);
265
+ WpService.writeWpConfig({ siteRoot, db, host, subDir, wp });
266
+ WpService.wpCliInstall({ siteRoot, db, host, wp, subDir });
267
+ } else {
268
+ logger.warn(`${host}: no db config provided — wp-config.php not written`);
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Runs WP-CLI to complete the WordPress installation non-interactively,
274
+ * then installs and activates the Wordfence security plugin.
275
+ * Safe to call on an already-installed site — wp-cli will detect it and skip.
276
+ * @param {object} opts
277
+ * @param {string} opts.siteRoot - Absolute path to the WordPress installation.
278
+ * @param {object} opts.db - MariaDB config (unused here but kept for signature consistency).
279
+ * @param {string} opts.host - Virtual-host name.
280
+ * @param {object|null} [opts.wp] - WordPress install config from server conf:
281
+ * `{ title, adminUser, adminPassword, adminEmail }`.
282
+ * @param {string} [opts.subDir] - Subdirectory name (e.g. 'wp').
283
+ */
284
+ static wpCliInstall({ siteRoot, db, host, wp, subDir = '' }) {
285
+ WpService.ensureWpCli();
286
+ WpService.ensureSendmail();
287
+ const siteUrl = subDir ? `https://${host}/${subDir}` : `https://${host}`;
288
+ const adminUser = (wp && wp.adminUser) || process.env.WP_ADMIN_USER || 'admin';
289
+ const adminPassword =
290
+ (wp && wp.adminPassword) ||
291
+ process.env.WP_ADMIN_PASSWORD ||
292
+ 'ChangeMe_' + Math.random().toString(36).slice(2, 10);
293
+ const adminEmail = (wp && wp.adminEmail) || process.env.WP_ADMIN_EMAIL || `admin@${host}`;
294
+ const siteTitle = (wp && wp.title) || process.env.WP_SITE_TITLE || host;
295
+ // Prepend XAMPP's bin dir so WP-CLI can find the bundled PHP binary.
296
+ const wpCli = (cmd) =>
297
+ shellExec(`PATH="${LAMPP_BIN}:$PATH" wp --allow-root --path="${siteRoot}" ${cmd}`, {
298
+ stdout: true,
299
+ silent: false,
300
+ });
301
+
302
+ // Step 1 — install WordPress core (skipped automatically by WP-CLI if already installed)
303
+ logger.info(`${host}: running wp core install`);
304
+ wpCli(
305
+ `core install` +
306
+ ` --url="${siteUrl}"` +
307
+ ` --title="${siteTitle}"` +
308
+ ` --admin_user="${adminUser}"` +
309
+ ` --admin_password="${adminPassword}"` +
310
+ ` --admin_email="${adminEmail}"` +
311
+ ` --skip-email`,
312
+ );
313
+
314
+ // Step 2 — install and activate Wordfence Security
315
+ logger.info(`${host}: installing Wordfence security plugin`);
316
+ wpCli(`plugin install wordfence --activate`);
317
+ wpCli(`plugin install all-in-one-wp-security-and-firewall --activate`);
318
+ wpCli(`plugin install sucuri-scanner --activate`);
319
+ wpCli(`plugin install cleantalk-spam-protect --activate`);
320
+
321
+ // Step 3 — enable auto-updates for the plugin
322
+ wpCli(`plugin auto-updates enable wordfence`);
323
+ wpCli(`plugin auto-updates enable all-in-one-wp-security-and-firewall`);
324
+ wpCli(`plugin auto-updates enable sucuri-scanner`);
325
+ wpCli(`plugin auto-updates enable cleantalk-spam-protect`);
326
+
327
+ // Step 4 — install and activate WP Mail SMTP when configured
328
+ if (wp && wp.wpMailSmtp) {
329
+ logger.info(`${host}: installing WP Mail SMTP plugin`);
330
+ wpCli(`plugin install wp-mail-smtp --activate`);
331
+ wpCli(`plugin auto-updates enable wp-mail-smtp`);
332
+ }
333
+
334
+ logger.info(`${host}: WP-CLI provisioning complete`, { siteUrl, adminUser, adminEmail });
335
+ }
336
+
337
+ /**
338
+ * Appends rewrite rules for a WordPress subdirectory to the root .htaccess.
339
+ * Each subdirectory gets its own scoped RewriteRule block so multiple
340
+ * WordPress installs under the same host do not overwrite each other.
341
+ * @param {{ vhostDir: string, subDir: string }} opts
342
+ */
343
+ static ensureSubdirHtaccess({ vhostDir, subDir }) {
344
+ if (!fs.existsSync(vhostDir)) fs.mkdirSync(vhostDir, { recursive: true });
345
+ const htaccessPath = path.join(vhostDir, '.htaccess');
346
+
347
+ // Marker comments to identify each subDir block
348
+ const marker = `# -- wp-subdir: ${subDir} --`;
349
+ const block = `${marker}
350
+ RewriteCond %{REQUEST_URI} ^\\/${subDir}(\\/|$) [NC]
351
+ RewriteCond %{REQUEST_FILENAME} !-f
352
+ RewriteCond %{REQUEST_FILENAME} !-d
353
+ RewriteRule ^${subDir}\\/?(.*)?$ \\/${subDir}\\/index.php [L]
354
+ ${marker} end`;
355
+
356
+ let existing = '';
357
+ if (fs.existsSync(htaccessPath)) {
358
+ existing = fs.readFileSync(htaccessPath, 'utf8');
359
+ }
360
+
361
+ // If this subDir block already exists, replace it; otherwise append
362
+ const markerRegex = new RegExp(
363
+ `${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} end`,
364
+ );
365
+
366
+ if (markerRegex.test(existing)) {
367
+ existing = existing.replace(markerRegex, block);
368
+ } else {
369
+ // Ensure the RewriteEngine directive and IfModule wrapper exist
370
+ if (!existing.includes('RewriteEngine on')) {
371
+ existing = `<IfModule mod_rewrite.c>\nRewriteEngine on\n\n</IfModule>\n`;
372
+ }
373
+ // Insert the new block before the closing </IfModule>
374
+ existing = existing.replace('</IfModule>', `${block}\n</IfModule>`);
375
+ }
376
+
377
+ fs.writeFileSync(htaccessPath, existing, 'utf8');
378
+ logger.info(`subdirectory .htaccess updated`, { vhostDir, subDir });
379
+ }
380
+
381
+ /**
382
+ * Drops and recreates a MariaDB database to ensure a clean state for fresh installs.
383
+ * @param {{ host: string, name: string, user: string, password: string }} db
384
+ */
385
+ static createDatabase({ host, name, user, password }) {
386
+ logger.info(`Dropping and recreating database "${name}" on ${host}`);
387
+ const mysql = `/opt/lampp/bin/mysql`;
388
+ const q = (s) => s.replace(/'/g, "\\'");
389
+ const exec = (sql) => shellExec(`${mysql} -h '${host}' -u '${q(user)}' -p'${q(password)}' -e "${sql}"`);
390
+ exec(`DROP DATABASE IF EXISTS \\\`${q(name)}\\\``);
391
+ exec(`CREATE DATABASE \\\`${q(name)}\\\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
392
+ }
393
+
394
+ /**
395
+ * Writes a minimal `wp-config.php` from `wp-config-sample.php`.
396
+ * When `wp.wpMailSmtp` is provided, injects WP Mail SMTP plugin constants
397
+ * (WPMS_ON, WPMS_SMTP_HOST, etc.) so the plugin is pre-configured on first boot.
398
+ * @param {{ siteRoot: string, db: { host: string, name: string, user: string, password: string }, host?: string, subDir?: string, wp?: object }} opts
399
+ */
400
+ static writeWpConfig({ siteRoot, db, host = '', subDir = '', wp }) {
401
+ const sample = path.join(siteRoot, 'wp-config-sample.php');
402
+ const target = path.join(siteRoot, 'wp-config.php');
403
+ if (!fs.existsSync(sample)) {
404
+ logger.warn(`wp-config-sample.php not found at ${siteRoot}`);
405
+ return;
406
+ }
407
+ let cfg = fs.readFileSync(sample, 'utf8');
408
+ cfg = cfg
409
+ .replace("define( 'DB_NAME', 'database_name_here' );", `define( 'DB_NAME', '${db.name}' );`)
410
+ .replace("define( 'DB_USER', 'username_here' );", `define( 'DB_USER', '${db.user}' );`)
411
+ .replace("define( 'DB_PASSWORD', 'password_here' );", `define( 'DB_PASSWORD', '${db.password}' );`)
412
+ .replace("define( 'DB_HOST', 'localhost' );", `define( 'DB_HOST', '${db.host}' );`)
413
+ .replace("define( 'DB_CHARSET', 'utf8' );", `define( 'DB_CHARSET', 'utf8mb4' );`);
414
+ // When WordPress is installed in a subdirectory, WP_HOME and WP_SITEURL must be set
415
+ if (host && subDir) {
416
+ const wpSiteUrl = `https://${host}/${subDir}`;
417
+ cfg = cfg.replace(
418
+ '/** Absolute path to the WordPress directory. */',
419
+ `define( 'WP_HOME', '${wpSiteUrl}' );\ndefine( 'WP_SITEURL', '${wpSiteUrl}' );\n\n/** Absolute path to the WordPress directory. */`,
420
+ );
421
+ }
422
+ // Inject reverse-proxy HTTPS detection (needed behind Contour/envoy)
423
+ const httpsSnippet = `
424
+ if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
425
+ $_SERVER['HTTPS'] = 'on';
426
+ }
427
+ `;
428
+ cfg = cfg.replace('<?php', `<?php\n${httpsSnippet}`);
429
+
430
+ // Inject WP Mail SMTP constants when wpMailSmtp config is provided
431
+ const wpMailSmtp = wp && wp.wpMailSmtp;
432
+ if (wpMailSmtp) {
433
+ const smtp = wpMailSmtp.smtp || {};
434
+ const wpmsLines = [
435
+ `define( 'WPMS_ON', true );`,
436
+ wpMailSmtp.fromEmail ? `define( 'WPMS_MAIL_FROM', '${wpMailSmtp.fromEmail}' );` : null,
437
+ wpMailSmtp.fromName ? `define( 'WPMS_MAIL_FROM_NAME', '${wpMailSmtp.fromName}' );` : null,
438
+ `define( 'WPMS_MAIL_FROM_FORCE', true );`,
439
+ `define( 'WPMS_MAIL_FROM_NAME_FORCE', false );`,
440
+ wpMailSmtp.mailer ? `define( 'WPMS_MAILER', '${wpMailSmtp.mailer}' );` : null,
441
+ wpMailSmtp.returnPath !== undefined ? `define( 'WPMS_SET_RETURN_PATH', ${wpMailSmtp.returnPath} );` : null,
442
+ smtp.host ? `define( 'WPMS_SMTP_HOST', '${smtp.host}' );` : null,
443
+ smtp.port ? `define( 'WPMS_SMTP_PORT', ${smtp.port} );` : null,
444
+ smtp.encryption ? `define( 'WPMS_SSL', '${smtp.encryption}' );` : null,
445
+ smtp.auth !== undefined ? `define( 'WPMS_SMTP_AUTH', ${smtp.auth} );` : null,
446
+ `define( 'WPMS_SMTP_AUTOTLS', true );`,
447
+ smtp.user ? `define( 'WPMS_SMTP_USER', '${smtp.user}' );` : null,
448
+ smtp.pass ? `define( 'WPMS_SMTP_PASS', '${smtp.pass}' );` : null,
449
+ ]
450
+ .filter(Boolean)
451
+ .join('\n');
452
+ cfg = cfg.replace(
453
+ '/** Absolute path to the WordPress directory. */',
454
+ `// WP Mail SMTP plugin constants\n${wpmsLines}\n\n/** Absolute path to the WordPress directory. */`,
455
+ );
456
+ logger.info(`${host}: WP Mail SMTP constants injected into wp-config.php`);
457
+ }
458
+
459
+ fs.writeFileSync(target, cfg, 'utf8');
460
+ logger.info(`wp-config.php written for ${db.name}`);
461
+ }
462
+
463
+ /**
464
+ * Backs up a WordPress site: commits all changes and pushes the
465
+ * site directory to its git remote.
466
+ * If no `.git` directory exists but `repository` is provided, git is
467
+ * initialized first so subsequent cron-triggered backups can push.
468
+ * @param {object} opts
469
+ * @param {string} opts.host - Virtual-host name.
470
+ * @param {string|null} [opts.repository] - Git remote URL; used to initialize git if missing.
471
+ */
472
+ static backup({ host, repository }) {
473
+ const siteRoot = WpService.siteDir(host);
474
+ const githubOrg = process.env.GITHUB_USERNAME || 'underpostnet';
475
+ if (!fs.existsSync(siteRoot)) {
476
+ logger.warn(`backup: site root does not exist — ${siteRoot}`);
477
+ return;
478
+ }
479
+ logger.info(`backup: ${host}`);
480
+
481
+ // Ensure git is initialized when a repository is configured
482
+ if (repository && !fs.existsSync(path.join(siteRoot, '.git'))) {
483
+ Underpost.repo.initLocalRepo({ path: siteRoot, origin: repository });
484
+ }
485
+
486
+ // MariaDB export is handled by the shared db.js backup flow — no duplicate dump here.
487
+ if (fs.existsSync(path.join(siteRoot, '.git'))) {
488
+ shellExec(`cd "${siteRoot}" && git add -A && git commit -m "wp backup $(date -u +%Y-%m-%dT%H:%M:%SZ)" || true`);
489
+ shellExec(`cd "${siteRoot}" && underpost push . ${githubOrg}/${repository.split('/').pop().split('.')[0]}`);
490
+ logger.info(`backup: git push done for ${siteRoot}`);
491
+ } else {
492
+ logger.warn(`backup: no .git and no repository configured for ${host} — skipping git push`);
493
+ }
494
+ }
495
+ }
496
+
497
+ 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
  /**
@@ -613,13 +635,13 @@ function applySecurity(app, opts = {}) {
613
635
  frameAncestors: frameAncestors,
614
636
  imgSrc: ["'self'", 'data:', httpDirective, 'https:', 'blob:'],
615
637
  objectSrc: ["'none'"],
616
- // script-src and script-src-elem include dynamic nonce
617
- scriptSrc: [
638
+ // script-src and script-src-elem: use 'unsafe-inline' for swagger (no nonce, otherwise
639
+ // the nonce causes 'unsafe-inline' to be ignored per CSP3 spec), nonce for everything else.
640
+ scriptSrc: ["'self'", (req, res) => (res.locals.isSwagger ? "'unsafe-inline'" : `'nonce-${res.locals.nonce}'`)],
641
+ scriptSrcElem: [
618
642
  "'self'",
619
- (req, res) => `'nonce-${res.locals.nonce}'`,
620
- (req, res) => (res.locals.isSwagger ? "'unsafe-inline'" : ''),
643
+ (req, res) => (res.locals.isSwagger ? "'unsafe-inline'" : `'nonce-${res.locals.nonce}'`),
621
644
  ],
622
- scriptSrcElem: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
623
645
  // style-src: avoid 'unsafe-inline' when possible; if you must inline styles,
624
646
  // use a nonce for them too (or hash).
625
647
  styleSrc: [
@@ -627,6 +649,7 @@ function applySecurity(app, opts = {}) {
627
649
  httpDirective,
628
650
  (req, res) => (res.locals.isSwagger ? "'unsafe-inline'" : `'nonce-${res.locals.nonce}'`),
629
651
  ],
652
+ styleSrcAttr: [(req, res) => (res.locals.isSwagger ? "'unsafe-inline'" : "'none'")],
630
653
  // deny plugins
631
654
  objectSrc: ["'none'"],
632
655
  },
@@ -677,6 +700,7 @@ export {
677
700
  jwtVerify as verifyJWT,
678
701
  adminGuard,
679
702
  moderatorGuard,
703
+ userGuard,
680
704
  validatePasswordMiddleware,
681
705
  getBearerToken,
682
706
  createSessionAndUserToken,
@@ -8,7 +8,8 @@ import fs from 'fs-extra';
8
8
  import { loggerFactory } from './logger.js';
9
9
  import { shellExec } from './process.js';
10
10
  import Underpost from '../index.js';
11
- import { loadCronDeployEnv } from './conf.js';
11
+ import { loadCronDeployEnv, readConfJson } from './conf.js';
12
+ import { WpService } from '../runtime/wp/Wp.js';
12
13
 
13
14
  const logger = loggerFactory(import.meta);
14
15
 
@@ -57,6 +58,23 @@ class BackUp {
57
58
  logger.info('Executing database export for', deployId);
58
59
  shellExec(command);
59
60
  }
61
+ {
62
+ const confServer = readConfJson(deployId, 'server');
63
+ for (const host of Object.keys(confServer)) {
64
+ for (const path of Object.keys(confServer[host])) {
65
+ const entry = confServer[host][path];
66
+ try {
67
+ switch (entry.runtime) {
68
+ case 'wp':
69
+ WpService.backup({ host, repository: entry.repository });
70
+ break;
71
+ }
72
+ } catch (err) {
73
+ logger.error(`Error during entry runtime backup for ${host}${path}:`, err);
74
+ }
75
+ }
76
+ }
77
+ }
60
78
  }
61
79
  };
62
80
  }