apostrophe 4.30.1-beta.1 → 4.31.0-alpha.1

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 (122) hide show
  1. package/CHANGELOG.md +62 -2
  2. package/claude-tools/detect-handles.js +46 -0
  3. package/claude-tools/minimal-hang-test.js +28 -0
  4. package/claude-tools/mongo-close-test.js +11 -0
  5. package/claude-tools/stdin-ref-test.js +14 -0
  6. package/eslint.config.js +3 -1
  7. package/modules/@apostrophecms/area/index.js +94 -2
  8. package/modules/@apostrophecms/area/lib/custom-tags/area.js +1 -40
  9. package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +0 -1
  10. package/modules/@apostrophecms/area/ui/apos/components/AposWidgetControls.vue +0 -1
  11. package/modules/@apostrophecms/attachment/index.js +4 -1
  12. package/modules/@apostrophecms/db/index.js +68 -27
  13. package/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js +5 -3
  14. package/modules/@apostrophecms/express/index.js +2 -0
  15. package/modules/@apostrophecms/http/index.js +1 -1
  16. package/modules/@apostrophecms/i18n/i18n/en.json +3 -0
  17. package/modules/@apostrophecms/image/ui/apos/components/AposMediaManagerEditor.vue +2 -2
  18. package/modules/@apostrophecms/job/index.js +9 -7
  19. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridColumn.vue +0 -1
  20. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +0 -1
  21. package/modules/@apostrophecms/login/ui/apos/components/TheAposLogin.vue +10 -2
  22. package/modules/@apostrophecms/login/ui/apos/components/TheAposLoginHeader.vue +3 -3
  23. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +52 -23
  24. package/modules/@apostrophecms/modal/ui/apos/components/AposModalTabs.vue +6 -1
  25. package/modules/@apostrophecms/oembed/index.js +2 -1
  26. package/modules/@apostrophecms/piece-type/index.js +2 -1
  27. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerDisplay.vue +7 -2
  28. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposCellTitle.vue +1 -0
  29. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +21 -4
  30. package/modules/@apostrophecms/schema/ui/apos/components/AposArrayEditor.vue +1 -0
  31. package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +7 -2
  32. package/modules/@apostrophecms/schema/ui/apos/components/AposInputSelect.vue +1 -0
  33. package/modules/@apostrophecms/schema/ui/apos/components/AposInputWrapper.vue +1 -1
  34. package/modules/@apostrophecms/schema/ui/apos/components/AposSubform.vue +1 -0
  35. package/modules/@apostrophecms/schema/ui/apos/logic/AposSubform.js +10 -0
  36. package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +1 -0
  37. package/modules/@apostrophecms/template/index.js +117 -11
  38. package/modules/@apostrophecms/template/lib/jsxLoader.js +128 -0
  39. package/modules/@apostrophecms/template/lib/jsxRender.js +490 -0
  40. package/modules/@apostrophecms/template/lib/jsxRuntime.js +276 -0
  41. package/modules/@apostrophecms/template/lib/nunjucksLoader.js +11 -36
  42. package/modules/@apostrophecms/template/lib/viewWatcher.js +113 -0
  43. package/modules/@apostrophecms/ui/ui/apos/components/AposButtonGroup.vue +1 -1
  44. package/modules/@apostrophecms/ui/ui/apos/components/AposCellLastEdited.vue +1 -1
  45. package/modules/@apostrophecms/ui/ui/apos/components/AposSelect.vue +1 -0
  46. package/modules/@apostrophecms/ui/ui/apos/components/AposSlat.vue +10 -4
  47. package/modules/@apostrophecms/ui/ui/apos/components/AposSlatList.vue +6 -1
  48. package/modules/@apostrophecms/ui/ui/apos/components/AposSubformPreview.vue +1 -1
  49. package/modules/@apostrophecms/ui/ui/apos/components/AposTreeHeader.vue +1 -1
  50. package/modules/@apostrophecms/ui/ui/apos/scss/global/_theme.scss +1 -0
  51. package/modules/@apostrophecms/uploadfs/index.js +3 -0
  52. package/modules/@apostrophecms/util/index.js +3 -3
  53. package/package.json +16 -12
  54. package/test/add-missing-schema-fields-project/test.js +22 -3
  55. package/test/assets.js +110 -67
  56. package/test/db-tools.js +365 -0
  57. package/test/db.js +24 -15
  58. package/test/default-adapter.js +256 -0
  59. package/test/external-front.js +419 -1
  60. package/test/job.js +1 -1
  61. package/test/modules/jsx-area-test/index.js +23 -0
  62. package/test/modules/jsx-area-test/views/bad-area.jsx +7 -0
  63. package/test/modules/jsx-area-test/views/with-area-ctx.jsx +13 -0
  64. package/test/modules/jsx-area-test/views/with-area.jsx +7 -0
  65. package/test/modules/jsx-area-test/views/with-widget-ctx.jsx +12 -0
  66. package/test/modules/jsx-area-test/views/with-widget.jsx +7 -0
  67. package/test/modules/jsx-async-widget/index.js +6 -0
  68. package/test/modules/jsx-async-widget/views/widget.jsx +11 -0
  69. package/test/modules/jsx-bridge-test/index.js +1 -0
  70. package/test/modules/jsx-bridge-test/views/cross-module.jsx +7 -0
  71. package/test/modules/jsx-bridge-test/views/disambig-name-only.jsx +7 -0
  72. package/test/modules/jsx-bridge-test/views/disambig-target.jsx +8 -0
  73. package/test/modules/jsx-bridge-test/views/disambig-with-template-name.jsx +7 -0
  74. package/test/modules/jsx-bridge-test/views/include-html.jsx +7 -0
  75. package/test/modules/jsx-bridge-test/views/include-target.html +4 -0
  76. package/test/modules/jsx-bridge-test/views/jsx-extends-via-extend.jsx +9 -0
  77. package/test/modules/jsx-bridge-test/views/jsx-extends.jsx +9 -0
  78. package/test/modules/jsx-bridge-test/views/jsx-layout.jsx +14 -0
  79. package/test/modules/jsx-bridge-test/views/njk-extends.jsx +14 -0
  80. package/test/modules/jsx-bridge-test/views/njk-layout.html +9 -0
  81. package/test/modules/jsx-bridge-test/views/short-form.jsx +7 -0
  82. package/test/modules/jsx-bridge-test/views/short-target.jsx +3 -0
  83. package/test/modules/jsx-component-test/index.js +15 -0
  84. package/test/modules/jsx-component-test/views/greet.html +1 -0
  85. package/test/modules/jsx-component-test/views/uses-component.jsx +8 -0
  86. package/test/modules/jsx-ctx-widget/index.js +6 -0
  87. package/test/modules/jsx-ctx-widget/views/widget.jsx +4 -0
  88. package/test/modules/jsx-mixed-test/index.js +9 -0
  89. package/test/modules/jsx-mixed-test/views/apos-full.jsx +21 -0
  90. package/test/modules/jsx-mixed-test/views/async-list.jsx +12 -0
  91. package/test/modules/jsx-mixed-test/views/lib/format.js +3 -0
  92. package/test/modules/jsx-mixed-test/views/localized.jsx +3 -0
  93. package/test/modules/jsx-mixed-test/views/partial.jsx +3 -0
  94. package/test/modules/jsx-mixed-test/views/safe-helper.jsx +3 -0
  95. package/test/modules/jsx-mixed-test/views/syntax-error.jsx +3 -0
  96. package/test/modules/jsx-mixed-test/views/throws.jsx +5 -0
  97. package/test/modules/jsx-mixed-test/views/uses-import.jsx +5 -0
  98. package/test/modules/jsx-mixed-test/views/uses-require.jsx +5 -0
  99. package/test/modules/jsx-watcher-cross-test/index.js +5 -0
  100. package/test/modules/jsx-watcher-cross-test/views/cross-template.jsx +3 -0
  101. package/test/modules/jsx-watcher-test/index.js +5 -0
  102. package/test/modules/jsx-watcher-test/views/watcher-test.jsx +3 -0
  103. package/test/modules/template-jsx-options-test/index.js +12 -0
  104. package/test/modules/template-jsx-options-test/views/options-test.jsx +9 -0
  105. package/test/modules/template-jsx-subclass-test/index.js +3 -0
  106. package/test/modules/template-jsx-subclass-test/views/override-test.jsx +3 -0
  107. package/test/modules/template-jsx-test/index.js +9 -0
  108. package/test/modules/template-jsx-test/views/boolean-attrs.jsx +11 -0
  109. package/test/modules/template-jsx-test/views/class-and-for.jsx +7 -0
  110. package/test/modules/template-jsx-test/views/dangerously-set.jsx +3 -0
  111. package/test/modules/template-jsx-test/views/escape-attr.jsx +3 -0
  112. package/test/modules/template-jsx-test/views/escape-body.jsx +3 -0
  113. package/test/modules/template-jsx-test/views/inherit-test.jsx +3 -0
  114. package/test/modules/template-jsx-test/views/list.jsx +7 -0
  115. package/test/modules/template-jsx-test/views/override-test.jsx +3 -0
  116. package/test/modules/template-jsx-test/views/svg-attrs.jsx +27 -0
  117. package/test/modules/template-jsx-test/views/test.jsx +3 -0
  118. package/test/modules/template-jsx-test/views/void-elements.jsx +9 -0
  119. package/test/templates-jsx-watcher.js +135 -0
  120. package/test/templates-jsx.js +537 -0
  121. package/test-lib/util.js +50 -14
  122. package/lib/mongodb-connect.js +0 -62
