apostrophe 4.28.1 → 4.29.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 (89) hide show
  1. package/CHANGELOG.md +29 -4
  2. package/README.md +2 -2
  3. package/defaults.js +1 -0
  4. package/lib/safe-json-script.js +27 -0
  5. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarLocale.vue +1 -1
  6. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue +1 -0
  7. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +3 -5
  8. package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +13 -1
  9. package/modules/@apostrophecms/asset/lib/globalIcons.js +3 -0
  10. package/modules/@apostrophecms/attachment/index.js +43 -1
  11. package/modules/@apostrophecms/color-field/index.js +7 -1
  12. package/modules/@apostrophecms/doc/index.js +11 -1
  13. package/modules/@apostrophecms/doc-type/index.js +165 -32
  14. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue +1 -1
  15. package/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js +104 -59
  16. package/modules/@apostrophecms/file/index.js +109 -8
  17. package/modules/@apostrophecms/i18n/i18n/de.json +0 -2
  18. package/modules/@apostrophecms/i18n/i18n/en.json +40 -1
  19. package/modules/@apostrophecms/i18n/i18n/es.json +0 -1
  20. package/modules/@apostrophecms/i18n/i18n/fr.json +0 -1
  21. package/modules/@apostrophecms/i18n/i18n/it.json +0 -1
  22. package/modules/@apostrophecms/i18n/i18n/pt-BR.json +0 -1
  23. package/modules/@apostrophecms/i18n/i18n/sk.json +0 -1
  24. package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nBatchReporting.js +18 -1
  25. package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nLocalizeActions.js +50 -0
  26. package/modules/@apostrophecms/i18n/ui/apos/components/AposI18nLocalize.vue +56 -13
  27. package/modules/@apostrophecms/image/ui/apos/components/AposImageRelationshipEditor.vue +8 -2
  28. package/modules/@apostrophecms/layout-column-widget/index.js +156 -163
  29. package/modules/@apostrophecms/layout-widget/index.js +7 -2
  30. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +6 -11
  31. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridColumn.vue +3 -5
  32. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +4 -4
  33. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +0 -16
  34. package/modules/@apostrophecms/layout-widget/ui/apos/lib/grid-state.mjs +7 -27
  35. package/modules/@apostrophecms/layout-widget/views/column.html +7 -9
  36. package/modules/@apostrophecms/login/index.js +39 -40
  37. package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +17 -2
  38. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +3 -2
  39. package/modules/@apostrophecms/notification/ui/apos/components/AposNotification.vue +1 -0
  40. package/modules/@apostrophecms/page/index.js +2 -0
  41. package/modules/@apostrophecms/piece-type/index.js +3 -1
  42. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManager.vue +1 -0
  43. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerDisplay.vue +5 -0
  44. package/modules/@apostrophecms/recently-edited/index.js +831 -0
  45. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposCellTitle.vue +54 -0
  46. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedCombo.vue +454 -0
  47. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilterTag.vue +75 -0
  48. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilters.vue +287 -0
  49. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedIcon.vue +16 -0
  50. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedManager.vue +346 -0
  51. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedBatch.js +193 -0
  52. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedData.js +276 -0
  53. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFetch.js +199 -0
  54. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFilters.js +100 -0
  55. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRelationship.js +8 -4
  56. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputWrapper.js +1 -1
  57. package/modules/@apostrophecms/styles/index.js +10 -0
  58. package/modules/@apostrophecms/styles/lib/apiRoutes.js +6 -0
  59. package/modules/@apostrophecms/styles/lib/handlers.js +5 -0
  60. package/modules/@apostrophecms/styles/lib/methods.js +9 -3
  61. package/modules/@apostrophecms/styles/lib/presets.js +119 -0
  62. package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +3 -8
  63. package/modules/@apostrophecms/styles/ui/apos/composables/AposStyles.js +1 -3
  64. package/modules/@apostrophecms/styles/ui/apos/render-factory.js +29 -0
  65. package/modules/@apostrophecms/styles/ui/apos/universal/backgroundHelpers.mjs +140 -0
  66. package/modules/@apostrophecms/styles/ui/apos/universal/customRules.mjs +105 -0
  67. package/modules/@apostrophecms/styles/ui/apos/universal/render.mjs +195 -15
  68. package/modules/@apostrophecms/template/index.js +22 -6
  69. package/modules/@apostrophecms/ui/ui/apos/components/AposCellContextMenu.vue +2 -0
  70. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +18 -4
  71. package/modules/@apostrophecms/ui/ui/apos/composables/useInfiniteScroll.js +91 -0
  72. package/modules/@apostrophecms/ui/ui/apos/scss/global/_theme.scss +1 -0
  73. package/modules/@apostrophecms/ui/ui/apos/stores/modal.js +5 -2
  74. package/modules/@apostrophecms/ui/ui/apos/utils/index.js +9 -0
  75. package/modules/@apostrophecms/url/index.js +38 -4
  76. package/modules/@apostrophecms/widget-type/index.js +22 -6
  77. package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +8 -4
  78. package/package.json +17 -17
  79. package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +2 -2
  80. package/test/layout-widget-migration.js +719 -0
  81. package/test/login-requirements.js +1 -1
  82. package/test/pieces-public-api.js +80 -0
  83. package/test/pieces.js +25 -0
  84. package/test/recently-edited.js +2311 -0
  85. package/test/schemas.js +39 -3
  86. package/test/static-build.js +642 -0
  87. package/test/styles.js +2569 -0
  88. package/.claude/settings.local.json +0 -15
  89. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposLayoutColControlDialog.vue +0 -171
