apostrophe 4.28.1 → 4.30.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 (105) hide show
  1. package/CHANGELOG.md +35 -4
  2. package/README.md +2 -2
  3. package/claude-tools/detect-handles.js +46 -0
  4. package/claude-tools/minimal-hang-test.js +28 -0
  5. package/claude-tools/mongo-close-test.js +11 -0
  6. package/claude-tools/stdin-ref-test.js +14 -0
  7. package/defaults.js +1 -0
  8. package/eslint.config.js +2 -1
  9. package/lib/safe-json-script.js +27 -0
  10. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarLocale.vue +1 -1
  11. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue +1 -0
  12. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +3 -5
  13. package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +13 -1
  14. package/modules/@apostrophecms/asset/lib/globalIcons.js +3 -0
  15. package/modules/@apostrophecms/attachment/index.js +43 -1
  16. package/modules/@apostrophecms/color-field/index.js +7 -1
  17. package/modules/@apostrophecms/db/index.js +68 -27
  18. package/modules/@apostrophecms/doc/index.js +11 -1
  19. package/modules/@apostrophecms/doc-type/index.js +165 -32
  20. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue +1 -1
  21. package/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js +104 -59
  22. package/modules/@apostrophecms/file/index.js +109 -8
  23. package/modules/@apostrophecms/http/index.js +1 -1
  24. package/modules/@apostrophecms/i18n/i18n/de.json +0 -2
  25. package/modules/@apostrophecms/i18n/i18n/en.json +40 -1
  26. package/modules/@apostrophecms/i18n/i18n/es.json +0 -1
  27. package/modules/@apostrophecms/i18n/i18n/fr.json +0 -1
  28. package/modules/@apostrophecms/i18n/i18n/it.json +0 -1
  29. package/modules/@apostrophecms/i18n/i18n/pt-BR.json +0 -1
  30. package/modules/@apostrophecms/i18n/i18n/sk.json +0 -1
  31. package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nBatchReporting.js +18 -1
  32. package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nLocalizeActions.js +50 -0
  33. package/modules/@apostrophecms/i18n/ui/apos/components/AposI18nLocalize.vue +56 -13
  34. package/modules/@apostrophecms/image/ui/apos/components/AposImageRelationshipEditor.vue +8 -2
  35. package/modules/@apostrophecms/job/index.js +9 -7
  36. package/modules/@apostrophecms/layout-column-widget/index.js +156 -163
  37. package/modules/@apostrophecms/layout-widget/index.js +7 -2
  38. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +6 -11
  39. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridColumn.vue +3 -5
  40. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +4 -4
  41. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +0 -16
  42. package/modules/@apostrophecms/layout-widget/ui/apos/lib/grid-state.mjs +7 -27
  43. package/modules/@apostrophecms/layout-widget/views/column.html +7 -9
  44. package/modules/@apostrophecms/login/index.js +39 -40
  45. package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +17 -2
  46. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +3 -2
  47. package/modules/@apostrophecms/notification/ui/apos/components/AposNotification.vue +1 -0
  48. package/modules/@apostrophecms/page/index.js +2 -0
  49. package/modules/@apostrophecms/piece-type/index.js +3 -1
  50. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManager.vue +1 -0
  51. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerDisplay.vue +5 -0
  52. package/modules/@apostrophecms/recently-edited/index.js +831 -0
  53. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposCellTitle.vue +54 -0
  54. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedCombo.vue +454 -0
  55. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilterTag.vue +75 -0
  56. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilters.vue +287 -0
  57. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedIcon.vue +16 -0
  58. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedManager.vue +346 -0
  59. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedBatch.js +193 -0
  60. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedData.js +276 -0
  61. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFetch.js +199 -0
  62. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFilters.js +100 -0
  63. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRelationship.js +8 -4
  64. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputWrapper.js +1 -1
  65. package/modules/@apostrophecms/styles/index.js +10 -0
  66. package/modules/@apostrophecms/styles/lib/apiRoutes.js +6 -0
  67. package/modules/@apostrophecms/styles/lib/handlers.js +5 -0
  68. package/modules/@apostrophecms/styles/lib/methods.js +9 -3
  69. package/modules/@apostrophecms/styles/lib/presets.js +119 -0
  70. package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +3 -8
  71. package/modules/@apostrophecms/styles/ui/apos/composables/AposStyles.js +1 -3
  72. package/modules/@apostrophecms/styles/ui/apos/render-factory.js +29 -0
  73. package/modules/@apostrophecms/styles/ui/apos/universal/backgroundHelpers.mjs +140 -0
  74. package/modules/@apostrophecms/styles/ui/apos/universal/customRules.mjs +105 -0
  75. package/modules/@apostrophecms/styles/ui/apos/universal/render.mjs +195 -15
  76. package/modules/@apostrophecms/template/index.js +22 -6
  77. package/modules/@apostrophecms/ui/ui/apos/components/AposCellContextMenu.vue +2 -0
  78. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +18 -4
  79. package/modules/@apostrophecms/ui/ui/apos/composables/useInfiniteScroll.js +91 -0
  80. package/modules/@apostrophecms/ui/ui/apos/scss/global/_theme.scss +1 -0
  81. package/modules/@apostrophecms/ui/ui/apos/stores/modal.js +5 -2
  82. package/modules/@apostrophecms/ui/ui/apos/utils/index.js +9 -0
  83. package/modules/@apostrophecms/url/index.js +38 -4
  84. package/modules/@apostrophecms/widget-type/index.js +22 -6
  85. package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +8 -4
  86. package/package.json +19 -19
  87. package/test/add-missing-schema-fields-project/test.js +11 -3
  88. package/test/assets.js +110 -67
  89. package/test/db-tools.js +365 -0
  90. package/test/db.js +24 -15
  91. package/test/default-adapter.js +256 -0
  92. package/test/job.js +1 -1
  93. package/test/layout-widget-migration.js +719 -0
  94. package/test/login-requirements.js +1 -1
  95. package/test/pieces-public-api.js +80 -0
  96. package/test/pieces.js +25 -0
  97. package/test/recently-edited.js +2311 -0
  98. package/test/schemas.js +39 -3
  99. package/test/static-build.js +642 -0
  100. package/test/styles.js +2569 -0
  101. package/test-lib/util.js +50 -14
  102. package/.claude/settings.local.json +0 -15
  103. package/lib/mongodb-connect.js +0 -62
  104. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposLayoutColControlDialog.vue +0 -171
  105. package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +0 -131
