apostrophe 3.37.0 → 3.38.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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.38.0 (2023-01-18)
4
+
5
+ ### Adds
6
+
7
+ * Emit a `beforeSave` event from the `@apostrophecms:notification` module, with `req` and the `notification` as arguments, in order to give the possibility to override the notification.
8
+ * Emit a `beforeInsert` event from the `@apostrophecms:attachment` module, with `req` and the `doc` as arguments, in order to give the possibility to override the attachment.
9
+ * Emit a `beforeSaveSafe` event from the `@apostrophecms:user` module, with `req`, `safeUser` and `user` as arguments, in order to give the possibility to override properties of the `safeUser` object which contains password hashes and other information too sensitive to be stored in the aposDocs collection.
10
+ * Automatically convert failed uppercase URLs to their lowercase version - can be disabled with `redirectFailedUpperCaseUrls: false` in `@apostrophecms/page/index.js` options. This only comes into play if a 404 is about to happen.
11
+ * Automatically convert country codes in locales like `xx-yy` to `xx-YY` before passing them to `i18next`, which is strict about uppercase country codes.
12
+
13
+ ### Fixes
14
+
15
+ * Documents kept as the `previous` version for undo purposes were not properly marked as such, breaking the public language switcher in some cases. This was fixed and a migration was added for existing data.
16
+ * Uploading an image in an apostrophe area with `minSize` requirements will not trigger an unexpected error anymore. If the image is too small, a notification will be displayed with the minimum size requirements. The `Edit Image` modal will now display the minimum size requirements, if any, above the `Browse Images` field.
17
+ * Some browsers saw the empty `POST` response for new notifications as invalid XML. It will now return an empty JSON object with the `Content-Type` set to `application/json`.
18
+
3
19
  ## 3.37.0 (2023-01-06)
4
20
 
5
21
  ### Adds
@@ -15,14 +31,14 @@
15
31
  e.g. `A permission.can() call was made with a type that has no manager: @apostrophecms/polymorphic-type`.
16
32
  * The module `webpack.extensions` configuration is not applied to the core Admin UI build anymore. This is the correct and intended behavior as explained in the [relevant documentation](https://v3.docs.apostrophecms.org/guide/webpack.html#extending-webpack-configuration).
17
33
  * The `previewImage` option now works properly for widget modules loaded from npm and those that subclass them. Specifically, the preview image may be provided in the `public/` subdirectory of the original module, the project-level configuration of it, or a subclass.
18
-
34
+
19
35
  ## 3.36.0 (2022-12-22)
20
36
 
21
37
  ### Adds
22
38
 
23
- * `shortcut` option for piece modules, allowing easy re-mapping of the manager command shortcut per module.
39
+ * `shortcut` option for piece modules, allowing easy re-mapping of the manager command shortcut per module.
24
40
 
25
- ### Fixes
41
+ ### Fixes
26
42
 
27
43
  * Ensure there are no conflicting command shortcuts for the core modules.
28
44
 
@@ -443,6 +443,9 @@ module.exports = {
443
443
  await Promise.promisify(self.uploadfs.copyIn)(file.path, '/attachments/' + info._id + '-' + info.name + '.' + info.extension);
444
444
  }
445
445
  info.createdAt = new Date();
446
+
447
+ await self.emit('beforeInsert', req, info);
448
+
446
449
  await self.db.insertOne(info);
447
450
  return info;
448
451
  },
@@ -37,6 +37,7 @@ module.exports = {
37
37
  await self.createIndexes();
38
38
  self.addLegacyMigrations();
39
39
  self.addCacheFieldMigration();
40
+ self.addSetPreviousDocsAposModeMigration();
40
41
  },
