apostrophe 4.31.0 → 4.32.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 (33) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/claude-tools/run-mocha-file.sh +29 -0
  3. package/modules/@apostrophecms/area/index.js +14 -5
  4. package/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js +72 -3
  5. package/modules/@apostrophecms/asset/index.js +170 -21
  6. package/modules/@apostrophecms/asset/lib/build/external-module-api.js +7 -16
  7. package/modules/@apostrophecms/asset/lib/build/internals.js +1 -3
  8. package/modules/@apostrophecms/asset/lib/build/task.js +7 -13
  9. package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +21 -1
  10. package/modules/@apostrophecms/doc-type/index.js +73 -15
  11. package/modules/@apostrophecms/http/index.js +175 -139
  12. package/modules/@apostrophecms/image/index.js +16 -7
  13. package/modules/@apostrophecms/image/ui/apos/components/AposMediaManager.vue +9 -1
  14. package/modules/@apostrophecms/job/index.js +3 -1
  15. package/modules/@apostrophecms/page/index.js +17 -1
  16. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +10 -1
  17. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapInsertItem.vue +3 -14
  18. package/modules/@apostrophecms/rich-text-widget/ui/apos/lib/remove-slash.js +18 -0
  19. package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +37 -6
  20. package/modules/@apostrophecms/url/index.js +54 -0
  21. package/package.json +11 -12
  22. package/test/asset-external.js +3 -0
  23. package/test/asset-lock-cache.js +243 -0
  24. package/test/assets.js +25 -2
  25. package/test/config-cascade-merge.js +73 -0
  26. package/test/files.js +10 -11
  27. package/test/image-tags.js +103 -0
  28. package/test/modules/collision-piece/index.js +23 -0
  29. package/test/pages-move-permissions.js +317 -0
  30. package/test/pages.js +12 -15
  31. package/test/pieces-public-api-relationship-choices.js +245 -0
  32. package/test/pieces.js +12 -15
  33. package/test-lib/test.js +32 -0
package/test/files.js CHANGED
@@ -1,6 +1,9 @@
1
1
  const t = require('../test-lib/test.js');
2
2
  const assert = require('assert/strict');
3
3
  const fs = require('fs');
4
+ // rawGet (raw node:http) lets this suite send a spoofed `Host` header, which
5
+ // the built-in fetch used by apos.http would drop as a forbidden header.
6
+ const { rawGet } = t;
4
7
 