package/CHANGELOG.md CHANGED
@@ -1,12 +1,37 @@
1
1
  # Changelog
2
2
 
3
- ## 4.28.1
3
+ ## 4.29.0 (2026-04-15)
4
4
 
5
- ### Patch Changes
5
+ ### Adds
6
+
7
+ - 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.
8
+ - Introduced Recently Edited manager as Admin Bar action, next to the existing Submitted Drafts. Allows modules to contribute filter choices.
9
+ - Fix batch operations executed in a modal in a different locale causing wrong browser URL rewrite
10
+ - Add background preset to the Styles Editor, supporting image, color, and gradient background CSS generation.
11
+
12
+ ### Fixes
13
+
14
+ - Fix a focus trap bug where in the context menu focus would jump back to the first element when reaching the last one.
15
+ - Bug fix: the "pretty URLs" feature of `@apostrophecms/file` is now compatible with locale prefixes.
16
+ - 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.
17
+ - Fix layout column breadcrumb operations leaking in layout edit mode.
18
+ - 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.
19
+ - Add background preset, supporting image, color and gradient background CSS generation.
20
+
21
+ ### Changes
22
+
23
+ - Combine Styles and Column configuration in a single Styles Editor experience.
24
+ - Use shorter placeholder text for relationship inputs in small/micro contexts.
25
+
26
+ ### Security
6
27
 
7
- - f8d1952: Bug fix: the "pretty URLs" feature of @apostrophecms/file is now compatible with locale prefixes.
28
+ - 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.
29
+ - 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.
30
+ - 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.
31
+ - Resolved a `publicApiProjection` bypass vulnerability for piece types. Thanks to [restriction](https://github.com/restriction) for reporting the issue and proposing the fix.
32
+ - 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
33
 
9
- ## 4.28.0
34
+ ## 4.28.0 (2026-03-19)
10
35
 
11
36
  ### Adds
12
37
 
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>
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
  }
