apostrophe 3.11.0 → 3.14.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 (43) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/index.js +37 -1
  3. package/lib/moog.js +2 -2
  4. package/modules/@apostrophecms/asset/index.js +24 -9
  5. package/modules/@apostrophecms/asset/lib/globalIcons.js +2 -0
  6. package/modules/@apostrophecms/attachment/index.js +1 -1
  7. package/modules/@apostrophecms/doc/index.js +9 -3
  8. package/modules/@apostrophecms/doc-type/index.js +2 -2
  9. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocContextMenu.vue +0 -7
  10. package/modules/@apostrophecms/express/index.js +50 -38
  11. package/modules/@apostrophecms/http/index.js +0 -20
  12. package/modules/@apostrophecms/i18n/i18n/en.json +1 -0
  13. package/modules/@apostrophecms/i18n/i18n/sk.json +23 -1
  14. package/modules/@apostrophecms/i18n/index.js +62 -13
  15. package/modules/@apostrophecms/login/index.js +282 -42
  16. package/modules/@apostrophecms/login/ui/apos/components/TheAposLogin.vue +242 -77
  17. package/modules/@apostrophecms/login/ui/apos/components/TheAposLoginHeader.vue +108 -0
  18. package/modules/@apostrophecms/module/index.js +12 -2
  19. package/modules/@apostrophecms/page/index.js +98 -82
  20. package/modules/@apostrophecms/permission/index.js +1 -1
  21. package/modules/@apostrophecms/piece-page-type/index.js +1 -1
  22. package/modules/@apostrophecms/piece-type/index.js +86 -73
  23. package/modules/@apostrophecms/schema/index.js +18 -2
  24. package/modules/@apostrophecms/schema/ui/apos/components/AposInputArea.vue +12 -5
  25. package/modules/@apostrophecms/schema/ui/apos/components/AposInputCheckboxes.vue +4 -0
  26. package/modules/@apostrophecms/ui/ui/apos/lib/i18next.js +18 -0
  27. package/modules/@apostrophecms/util/index.js +3 -9
  28. package/modules/@apostrophecms/util/ui/src/http.js +1 -7
  29. package/package.json +2 -1
  30. package/test/express.js +2 -26
  31. package/test/http.js +0 -24
  32. package/test/login-requirements.js +328 -0
  33. package/test/modules/base-type/i18n/custom/en.json +4 -0
  34. package/test/modules/base-type/i18n/en.json +3 -0
  35. package/test/modules/nested-module-subdirs/example1/index.js +5 -0
  36. package/test/modules/nested-module-subdirs/modules.js +7 -0
  37. package/test/modules/subtype/i18n/custom/en.json +4 -0
  38. package/test/modules/subtype/index.js +7 -0
  39. package/test/pages-rest.js +39 -0
  40. package/test/pieces-page-type.js +63 -0
  41. package/test/static-i18n.js +28 -0
  42. package/test/with-nested-module-subdirs.js +32 -0
  43. package/test/without-nested-module-subdirs.js +31 -0
@@ -10,6 +10,7 @@ const fs = require('fs');
10
10
  const _ = require('lodash');
11
11
  const { stripIndent } = require('common-tags');
12
12
  const ExpressSessionCookie = require('express-session/session/cookie');
13
+ const path = require('path');
13
14
 
14
15
  const apostropheI18nDebugPlugin = {
15
16
  type: 'postProcessor',
@@ -46,6 +47,7 @@ module.exports = {
46
47
  }
47
48
  },
48
49
  async init(self) {
50
+ self.defaultNamespace = 'default';
49
51
  self.namespaces = {};
50
52
  self.debug = process.env.APOS_DEBUG_I18N ? true : self.options.debug;
51
53
  self.show = process.env.APOS_SHOW_I18N ? true : self.options.show;
@@ -83,7 +85,7 @@ module.exports = {
83
85
  // Nunjucks and Vue will already do this
84
86
  escapeValue: false
85
87
  },
86
- defaultNS: 'default',
88
+ defaultNS: self.defaultNamespace,
87
89
  debug: self.debug
88
90
  });