41
42
  restApiRoutes(self) {
42
43
  return {
@@ -1288,6 +1289,20 @@ module.exports = {
1288
1289
  addCacheFieldMigration() {
1289
1290
  self.apos.migration.add('add-cache-invalidated-at-field', self.setCacheField);
1290
1291
  },
1292
+
1293
+ async addSetPreviousDocsAposModeMigration () {
1294
+ self.apos.migration.add('set-previous-docs-apos-mode', async () => {
1295
+ await self.apos.doc.db.updateMany({
1296
+ _id: { $regex: ':previous$' },
1297
+ aposMode: { $ne: 'previous' }
1298
+ }, {
1299
+ $set: {
1300
+ aposMode: 'previous'
1301
+ }
1302
+ });
1303
+ });
1304
+ },
1305
+
1291
1306
  ...require('./lib/legacy-migrations')(self)
1292
1307
  };
1293
1308
  }
@@ -847,6 +847,7 @@ module.exports = {
847
847
  if (previousPublished) {
848
848
  previousPublished._id = previousPublished._id.replace(':published', ':previous');
849
849
  previousPublished.aposLocale = previousPublished.aposLocale.replace(':published', ':previous');
850
+ previousPublished.aposMode = 'previous';
850
851
  Object.assign(previousPublished, await self.getDeduplicationSet(req, previousPublished));
851
852
  await self.apos.doc.db.replaceOne({
852
853
  _id: previousPublished._id
@@ -409,7 +409,11 @@ module.exports = {
409
409
  continue;
410
410
  }
411
411
  const data = JSON.parse(fs.readFileSync(path.join(localizationsDir, localizationFile)));
412
- const locale = localizationFile.replace('.json', '');
412
+
413
+ // enforce i18next locale format as xx-XX
414
+ const [ language, country ] = localizationFile.replace('.json', '').split('-');
415
+ const locale = country ? `${language.toLocaleLowerCase()}-${country.toUpperCase()}` : language;
416
+
413
417
  self.i18next.addResourceBundle(locale, ns, data, true, true);
414
418
  }
415
419
  }
@@ -566,11 +570,17 @@ module.exports = {
566
570
  return i18n;
567
571
  },
568
572
  getLocales() {
569
- const locales = self.options.locales || {
570
- en: {
571
- label: 'English'
572
- }
573
- };
573
+ const locales = self.options.locales
574
+ ? Object.fromEntries(Object.entries(self.options.locales).map(([ name, options ]) => {
575
+ // enforce i18next locale format as xx-XX
576
+ const [ language, country ] = name.split('-');
577
+ return country ? [ `${language.toLocaleLowerCase()}-${country.toUpperCase()}`, options ] : [ language, options ];
578
+ }))
579
+ : {
580
+ en: {
581
+ label: 'English'
582
+ }
583
+ };
574
584
  verifyLocales(locales, self.apos.options.baseUrl);
575
585
  return locales;
576
586
  },
@@ -318,6 +318,21 @@ export default {
318
318
  this.uploading = false;
319
319
  await this.getMedia();
320
320
 
321
+ if (Array.isArray(imgIds) && imgIds.length && this.items.length === 0) {
322
+ const [ widgetOptions = {} ] = apos.area.widgetOptions;
323
+ const [ width, height ] = widgetOptions.minSize || [];
324
+ await apos.notify('apostrophe:minSize', {
325
+ type: 'danger',
326
+ icon: 'alert-circle-icon',
327
+ dismiss: true,
328
+ interpolate: {
329
+ width,
330
+ height
331
+ }
332
+ });
333
+ this.updateEditing(null);
334
+ return;
335
+ }
321
336
  if (Array.isArray(imgIds) && imgIds.length) {
322
337
  this.checked = this.checked.concat(imgIds);
323
338
 
@@ -219,7 +219,8 @@ export default {
219
219
  // database.
220
220
  this.checked.forEach(id => {
221
221
  if (this.checkedDocs.findIndex(doc => doc._id === id) === -1) {
222
- this.checkedDocs.push(this.items.find(item => item._id === id));
222
+ const found = this.items.find(item => item._id === id);
223
+ found && this.checkedDocs.push(found);
223
224
  }
224
225
  });
225
226
 
@@ -92,6 +92,7 @@ module.exports = {
92
92
  async post(req) {
93
93
  const type = self.apos.launder.select(req.body.type, [
94
94
  'danger',
95
+ 'error',
95
96
  'warning',
96
97
  'success',
97
98
  'info'
@@ -143,9 +144,14 @@ module.exports = {
143
144
  put(req, _id) {
144
145
  throw self.apos.error('unimplemented');
145
146
  },
146
- patch(req, _id) {
147
+ async patch(req, _id) {
147
148
  const dismissed = self.apos.launder.boolean(req.body.dismissed);
148
149
  if (dismissed) {
150
+ await self.emit('beforeSave', req, {
151
+ _id,
152
+ dismissed
153
+ });
154
+
149
155
  return self.db.updateOne({ _id }, {
150
156
  $set: {
151
157
  dismissed
@@ -283,6 +289,8 @@ module.exports = {
283
289
 
284
290
  Object.assign(notification, copiedOptions);
285
291
 
292
+ await self.emit('beforeSave', req, notification);
293
+
286
294
  // We await here rather than returning because we expressly do not
287
295
  // want to leak mongodb metadata to the browser
288
296
  await self.db.updateOne(
@@ -302,6 +310,8 @@ module.exports = {
302
310
  noteId: notification._id
303
311
  };
304
312
  }
313
+
314
+ return {};
305
315
  },
306
316
 
307
317
  // The dismiss method accepts the following arguments:
@@ -317,6 +327,11 @@ module.exports = {
317
327
  await pause(delay);
318
328
 
319
329
  try {
330
+ await self.emit('beforeSave', req, {
331
+ _id: noteId,
332
+ dismissed: true
333
+ });
334
+
320
335
  await self.db.updateOne(
321
336
  {
322
337
  _id: noteId
@@ -33,7 +33,8 @@ module.exports = {
33
33
  orphan: true,
34
34
  title: 'Archive'
35
35
  }
36
- ]
36
+ ],
37
+ redirectFailedUpperCaseUrls: true
37
38
  },
38
39
  batchOperations: {
39
40
  add: {
@@ -1565,6 +1566,7 @@ database.`);
1565
1566
  if (self.isFound(req)) {
1566
1567
  return;
1567
1568
  }
1569
+
1568
1570
  if (req.user && (req.mode === 'published')) {
1569
1571
  // Try again in draft mode
1570
1572
  try {
@@ -1613,6 +1615,12 @@ database.`);
1613
1615
  // Nonfatal, we were just probing
1614
1616
  }
1615
1617
  }
1618
+
1619
+ // If uppercase letters in URL, try with lowercase
1620
+ if (self.options.redirectFailedUpperCaseUrls && /[A-Z]/.test(req.path)) {
1621
+ req.redirect = self.apos.url.build(req.path.toLowerCase(), req.query);
1622
+ }
1623
+
1616
1624
  // Give all modules a chance to save the day
1617
1625
  await self.emit('notFound', req);
1618
1626
  // Are we happy now?
@@ -6,6 +6,17 @@
6
6
  :modifiers="modifiers"
7
7
  >
8
8
  <template #additional>
9
+ <div
10
+ v-if="minSize[0] || minSize[1]"
11
+ class="apos-field__min-size"
12
+ >
13
+ {{
14
+ $t('apostrophe:minSize', {
15
+ width: minSize[0] || '???',
16
+ height: minSize[1] || '???'
17
+ })
18
+ }}
19
+ </div>
9
20
  <AposMinMaxCount
10
21
  :field="field"
11
22
  :value="next"
@@ -122,6 +133,11 @@ export default {
122
133
  modifiers.push('block');
123
134
  }
124
135
  return modifiers;
136
+ },
137
+ minSize() {
138
+ const [ widgetOptions = {} ] = apos.area.widgetOptions;
139
+
140
+ return widgetOptions.minSize || [];
125
141
  }
126
142
  },
127
143
  watch: {
@@ -287,4 +303,12 @@ export default {
287
303
  padding: 0;
288
304
  }
289
305
  }
306
+
307
+ .apos-field__min-size {
308
+ @include type-help;
309
+ display: flex;
310
+ flex-grow: 1;
311
+ margin-bottom: $spacing-base;
312
+ font-weight: var(--a-weight-bold);
313
+ }
290
314
  </style>
@@ -292,6 +292,9 @@ module.exports = {
292
292
 
293
293
  await self.hashPassword(doc, safeUser);
294
294
  await self.hashSecrets(doc, safeUser);
295
+
296
+ await self.emit('beforeSaveSafe', req, safeUser, doc);
297
+
295
298
  if (action === 'insert') {
296
299
  await self.safe.insertOne(safeUser);
297
300
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "3.37.0",
3
+ "version": "3.38.0",
4
4
  "description": "The Apostrophe Content Management System.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -209,11 +209,12 @@ describe('Draft / Published', function() {
209
209
  }), testDraftProduct);
210
210
  });
211
211
 
212
- it('"previous published" should be deduplicated at this point', async function() {
212
+ it('"previous published" should be deduplicated at this point, and previous have the right props', async function() {
213
213
  const previous = await apos.doc.db.findOne({
214
214
  _id: testDraftProduct._id.replace(':draft', ':previous')
215
215
  });
216
216
  assert(previous);
217
+ assert(previous.aposMode === 'previous');
217
218
  assert.strictEqual(previous.slug, `deduplicate-${previous.aposDocId}-test-product`);
218
219
  });
219
220
 
@@ -539,6 +540,7 @@ describe('Draft / Published', function() {
539
540
  }), {
540
541
  aposDocId: sibling.aposDocId
541
542
  }).children(true).toObject();
543
+
542
544
  assert(siblingPublished && siblingPublished._children && siblingPublished._children[0] && siblingPublished._children[0]._id === grandchild._id.replace(':draft', ':published'));
543
545
  });
544
546
 
@@ -556,17 +558,20 @@ describe('Draft / Published', function() {
556
558
  const draftItem = {
557
559
  ...baseItem,
558
560
  _id: 'some-page:en:draft',
559
- aposLocale: 'en:draft'
561
+ aposLocale: 'en:draft',
562
+ aposMode: 'draft'
560
563
  };
561
564
  const publishedItem = {
562
565
  ...baseItem,
563
566
  _id: 'some-page:en:published',
564
- aposLocale: 'en:published'
567
+ aposLocale: 'en:published',
568
+ aposMode: 'published'
565
569
  };
566
570
  const previousItem = {
567
571
  ...baseItem,
568
572
  _id: 'some-page:en:previous',
569
- aposLocale: 'en:previous'
573
+ aposLocale: 'en:previous',
574
+ aposMode: 'previous'
570
575
  };
571
576
 
572
577
  let draft;
@@ -10,4 +10,6 @@
10
10
  {% for tab in data.home._children %}
11
11
  <a href="{{ tab._url }}">Tab: {{ tab.slug }}</a>
12
12
  {% endfor %}
13
+
14
+ URL: {{ data.page._url }}
13
15
  {% endblock %}
package/test/pages.js CHANGED
@@ -215,6 +215,24 @@ describe('Pages', function() {
215
215
  assert(page.path === `${homeId.replace(':en:published', '')}/parent/child`);
216
216
  });
217
217
 
218
+ it('should convert an uppercase URL to its lowercase version', async function() {
219
+ const response = await apos.http.get('/PArent/cHild', {
220
+ fullResponse: true
221
+ });
222
+ assert(response.body.match(/URL: \/parent\/child/));
223
+ });
224
+
225
+ it('should NOT convert an uppercase URL if redirectFailedUpperCaseUrls is false', async function() {
226
+ apos.page.options.redirectFailedUpperCaseUrls = false;
227
+ try {
228
+ await apos.http.get('/PArent/cHild', {
229
+ fullResponse: true
230
+ });
231
+ } catch (error) {
232
+ assert(error.status === 404);
233
+ }
234
+ });
235
+
218
236
  it('should be able to include the ancestors of a page', async function() {
219
237
  const cursor = apos.page.find(apos.task.getAnonReq(), { slug: '/parent/child' });
220
238
 
@@ -1151,8 +1169,6 @@ describe('Pages', function() {
1151
1169
 
1152
1170
  it('should grant public access to a draft after having enabled draft sharing', async function() {
1153
1171
  const publicUrl = generatePublicUrl(shareResponse);
1154
- console.log('publicUrl', publicUrl);
1155
-
1156
1172
  const response = await apos.http.get(shareResponse._url, { fullResponse: true });
1157
1173
  const publicResponse = await apos.http.get(publicUrl, { fullResponse: true });
1158
1174