@@ -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) {
@@ -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) {
@@ -1261,6 +1261,17 @@ module.exports = {
1261
1261
  result = await actionModule.update(toReq, update);
1262
1262
  }
1263
1263
 
1264
+ // Record when this document was localized. Uses a direct DB
1265
+ // update so the timestamp survives regardless of the schema
1266
+ // convert pipeline. Covers both single and batch localization
1267
+ // (localizeBatch calls this method per document).
1268
+ const localizedAt = new Date();
1269
+ await self.apos.doc.db.updateOne(
1270
+ { _id: result._id },
1271
+ { $set: { localizedAt } }
1272
+ );
1273
+ result.localizedAt = localizedAt;
1274
+
1264
1275
  await self.emit('afterLocalize', req, draft, result, eventOptions);
1265
1276
 
1266
1277
  return result;
@@ -1610,6 +1621,59 @@ module.exports = {
1610
1621
  return doc;
1611
1622
  },
1612
1623
 
1624
+ // Returns true if the named filter is permitted to expose distinct
1625
+ // values through the `choices` / `counts` query builders given the
1626
+ // publicApiProjection. When no publicApiProjection is in effect
1627
+ // (authenticated API callers), all fields are permitted.
1628
+ //
1629
+ // This guards against leaking distinct values of fields excluded by
1630
+ // `publicApiProjection`, since MongoDB's `distinct` operator ignores
1631
+ // projections. Projections set explicitly by authenticated users are
1632
+ // not restricted — they are a voluntary narrowing of query results,
1633
+ // not a security boundary.
1634
+ choicesFieldAllowedByProjection(filter, projection) {
1635
+ if (!projection || !Object.keys(projection).length) {
1636
+ return true;
1637
+ }
1638
+ // Builders that aren't named after a top-level schema field are
1639
+ // not gated by the projection.
1640
+ const field = self.schema.find(f => f.name === filter);
1641
+ if (!field) {
1642
+ return true;
1643
+ }
1644
+ const topLevel = filter.split('.')[0];
1645
+ const values = Object.values(projection);
1646
+ const hasInclusion = values.some(v => v && v !== 0);
1647
+ const hasExclusion = values.some(v => v === 0 || v === false);
1648
+ if (hasInclusion) {
1649
+ // Inclusion projection: field must be explicitly included.
1650
+ return Boolean(projection[topLevel]);
1651
+ }
1652
+ if (hasExclusion) {
1653
+ // Exclusion projection: field must not be explicitly excluded.
1654
+ return projection[topLevel] !== 0 && projection[topLevel] !== false;
1655
+ }
1656
+ return true;
1657
+ },
1658
+
1659
+ // Returns true if the current user is permitted to view the named
1660
+ // schema field according to any schema-level `viewPermission`.
1661
+ // Non-schema filters are permitted. A dot-notated filter is matched
1662
+ // against the top-level schema field, since `viewPermission` is
1663
+ // declared on top-level fields (see `removeForbiddenFields`).
1664
+ choicesFieldAllowedByViewPermission(req, filter) {
1665
+ const topLevel = filter.split('.')[0];
1666
+ const field = self.schema.find(f => f.name === topLevel);
1667
+ if (!field || !field.viewPermission) {
1668
+ return true;
1669
+ }
1670
+ return self.apos.permission.can(
1671
+ req,
1672
+ field.viewPermission.action,
1673
+ field.viewPermission.type
1674
+ );
1675
+ },
1676
+
1613
1677
  composeFilters() {
1614
1678
  // TODO: keep in sync with page/index.js composeFilters
1615
1679
  self.filters = Object.entries(self.filters)
@@ -2157,24 +2221,6 @@ module.exports = {
2157
2221
  }
2158
2222
  },
2159
2223
 
2160
- // `.attachments(true)` annotates all attachment fields in the
2161
- // returned documents with URLs as documented for the
2162
- // `apos.attachment.all` method. Used by our REST APIs.
2163
-
2164
- attachments: {
2165
- def: true,
2166
- after(results) {
2167
- const attachments = query.get('attachments');
2168
-
2169
- if (attachments) {
2170
- self.apos.attachment.all(results, { annotate: true });
2171
- }
2172
- },
2173
- launder(b) {
2174
- return self.apos.launder.boolean(b);
2175
- }
2176
- },
2177
-
2178
2224
  // `.autocomplete('sta')` limits results to docs which are a good match
2179
2225
  // for a partial string beginning with `sta`, for instance `station`.
2180
2226
  // Appropriate words must exist in the title or other text schema fields
@@ -2427,6 +2473,28 @@ module.exports = {
2427
2473
  }
2428
2474
  },
2429
2475
 
2476
+ // `.attachments(true)` annotates all attachment fields in the
2477
+ // returned documents with URLs as documented for the
2478
+ // `apos.attachment.all` method. Used by our REST APIs.
2479
+ //
2480
+ // Must appear after the `relationships` builder so that
2481
+ // relationship `_fields` (e.g. crop coordinates) are
2482
+ // available when `attachment.all` generates `_urls`.
2483
+
2484
+ attachments: {
2485
+ def: true,
2486
+ after(results) {
2487
+ const attachments = query.get('attachments');
2488
+
2489
+ if (attachments) {
2490
+ self.apos.attachment.all(results, { annotate: true });
2491
+ }
2492
+ },
2493
+ launder(b) {
2494
+ return self.apos.launder.boolean(b);
2495
+ }
2496
+ },
2497
+
2430
2498
  // `.addUrls(true)`. Invokes the `addUrls` method of all doc type
2431
2499
  // managers with relevant docs among the results, if they have one.
2432
2500
  //