package/CHANGELOG.md CHANGED
@@ -1,12 +1,43 @@
1
1
  # Changelog
2
2
 
3
- ## 4.28.1
3
+ ## 4.30.0-alpha.1
4
4
 
5
- ### Patch Changes
5
+ ### Adds
6
+
7
+ - Postgres and SQLite alpha release
8
+
9
+ ## 4.29.0 (2026-04-15)
10
+
11
+ ### Adds
12
+
13
+ - Added support for pretty URL file attachments in the static build metadata pipeline. When `@apostrophecms/file` has `options.prettyUrls` enabled, the `getAllUrlMetadata` API now annotates affected attachments properly. The backend streaming proxy route was also fixed to correctly resolve relative uploadfs URLs during static builds.
14
+ - Introduced Recently Edited manager as Admin Bar action, next to the existing Submitted Drafts. Allows modules to contribute filter choices.
15
+ - Fix batch operations executed in a modal in a different locale causing wrong browser URL rewrite
16
+ - Add background preset to the Styles Editor, supporting image, color, and gradient background CSS generation.
17
+
18
+ ### Fixes
19
+
20
+ - Fix a focus trap bug where in the context menu focus would jump back to the first element when reaching the last one.
21
+ - Bug fix: the "pretty URLs" feature of `@apostrophecms/file` is now compatible with locale prefixes.
22
+ - Removed misleading return from `pruneDataForExternalFront`, a method intended to be overridden to modify data "in place" before it is sent to Astro or a similar frontend.
23
+ - Fix layout column breadcrumb operations leaking in layout edit mode.
24
+ - Fix edge case where widgets having styles and fields at the same time would show "Ungrouped" tab. Add `hideSingleTab` option that can be enabled in any widget to hide tabs from the widget editor when there is only one tab containing fields. This option can also be enabled globally in `@apostrophecms/widget-type` options.
25
+ - Add background preset, supporting image, color and gradient background CSS generation.
26
+
27
+ ### Changes
28
+
29
+ - Combine Styles and Column configuration in a single Styles Editor experience.
30
+ - Use shorter placeholder text for relationship inputs in small/micro contexts.
31
+
32
+ ### Security
6
33
 
