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.
- package/CHANGELOG.md +29 -4
- package/README.md +2 -2
- package/defaults.js +1 -0
- package/lib/safe-json-script.js +27 -0
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarLocale.vue +1 -1
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue +1 -0
- package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +3 -5
- package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +13 -1
- package/modules/@apostrophecms/asset/lib/globalIcons.js +3 -0
- package/modules/@apostrophecms/attachment/index.js +43 -1
- package/modules/@apostrophecms/color-field/index.js +7 -1
- package/modules/@apostrophecms/doc/index.js +11 -1
- package/modules/@apostrophecms/doc-type/index.js +165 -32
- package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue +1 -1
- package/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js +104 -59
- package/modules/@apostrophecms/file/index.js +109 -8
- package/modules/@apostrophecms/i18n/i18n/de.json +0 -2
- package/modules/@apostrophecms/i18n/i18n/en.json +40 -1
- package/modules/@apostrophecms/i18n/i18n/es.json +0 -1
- package/modules/@apostrophecms/i18n/i18n/fr.json +0 -1
- package/modules/@apostrophecms/i18n/i18n/it.json +0 -1
- package/modules/@apostrophecms/i18n/i18n/pt-BR.json +0 -1
- package/modules/@apostrophecms/i18n/i18n/sk.json +0 -1
- package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nBatchReporting.js +18 -1
- package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nLocalizeActions.js +50 -0
- package/modules/@apostrophecms/i18n/ui/apos/components/AposI18nLocalize.vue +56 -13
- package/modules/@apostrophecms/image/ui/apos/components/AposImageRelationshipEditor.vue +8 -2
- package/modules/@apostrophecms/layout-column-widget/index.js +156 -163
- package/modules/@apostrophecms/layout-widget/index.js +7 -2
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +6 -11
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridColumn.vue +3 -5
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +4 -4
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +0 -16
- package/modules/@apostrophecms/layout-widget/ui/apos/lib/grid-state.mjs +7 -27
- package/modules/@apostrophecms/layout-widget/views/column.html +7 -9
- package/modules/@apostrophecms/login/index.js +39 -40
- package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +17 -2
- package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +3 -2
- package/modules/@apostrophecms/notification/ui/apos/components/AposNotification.vue +1 -0
- package/modules/@apostrophecms/page/index.js +2 -0
- package/modules/@apostrophecms/piece-type/index.js +3 -1
- package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManager.vue +1 -0
- package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerDisplay.vue +5 -0
- package/modules/@apostrophecms/recently-edited/index.js +831 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposCellTitle.vue +54 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedCombo.vue +454 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilterTag.vue +75 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilters.vue +287 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedIcon.vue +16 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedManager.vue +346 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedBatch.js +193 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedData.js +276 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFetch.js +199 -0
- package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFilters.js +100 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRelationship.js +8 -4
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputWrapper.js +1 -1
- package/modules/@apostrophecms/styles/index.js +10 -0
- package/modules/@apostrophecms/styles/lib/apiRoutes.js +6 -0
- package/modules/@apostrophecms/styles/lib/handlers.js +5 -0
- package/modules/@apostrophecms/styles/lib/methods.js +9 -3
- package/modules/@apostrophecms/styles/lib/presets.js +119 -0
- package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +3 -8
- package/modules/@apostrophecms/styles/ui/apos/composables/AposStyles.js +1 -3
- package/modules/@apostrophecms/styles/ui/apos/render-factory.js +29 -0
- package/modules/@apostrophecms/styles/ui/apos/universal/backgroundHelpers.mjs +140 -0
- package/modules/@apostrophecms/styles/ui/apos/universal/customRules.mjs +105 -0
- package/modules/@apostrophecms/styles/ui/apos/universal/render.mjs +195 -15
- package/modules/@apostrophecms/template/index.js +22 -6
- package/modules/@apostrophecms/ui/ui/apos/components/AposCellContextMenu.vue +2 -0
- package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +18 -4
- package/modules/@apostrophecms/ui/ui/apos/composables/useInfiniteScroll.js +91 -0
- package/modules/@apostrophecms/ui/ui/apos/scss/global/_theme.scss +1 -0
- package/modules/@apostrophecms/ui/ui/apos/stores/modal.js +5 -2
- package/modules/@apostrophecms/ui/ui/apos/utils/index.js +9 -0
- package/modules/@apostrophecms/url/index.js +38 -4
- package/modules/@apostrophecms/widget-type/index.js +22 -6
- package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +8 -4
- package/package.json +17 -17
- package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +2 -2
- package/test/layout-widget-migration.js +719 -0
- package/test/login-requirements.js +1 -1
- package/test/pieces-public-api.js +80 -0
- package/test/pieces.js +25 -0
- package/test/recently-edited.js +2311 -0
- package/test/schemas.js +39 -3
- package/test/static-build.js +642 -0
- package/test/styles.js +2569 -0
- package/.claude/settings.local.json +0 -15
- 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.
|
|
3
|
+
## 4.29.0 (2026-04-15)
|
|
4
4
|
|
|
5
|
-
###
|
|
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
|
-
-
|
|
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
|
@@ -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
|
+
};
|
|
@@ -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 (
|
|
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 && !
|
|
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
|
-
|
|
2454
|
-
|
|
2455
|
-
byType
|
|
2456
|
-
|
|
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
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
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 (
|
|
2515
|
-
result.
|
|
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') });
|