5
8
  describe('Files', function() {
6
9
 
@@ -142,17 +145,13 @@ describe('Files', function() {
142
145
  const attachment = apos.attachment.first(file);
143
146
  const url = apos.attachment.url(attachment);
144
147
  assert(url);
145
- // Send an attacker-controlled Host header (e.g. the cloud metadata
146
- // address from the advisory). The upstream fetch must be resolved
147
- // against the server-trusted baseUrl, not this header, so the
148
- // legitimate content is still served and the request is never
149
- // steered at the spoofed host.
150
- const response = await apos.http.get(url, {
151
- headers: {
152
- Host: '169.254.169.254'
153
- },
154
- fullResponse: true
155
- });
148
+ // Spoof the Host header (the cloud-metadata address from the advisory)
149
+ // over a raw request: apos.http uses the built-in fetch, which drops a
150
+ // forbidden `Host` header and so cannot deliver the spoof. The server
151
+ // must resolve the upstream fetch against its trusted baseUrl, not this
152
+ // header, so the legitimate content is still served and the request is
153
+ // never steered at the spoofed host.
154
+ const response = await rawGet(apos, url, { Host: '169.254.169.254' });
156
155
  assert.strictEqual(response.status, 200);
157
156
  assert.strictEqual(response.body, attachment.data);
158
157
  } finally {
@@ -0,0 +1,103 @@
1
+ const t = require('../test-lib/test.js');
2
+ const assert = require('assert/strict');
3
+
4
+ describe('Image tags', function() {
5
+
6
+ let apos;
7
+ let jar;
8
+
9
+ this.timeout(t.timeout);
10
+
11
+ // Initialization, not a test: boot apostrophe and log in an admin, so any
12
+ // single test below can run in isolation (e.g. with `.only`).
13
+ before(async function() {
14
+ apos = await t.create({
15
+ root: module
16
+ });
17
+ assert(apos.image);
18
+ assert(apos.modules['@apostrophecms/image-tag']);
19
+
20
+ const user = apos.user.newInstance();
21
+ Object.assign(user, {
22
+ title: 'admin',
23
+ username: 'admin',
24
+ password: 'admin',
25
+ email: 'ad@min.com',
26
+ role: 'admin'
27
+ });
28
+ await apos.user.insert(apos.task.getReq(), user);
29
+ jar = await login('admin');
30
+ });
31
+
32
+ after(function() {
33
+ return t.destroy(apos);
34
+ });
35
+
36
+ it('should reuse an existing tag instead of creating a duplicate', async function() {
37
+ const manager = apos.modules['@apostrophecms/image-tag'];
38
+ // A tag the editor's popover would not necessarily have loaded.
39
+ const existing = await manager.insert(apos.task.getReq(), {
40
+ ...manager.newInstance(),
41
+ title: 'Reused Tag'
42
+ });
43
+
44
+ const response = await tagCreate('Reused Tag');
45
+ assert(response.jobId);
46
+
47
+ const ids = await distinctTagIds('Reused Tag');
48
+ assert.equal(ids.length, 1, 'exactly one image-tag for the title');
49
+ assert.equal(ids[0], existing.aposDocId, 'the existing tag was reused');
50
+ });
51
+
52
+ it('should not create duplicates under concurrent create requests', async function() {
53
+ const title = 'Concurrent Tag';
54
+
55
+ await Promise.all([
56
+ tagCreate(title),
57
+ tagCreate(title)
58
+ ]);
59
+
60
+ const ids = await distinctTagIds(title);
61
+ assert.equal(ids.length, 1, 'exactly one image-tag despite concurrent creates');
62
+ });
63
+
64
+ // The `tag` route runs the find-or-create before the (here empty) batch
65
+ // job, so no images are needed to exercise it.
66
+ async function tagCreate(title) {
67
+ return apos.http.post('/api/v1/@apostrophecms/image/tag', {
68
+ body: {
69
+ _ids: [],
70
+ operation: 'create',
71
+ title
72
+ },
73
+ jar
74
+ });
75
+ }
76
+
77
+ async function distinctTagIds(title) {
78
+ const docs = await apos.doc.db
79
+ .find({
80
+ type: '@apostrophecms/image-tag',
81
+ title
82
+ })
83
+ .toArray();
84
+ return [ ...new Set(docs.map(doc => doc.aposDocId)) ];
85
+ }
86
+
87
+ async function login(username, password = username) {
88
+ const loginJar = apos.http.jar();
89
+ let page = await apos.http.get('/', { jar: loginJar });
90
+ assert(page.match(/logged out/));
91
+ await apos.http.post('/api/v1/@apostrophecms/login/login', {
92
+ body: {
93
+ username,
94
+ password,
95
+ session: true
96
+ },
97
+ jar: loginJar
98
+ });
99
+ page = await apos.http.get('/', { jar: loginJar });
100
+ assert(page.match(/logged in/));
101
+ return loginJar;
102
+ }
103
+ });
@@ -0,0 +1,23 @@
1
+ // Project-level file for a module that is ALSO configured (with fields)
2
+ // via the apostrophe() config object, to reproduce PRO-9564.
3
+ module.exports = {
4
+ // An index.js-only option: it should survive via the gap-fill pass, unlike
5
+ // the project-level fields, which get dropped wholesale.
6
+ options: {
7
+ fromProjectFile: true
8
+ },
9
+ fields: {
10
+ add: {
11
+ projectField: {
12
+ type: 'string',
13
+ label: 'Project Field'
14
+ }
15
+ },
16
+ group: {
17
+ projectGroup: {
18
+ label: 'Project Group',
19
+ fields: [ 'projectField' ]
20
+ }
21
+ }
22
+ }
23
+ };
@@ -0,0 +1,317 @@
1
+ // Regression tests for GHSA-wr5r-wqp2-x4fh:
2
+ // "Missing destination-parent authorization in page move() allows a
3
+ // low-privileged editor to move and re-rank pages inside a restricted subtree"
4
+ //
5
+ // A regression in the move() authorization guard silently disabled the
6
+ // destination "create" permission check for every normal (non-archive) move.
7
+ // The only surviving gate was moved._edit ("can the actor edit the page being
8
+ // moved"), which an editor legitimately holds for their own ordinary pages.
9
+ // As a result a non-admin editor could relocate a page they control under a
10
+ // parent of a restricted page type (e.g. one declaring editRole: 'admin')
11
+ // that they have no create/edit rights over, and in doing so trigger an
12
+ // unchecked updateMany that re-ranks the restricted parent's existing
13
+ // children (documents the actor cannot edit).
14
+ //
15
+ // These tests exercise the real apos.page.move() code path (and the REST
16
+ // route it backs) with a non-admin request and assert that the destination
17
+ // authorization boundary is enforced, while confirming legitimate moves and
18
+ // the archive restore flow are still permitted.
19
+
20
+ const t = require('../test-lib/test.js');
21
+ const assert = require('assert/strict');
22
+
23
+ describe('pages - move destination permission (GHSA-wr5r-wqp2-x4fh)', function () {
24
+ this.timeout(t.timeout);
25
+
26
+ let apos;
27
+ let homeId;
28
+
29
+ after(async function () {
30
+ await t.destroy(apos);
31
+ apos = null;
32
+ });
33
+
34
+ before(async function () {
35
+ apos = await t.create({
36
+ root: module,
37
+ modules: {
38
+ // A restricted page type: only admins may create/edit pages of this
39
+ // type. This is the same shape as the core @apostrophecms/archive-page
40
+ // (editRole/publishRole: 'admin') and of restricted "section" page
41
+ // types projects commonly configure.
42
+ 'secret-page': {
43
+ extend: '@apostrophecms/page-type',
44
+ options: {
45
+ editRole: 'admin',
46
+ publishRole: 'admin'
47
+ }
48
+ },
49
+ // An ordinary page type any editor may create/edit.
50
+ 'public-page': {
51
+ extend: '@apostrophecms/page-type'
52
+ },
53
+ '@apostrophecms/page': {
54
+ options: {
55
+ park: [],
56
+ types: [
57
+ {
58
+ name: '@apostrophecms/home-page',
59
+ label: 'Home'
60
+ },
61
+ {
62
+ name: 'secret-page',
63
+ label: 'Secret'
64
+ },
65
+ {
66
+ name: 'public-page',
67
+ label: 'Public'
68
+ }
69
+ ]
70
+ }
71
+ }
72
+ }
73
+ });
74
+
75
+ const home = await apos.page
76
+ .find(apos.task.getReq({ role: 'admin' }), { level: 0 })
77
+ .toObject();
78
+ assert.ok(home, 'home page should exist');
79
+ homeId = home._id;
80
+ });
81
+
82
+ // A non-admin editor request. Editors may create/edit ordinary pages but
83
+ // NOT pages of a type gated by editRole: 'admin'.
84
+ function editorReq() {
85
+ return apos.task.getReq({
86
+ role: 'editor',
87
+ user: {
88
+ _id: 'editor-user',
89
+ title: 'Editor',
90
+ role: 'editor'
91
+ }
92
+ });
93
+ }
94
+
95
+ function adminReq() {
96
+ return apos.task.getReq({ role: 'admin' });
97
+ }
98
+
99
+ // Create an admin-only "secret" section with one pre-existing admin-only
100
+ // child, plus an ordinary editor-editable page under home. `slugPrefix`
101
+ // keeps each test's fixtures independent within the shared database.
102
+ async function fixtures(slugPrefix) {
103
+ const admin = adminReq();
104
+
105
+ const secret = await apos.page.insert(admin, homeId, 'lastChild', {
106
+ title: 'Secret Section',
107
+ type: 'secret-page',
108
+ slug: `/${slugPrefix}-secret`
109
+ });
110
+ const secretChild = await apos.page.insert(admin, secret._id, 'lastChild', {
111
+ title: 'Secret Child',
112
+ type: 'secret-page',
113
+ slug: `/${slugPrefix}-secret/child`
114
+ });
115
+ const mine = await apos.page.insert(admin, homeId, 'lastChild', {
116
+ title: 'My Page',
117
+ type: 'public-page',
118
+ slug: `/${slugPrefix}-mine`
119
+ });
120
+
121
+ return {
122
+ secret,
123
+ secretChild,
124
+ mine
125
+ };
126
+ }
127
+
128
+ it('precondition: an editor has no create/edit rights on an admin-only section', async function () {
129
+ const { secret } = await fixtures('precondition');
130
+
131
+ const secretForEditor = await apos.page
132
+ .find(editorReq(), { _id: secret._id })
133
+ .permission(false)
134
+ .toObject();
135
+
136
+ assert.ok(secretForEditor, 'the section is locatable regardless of rights');
137
+ assert.notEqual(
138
+ secretForEditor._create,
139
+ true,
140
+ 'editor must NOT have create rights on the admin-only section'
141
+ );
142
+ assert.notEqual(
143
+ secretForEditor._edit,
144
+ true,
145
+ 'editor must NOT have edit rights on the admin-only section'
146
+ );
147
+ });
148
+
149
+ it('forbids an editor from moving their page under an admin-only section', async function () {
150
+ const { secret, mine } = await fixtures('forbid-move');
151
+
152
+ await assert.rejects(
153
+ apos.page.move(editorReq(), mine._id, secret._id, 'firstChild'),
154
+ { name: 'forbidden' },
155
+ 'moving a page under a parent the actor cannot create within must be forbidden'
156
+ );
157
+
158
+ // The page must NOT have been relocated under the restricted branch.
159
+ const moved = await apos.page
160
+ .find(adminReq(), { _id: mine._id })
161
+ .toObject();
162
+ assert.ok(
163
+ !moved.path.includes(secret.aposDocId),
164
+ 'the editor must not have relocated their page under the admin-only section'
165
+ );
166
+ });
167
+
168
+ it('does not re-rank an admin-only section\'s protected children on a forbidden move', async function () {
169
+ const {
170
+ secret, secretChild, mine
171
+ } = await fixtures('no-rerank');
172
+
173
+ const before = await apos.page
174
+ .find(adminReq(), { _id: secretChild._id })
175
+ .toObject();
176
+
177
+ // 'firstChild' would place the moved page at rank 0 and (if the move were
178
+ // wrongly allowed) nudge the pre-existing admin-only child from rank 0 to
179
+ // rank 1 via an unchecked updateMany.
180
+ await assert.rejects(
181
+ apos.page.move(editorReq(), mine._id, secret._id, 'firstChild'),
182
+ { name: 'forbidden' }
183
+ );
184
+
185
+ const after = await apos.page
186
+ .find(adminReq(), { _id: secretChild._id })
187
+ .toObject();
188
+
189
+ assert.equal(
190
+ after.rank,
191
+ before.rank,
192
+ 'the protected sibling\'s rank must be untouched by a forbidden move'
193
+ );
194
+ });
195
+
196
+ it('still allows an editor to move their page under a parent they may create within', async function () {
197
+ const { mine } = await fixtures('allow-move');
198
+
199
+ // Another ordinary parent the editor legitimately controls.
200
+ const otherParent = await apos.page.insert(adminReq(), homeId, 'lastChild', {
201
+ title: 'Other Parent',
202
+ type: 'public-page',
203
+ slug: '/allow-move-other'
204
+ });
205
+
206
+ // Sanity: the editor genuinely has create rights on the destination.
207
+ const otherForEditor = await apos.page
208
+ .find(editorReq(), { _id: otherParent._id })
209
+ .permission(false)
210
+ .toObject();
211
+ assert.equal(
212
+ otherForEditor._create,
213
+ true,
214
+ 'precondition: editor has create rights on the ordinary destination'
215
+ );
216
+
217
+ await assert.doesNotReject(
218
+ apos.page.move(editorReq(), mine._id, otherParent._id, 'firstChild'),
219
+ 'a move into a destination the editor may create within must be allowed'
220
+ );
221
+
222
+ const moved = await apos.page.find(adminReq(), { _id: mine._id }).toObject();
223
+ assert.ok(
224
+ moved.path.includes(otherParent.aposDocId),
225
+ 'the page should now live under the permitted destination'
226
+ );
227
+ });
228
+
229
+ it('still allows an admin to move a page under the admin-only section', async function () {
230
+ const { secret, mine } = await fixtures('admin-move');
231
+
232
+ await assert.doesNotReject(
233
+ apos.page.move(adminReq(), mine._id, secret._id, 'firstChild'),
234
+ 'an admin has create rights on the section and may move pages into it'
235
+ );
236
+
237
+ const moved = await apos.page.find(adminReq(), { _id: mine._id }).toObject();
238
+ assert.ok(
239
+ moved.path.includes(secret.aposDocId),
240
+ 'the admin-moved page should live under the section'
241
+ );
242
+ });
243
+
244
+ it('still allows archiving a page and restoring it out of the archive', async function () {
245
+ // Exercises the guard\'s archive-restore branch: a move whose old parent
246
+ // is the archive page must remain permitted for a destination the actor
247
+ // may edit. Using an editor on an ordinary page they fully control.
248
+ const { mine } = await fixtures('restore');
249
+
250
+ // Archive: moves the page under the archive (old parent becomes archive).
251
+ await assert.doesNotReject(
252
+ apos.page.archive(editorReq(), mine._id),
253
+ 'an editor may archive their own ordinary page'
254
+ );
255
+
256
+ const archived = await apos.page
257
+ .find(adminReq(), { _id: mine._id })
258
+ .archived(null)
259
+ .ancestors({ archived: null })
260
+ .toObject();
261
+ assert.equal(archived.archived, true, 'the page should be archived');
262
+ assert.equal(
263
+ archived._ancestors[archived._ancestors.length - 1].type,
264
+ '@apostrophecms/archive-page',
265
+ 'the archived page\'s parent should be the archive'
266
+ );
267
+
268
+ // Restore: move it back out of the archive, under home.
269
+ await assert.doesNotReject(
270
+ apos.page.move(editorReq(), mine._id, homeId, 'lastChild'),
271
+ 'restoring a page out of the archive to a destination the actor may edit must be allowed'
272
+ );
273
+
274
+ const restored = await apos.page
275
+ .find(adminReq(), { _id: mine._id })
276
+ .toObject();
277
+ assert.ok(restored, 'the restored page should be live again');
278
+ assert.notEqual(restored.archived, true, 'the restored page should not be archived');
279
+ });
280
+
281
+ it('forbids the same move over the REST PATCH route for a logged-in editor', async function () {
282
+ const { secret, mine } = await fixtures('rest');
283
+
284
+ // A real, logged-in editor session.
285
+ await t.createUser(apos, 'editor', { username: 'rest-editor' });
286
+ const jar = await t.loginAs(apos, 'rest-editor');
287
+ // A safe request first, so the jar carries the CSRF cookie the write
288
+ // routes require. Otherwise the PATCH is rejected for CSRF before it can
289
+ // ever reach move(), which would mask the authorization check under test.
290
+ await apos.http.get('/', { jar });
291
+
292
+ const draftId = mine._id.replace(':published', ':draft');
293
+ const targetDraftId = secret._id.replace(':published', ':draft');
294
+
295
+ let status = null;
296
+ try {
297
+ await apos.http.patch(`/api/v1/@apostrophecms/page/${draftId}`, {
298
+ body: {
299
+ _targetId: targetDraftId,
300
+ _position: 'firstChild'
301
+ },
302
+ jar
303
+ });
304
+ } catch (e) {
305
+ status = e.status;
306
+ }
307
+
308
+ assert.equal(status, 403, 'the REST move must be rejected with 403 Forbidden');
309
+
310
+ // Confirm no relocation happened.
311
+ const moved = await apos.page.find(adminReq(), { _id: mine._id }).toObject();
312
+ assert.ok(
313
+ !moved.path.includes(secret.aposDocId),
314
+ 'the editor must not have relocated their page under the admin-only section via REST'
315
+ );
316
+ });
317
+ });
package/test/pages.js CHANGED
@@ -1,6 +1,12 @@
1
1
  const t = require('../test-lib/test.js');
2
2
  const assert = require('assert');
3
3
  const _ = require('lodash');
4
+ // The REST API etag tests below issue their conditional request via rawGet
5
+ // (raw node:http): the built-in fetch used by apos.http adds Cache-Control:
6
+ // no-cache to any request carrying a conditional header (Fetch standard), which
7
+ // would suppress the asserted 304s. The page-serving etag tests stay on
8
+ // apos.http (those routes set 304 explicitly).
9
+ const { rawGet } = t;
4
10
 
5
11
  describe('Pages', function() {
6
12
  let apos;
@@ -1087,11 +1093,8 @@ describe('Pages', function() {
1087
1093
  };
1088
1094
 
1089
1095
  const response1 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, { fullResponse: true });
1090
- const response2 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, {
1091
- fullResponse: true,
1092
- headers: {
1093
- 'if-none-match': response1.headers.etag
1094
- }
1096
+ const response2 = await rawGet(apos, `/api/v1/@apostrophecms/page/${homeId}`, {
1097
+ 'if-none-match': response1.headers.etag
1095
1098
  });
1096
1099
 
1097
1100
  assert(response1.status === 200);
@@ -1125,11 +1128,8 @@ describe('Pages', function() {
1125
1128
  // so requesting it again should not return a 304 status code
1126
1129
  const pageUpdateResponse = await apos.doc.update(apos.task.getReq(), pageDoc);
1127
1130
 
1128
- const response2 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, {
1129
- fullResponse: true,
1130
- headers: {
1131
- 'if-none-match': response1.headers.etag
1132
- }
1131
+ const response2 = await rawGet(apos, `/api/v1/@apostrophecms/page/${homeId}`, {
1132
+ 'if-none-match': response1.headers.etag
1133
1133
  });
1134
1134
 
1135
1135
  const eTag1Parts = response1.headers.etag.split(':');
@@ -1165,11 +1165,8 @@ describe('Pages', function() {
1165
1165
  outOfDateETagParts[2] = Number(outOfDateETagParts[2]) -
1166
1166
  (4444 + 1) * 1000; // 1s outdated
1167
1167
 
1168
- const response2 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, {
1169
- fullResponse: true,
1170
- headers: {
1171
- 'if-none-match': outOfDateETagParts.join(':')
1172
- }
1168
+ const response2 = await rawGet(apos, `/api/v1/@apostrophecms/page/${homeId}`, {
1169
+ 'if-none-match': outOfDateETagParts.join(':')
1173
1170
  });
1174
1171
 
1175
1172
  const eTag1Parts = response1.headers.etag.split(':');