7
- - f8d1952: Bug fix: the "pretty URLs" feature of @apostrophecms/file is now compatible with locale prefixes.
34
+ - Fix an XSS vulnerability allowing arbitrary markup to be inserted via the "SEO Title" or "Meta Description" fields provided by the `@apostrophecms/seo` module. The fix requires upgrading BOTH `apostrophe` and `@apostrophecms/seo`. A new mechanism for safely emitting JSON nodes has been introduced to make this type of vulnerability unlikely in the future. Thanks to [K Shanmukha Srinivasulu Royal](https://github.com/Chittu13) for reporting the vulnerability.
35
+ - Fixed a security hole in the `.choices()` and `.counts()` query builders: formerly, these query builders could be used by the public to exfiltrate schema fields not included in the `publicApiProjection`, or fields locked down with a `viewPermission` property. Thanks to [offset](https://github.com/offset) for reporting this issue, which was not made public prior to the release of the fix.
36
+ - Fixed an XSS vulnerability in color fields, which formerly accepted `-` followed by anything, including `</style>`, which could be used to inject other markup. Thanks to [restriction](https://github.com/restriction) for reporting the issue and proposing the fix.
37
+ - Resolved a `publicApiProjection` bypass vulnerability for piece types. Thanks to [restriction](https://github.com/restriction) for reporting the issue and proposing the fix.
38
+ - Ensured a minimum 2-second delay in the password reset flow to avoid disclosing whether the email or username was valid or not. Thanks to [restriction](https://github.com/restriction) for reporting the issue and proposing the fix.
8
39
 
9
- ## 4.28.0
40
+ ## 4.28.0 (2026-03-19)
10
41
 
11
42
  ### Adds
12
43
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <div align="center">
2
2
  <a href="https://github.com/apostrophecms/apostrophe">
3
- <img src="logo.svg" alt="ApostropheCMS logo" width="80" height="80">
3
+ <img src="https://static.apostrophecms.com/apostrophecms/apostrophe/logo.svg" alt="ApostropheCMS logo" width="80" height="80">
4
4
  </a>
5
5
 
6
6
  <h1>ApostropheCMS</h1>
@@ -139,4 +139,4 @@ ApostropheCMS is open source software licensed under the [MIT License](https://g
139
139
  <p>
140
140
  <em>Built with ❤️ by the <a href="https://apostrophecms.com">ApostropheCMS team</a></em>
141
141
  </p>
142
- </div>
142
+ </div>
@@ -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/defaults.js CHANGED
@@ -61,6 +61,7 @@ module.exports = {
61
61
  '@apostrophecms/file-tag': {},
62
62
  '@apostrophecms/soft-redirect': {},
63
63
  '@apostrophecms/submitted-draft': {},
64
+ '@apostrophecms/recently-edited': {},
64
65
  '@apostrophecms/command-menu': {},
65
66
  '@apostrophecms/translation': {}
66
67
  }
package/eslint.config.js CHANGED
@@ -7,7 +7,8 @@ module.exports = defineConfig([
7
7
  '**/blueimp/**/*.js',
8
8
  'test/public',
9
9
  'test/apos-build',
10
- 'coverage'
10
+ 'coverage',
11
+ 'claude-tools'
11
12
  ]),
12
13
  apostrophe
13
14
  ]);
@@ -0,0 +1,27 @@
1
+ // Serialize `data` to a JSON string that is safe to embed inside an HTML
2
+ // `<script>` element. `JSON.stringify` on its own does NOT escape the
3
+ // sequences `</script>`, `<!--` or `<![CDATA[`, so untrusted data (e.g.
4
+ // editor-provided SEO fields) in a JSON body could otherwise break out of
5
+ // the surrounding script tag and inject arbitrary HTML/JS (stored XSS).
6
+ // Escaping `<` as its `\u003c` form keeps the JSON valid while neutralizing
7
+ // all of those sequences. Line and paragraph separators are also escaped
8
+ // since they are valid in JSON but illegal in some JavaScript parsers.
9
+ //
10
+ // This is the single source of truth for that escaping. The template
11
+ // `renderNodes` helper uses it to render `{ json: ... }` node bodies, so in
12
+ // most cases you should just build a node like:
13
+ //
14
+ // {
15
+ // name: 'script',
16
+ // attrs: { type: 'application/ld+json' },
17
+ // body: [ { json: data } ]
18
+ // }
19
+ //
20
+ // and let `renderNodes` do the right thing.
21
+
22
+ module.exports = function safeJsonForScript(data) {
23
+ return JSON.stringify(data, null, 2)
24
+ .replace(/</g, '\\u003c')
25
+ .replace(/\u2028/g, '\\u2028')
26
+ .replace(/\u2029/g, '\\u2029');
27
+ };
@@ -123,7 +123,7 @@ export default {
123
123
  itemName: '@apostrophecms/i18n:localize',
124
124
  props: {
125
125
  doc: apos.adminBar.context,
126
- locale
126
+ targetLocale: locale
127
127
  }
128
128
  });
129
129
  } else {
@@ -579,6 +579,7 @@ export default {
579
579
  doc = await apos.http.get(`${action}/${this.context.aposDocId}`, {
580
580
  qs: {
581
581
  aposMode: this.draftMode,
582
+ aposLocale: apos.i18n.locale,
582
583
  project: { _url: 1 }
583
584
  }
584
585
  });
@@ -83,6 +83,7 @@
83
83
  :tiny-screen="tinyScreen"
84
84
  :widget="widget"
85
85
  :options="options"
86
+ :breadcrumb-operations="widgetBreadcrumbOperations"
86
87
  :disabled="disabled"
87
88
  :is-focused="isFocused"
88
89
  @widget-focus="getFocus"
@@ -304,10 +305,6 @@ export default {
304
305
  type: Boolean,
305
306
  default: false
306
307
  },
307
- breadcrumbDisabled: {
308
- type: Boolean,
309
- default: false
310
- },
311
308
  generation: {
312
309
  type: Number,
313
310
  required: false,
@@ -426,7 +423,8 @@ export default {
426
423
  return apos.modules[this.moduleOptions?.widgetManagers[this.widget?.type]] ?? {};
427
424
  },
428
425
  widgetBreadcrumbOperations() {
429
- return (this.widgetModuleOptions.widgetBreadcrumbOperations || []);
426
+ return (this.widgetModuleOptions.widgetBreadcrumbOperations || [])
427
+ .filter(op => op.hidden !== true);
430
428
  },
431
429
  shouldSkipEdit() {
432
430
  return !this.widgetModuleOptions.widgetOperations
@@ -75,6 +75,15 @@ export default {
75
75
  return {};
76
76
  }
77
77
  },
78
+ // Override module breadcrumb operations.
79
+ // If not provided (undefined or null), operations will be pulled from the
80
+ // widget's module options.
81
+ breadcrumbOperations: {
82
+ type: Array,
83
+ default() {
84
+ return null;
85
+ }
86
+ },
78
87
  isFocused: {
79
88
  type: Boolean,
80
89
  default: false
@@ -130,7 +139,10 @@ export default {
130
139
  return apos.modules[this.moduleOptions?.widgetManagers[this.widget?.type]] ?? {};
131
140
  },
132
141
  widgetBreadcrumbOperations() {
133
- return (this.widgetModuleOptions.widgetBreadcrumbOperations || [])
142
+ return (
143
+ this.breadcrumbOperations ||
144
+ this.widgetModuleOptions.widgetBreadcrumbOperations || []
145
+ )
134
146
  .map((operation) => ({
135
147
  component: this.getOperationComponent(operation),
136
148
  props: this.getOperationProps(operation),
@@ -23,6 +23,7 @@ module.exports = {
23
23
  'arrow-expand-vertical-icon': 'ArrowExpandVertical',
24
24
  'arrow-left-icon': 'ArrowLeft',
25
25
  'arrow-right-icon': 'ArrowRight',
26
+ 'arrow-top-right-icon': 'ArrowTopRight',
26
27
  'arrow-up-icon': 'ArrowUp',
27
28
  'binoculars-icon': 'Binoculars',
28
29
  'calendar-icon': 'Calendar',
@@ -109,6 +110,7 @@ module.exports = {
109
110
  'keyboard-tab': 'KeyboardTab',
110
111
  'label-icon': 'Label',
111
112
  'lightbulb-on-icon': 'LightbulbOn',
113
+ 'link-external-icon': 'OpenInNew',
112
114
  'link-icon': 'Link',
113
115
  'list-status-icon': 'ListStatus',
114
116
  'lock-icon': 'Lock',
@@ -124,6 +126,7 @@ module.exports = {
124
126
  'play-box-icon': 'PlayBox',
125
127
  'playlist-edit-icon': 'PlaylistEdit',
126
128
  'plus-icon': 'Plus',
129
+ 'recently-edited-icon': 'ClockOutline',
127
130
  'redo-icon': 'RedoVariant',
128
131
  'refresh-icon': 'Refresh',
129
132
  'resize-bottom-right-icon': 'ResizeBottomRight',
@@ -217,6 +217,11 @@ module.exports = {
217
217
 
218
218
  await self.crop(req, _id, sanitizedCrop);
219
219
 
220
+ if (req.body.annotate) {
221
+ const attachment = await self.db.findOne({ _id });
222
+ return self.annotateAttachment(attachment, sanitizedCrop);
223
+ }
224
+
220
225
  return true;
221
226
  }
222
227
  ]
@@ -611,6 +616,42 @@ module.exports = {
611
616
  height: sanitizeInteger(crop.height, 0, 0, 10000)
612
617
  };
613
618
  },
619
+ // Given an attachment object and an optional crop,
620
+ // return a clone with `_urls` fully populated for all
621
+ // configured image sizes. For non-image attachments
622
+ // a single `_url` is set instead.
623
+ annotateAttachment(attachment, crop) {
624
+ const result = { ...attachment };
625
+ result._isCroppable = self.isCroppable(result);
626
+ if (crop && crop.width) {
627
+ result._crop = _.pick(crop, 'width', 'height', 'top', 'left');
628
+ }
629
+ if (result.group === 'images') {
630
+ result._urls = {};
631
+ if (result._crop) {
632
+ result._urls.uncropped = {};
633
+ }
634
+ for (const size of self.imageSizes) {
635
+ result._urls[size.name] = self.url(result, { size: size.name });
636
+ if (result._crop) {
637
+ result._urls.uncropped[size.name] = self.url(result, {
638
+ size: size.name,
639
+ crop: false
640
+ });
641
+ }
642
+ }
643
+ result._urls.original = self.url(result, { size: 'original' });
644
+ if (result._crop) {
645
+ result._urls.uncropped.original = self.url(result, {
646
+ size: 'original',
647
+ crop: false
648
+ });
649
+ }
650
+ } else {
651
+ result._url = self.url(result);
652
+ }
653
+ return result;
654
+ },
614
655
  // This method return a default icon url if an attachment is missing
615
656
  // to avoid template errors
616
657
  getMissingAttachmentUrl() {
@@ -1459,7 +1500,8 @@ module.exports = {
1459
1500
  uploadsUrl: self.uploadfs.getUrl(),
1460
1501
  croppable: self.croppable,
1461
1502
  sized: self.sized,
1462
- maxSize: self.options.maxSize
1503
+ maxSize: self.options.maxSize,
1504
+ imageSizes: self.imageSizes
1463
1505
  };
1464
1506
  },
1465
1507
  // Middleware method used when only those with attachment privileges
@@ -32,9 +32,15 @@ module.exports = {
32
32
  throw self.apos.error('required');
33
33
  }
34
34
 
35
+ const isVariable = destination[field.name].startsWith('--');
35
36
  const test = new TinyColor(destination[field.name]);
36
- if (!test.isValid && !destination[field.name].startsWith('--')) {
37
+ if (!test.isValid && !isVariable) {
37
38
  destination[field.name] = null;
39
+ } else if (isVariable) {
40
+ // CSS custom property names: only allow alphanumeric, hyphens, underscores
41
+ if (!/^--[a-zA-Z0-9_-]+$/.test(destination[field.name])) {
42
+ destination[field.name] = null;
43
+ }
38
44
  }
39
45
  },
40
46
  isEmpty: function (field, value) {
@@ -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;
@@ -1553,7 +1553,8 @@ module.exports = {
1553
1553
  ];
1554
1554
 
1555
1555
  function validate ({
1556
- action, context, type = 'modal', label, modal, conditions, if: ifProps
1556
+ action, context, type = 'modal', label, modal, conditions, if: ifProps,
1557
+ crossLocale
1557
1558
  }) {
1558
1559
  const allowedConditions = [
1559
1560
  'canPublish',
@@ -1596,6 +1597,15 @@ module.exports = {
1596
1597
  'invalid', 'The if property in addContextOperation must be an object containing properties and values that will be checked against the current document in order to show or not the context operation.'
1597
1598
  );
1598
1599
  }
1600
+
1601
+ if (
1602
+ crossLocale !== undefined &&
1603
+ typeof crossLocale !== 'boolean'
1604
+ ) {
1605
+ throw self.apos.error(
1606
+ 'invalid', 'The crossLocale property in addContextOperation must be a boolean.'
1607
+ );
1608
+ }
1599
1609
  }
1600
1610
  },
1601
1611
  getBrowserData(req) {