package/CHANGELOG.md CHANGED
@@ -1,10 +1,70 @@
1
1
  # Changelog
2
2
 
3
- ## 4.30.1
3
+ ## 4.31.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 33bb4c0: Added support for `draggable: false` on non-inline `array` schema fields. Previously this option was only respected when `inline: true`. When set on a standard (modal-based) array field, drag-and-drop reordering and keyboard reordering are now disabled in the array editor's slat list.
8
+ - dfd25cf: Introduced support for postgres://, sqlite://, and multipostgres:// database URIs in addition to mongodb://. The new db-connect API supports all of the database operations currently used in our own core, pro and multisite modules. For more information see the documentation.
9
+ - 950927d: The session secret and the uploadfs `disabledFileKey` can now be supplied via the `APOS_SESSION_SECRET` and `APOS_UPLOADFS_DISABLED_FILE_KEY` environment variables. As with other Apostrophe environment variables, these take precedence over the corresponding `app.js` configuration.
10
+ - cf1a639: Fixed an issue where using the Tab key to navigate within modals could incorrectly jump focus to a wrong element instead of the next input field.
11
+
12
+ Fixed Tab navigation escaping out of modals when the form contained hidden sections or elements that became disabled after editing.
13
+
14
+ - 77a2968: Fix layout widget not regaining full focus on switching back to Edit content mode.
15
+ - f67c272: Fixed adding or removing an area field from a schema breaking existing documents on an external front such as Astro.
16
+
17
+ - `AposArea` now renders only schema-backed areas. A missing area no longer throws, and an area orphaned by removing its field from the schema (while its content remains in the document) renders nothing instead of breaking sibling areas in edit mode. Logged-in editors get a diagnostic message in place of an orphaned area; anonymous visitors see nothing.
18
+ - Editable documents sent to an external front now materialize empty area objects for schema area fields added after the document was created, so they can be edited in context.
19
+ - `apos.util.getManagerOf` accepts a `{ log }` option to suppress its error log when probing objects that may not have a manager.
20
+
21
+ - bc8f7be: Editors can now control the layout-widget gap through the styles system, both site-wide via a global `layoutGap` preset and per widget via a `gap` styles field. New Layout widget option `className` allows for additional CSS class names to be added to the widget Grid container.
4
22
 