@@ -2450,20 +2518,47 @@ module.exports = {
2450
2518
  if (!val) {
2451
2519
  return;
2452
2520
  }
2453
- const byType = {};
2454
- for (const doc of results) {
2455
- byType[doc.type] = byType[doc.type] || [];
2456
- byType[doc.type].push(doc);
2521
+
2522
+ async function addUrlsByType(reqForUrls, docs) {
2523
+ const byType = {};
2524
+ for (const doc of docs) {
2525
+ byType[doc.type] = byType[doc.type] || [];
2526
+ byType[doc.type].push(doc);
2527
+ }
2528
+ for (const type of Object.keys(byType)) {
2529
+ const manager = self.apos.doc.getManager(type);
2530
+ if (manager?.addUrls) {
2531
+ await manager.addUrls(reqForUrls, byType[type]);
2532
+ }
2533
+ }
2457
2534
  }
2458
- const interesting = Object.keys(byType).filter(type => {
2459
- // Don't freak out if the projection was really conservative
2460
- // and the type is unknown, etc.
2461
- const manager = self.apos.doc.getManager(type);
2462
- return manager && manager.addUrls;
2463
- });
2464
- for (const type of interesting) {
2465
- await self.apos.doc.getManager(type).addUrls(req, byType[type]);
2535
+
2536
+ // When locale(null) was used, docs may span multiple
2537
+ // locales. Group by locale and resolve URLs with a
2538
+ // locale-appropriate req so each doc gets the correct
2539
+ // _url for its own locale.
2540
+ if (query.get('locale') === null) {
2541
+ const locales = self.apos.i18n.locales;
2542
+ const byLocale = {};
2543
+ for (const doc of results) {
2544
+ const locale =
2545
+ doc.aposLocale?.split(':')[0] || req.locale;
2546
+ if (!locales[locale]) {
2547
+ continue;
2548
+ }
2549
+ byLocale[locale] = byLocale[locale] || [];
2550
+ byLocale[locale].push(doc);
2551
+ }
2552
+ for (const [ locale, localeDocs ] of Object.entries(byLocale)) {
2553
+ const localeReq = locale === req.locale
2554
+ ? req
2555
+ : req.clone({ locale });
2556
+ await addUrlsByType(localeReq, localeDocs);
2557
+ }
2558
+ return;
2466
2559
  }
2560
+
2561
+ await addUrlsByType(req, results);
2467
2562
  }
2468
2563
  },
2469
2564
 
@@ -2510,10 +2605,34 @@ module.exports = {
2510
2605
  pageUrl: {
2511
2606
  def: true,
2512
2607
  after(results) {
2608
+ const req = query.req;
2609
+ const crossLocale = query.get('locale') === null;
2610
+ const localeReqs = {};
2513
2611
  for (const result of results) {
2514
- if ((!result.archived) && result.slug && self.apos.page.isPage(result)) {
2515
- result._url = `${query.req.prefix}${result.slug}`;
2612
+ if (
2613
+ (!result.archived) &&
2614
+ result.slug &&
2615
+ self.apos.page.isPage(result)
2616
+ ) {
2617
+ const urlReq = getReqForLocale(result);
2618
+ result._url = `${urlReq.prefix}${result.slug}`;
2619
+ }
2620
+ }
2621
+ function getReqForLocale(doc) {
2622
+ if (!crossLocale || !doc.aposLocale) {
2623
+ return req;
2624
+ }
2625
+ const locale = doc.aposLocale.split(':')[0];
2626
+ if (!self.apos.i18n.locales[locale]) {
2627
+ return req;
2628
+ }
2629
+ if (!localeReqs[locale]) {
2630
+ localeReqs[locale] =
2631
+ locale === req.locale
2632
+ ? req
2633
+ : req.clone({ locale });
2516
2634
  }
2635
+ return localeReqs[locale];
2517
2636
  }
2518
2637
  }
2519
2638
  },
@@ -2641,6 +2760,7 @@ module.exports = {
2641
2760
  const choices = {};
2642
2761
  const baseQuery = query.get('choices-query-prefinalize');
2643
2762
  baseQuery.set('choices-query-prefinalize', null);
2763
+ const publicApiProjection = query.get('publicApiProjection');
2644
2764
  for (const filter of filters) {
2645
2765
  // The choices for each filter should reflect the effect of all
2646
2766
  // filters except this one (filtering by topic pairs down the list
@@ -2656,6 +2776,19 @@ module.exports = {
2656
2776
  if (!query.builders[filter].launder) {
2657
2777
  continue;
2658
2778
  }
2779
+ // Do not leak distinct values of fields excluded by the
2780
+ // publicApiProjection. MongoDB's `distinct` ignores projections,
2781
+ // so we must enforce this ourselves here. Only applies to the
2782
+ // public API; authenticated users who set their own projections
2783
+ // are not restricted.
2784
+ if (!self.choicesFieldAllowedByProjection(filter, publicApiProjection)) {
2785
+ continue;
2786
+ }
2787
+ // Do not leak distinct values of fields the current user does
2788
+ // not have permission to view via a schema-level viewPermission.
2789
+ if (!self.choicesFieldAllowedByViewPermission(query.req, filter)) {
2790
+ continue;
2791
+ }
2659
2792
  // Now shut it off
2660
2793
  _query[filter](null);
2661
2794
  choices[filter] = await _query.toChoices(filter, { counts: query.get('counts') });
@@ -990,7 +990,7 @@ export default {
990
990
 
991
991
  const isLocalized = await apos.modal.execute('AposI18nLocalize', {
992
992
  doc: saved || this.original,
993
- locale,
993
+ targetLocale: locale,
994
994
  moduleName: this.moduleName,
995
995
  shouldRedirect: false
996
996
  });