89
91
  if (self.show) {
@@ -380,21 +382,65 @@ module.exports = {
380
382
  // Add the i18next resources provided by the specified module,
381
383
  // merging with any existing phrases for the same locales and namespaces
382
384
  addResourcesForModule(module) {
383
- if (!module.options.i18n) {
384
- return;
385
- }
386
- const ns = module.options.i18n.ns || 'default';
385
+ self.addDefaultResourcesForModule(module);
386
+ self.addNamespacedResourcesForModule(module);
387
+ },
388
+ // Automatically adds any localizations found in .json files in the main `i18n` subdirectory
389
+ // of a module.
390
+ //
391
+ // These are added to the `default` namespace, unless the legacy `i18n.ns` option is set
392
+ // for the module (not the preferred way, use namespace subdirectories in new projects).
393
+ addDefaultResourcesForModule(module) {
394
+ const ns = (module.options.i18n && module.options.i18n.ns) || 'default';
387
395
  self.namespaces[ns] = self.namespaces[ns] || {};
388
- self.namespaces[ns].browser = self.namespaces[ns].browser || !!module.options.i18n.browser;
396
+ self.namespaces[ns].browser = self.namespaces[ns].browser || (module.options.i18n && module.options.i18n.browser);
389
397
  for (const entry of module.__meta.chain) {
390
- const localizationsDir = `${entry.dirname}/i18n`;
391
- if (!fs.existsSync(localizationsDir)) {
392
- continue;
398
+ const localizationsDir = path.join(entry.dirname, 'i18n');
399
+ if (!self.defaultLocalizationsDirsAdded.has(localizationsDir)) {
400
+ self.defaultLocalizationsDirsAdded.add(localizationsDir);
401
+ if (!fs.existsSync(localizationsDir)) {
402
+ continue;
403
+ }
404
+ for (const localizationFile of fs.readdirSync(localizationsDir)) {
405
+ if (!localizationFile.endsWith('.json')) {
406
+ // Likely a namespace subdirectory
407
+ continue;
408
+ }
409
+ const data = JSON.parse(fs.readFileSync(path.join(localizationsDir, localizationFile)));
410
+ const locale = localizationFile.replace('.json', '');
411
+ self.i18next.addResourceBundle(locale, ns, data, true, true);
412
+ }
393
413
  }
394
- for (const localizationFile of fs.readdirSync(localizationsDir)) {
395
- const data = JSON.parse(fs.readFileSync(`${localizationsDir}/${localizationFile}`));
396
- const locale = localizationFile.replace('.json', '');
397
- self.i18next.addResourceBundle(locale, ns, data, true, true);
414
+ }
415
+ },
416
+ // Automatically adds any localizations found in subdirectories of the main `i18n`
417
+ // subdirectory of a module. The subdirectory's name is treated as an i18n namespace
418
+ // name.
419
+ addNamespacedResourcesForModule(module) {
420
+ for (const entry of module.__meta.chain) {
421
+ const metadata = module.__meta.i18n[entry.name] || {};
422
+ const localizationsDir = `${entry.dirname}/i18n`;
423
+ if (!self.namespacedLocalizationsDirsAdded.has(localizationsDir)) {
424
+ self.namespacedLocalizationsDirsAdded.add(localizationsDir);
425
+ if (!fs.existsSync(localizationsDir)) {
426
+ continue;
427
+ }
428
+ for (const ns of fs.readdirSync(localizationsDir)) {
429
+ if (ns.endsWith('.json')) {
430
+ // A JSON file for the default namespace, already handled
431
+ continue;
432
+ }
433
+ self.namespaces[ns] = self.namespaces[ns] || {};
434
+ self.namespaces[ns].browser = self.namespaces[ns].browser ||
435
+ (metadata[ns] && metadata[ns].browser);
436
+ const namespaceDir = path.join(localizationsDir, ns);
437
+ for (const localizationFile of fs.readdirSync(namespaceDir)) {
438
+ const fullLocalizationFile = path.join(namespaceDir, localizationFile);
439
+ const data = JSON.parse(fs.readFileSync(fullLocalizationFile));
440
+ const locale = localizationFile.replace('.json', '');
441
+ self.i18next.addResourceBundle(locale, ns, data, true, true);
442
+ }
443
+ }
398
444
  }
399
445
  }
400
446
  },
@@ -402,6 +448,8 @@ module.exports = {
402
448
  // itself, called by init. Later modules call addResourcesForModule(self),
403
449
  // making phrases available gradually as Apostrophe starts up
404
450
  addInitialResources() {
451
+ self.defaultLocalizationsDirsAdded = new Set();
452
+ self.namespacedLocalizationsDirsAdded = new Set();
405
453
  for (const module of Object.values(self.apos.modules)) {
406
454
  self.addResourcesForModule(module);
407
455
  }
@@ -493,6 +541,7 @@ module.exports = {
493
541
  i18n,
494
542
  locale: req.locale,
495
543
  defaultLocale: self.defaultLocale,
544
+ defaultNamespace: self.defaultNamespace,
496
545
  locales: self.locales,
497
546
  debug: self.debug,
498
547
  show: self.show,
@@ -42,8 +42,10 @@ const Passport = require('passport').Passport;
42
42
  const LocalStrategy = require('passport-local');
43
43
  const Promise = require('bluebird');
44
44
  const cuid = require('cuid');
45
+ const expressSession = require('express-session');
45
46
 
46
47
  module.exports = {
48
+ cascades: [ 'requirements' ],
47
49
  options: {
48
50
  alias: 'login',
49
51
  localLogin: true,
@@ -106,34 +108,13 @@ module.exports = {
106
108
  return {
107
109
  post: {
108
110
  async login(req) {
109
- const username = self.apos.launder.string(req.body.username);
110
- const password = self.apos.launder.string(req.body.password);
111
- const session = self.apos.launder.boolean(req.body.session);
112
- if (!(username && password)) {
113
- throw self.apos.error('invalid', req.t('apostrophe:loginPageBothRequired'));
114
- }
115
- const user = await self.apos.login.verifyLogin(username, password);
116
- if (!user) {
117
- // For security reasons we may not tell the user which case applies
118
- throw self.apos.error('invalid', req.t('apostrophe:loginPageBadCredentials'));
119
- }
120
- if (session) {
121
- const passportLogin = (user) => {
122
- return require('util').promisify(function(user, callback) {
123
- return req.login(user, callback);
124
- })(user);
125
- };
126
- await passportLogin(user);
111
+ // Don't make verify functions worry about whether this object
112
+ // is present, just the value of their own sub-property
113
+ req.body.requirements = req.body.requirements || {};
114
+ if (req.body.incompleteToken) {
115
+ return self.finalizeIncompleteLogin(req);
127
116
  } else {
128
- const token = cuid();
129
- await self.bearerTokens.insert({
130
- _id: token,
131
- userId: user._id,
132
- expires: new Date(new Date().getTime() + (self.options.bearerTokens.lifetime || (86400 * 7 * 2)) * 1000)
133
- });
134
- return {
135
- token
136
- };
117
+ return self.initialLogin(req);
137
118
  }
138
119
  },
139
120
  async logout(req) {
@@ -141,7 +122,7 @@ module.exports = {
141
122
  throw self.apos.error('forbidden', req.t('apostrophe:logOutNotLoggedIn'));
142
123
  }
143
124
  if (req.token) {
144
- await self.bearerTokens.remove({
125
+ await self.bearerTokens.removeOne({
145
126
  userId: req.user._id,
146
127
  _id: req.token
147
128
  });
@@ -153,8 +134,79 @@ module.exports = {
153
134
  return req.session.destroy(callback);
154
135
  })();
155
136
  };
137
+ const cookie = req.session.cookie;
156
138
  await destroySession();
139
+ // Session cookie expiration isn't automatic with `req.session.destroy`.
140
+ // Fix that to reduce challenges for those attempting to implement custom
141
+ // caching strategies at the edge
142
+ // https://github.com/expressjs/session/issues/241
143
+ const expireCookie = new expressSession.Cookie(cookie);
144
+ expireCookie.expires = new Date(0);
145
+ const name = self.apos.modules['@apostrophecms/express'].sessionOptions.name;
146
+ req.res.header('set-cookie', expireCookie.serialize(name, 'deleted'));
147
+ }
148
+ },
149
+ // invokes the `props(req, user)` function for the requirement specified by
150
+ // `body.name`. Invoked before displaying each `afterPasswordVerified`
151
+ // requirement. The return value of the function, which should
152
+ // be an object, is delivered as the API response
153
+ async requirementProps(req) {
154
+ const { user } = await self.findIncompleteTokenAndUser(req, req.body.incompleteToken);
155
+
156
+ const name = self.apos.launder.string(req.body.name);
157
+
158
+ const requirement = self.requirements[name];
159
+ if (!requirement) {
160
+ throw self.apos.error('notfound');
161
+ }
162
+ if (!requirement.props) {
163
+ return {};
157
164
  }
165
+ return requirement.props(req, user);
166
+ },
167
+ async requirementVerify(req) {
168
+ const name = self.apos.launder.string(req.body.name);
169
+
170
+ const { user } = await self.findIncompleteTokenAndUser(req, req.body.incompleteToken);
171
+
172
+ const requirement = self.requirements[name];
173
+
174
+ if (!requirement) {
175
+ throw self.apos.error('notfound');
176
+ }
177
+
178
+ if (!requirement.verify) {
179
+ throw self.apos.error('invalid', 'You must provide a verify method in your requirement');
180
+ }
181
+
182
+ try {
183
+ await requirement.verify(req, req.body.value, user);
184
+
185
+ const token = await self.bearerTokens.findOne({
186
+ _id: self.apos.launder.string(req.body.incompleteToken),
187
+ requirementsToVerify: { $exists: true },
188
+ expires: {
189
+ $gte: new Date()
190
+ }
191
+ });
192
+
193
+ if (!token) {
194
+ throw self.apos.error('notfound');
195
+ }
196
+
197
+ await self.bearerTokens.updateOne(token, {
198
+ $pull: { requirementsToVerify: name }
199
+ });
200
+
201
+ return {};
202
+ } catch (err) {
203
+ err.data = err.data || {};
204
+ err.data.requirement = name;
205
+ throw err;
206
+ }
207
+ },
208
+ async context(req) {
209
+ return self.getContext(req);
158
210
  },
159
211
  ...(self.options.passwordReset ? {
160
212
  async resetRequest(req) {
@@ -217,19 +269,12 @@ module.exports = {
217
269
  } : {})
218
270
  },
219
271
  get: {
220
- context () {
221
- let aposPackage = {};
222
- try {
223
- aposPackage = require('../../../package.json');
224
- } catch (err) {
225
- self.apos.util.error(err);
226
- }
227
-
228
- return {
229
- env: process.env.NODE_ENV || 'development',
230
- name: (process.env.npm_package_name && process.env.npm_package_name.replace(/-/g, ' ')) || 'Apostrophe',
231
- version: aposPackage.version || '3'
232
- };
272
+ // For bc this route is still available via GET, however
273
+ // it should be accessed via POST because the result
274
+ // may differ by individual user session and should not
275
+ // be cached
276
+ async context(req) {
277
+ return self.getContext(req);
233
278
  }
234
279
  }
235
280
  };
@@ -237,6 +282,33 @@ module.exports = {
237
282
  methods(self) {
238
283
  return {
239
284
 
285
+ // Implements the context route, which provides basic
286
+ // information about the site being logged into and also
287
+ // props for beforeSubmit requirements
288
+ async getContext(req) {
289
+ const aposPackage = require('../../../package.json');
290
+ // For performance beforeSubmit requirement props all happen together here
291
+ const requirementProps = {};
292
+ for (const [ name, requirement ] of Object.entries(self.requirements)) {
293
+ if ((requirement.phase !== 'afterPasswordVerified') && requirement.props) {
294
+ try {
295
+ requirementProps[name] = await requirement.props(req);
296
+ } catch (e) {
297
+ if (e.body && e.body.data) {
298
+ e.body.data.requirement = name;
299
+ }
300
+ throw e;
301
+ }
302
+ }
303
+ }
304
+ return {
305
+ env: process.env.NODE_ENV || 'development',
306
+ name: (process.env.npm_package_name && process.env.npm_package_name.replace(/-/g, ' ')) || 'Apostrophe',
307
+ version: aposPackage.version || '3',
308
+ requirementProps
309
+ };
310
+ },
311
+
240
312
  // return the loginUrl option
241
313
  login(url) {
242
314
  return self.options.loginUrl ? self.options.loginUrl : '/login';
@@ -368,7 +440,17 @@ module.exports = {
368
440
  username: req.user.username,
369
441
  email: req.user.email
370
442
  }
371
- } : {})
443
+ } : {}),
444
+ requirements: Object.fromEntries(
445
+ Object.entries(self.requirements).map(([ name, requirement ]) => {
446
+ const browserRequirement = {
447
+ phase: requirement.phase,
448
+ propsRequired: !!requirement.props,
449
+ askForConfirmation: requirement.askForConfirmation || false
450
+ };
451
+ return [ name, browserRequirement ];
452
+ })
453
+ )
372
454
  };
373
455
  },
374
456
 
@@ -384,6 +466,164 @@ module.exports = {
384
466
  async enableBearerTokens() {
385
467
  self.bearerTokens = self.apos.db.collection('aposBearerTokens');
386
468
  await self.bearerTokens.createIndex({ expires: 1 }, { expireAfterSeconds: 0 });
469
+ },
470
+
471
+ // Finalize an incomplete login based on the provided incompleteToken
472
+ // and various `requirements` subproperties. Implementation detail of the login route
473
+ async finalizeIncompleteLogin(req) {
474
+ const session = self.apos.launder.boolean(req.body.session);
475
+ // Completing a previous incomplete login
476
+ // (password was verified but post-password-verification
477
+ // requirements were not supplied)
478
+ const token = await self.bearerTokens.findOne({
479
+ _id: self.apos.launder.string(req.body.incompleteToken),
480
+ requirementsToVerify: {
481
+ $exists: true
482
+ },
483
+ expires: {
484
+ $gte: new Date()
485
+ }
486
+ });
487
+
488
+ if (!token) {
489
+ throw self.apos.error('notfound');
490
+ }
491
+
492
+ if (token.requirementsToVerify.length) {
493
+ throw self.apos.error('forbidden', 'All requirements must be verified');
494
+ }
495
+
496
+ const user = await self.deserializeUser(token.userId);
497
+ if (!user) {
498
+ await self.bearerTokens.removeOne({
499
+ _id: token.userId
500
+ });
501
+ throw self.apos.error('notfound');
502
+ }
503
+
504
+ if (session) {
505
+ await self.bearerTokens.removeOne({
506
+ _id: token.userId
507
+ });
508
+ await self.passportLogin(req, user);
509
+ } else {
510
+ delete token.requirementsToVerify;
511
+ self.bearerTokens.updateOne(token, {
512
+ $unset: {
513
+ requirementsToVerify: 1
514
+ }
515
+ });
516
+ return {
517
+ token
518
+ };
519
+ }
520
+ },
521
+
522
+ // Implementation detail of the login route and the requirementProps mechanism for
523
+ // custom login requirements. Given the string `token`, returns
524
+ // `{ token, user }`. Throws an exception if the token is not found.
525
+ // `token` is sanitized before passing to mongodb.
526
+ async findIncompleteTokenAndUser(req, token) {
527
+ token = await self.bearerTokens.findOne({
528
+ _id: self.apos.launder.string(token),
529
+ requirementsToVerify: {
530
+ $exists: true,
531
+ $ne: []
532
+ },
533
+ expires: {
534
+ $gte: new Date()
535
+ }
536
+ });
537
+ if (!token) {
538
+ throw self.apos.error('notfound');
539
+ }
540
+ const user = await self.deserializeUser(token.userId);
541
+ if (!user) {
542
+ await self.bearerTokens.removeOne({
543
+ _id: token._id
544
+ });
545
+ throw self.apos.error('notfound');
546
+ }
547
+ return {
548
+ token,
549
+ user
550
+ };
551
+ },
552
+
553
+ // Implementation detail of the login route. Log in the user, or if there are
554
+ // `requirements` that require password verification occur first, return an incomplete token.
555
+ async initialLogin(req) {
556
+ // Initial login step
557
+ const username = self.apos.launder.string(req.body.username);
558
+ const password = self.apos.launder.string(req.body.password);
559
+ if (!(username && password)) {
560
+ throw self.apos.error('invalid', req.t('apostrophe:loginPageBothRequired'));
561
+ }
562
+ const { earlyRequirements, lateRequirements } = self.filterRequirements();
563
+ for (const [ name, requirement ] of Object.entries(earlyRequirements)) {
564
+ try {
565
+ await requirement.verify(req, req.body.requirements && req.body.requirements[name]);
566
+ } catch (e) {
567
+ e.data = e.data || {};
568
+ e.data.requirement = name;
569
+ throw e;
570
+ }
571
+ }
572
+ const user = await self.apos.login.verifyLogin(username, password);
573
+ if (!user) {
574
+ // For security reasons we may not tell the user which case applies
575
+ throw self.apos.error('invalid', req.t('apostrophe:loginPageBadCredentials'));
576
+ }
577
+
578
+ const requirementsToVerify = Object.keys(lateRequirements);
579
+
580
+ if (requirementsToVerify.length) {
581
+ const token = cuid();
582
+
583
+ await self.bearerTokens.insert({
584
+ _id: token,
585
+ userId: user._id,
586
+ requirementsToVerify,
587
+ // Default lifetime of 1 hour is generous to permit situations like
588
+ // installing a TOTP app for the first time
589
+ expires: new Date(new Date().getTime() + (self.options.incompleteLifetime || 60 * 60 * 1000))
590
+ });
591
+ return {
592
+ incompleteToken: token
593
+ };
594
+ } else {
595
+ const session = self.apos.launder.boolean(req.body.session);
596
+ if (session) {
597
+ await self.passportLogin(req, user);
598
+ } else {
599
+ const token = cuid();
600
+ await self.bearerTokens.insert({
601
+ _id: token,
602
+ userId: user._id,
603
+ expires: new Date(new Date().getTime() + (self.options.bearerTokens.lifetime || (86400 * 7 * 2)) * 1000)
604
+ });
605
+ return {
606
+ token
607
+ };
608
+ }
609
+ }
610
+ },
611
+
612
+ filterRequirements() {
613
+ return {
614
+ earlyRequirements: Object.fromEntries(Object.entries(self.requirements).filter(([ name, requirement ]) => requirement.phase === 'beforeSubmit')),
615
+ lateRequirements: Object.fromEntries(Object.entries(self.requirements).filter(([ name, requirement ]) => requirement.phase === 'afterPasswordVerified'))
616
+ };
617
+ },
618
+
619
+ // Awaitable wrapper for req.login. An implementation detail of the login route
620
+ async passportLogin(req, user) {
621
+ const passportLogin = (user) => {
622
+ return require('util').promisify(function(user, callback) {
623
+ return req.login(user, callback);
624
+ })(user);
625
+ };
626
+ await passportLogin(user);
387
627
  }
388
628
  };
389
629
  },