5
23
  ### Patch Changes
6
24
 
7
- - Sites with a custom filterByIndexPage method no longer experience failures in the sitemap module and potential creeping CPU performance penalties. A regression introduced with our static site support, but not specific to static sites.
25
+ - 2e2f3b4: Accessibility: corrected ARIA semantics on the top admin navigation bar.
26
+ - 2e2f3b4: Accessibility: improvements to the document context title (admin bar middle group) and the underlying `AposContextMenu` machinery.
27
+ - 2e2f3b4: Accessibility: improve the locale switcher (`AposLocalePicker`).
28
+ - 2e2f3b4: Accessibility: the Recently Edited Documents tray icon (admin bar) now exposes its action through `aria-label`.
29
+ - 2e2f3b4: Accessibility: fix `.apos-sr-only` so screen-reader-only content is exposed to the accessibility tree.
30
+ - 2e2f3b4: Accessibility: icon-only context-utility buttons in the admin bar tray (e.g. the global settings cog) now expose their action through `aria-label`.
31
+ - 41670a3: Security fix: the password reset request feature will refuse to operate unless the baseUrl option or the APOS_BASE_URL environment variable has been set (note that this is automatic in multisite projects). This fix is necessary to prevent a vulnerability that can be used to convince ApostropheCMS to send emails containing links to other sites. It is only a vulnerability if you have enabled the passwordReset: true option for the login module. Thanks to [SPIDY](https://github.com/Mujahidkhan525) for reporting the issue.
32
+ - 9f458b5: Fix illegal HTML id attribute values generated by the admin UI
33
+ - 08845c5: - Removed duplicate <meta charset> tag from `outerLayoutBase.html`
34
+ - Standardized charset to utf-8 (the legacy configuration option is now ignored). Per the spec this is the only legal setting, so we classify this as a bug fix
35
+ - Altered unused/legacy i18n template helper to return `utf-8`, ensuring backwards compatibility
36
+ - b9b32bd: Keyboard shortcuts for widget operations (copy, cut, paste, duplicate, remove) no longer block the browser's native clipboard behavior when no widget is focused. Previously, selecting and copying text on a page while logged in was prevented by the admin UI intercepting those shortcuts unconditionally.
37
+ - 2191c8a: Fix more admin UI a11y issues
38
+ - 08dfcac: Security: a malicious full name containing HTML was executed as HTML in the tooltip displayed with an "i" icon next to the title of the current page, creating an
39
+ XSS attack risk versus other users. Since most projects permit users to change their full name (the "title" property), All projects with multiple users should be
40
+ updated promptly to close this vulnerability. Thanks to [Muhammad Uwais](https://github.com/MuhammadUwais) for reporting the issue.
41
+ - 5d1a028: Security: launder now uses and exports the best available naughtyHref function for detecting malicious URLs. sanitize-html now depends on it, and apostrophe now uses type: 'url' for the link URL field of image widgets, which leverages it. Prior to this fix, it was possible for any user with editing privileges, including a contributor, to trigger arbitrary JavaScript via a javascript: URL in the link URL field of an image widget. A migration has been included to strip any such malicious URLs already present in the database. All users of apostrophe are encouraged to upgrade to get this security fix. Thanks to [Muhammad Uwais](https://github.com/MuhammadUwais) for reporting the issue.
42
+ - 8098017: Security: the HTML import feature of the rich text widget no longer permits images to be fetched
43
+ from arbitrary hosts. This could be used to probe internal networks, and to exfiltrate images from
44
+ internal hosts if their URLs were known. Instead, the `imageImportAllowedHostnames` option of the
45
+ `@apostrophecms/rich-text-widget` must be configured to opt into that feature.
46
+
47
+ Thanks to [Yiğit Şengezer](https://github.com/yigitsengezer) and [Sainithin0309](https://github.com/Sainithin0309) for reporting this issue.
48
+
49
+ - b7f9ad5: Sites with a custom filterByIndexPage method no longer experience failures in the sitemap module and potential creeping CPU performance penalties. A regression introduced with our static site support, but not specific to static sites.
50
+ - b360b05: fixes issue where orderable table array items drag the entire floating window
51
+ - e9b3bac: apostrophe and oembetter have been updated to eliminate a number of services that formerly supported
52
+ oembed for the general public, but no longer do so. While there is no security risk today, removing
53
+ these ensures that if these domains are ever allowed to lapse, they do not become an XSS
54
+ attack vector in the future.
55
+
56
+ Because oembed responses are not always iframes, it is important that this list be maintained
57
+ over time. In addition, developers always have the option to prune it on their own by setting
58
+ the new minimumAllowlist and minimumEndpoints options of the @apostrophecms/oembed module.
59
+
60
+ Thanks to [Sainithin0309](https://github.com/Sainithin0309) for pointing out the potential
61
+ long-term security concern.
62
+
63
+ - Updated dependencies [862d760]
64
+ - Updated dependencies [5d1a028]
65
+ - Updated dependencies [8d4c882]
66
+ - sanitize-html@2.17.5
67
+ - launder@1.7.2
8
68
 
9
69
  ## 4.30.0
10
70
 
@@ -0,0 +1,46 @@
1
+ // Require this before running mocha to detect what activates process.stdin
2
+ // Usage: npx mocha -t 10000 --require ./claude-tools/detect-handles.js test/assets.js
3
+
4
+ console.log(`stdin paused at startup: ${process.stdin.isPaused()}`);
5
+ console.log(`stdin readableFlowing at startup: ${process.stdin.readableFlowing}`);
6
+
7
+ // Monkey-patch stdin.resume to capture the call stack
8
+ const origResume = process.stdin.resume.bind(process.stdin);
9
+ process.stdin.resume = function(...args) {
10
+ console.log('\n=== process.stdin.resume() called ===');
11
+ console.log(new Error().stack);
12
+ return origResume(...args);
13
+ };
14
+
15
+ // Monkey-patch stdin.on to detect 'data' listener additions
16
+ const origOn = process.stdin.on.bind(process.stdin);
17
+ process.stdin.on = function(event, ...args) {
18
+ if (event === 'data' || event === 'readable') {
19
+ console.log(`\n=== process.stdin.on('${event}') called ===`);
20
+ console.log(new Error().stack);
21
+ }
22
+ return origOn(event, ...args);
23
+ };
24
+
25
+ // Periodically check stdin state changes
26
+ let lastState = process.stdin.readableFlowing;
27
+ const checker = setInterval(() => {
28
+ if (process.stdin.readableFlowing !== lastState) {
29
+ console.log(`\n=== stdin readableFlowing changed: ${lastState} -> ${process.stdin.readableFlowing} ===`);
30
+ console.log(new Error().stack);
31
+ lastState = process.stdin.readableFlowing;
32
+ }
33
+ }, 100);
34
+ checker.unref();
35
+
36
+ const origRun = require('mocha/lib/runner').prototype.run;
37
+ require('mocha/lib/runner').prototype.run = function(fn) {
38
+ return origRun.call(this, function(failures) {
39
+ console.log(`\nstdin paused at end: ${process.stdin.isPaused()}`);
40
+ console.log(`stdin readableFlowing at end: ${process.stdin.readableFlowing}`);
41
+ setTimeout(() => {
42
+ process.exit(failures ? 3 : 0);
43
+ }, 2000);
44
+ if (fn) fn(failures);
45
+ });
46
+ };
@@ -0,0 +1,28 @@
1
+ // Minimal test to isolate what causes the hang.
2
+ // Must reference the test/ directory as root for proper module resolution.
3
+ const t = require('../test-lib/test.js');
4
+ const path = require('path');
5
+
6
+ // Fake a module object rooted in test/ like the real tests do
7
+ const fakeModule = {
8
+ id: path.join(__dirname, '../test/fake'),
9
+ filename: path.join(__dirname, '../test/fake.js'),
10
+ paths: [path.join(__dirname, '../test/node_modules')]
11
+ };
12
+
13
+ describe('Minimal hang test', function() {
14
+ this.timeout(60000);
15
+ let apos;
16
+
17
+ after(async function() {
18
+ await t.destroy(apos);
19
+ console.log('after: destroy complete');
20
+ });
21
+
22
+ it('should create and use apos without hanging', async function() {
23
+ apos = await t.create({
24
+ root: fakeModule
25
+ });
26
+ console.log('apos created successfully');
27
+ });
28
+ });
@@ -0,0 +1,11 @@
1
+ // Test whether a MongoDB connection keeps the process alive after close()
2
+ const mongoConnect = require('../../../packages/db-connect/lib/mongodb-connect');
3
+
4
+ (async () => {
5
+ const uri = 'mongodb://localhost:27017/test_handle_leak';
6
+ console.log('Connecting...');
7
+ const client = await mongoConnect(uri);
8
+ console.log('Connected. Closing...');
9
+ await client.close();
10
+ console.log('Closed. Process should exit now if no leaked handles.');
11
+ })();
@@ -0,0 +1,14 @@
1
+ // Check if process.stdin keeps the process alive
2
+ // If this script hangs, stdin is ref'd. If it exits, stdin is unref'd.
3
+
4
+ console.log(`stdin isTTY: ${process.stdin.isTTY}`);
5
+ console.log(`stdin readableFlowing: ${process.stdin.readableFlowing}`);
6
+ console.log(`stdin isPaused: ${process.stdin.isPaused()}`);
7
+
8
+ // Check ref status
9
+ if (typeof process.stdin.unref === 'function') {
10
+ console.log('stdin has unref method');
11
+ }
12
+
13
+ console.log('Waiting to see if process exits on its own...');
14
+ // Don't do anything - just see if the process exits
package/eslint.config.js CHANGED
@@ -7,7 +7,9 @@ module.exports = defineConfig([
7
7
  '**/blueimp/**/*.js',
8
8
  'test/public',
9
9
  'test/apos-build',
10
- 'coverage'
10
+ 'test/modules/jsx-mixed-test/views/syntax-error.jsx',
11
+ 'coverage',
12
+ 'claude-tools'
11
13
  ]),
12
14
  apostrophe
13
15
  ]);
@@ -140,7 +140,7 @@ module.exports = {
140
140
  // so this logic is reproduced partially
141
141
  self.apos.doc.walk(area, (o, k, v) => {
142
142
  if (v && v.metaType === 'area') {
143
- const manager = self.apos.util.getManagerOf(o);
143
+ const manager = self.apos.util.getManagerOf(o, { log: false });
144
144
  if (!manager) {
145
145
  self.apos.util.warnDevOnce(
146
146
  'noManagerForDocInExternalFront',
@@ -285,6 +285,95 @@ module.exports = {
285
285
  self.missingWidgetTypes[name] = true;
286
286
  }
287
287
  },
288
+ // Build an empty area and attach it to `parent[name]`. When the area's
289
+ // location can be resolved inside the *persisted* document, also stub it
290
+ // into the database so the backend recognizes it for later edits.
291
+ // Returns the area.
292
+ //
293
+ // Options:
294
+ // - `throwIfNotFound` (default `false`): when `parent` is doc-backed but
295
+ // the document or the container cannot be located in the database,
296
+ // throw a `notfound` error instead of returning an in-memory-only
297
+ // stub. The `{% area %}` tag opts in to preserve its historical
298
+ // behavior; the external front annotator leaves it off so a render is
299
+ // never brought down by such a case.
300
+ //
301
+ // Used by the `{% area %}` tag and the external front annotator as the
302
+ // single source of truth for stubbing schema areas that have no value
303
+ // yet.
304
+ async addMissingArea(parent, name, { throwIfNotFound = false } = {}) {
305
+ const area = {
306
+ metaType: 'area',
307
+ _id: self.apos.util.generateId(),
308
+ items: []
309
+ };
310
+ parent[name] = area;
311
+
312
+ const docId = parent._docId ??
313
+ (parent.metaType === 'doc' ? parent._id : null);
314
+ const areaDotPath = await self.resolvePersistedAreaDotPath(parent, name);
315
+ if (!areaDotPath) {
316
+ if (throwIfNotFound && docId) {
317
+ throw self.apos.error('notfound');
318
+ }
319
+ return area;
320
+ }
321
+
322
+ const result = await self.apos.doc.db.updateOne(
323
+ {
324
+ _id: docId,
325
+ // Idempotent and race-safe: only write when still absent.
326
+ [areaDotPath]: { $eq: null }
327
+ },
328
+ {
329
+ $set: { [areaDotPath]: self.apos.util.clonePermanent(area) }
330
+ }
331
+ );
332
+ if (result.modifiedCount === 0) {
333
+ // Another request stubbed it first (or it already existed): adopt
334
+ // the persisted `_id` so we render the same area.
335
+ const refreshed = await self.apos.doc.db.findOne({ _id: docId });
336
+ const persisted = refreshed && self.apos.util.get(refreshed, areaDotPath);
337
+ if (persisted?._id) {
338
+ area._id = persisted._id;
339
+ }
340
+ }
341
+ return area;
342
+ },
343
+
344
+ // Resolve the MongoDB dot-path at which `parent[name]` should be stored,
345
+ // computed from the *persisted* document so it always reflects real
346
+ // storage (not the in-memory graph with its loaded relationships). The
347
+ // `parent` object is located inside the freshly read document by its
348
+ // `_id`. Returns the dot-path string, or `null` when the area cannot be
349
+ // safely persisted (no doc id, doc not in the database, or `parent` is
350
+ // not part of the persisted document, e.g. loaded relationship data).
351
+ async resolvePersistedAreaDotPath(parent, name) {
352
+ const docId = parent._docId ??
353
+ (parent.metaType === 'doc' ? parent._id : null);
354
+ if (!docId) {
355
+ return null;
356
+ }
357
+ const mainDoc = await self.apos.doc.db.findOne({ _id: docId });
358
+ if (!mainDoc) {
359
+ return null;
360
+ }
361
+ if (parent._id === docId) {
362
+ return name;
363
+ }
364
+ if (!parent._id) {
365
+ return null;
366
+ }
367
+ const found = self.apos.util.findNestedObjectAndDotPathById(
368
+ mainDoc,
369
+ parent._id,
370
+ { ignoreDynamicProperties: true }
371
+ );
372
+ if (!found) {
373
+ return null;
374
+ }
375
+ return `${found.dotPath}.${name}`;
376
+ },
288
377
  prepForRender(area, context, fieldName) {
289
378
  const manager = self.apos.util.getManagerOf(context);
290
379
  const field = manager.schema.find(field => field.name === fieldName);
@@ -451,7 +540,10 @@ module.exports = {
451
540
  // Loop over the docs in the array passed in.
452
541
  for (const doc of within) {
453
542
  if (self.apos.externalFrontKey) {
454
- self.apos.template.annotateDocForExternalFront(doc, { scene: req.scene });
543
+ await self.apos.template.annotateDocForExternalFront(
544
+ doc,
545
+ { scene: req.scene }
546
+ );
455
547
  }
456
548
 
457
549
  const rendered = [];
@@ -59,46 +59,7 @@ module.exports = function(self) {
59
59
  }
60
60
  area = doc[name];
61
61
  if (!area) {
62
- // Problem: area is in schema but that doesn't guarantee it
63
- // has a value, for instance the field could be new in the schema.
64
- // But we need an area _id. Stub it into the db on the fly
65
- // without race conditions
66
- area = {
67
- metaType: 'area',
68
- _id: self.apos.util.generateId(),
69
- items: []
70
- };
71
- doc[name] = area;
72
- const docId = doc._docId || ((doc.metaType === 'doc') ? doc._id : null);
73
- if (docId) {
74
- let mainDoc = await self.apos.doc.db.findOne({ _id: docId });
75
- if (!mainDoc) {
76
- throw self.apos.error('notfound');
77
- }
78
- let docDotPath;
79
- try {
80
- docDotPath = (doc._id === docId) ? '' : self.apos.util.findNestedObjectAndDotPathById(mainDoc, doc._id).dotPath;
81
- } catch (e) {
82
- // Race condition: someone removed the area's parent object.
83
- // Unlikely thanks to advisory locking
84
- throw self.apos.error('notfound');
85
- }
86
- const areaDotPath = docDotPath ? `${docDotPath}.${name}` : name;
87
- await self.apos.doc.db.updateOne({
88
- _id: docId,
89
- // Prevent race condition
90
- [areaDotPath]: {
91
- $eq: null
92
- }
93
- }, {
94
- $set: {
95
- [areaDotPath]: self.apos.util.clonePermanent(area)
96
- }
97
- });
98
- mainDoc = await self.apos.doc.db.findOne({ _id: docId });
99
- // Prevent race condition
100
- area._id = self.apos.util.get(mainDoc, areaDotPath)._id;
101
- }
62
+ area = await self.apos.area.addMissingArea(doc, name, { throwIfNotFound: true });
102
63
  }
103
64
  const manager = self.apos.util.getManagerOf(doc);
104
65
  const field = manager.schema.find(field => field.name === name);
@@ -119,7 +119,6 @@ export default {
119
119
  icon: 'plus-icon',
120
120
  type: 'group',
121
121
  modifiers: [ 'small', 'inline' ],
122
- role: 'menuitem',
123
122
  class: 'apos-area-modify-controls__button',
124
123
  iconSize: 16,
125
124
  disableFocus: !this.isFocused
@@ -90,7 +90,6 @@ export default {
90
90
  icon: 'plus-icon',
91
91
  type: 'group',
92
92
  modifiers: [ 'small', 'inline' ],
93
- role: 'menuitem',
94
93
  class: 'apos-area-modify-controls__button',
95
94
  iconSize: 16,
96
95
  disableFocus: !this.tabbable
@@ -656,7 +656,10 @@ module.exports = {
656
656
  // to avoid template errors
657
657
  getMissingAttachmentUrl() {
658
658
  const defaultIconUrl = '/modules/@apostrophecms/attachment/img/missing-icon.svg';
659
- self.apos.util.warn('Template warning: Impossible to retrieve the attachment url since it is missing, a default icon has been set. Please fix this ASAP!');
659
+ const e = new Error();
660
+ self.apos.util.warn('Template warning: Impossible to retrieve the attachment url since it is missing, a default icon has been set. Please fix this ASAP!\n\n' +
661
+ e.stack
662
+ );
660
663
  // Convert static asset path to full URL, which matters when static
661
664
  // assets are in uploadfs
662
665
  return self.apos.asset.url(defaultIconUrl);
@@ -4,11 +4,12 @@
4
4
  //
5
5
  // ### `uri`
6
6
  //
7
- // The MongoDB connection URI. See the [MongoDB URI documentation](https://docs.mongodb.com/manual/reference/connection-string/).
7
+ // The databse connection URI. See the [MongoDB URI documentation](https://docs.mongodb.com/manual/reference/connection-string/)
8
+ // and the postgres documentation.
8
9
  //
9
10
  // ### `connect`
10
11
  //
11
- // If present, this object is passed on as options to MongoDB's "connect"
12
+ // If present, this object is passed on as options to the database adapters "connect"
12
13
  // method, along with the uri. See the [MongoDB connect settings documentation](http://mongodb.github.io/node-mongodb-native/2.2/reference/connecting/connection-settings/).
13
14
  //
14
15
  // By default, Apostrophe sets options to retry lost connections forever,
@@ -20,9 +21,16 @@
20
21
  //
21
22
  // ### `client`
22
23
  //
23
- // An existing MongoDB connection (MongoClient) object. If present, it is used
24
+ // An existing MongoDB-compatible client object. If present, it is used
24
25
  // and `uri`, `host`, `connect`, etc. are ignored.
25
26
  //
27
+ // ### `adapters`
28
+ //
29
+ // An array of adapters, each of which must provide `name`, `connect(uri, options)`,
30
+ // and `protocols` properties. `name` may be used to override a core adapter,
31
+ // such as `postgres` or `mongodb`. `connect` must resolve to a client object
32
+ // supporting a sufficient subset of the mongodb API.
33
+ //
26
34
  // ### `versionCheck`
27
35
  //
28
36
  // If `true`, check to make sure the database does not belong to an
@@ -49,15 +57,15 @@
49
57
  // in your project. However you may find it easier to just use the
50
58
  // `client` option.
51
59
 
52
- const mongodbConnect = require('../../../lib/mongodb-connect');
53
- const escapeHost = require('../../../lib/escape-host');
60
+ const dbConnect = require('@apostrophecms/db-connect');
61
+ const escapeHost = require('../../../lib/escape-host.js');
54
62
 
55
63
  module.exports = {
56
64
  options: {
57
65
  versionCheck: true
58
66
  },
59
67
  async init(self) {
60
- await self.connectToMongo();
68
+ await self.connectToDb();
61
69
  await self.versionCheck();
62
70
  },
63
71
  handlers(self) {
@@ -81,14 +89,12 @@ module.exports = {
81
89
  },
82
90
  methods(self) {
83
91
  return {
84
- // Open the database connection. Always uses MongoClient with its
85
- // sensible defaults. Builds a URI if necessary, so we can call it
86
- // in a consistent way.
87
- //
88
- // One default we override: if the connection is lost, we keep
89
- // attempting to reconnect forever. This is the most sensible behavior
90
- // for a persistent process that requires MongoDB in order to operate.
91
- async connectToMongo() {
92
+ // Connect to the database and sets self.apos.dbClient
93
+ // and self.apos.db. Builds a mongodb URI by default,
94
+ // accepting host, port, user, password and name options
95
+ // if present. More typically a URI is specified via
96
+ // APOS_DB_URI, or via APOS_MONGODB_URI for bc.
97
+ async connectToDb() {
92
98
  if (self.options.client) {
93
99
  // Reuse a single client connection http://mongodb.github.io/node-mongodb-native/2.2/api/Db.html#db
94
100
  self.apos.dbClient = self.options.client;
@@ -96,32 +102,67 @@ module.exports = {
96
102
  self.connectionReused = true;
97
103
  return;
98
104
  }
99
- let uri = 'mongodb://';
100
- if (process.env.APOS_MONGODB_URI) {
101
- uri = process.env.APOS_MONGODB_URI;
105
+ let uri;
106
+ const viaEnv = process.env.APOS_DB_URI || process.env.APOS_MONGODB_URI;
107
+ if (viaEnv) {
108
+ uri = viaEnv;
102
109
  } else if (self.options.uri) {
103
110
  uri = self.options.uri;
104
111
  } else {
105
- if (self.options.user) {
106
- uri += self.options.user + ':' + self.options.password + '@';
107
- }
108
- if (!self.options.host) {
109
- self.options.host = 'localhost';
110
- }
111
- if (!self.options.port) {
112
- self.options.port = 27017;
112
+ const validAdapters = [ 'mongodb', 'sqlite', 'postgres', 'multipostgres' ];
113
+ const adapter = process.env.APOS_DEFAULT_DB_ADAPTER || self.options.defaultAdapter || 'mongodb';
114
+ if (!validAdapters.includes(adapter)) {
115
+ throw new Error(`Invalid defaultAdapter: "${adapter}". Must be one of: ${validAdapters.join(', ')}`);
113
116
  }
114
117
  if (!self.options.name) {
115
118
  self.options.name = self.apos.shortName;
116
119
  }
117
- uri += escapeHost(self.options.host) + ':' + self.options.port + '/' + self.options.name;
120
+ if (adapter === 'sqlite') {
121
+ const path = require('path');
122
+ uri = `sqlite://${path.resolve(self.apos.rootDir, 'data', self.options.name + '.sqlite')}`;
123
+ } else {
124
+ const credentials = self.options.user
125
+ ? encodeURIComponent(self.options.user) + ':' + encodeURIComponent(self.options.password) + '@'
126
+ : '';
127
+ if (adapter === 'mongodb') {
128
+ if (!self.options.host) {
129
+ self.options.host = 'localhost';
130
+ }
131
+ if (!self.options.port) {
132
+ self.options.port = 27017;
133
+ }
134
+ uri = 'mongodb://' + credentials + escapeHost(self.options.host) + ':' + self.options.port + '/' + self.options.name;
135
+ } else {
136
+ // postgres or multipostgres
137
+ if (!self.options.host) {
138
+ self.options.host = 'localhost';
139
+ }
140
+ if (!self.options.port) {
141
+ self.options.port = 5432;
142
+ }
143
+ uri = adapter + '://' + credentials + escapeHost(self.options.host) + ':' + self.options.port + '/' + self.options.name;
144
+ }
145
+ }
118
146
  }
119
147
 
120
- self.apos.dbClient = await mongodbConnect(uri, self.options.connect);
148
+ self.apos.dbClient = await dbConnect(uri, {
149
+ ...self.options.connect,
150
+ adapters: self.options.adapters
151
+ });
121
152
  self.uri = uri;
122
153
  // Automatically uses the db name in the connection string
123
154
  self.apos.db = self.apos.dbClient.db();
124
155
  },
156
+ // Connect to a database using the appropriate adapter based on the URI protocol.
157
+ // Returns a client object compatible with the MongoDB driver interface.
158
+ // This method has no side effects — it does not set apos.db or apos.dbClient.
159
+ // It can be used to make temporary connections, e.g. for dropping a test database.
160
+ async connectToAdapter(uri, options) {
161
+ return dbConnect(uri, {
162
+ ...options,
163
+ adapters: self.options.adapters
164
+ });
165
+ },
125
166
  async versionCheck() {
126
167
  if (!self.options.versionCheck) {
127
168
  return;
@@ -273,13 +273,13 @@ export default {
273
273
  });
274
274
  },
275
275
  moduleName() {
276
- if (apos.modules[this.context.type].action === apos.modules['@apostrophecms/page'].action) {
276
+ if (apos.modules[this.context.type]?.action === apos.modules['@apostrophecms/page'].action) {
277
277
  return '@apostrophecms/page';
278
278
  }
279
279
  return this.context.type;
280
280
  },
281
281
  moduleOptions() {
282
- return apos.modules[this.moduleName];
282
+ return apos.modules[this.moduleName] || {};
283
283
  },
284
284
  isUpdateOperation() {
285
285
  return !!this.context._id;
@@ -482,7 +482,9 @@ export default {
482
482
  );
483
483
  },
484
484
  preview(doc) {
485
- window.open(doc._url, '_blank').focus();
485
+ // window.open() returns null when a popup blocker intercepts it,
486
+ // so guard before calling focus().
487
+ window.open(doc._url, '_blank')?.focus();
486
488
  },
487
489
  async copy(doc) {
488
490
  // If there are changes warn the user before discarding them before
@@ -579,6 +579,8 @@ module.exports = {
579
579
  name: self.apos.shortName + '.sid',
580
580
  cookie: {}
581
581
  });
582
+ // Env overrides config, per Apostrophe convention.
583
+ sessionOptions.secret = process.env.APOS_SESSION_SECRET || sessionOptions.secret;
582
584
  _.defaults(sessionOptions.cookie, {
583
585
  path: '/',
584
586
  httpOnly: true,
@@ -2,7 +2,7 @@ const _ = require('lodash');
2
2
  const qs = require('qs');
3
3
  const fetch = require('node-fetch');
4
4
  const tough = require('tough-cookie');
5
- const escapeHost = require('../../../lib/escape-host');
5
+ const escapeHost = require('../../../lib/escape-host.js');
6
6
  const util = require('util');
7
7
 
8
8
  module.exports = {
@@ -205,6 +205,7 @@
205
205
  "editImageRelationshipTitle": "Adjust Image",
206
206
  "editRelationship": "Edit Relationship",
207
207
  "editRelationshipFor": "Edit Relationship for {{ title }}",
208
+ "editSubform": "Edit {{ label }}",
208
209
  "editType": "Edit {{ type }}",
209
210
  "editWidget": "Edit Widget",
210
211
  "editWidgetForeignTooltip": "Click to edit this content in its natural context",
@@ -569,6 +570,7 @@
569
570
  "richTextHighlight": "Mark",
570
571
  "richTextHorizontalRule": "Horizontal Rule",
571
572
  "richTextHorizontalRuleDescription": "Add a horizontal separator",
573
+ "richTextEditor": "Rich text editor",
572
574
  "richTextInsertMenuHeading": "Insert element...",
573
575
  "richTextItalic": "Italic",
574
576
  "richTextLink": "Link",
@@ -632,6 +634,7 @@
632
634
  "slugInUse": "Slug already in use",
633
635
  "someoneElseTookControl": "{{ who }} took control of this document in another tab or window. A document can only be edited in one place at a time.",
634
636
  "splitCell": "Split Cell",
637
+ "status": "Status",
635
638
  "style": "Style",
636
639
  "styleAlignment": "Alignment",
637
640
  "styleBackground": "Background",
@@ -14,7 +14,7 @@
14
14
  v-if="activeMedia.attachment && activeMedia.attachment._urls"
15
15
  class="apos-media-editor__thumb"
16
16
  :src="activeMedia.attachment._urls[restoreOnly ? 'one-sixth' : 'one-third']"
17
- :alt="activeMedia.description"
17
+ :alt="activeMedia.description || ''"
18
18
  >
19
19
  </div>
20
20
  <ul class="apos-media-editor__details">
@@ -484,7 +484,7 @@ export default {
484
484
 
485
485
  & {
486
486
  line-height: var(--a-line-tallest);
487
- color: var(--a-base-4);
487
+ color: var(--a-base-2);
488
488
  }
489
489
  }
490
490