apostrophe 3.13.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.14.0 (2022-02-22)
4
+
5
+ ### Adds
6
+
7
+ * To reduce complications for those implementing caching strategies, the CSRF protection cookie now contains a simple constant string, and is not recorded in `req.session`. This is acceptable because the real purpose of the CSRF check is simply to verify that the browser has sent the cookie at all, which it will not allow a cross-origin script to do.
8
+ * As a result of the above, a session cookie is not generated and sent at all unless `req.session` is actually used or a user logs in. Again, this reduces complications for those implementing caching strategies.
9
+ * When logging out, the session cookie is now cleared in the browser. Formerly the session was destroyed on the server side only, which was sufficient for security purposes but could create caching issues.
10
+ * Uses `express-cache-on-demand` lib to make similar and concurrent requests on pieces and pages faster.
11
+ * Frontend build errors now stop app startup in development, and SCSS and JS/Vue build warnings are visible on the terminal console for the first time.
12
+
13
+ ### Fixes
14
+
15
+ * Fixed a bug when editing a page more than once if the page has a relationship to itself, whether directly or indirectly. Widget ids were unnecessarily regenerated in this situation, causing in-context edits after the first to fail to save.
16
+ * Pages no longer emit double `beforeUpdate` and `beforeSave` events.
17
+ * When the home page extends `@apostrophecms/piece-page-type`, the "show page" URLs for individual pieces should not contain two slashes before the piece slug. Thanks to [Martí Bravo](https://github.com/martibravo) for the fix.
18
+ * Fixes transitions between login page and `afterPasswordVerified` login steps.
19
+ * Frontend build errors now stop the `@apostrophecms/asset:build` task properly in production.
20
+ * `start` replaced with `flex-start` to address SCSS warnings.
21
+ * Dead code removal, as a result of following up on JS/Vue build warnings.
22
+
3
23
  ## 3.13.0 - 2022-02-04
4
24
 
5
25
  ### Adds
@@ -249,15 +249,23 @@ module.exports = {
249
249
  fs.removeSync(`${bundleDir}/${outputFilename}`);
250
250
  const cssPath = `${bundleDir}/${outputFilename}`.replace(/\.js$/, '.css');
251
251
  fs.removeSync(cssPath);
252
- await Promise.promisify(webpackModule)(require(`./lib/webpack/${name}/webpack.config`)(
253
- {
254
- importFile,
255
- modulesDir,
256
- outputPath: bundleDir,
257
- outputFilename
258
- },
259
- self.apos
260
- ));
252
+ const webpack = Promise.promisify(webpackModule);
253
+ const webpackBaseConfig = require(`./lib/webpack/${name}/webpack.config`);
254
+ const webpackInstanceConfig = webpackBaseConfig({
255
+ importFile,
256
+ modulesDir,
257
+ outputPath: bundleDir,
258
+ outputFilename
259
+ }, self.apos);
260
+ const result = await webpack(webpackInstanceConfig);
261
+ if (result.compilation.errors.length) {
262
+ // Throwing a string is appropriate in a command line task
263
+ throw cleanErrors(result.toString('errors'));
264
+ } else if (result.compilation.warnings.length) {
265
+ self.apos.util.warn(result.toString('errors-warnings'));
266
+ } else if (process.env.APOS_WEBPACK_VERBOSE) {
267
+ self.apos.util.info(result.toString('verbose'));
268
+ }
261
269
  if (fs.existsSync(cssPath)) {
262
270
  fs.writeFileSync(cssPath, self.filterCss(fs.readFileSync(cssPath, 'utf8'), {
263
271
  modulesPrefix: `${self.getAssetBaseUrl()}/modules`
@@ -518,6 +526,13 @@ module.exports = {
518
526
  function getComponentName(component, options, i) {
519
527
  return require('path').basename(component).replace(/\.\w+/, '') + (options.enumerateImports ? `_${i}` : '');
520
528
  }
529
+
530
+ function cleanErrors(errors) {
531
+ // Dev experience: remove confusing and inaccurate webpack warning about module loaders
532
+ // when straightforward JS parse errors occur, stackoverflow is full of people
533
+ // confused by this
534
+ return errors.replace(/(ERROR in[\s\S]*?Module parse failed[\s\S]*)You may need an appropriate loader.*/, '$1');
535
+ }
521
536
  }
522
537
  }
523
538
  };
@@ -1075,7 +1075,13 @@ module.exports = {
1075
1075
  },
1076
1076
  deduplicateWidgetIds(doc) {
1077
1077
  const seen = new Set();
1078
- self.apos.area.walk(doc, area => {
1078
+ self.apos.area.walk(doc, (area, dotPath) => {
1079
+ if (dotPath.includes('_')) {
1080
+ // Ignore relationships so recursive references from a
1081
+ // doc to itself can't result in random regeneration of
1082
+ // widget ids in the doc proper
1083
+ return;
1084
+ }
1079
1085
  for (const widget of area.items || []) {
1080
1086
  if ((!widget._id) || seen.has(widget._id)) {
1081
1087
  widget._id = cuid();
@@ -23,7 +23,6 @@ import { detectDocChange } from 'Modules/@apostrophecms/schema/lib/detectChange'
23
23
  import AposPublishMixin from 'Modules/@apostrophecms/ui/mixins/AposPublishMixin';
24
24
  import AposArchiveMixin from 'Modules/@apostrophecms/ui/mixins/AposArchiveMixin';
25
25
  import AposModifiedMixin from 'Modules/@apostrophecms/ui/mixins/AposModifiedMixin';
26
- import klona from 'klona';
27
26
 
28
27
  export default {
29
28
  name: 'AposDocContextMenu',
@@ -263,12 +262,6 @@ export default {
263
262
  // moduleOptions gives us the action, etc. but here we need the schema
264
263
  // which is always type specific, even for pages so get it ourselves
265
264
  let schema = (apos.modules[this.context.type].schema || []).filter(field => apos.schema.components.fields[field.type]);
266
- if (this.restoreOnly) {
267
- schema = klona(schema);
268
- for (const field of schema) {
269
- field.readOnly = true;
270
- }
271
- }
272
265
  // Archive UI is handled via action buttons
273
266
  schema = schema.filter(field => field.name !== 'archived');
274
267
  return schema;
@@ -77,7 +77,15 @@
77
77
  // rolling: true,
78
78
  // secret: 'you should have a secret',
79
79
  // name: self.apos.shortName + '.sid',
80
- // cookie: {}
80
+ // cookie: {
81
+ // path: '/',
82
+ // httpOnly: true,
83
+ // secure: false,
84
+ // // using 'strict' will confuse users if you link to your site
85
+ // // with the expectation that the user is still logged in on arrival.
86
+ // // 'lax' still protects against CSRF attacks
87
+ // sameSite: 'lax'
88
+ // }
81
89
  // }
82
90
  // ```
83
91
  //
@@ -98,17 +106,16 @@
98
106
  //
99
107
  // ### `csrf`
100
108
  //
101
- // By default, Apostrophe implements Angular-compatible [CSRF protection](https://en.wikipedia.org/wiki/Cross-site_request_forgery)
102
- // via an `XSRF-TOKEN` cookie. The `@apostrophecms/asset` module pushes
103
- // a call to the browser to set a jQuery `ajaxPrefilter` which
104
- // adds an `X-XSRF-TOKEN` header to all requests, which must
105
- // match the cookie. This is effective because code running from
106
- // other sites or iframes will not be able to read the cookie and
107
- // send the header.
109
+ // By default, Apostrophe implements [CSRF protection](https://en.wikipedia.org/wiki/Cross-site_request_forgery)
110
+ // by setting a cookie with the value `csrf`, which all legitimate requests originating fromt he page will send
111
+ // back (see the [same-origin policy](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy)).
112
+ // All modern browsers will refuse to allow a CSRF attacker, such as a malicious `POST`-method `form` tag on a third
113
+ // party site pointing to an Apostrophe site, to send cookies to the Apostrophe site.
108
114
  //
109
115
  // All non-safe HTTP requests (not `GET`, `HEAD`, `OPTIONS` or `TRACE`)
110
- // automatically receive this proection via the csrf middleware, which
111
- // rejects requests in which the CSRF token does not match the header.
116
+ // automatically receive this protection via the csrf middleware, which
117
+ // rejects requests in which the cookie is not present.
118
+ //
112
119
  // If the request was made with a valid api key or bearer token it
113
120
  // bypasses this check.
114
121
  //
@@ -124,12 +131,6 @@
124
131
  // You may need to use this feature when implementing POST form
125
132
  // submissions that do not use AJAX and thus don't send the header.
126
133
  //
127
- // There is also a `minimumExceptions` option, which defaults
128
- // to `[ /login ]`. The login form is the only non-AJAX form
129
- // that ships with Apostrophe. XSRF protection for login forms
130
- // is unnecessary because the password itself is unknown to the
131
- // third party site; it effectively serves as an XSRF token.
132
- //
133
134
  // ### Adding your own middleware
134
135
  //
135
136
  // Use the `middleware` section in your module. That function should
@@ -323,12 +324,11 @@ module.exports = {
323
324
  },
324
325
  ...((self.options.csrf === false) ? {} : {
325
326
  // Angular-compatible CSRF protection middleware. On safe requests (GET, HEAD, OPTIONS, TRACE),
326
- // set the XSRF-TOKEN cookie if missing. On unsafe requests (everything else),
327
- // make sure our jQuery `ajaxPrefilter` set the X-XSRF-TOKEN header to match the
328
- // cookie.
327
+ // set the csrf cookie if missing.
329
328
  //
330
- // This works because if we're running via a script tag or iframe, we won't
331
- // be able to read the cookie.
329
+ // This works because requests not meeting the expectations of the same-origin policy
330
+ // won't be able to send cookies to the origin at all, even though the value is
331
+ // well-known.
332
332
  csrf(req, res, next) {
333
333
  if (req.csrfExempt) {
334
334
  return next();
@@ -426,7 +426,11 @@ module.exports = {
426
426
  _.defaults(sessionOptions.cookie, {
427
427
  path: '/',
428
428
  httpOnly: true,
429
- secure: false
429
+ secure: false,
430
+ // Ensure that Safari follows the same policy as other modern browsers
431
+ // to prevent CSRF attacks. "lax" just means that navigation links
432
+ // leading to the site will receive the cookie, it is not insecure
433
+ sameSite: 'lax'
430
434
  // maxAge is set for us by connect-mongo,
431
435
  // and defaults to 2 weeks
432
436
  });
@@ -506,28 +510,30 @@ module.exports = {
506
510
  // that this URL should be subject to CSRF.
507
511
 
508
512
  csrfWithoutExceptions(req, res, next) {
509
- let token;
510
513
  // OPTIONS request cannot set a cookie, so manipulating the session here
511
514
  // is not helpful. Do not attempt to set XSRF-TOKEN for OPTIONS
512
515
  if (req.method === 'OPTIONS') {
513
516
  return next();
514
517
  }
515
- // Safe request establishes XSRF-TOKEN in session if not set already
518
+ // Safe request establishes CSRF cookie, whose purpose is only to check
519
+ // that the same-origin policy is followed, not to be unique and secure
520
+ // in itself
516
521
  if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'TRACE') {
517
- token = req.session && req.session['XSRF-TOKEN'];
518
- if (!token) {
519
- token = self.apos.util.generateId();
520
- req.session['XSRF-TOKEN'] = token;
521
- }
522
- // Reset the cookie so that if its lifetime somehow detaches from
523
- // that of the session cookie we're still OK
524
- res.cookie(self.apos.csrfCookieName, token);
522
+ // Use the same standard for the session and CSRF cookies
523
+ res.cookie(self.apos.csrfCookieName, 'csrf', {
524
+ // Will inherit sameSite: 'lax', which is important for
525
+ // CSRF protection in Safari
526
+ ...self.sessionOptions.cookie,
527
+ // 1 year (the limit). The value is known, we are relying
528
+ // on SameSite (modern browsers)
529
+ maxAge: 31536000
530
+ });
525
531
  } else {
526
- // All non-safe requests must be preceded by a safe request that establishes
527
- // the CSRF token, both as a cookie and in the session. Otherwise a user who is logged
528
- // in but doesn't currently have a CSRF token is still vulnerable.
529
- // See options.csrfExceptions
530
- if (!req.cookies[self.apos.csrfCookieName] || req.get('X-XSRF-TOKEN') !== req.cookies[self.apos.csrfCookieName] || req.session['XSRF-TOKEN'] !== req.cookies[self.apos.csrfCookieName]) {
532
+ // Check that the request arrived with the CSRF cookie.
533
+ // This isn't meant to be a unique code that no one could guess,
534
+ // but rather a check that the request from the same origin,
535
+ // as cross-origin requests cannot set cookies on our origin at all.
536
+ if (req.cookies[self.apos.csrfCookieName] !== 'csrf') {
531
537
  res.statusCode = 403;
532
538
  return res.send({
533
539
  name: 'forbidden',
@@ -86,9 +86,6 @@ module.exports = {
86
86
  // `parse` (can be 'json` to always parse the response body as JSON, otherwise the response body is
87
87
  // parsed as JSON only if the content-type is application/json)
88
88
  // `headers` (an object containing header names and values)
89
- // `csrf` (if true, which is the default, and the `jar` contains the CSRF cookie for this Apostrophe site
90
- // due to a previous GET request, send it as the X-XSRF-TOKEN header; if a string, send the current value of the cookie of that name
91
- // in the `jar` as the X-XSRF-TOKEN header; if false, disable this feature)
92
89
  // `fullResponse` (if true, return an object with `status`, `headers` and `body`
93
90
  // properties, rather than returning the body directly; the individual `headers` are canonicalized
94
91
  // to lowercase names. If a header appears multiple times an array is returned for it)
@@ -113,9 +110,6 @@ module.exports = {
113
110
  // `parse` (can be 'json` to always parse the response body as JSON, otherwise the response body is
114
111
  // parsed as JSON only if the content-type is application/json)
115
112
  // `headers` (an object containing header names and values)
116
- // `csrf` (if true, which is the default, and the `jar` contains the CSRF cookie for this Apostrophe site
117
- // due to a previous GET request, send it as the X-XSRF-TOKEN header; if a string, send the current value of the cookie of that name
118
- // in the `jar` as the X-XSRF-TOKEN header; if false, disable this feature)
119
113
  // `fullResponse` (if true, return an object with `status`, `headers` and `body`
120
114
  // properties, rather than returning the body directly; the individual `headers` are canonicalized
121
115
  // to lowercase names. If a header appears multiple times an array is returned for it)
@@ -140,9 +134,6 @@ module.exports = {
140
134
  // `parse` (can be 'json` to always parse the response body as JSON, otherwise the response body is
141
135
  // parsed as JSON only if the content-type is application/json)
142
136
  // `headers` (an object containing header names and values)
143
- // `csrf` (if true, which is the default, and the `jar` contains the CSRF cookie for this Apostrophe site
144
- // due to a previous GET request, send it as the X-XSRF-TOKEN header; if a string, send the current value of the cookie of that name
145
- // in the `jar` as the X-XSRF-TOKEN header; if false, disable this feature)
146
137
  // `fullResponse` (if true, return an object with `status`, `headers` and `body`
147
138
  // properties, rather than returning the body directly; the individual `headers` are canonicalized
148
139
  // to lowercase names. If a header appears multiple times an array is returned for it)
@@ -167,9 +158,6 @@ module.exports = {
167
158
  // `parse` (can be 'json` to always parse the response body as JSON, otherwise the response body is
168
159
  // parsed as JSON only if the content-type is application/json)
169
160
  // `headers` (an object containing header names and values)
170
- // `csrf` (if true, which is the default, and the `jar` contains the CSRF cookie for this Apostrophe site
171
- // due to a previous GET request, send it as the X-XSRF-TOKEN header; if a string, send the current value of the cookie of that name
172
- // in the `jar` as the X-XSRF-TOKEN header; if false, disable this feature)
173
161
  // `fullResponse` (if true, return an object with `status`, `headers` and `body`
174
162
  // properties, rather than returning the body directly; the individual `headers` are canonicalized
175
163
  // to lowercase names. If a header appears multiple times an array is returned for it)
@@ -228,14 +216,6 @@ module.exports = {
228
216
  options.headers = options.headers || {};
229
217
  options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
230
218
  }
231
- if ((options.csrf !== false) && options.jar) {
232
- options.headers = options.headers || {};
233
- const cookieName = ((typeof options.csrf) === 'string') ? options.csrf : self.apos.csrfCookieName;
234
- const cookieValue = self.getCookie(options.jar, url, cookieName);
235
- if (cookieValue != null) {
236
- options.headers['x-xsrf-token'] = cookieValue;
237
- }
238
- }
239
219
  const res = await fetch(url, options);
240
220
  let body;
241
221
  if (options.jar) {
@@ -42,6 +42,7 @@ 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 = {
47
48
  cascades: [ 'requirements' ],
@@ -133,7 +134,16 @@ module.exports = {
133
134
  return req.session.destroy(callback);
134
135
  })();
135
136
  };
137
+ const cookie = req.session.cookie;
136
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'));
137
147
  }
138
148
  },
139
149
  // invokes the `props(req, user)` function for the requirement specified by
@@ -6,8 +6,9 @@
6
6
  :class="themeClass"
7
7
  >
8
8
  <div class="apos-login__wrapper">
9
- <transition name="fade-body">
9
+ <transition name="fade-body" mode="out-in">
10
10
  <div
11
+ key="1"
11
12
  class="apos-login__upper"
12
13
  v-if="loaded && phase === 'beforeSubmit'"
13
14
  >
@@ -18,25 +19,19 @@
18
19
  />
19
20
 
20
21
  <div class="apos-login__body">
21
- <form
22
- @submit.prevent="submit"
23
- >
22
+ <form @submit.prevent="submit">
24
23
  <AposSchema
25
24
  :schema="schema"
26
25
  v-model="doc"
27
26
  />
28
- <!-- Do not ask these components to render without their props,
29
- v-show is not enough -->
30
- <template v-if="loaded">
31
- <Component
32
- v-for="requirement in beforeSubmitRequirements"
33
- :key="requirement.name"
34
- :is="requirement.component"
35
- v-bind="getRequirementProps(requirement.name)"
36
- @done="requirementDone(requirement, $event)"
37
- @block="requirementBlock(requirement)"
38
- />
39
- </template>
27
+ <Component
28
+ v-for="requirement in beforeSubmitRequirements"
29
+ :key="requirement.name"
30
+ :is="requirement.component"
31
+ v-bind="getRequirementProps(requirement.name)"
32
+ @done="requirementDone(requirement, $event)"
33
+ @block="requirementBlock(requirement)"
34
+ />
40
35
  <!-- TODO -->
41
36
  <!-- <a href="#" class="apos-login__link">Forgot Password</a> -->
42
37
  <AposButton
@@ -53,8 +48,9 @@
53
48
  </div>
54
49
  </div>
55
50
  <div
51
+ key="2"
56
52
  class="apos-login__upper"
57
- v-else-if="activeSoloRequirement && !fetchingRequirementProps"
53
+ v-else-if="activeSoloRequirement"
58
54
  >
59
55
  <TheAposLoginHeader
60
56
  :env="context.env"
@@ -64,6 +60,7 @@
64
60
  />
65
61
  <div class="apos-login__body">
66
62
  <Component
63
+ v-if="!fetchingRequirementProps"
67
64
  v-bind="getRequirementProps(activeSoloRequirement.name)"
68
65
  :is="activeSoloRequirement.component"
69
66
  :success="activeSoloRequirement.success"
@@ -392,12 +389,11 @@ function getRequirements() {
392
389
  transition-delay: 0.6s;
393
390
  }
394
391
 
395
- .fade-leave-active {
392
+ .fade-body-leave-active {
396
393
  transition: all 0.25s linear;
397
- transition-delay: 0;
398
394
  }
399
395
 
400
- .fade-body-enter-to,.fade-body-leave {
396
+ .fade-body-enter-to, .fade-body-leave {
401
397
  transform: translateY(0);
402
398
  }
403
399
 
@@ -459,7 +455,7 @@ function getRequirements() {
459
455
  max-width: $login-container;
460
456
  margin: auto;
461
457
  align-items: center;
462
- justify-content: start;
458
+ justify-content: flex-start;
463
459
  }
464
460
 
465
461
  &__project-version {
@@ -50,7 +50,7 @@ export default {
50
50
  display: flex;
51
51
  flex-direction: column;
52
52
  justify-content: center;
53
- align-items: start;
53
+ align-items: flex-start;
54
54
  width: max-content;
55
55
  }
56
56
 
@@ -1,6 +1,7 @@
1
1
  const _ = require('lodash');
2
2
  const path = require('path');
3
3
  const { klona } = require('klona');
4
+ const expressCacheOnDemand = require('express-cache-on-demand')();
4
5
 
5
6
  module.exports = {
6
7
  cascades: [ 'batchOperations' ],
@@ -47,6 +48,9 @@ module.exports = {
47
48
  }
48
49
  },
49
50
  async init(self) {
51
+ const { enableCacheOnDemand = true } = self.apos
52
+ .modules['@apostrophecms/express'].options;
53
+ self.enableCacheOnDemand = enableCacheOnDemand;
50
54
  self.typeChoices = self.options.types || [];
51
55
  // If "park" redeclares something with a parkedId present in "minimumPark",
52
56
  // the later one should win
@@ -58,6 +62,7 @@ module.exports = {
58
62
  await self.createIndexes();
59
63
  },
60
64
  restApiRoutes(self) {
65
+
61
66
  return {
62
67
  // Trees are arranged in a tree, not a list. So this API returns the home page,
63
68
  // with _children populated if ?_children=1 is in the query string. An editor can
@@ -77,95 +82,102 @@ module.exports = {
77
82
  // If querying for draft pages, you may add ?published=1 to attach a
78
83
  // `_publishedDoc` property to each draft that also exists in a published form.
79
84
 
80
- async getAll(req) {
81
- self.publicApiCheck(req);
82
- const all = self.apos.launder.boolean(req.query.all);
83
- const archived = self.apos.launder.booleanOrNull(req.query.archived);
84
- const flat = self.apos.launder.boolean(req.query.flat);
85
- const autocomplete = self.apos.launder.string(req.query.autocomplete);
85
+ getAll: [
86
+ ...self.enableCacheOnDemand ? [ expressCacheOnDemand ] : [],
87
+ async (req) => {
88
+ self.publicApiCheck(req);
89
+ const all = self.apos.launder.boolean(req.query.all);
90
+ const archived = self.apos.launder.booleanOrNull(req.query.archived);
91
+ const flat = self.apos.launder.boolean(req.query.flat);
92
+ const autocomplete = self.apos.launder.string(req.query.autocomplete);
86
93
 
87
- if (autocomplete.length) {
88
- if (!self.apos.permission.can(req, 'edit', '@apostrophecms/any-page-type')) {
89
- throw self.apos.error('forbidden');
94
+ if (autocomplete.length) {
95
+ if (!self.apos.permission.can(req, 'edit', '@apostrophecms/any-page-type')) {
96
+ throw self.apos.error('forbidden');
97
+ }
98
+ return {
99
+ // For consistency with the pieces REST API we
100
+ // use a results property when returning a flat list
101
+ results: await self.getRestQuery(req).limit(10).relationships(false)
102
+ .areas(false).toArray()
103
+ };
90
104
  }
91
- return {
92
- // For consistency with the pieces REST API we
93
- // use a results property when returning a flat list
94
- results: await self.getRestQuery(req).limit(10).relationships(false)
95
- .areas(false).toArray()
96
- };
97
- }
98
105
 
99
- if (all) {
100
- if (!self.apos.permission.can(req, 'edit', '@apostrophecms/any-page-type')) {
101
- throw self.apos.error('forbidden');
102
- }
103
- const page = await self.getRestQuery(req).and({ level: 0 }).children({
104
- depth: 1000,
105
- archived,
106
- orphan: null,
107
- relationships: false,
108
- areas: false,
109
- permission: false,
110
- withPublished: self.apos.launder.boolean(req.query.withPublished),
111
- project: self.getAllProjection()
112
- }).toObject();
106
+ if (all) {
107
+ if (!self.apos.permission.can(req, 'edit', '@apostrophecms/any-page-type')) {
108
+ throw self.apos.error('forbidden');
109
+ }
110
+ const page = await self.getRestQuery(req).and({ level: 0 }).children({
111
+ depth: 1000,
112
+ archived,
113
+ orphan: null,
114
+ relationships: false,
115
+ areas: false,
116
+ permission: false,
117
+ withPublished: self.apos.launder.boolean(req.query.withPublished),
118
+ project: self.getAllProjection()
119
+ }).toObject();
113
120
 
114
- if (!page) {
115
- throw self.apos.error('notfound');
116
- }
121
+ if (!page) {
122
+ throw self.apos.error('notfound');
123
+ }
117
124
 
118
- if (flat) {
119
- const result = [];
120
- flatten(result, page);
125
+ if (flat) {
126
+ const result = [];
127
+ flatten(result, page);
121
128
 
122
- return {
123
- // For consistency with the pieces REST API we
124
- // use a results property when returning a flat list
125
- results: result
126
- };
129
+ return {
130
+ // For consistency with the pieces REST API we
131
+ // use a results property when returning a flat list
132
+ results: result
133
+ };
134
+ } else {
135
+ return page;
136
+ }
127
137
  } else {
128
- return page;
129
- }
130
- } else {
131
- const result = await self.getRestQuery(req).and({ level: 0 }).toObject();
132
- if (!result) {
133
- throw self.apos.error('notfound');
134
- }
138
+ const result = await self.getRestQuery(req).and({ level: 0 }).toObject();
139
+ if (!result) {
140
+ throw self.apos.error('notfound');
141
+ }
135
142
 
136
- // Attach `_url` and `_urls` properties to the home page
137
- self.apos.attachment.all(result, { annotate: true });
138
- return result;
139
- }
143
+ // Attach `_url` and `_urls` properties to the home page
144
+ self.apos.attachment.all(result, { annotate: true });
145
+ return result;
146
+ }
140
147
 
141
- function flatten(result, node) {
142
- const children = node._children;
143
- node._children = _.map(node._children, '_id');
144
- result.push(node);
145
- _.each(children || [], function(child) {
146
- flatten(result, child);
147
- });
148
+ function flatten(result, node) {
149
+ const children = node._children;
150
+ node._children = _.map(node._children, '_id');
151
+ result.push(node);
152
+ _.each(children || [], function(child) {
153
+ flatten(result, child);
154
+ });
148
155
 
156
+ }
149
157
  }
150
- },
158
+ ],
151
159
  // _id may be a page _id, or the convenient shorthands
152
160
  // `_home` or `_archive`
153
- async getOne(req, _id) {
154
- _id = self.inferIdLocaleAndMode(req, _id);
155
- // Edit access to draft is sufficient to fetch either
156
- self.publicApiCheck(req);
157
- const criteria = self.getIdCriteria(_id);
158
- const result = await self.getRestQuery(req).and(criteria).toObject();
159
- if (!result) {
160
- throw self.apos.error('notfound');
161
- }
162
- if (self.apos.launder.boolean(req.query['render-areas']) === true) {
163
- await self.apos.area.renderDocsAreas(req, [ result ]);
161
+
162
+ getOne: [
163
+ ...self.enableCacheOnDemand ? [ expressCacheOnDemand ] : [],
164
+ async (req, _id) => {
165
+ _id = self.inferIdLocaleAndMode(req, _id);
166
+ // Edit access to draft is sufficient to fetch either
167
+ self.publicApiCheck(req);
168
+ const criteria = self.getIdCriteria(_id);
169
+ const result = await self.getRestQuery(req).and(criteria).toObject();
170
+ if (!result) {
171
+ throw self.apos.error('notfound');
172
+ }
173
+ if (self.apos.launder.boolean(req.query['render-areas']) === true) {
174
+ await self.apos.area.renderDocsAreas(req, [ result ]);
175
+ }
176
+ // Attach `_url` and `_urls` properties
177
+ self.apos.attachment.all(result, { annotate: true });
178
+ return result;
164
179
  }
165
- // Attach `_url` and `_urls` properties
166
- self.apos.attachment.all(result, { annotate: true });
167
- return result;
168
- },
180
+ ],
169
181
  // POST a new page to the site. The schema fields should be part of the JSON request body.
170
182
  //
171
183
  // You may pass `_targetId` and `_position` to specify the location in the page tree.
@@ -576,7 +588,14 @@ database.`);
576
588
  },
577
589
  'apostrophe:ready': {
578
590
  addServeRoute() {
579
- self.apos.app.get('*', self.serve);
591
+ self.apos.app.get('*',
592
+ (req, res, next) => {
593
+ return self.enableCacheOnDemand
594
+ ? expressCacheOnDemand(req, res, next)
595
+ : next();
596
+ },
597
+ self.serve
598
+ );
580
599
  }
581
600
  }
582
601
  };
@@ -1298,9 +1317,6 @@ database.`);
1298
1317
  if (!options) {
1299
1318
  options = {};
1300
1319
  }
1301
- const manager = self.apos.doc.getManager(page.type);
1302
- await manager.emit('beforeUpdate', req, page, options);
1303
- await manager.emit('beforeSave', req, page, options);
1304
1320
  await self.apos.doc.update(req, page, options);
1305
1321
  return page;
1306
1322
  },
@@ -220,7 +220,7 @@ module.exports = {
220
220
  if (!page) {
221
221
  return false;
222
222
  }
223
- return page._url + '/' + piece.slug;
223
+ return self.apos.util.addSlashIfNeeded(page._url) + piece.slug;
224
224
  },
225
225
 
226
226
  // Adds the `._url` property to all of the provided pieces,
@@ -1,4 +1,5 @@
1
1
  const _ = require('lodash');
2
+ const expressCacheOnDemand = require('express-cache-on-demand')();
2
3
 
3
4
  module.exports = {
4
5
  extend: '@apostrophecms/doc-type',
@@ -164,79 +165,91 @@ module.exports = {
164
165
  self.addManagerModal();
165
166
  self.addEditorModal();
166
167
  },
167
- restApiRoutes: (self) => ({
168
- async getAll(req) {
169
- self.publicApiCheck(req);
170
- const query = self.getRestQuery(req);
171
- if (!query.get('perPage')) {
172
- query.perPage(
173
- self.options.perPage
174
- );
175
- }
176
- const result = {};
177
- // Also populates totalPages when perPage is present
178
- const count = await query.toCount();
179
- if (self.apos.launder.boolean(req.query.count)) {
180
- return {
181
- count
182
- };
183
- }
184
- result.pages = query.get('totalPages');
185
- result.currentPage = query.get('page') || 1;
186
- result.results = await query.toArray();
187
- if (self.apos.launder.boolean(req.query['render-areas']) === true) {
188
- await self.apos.area.renderDocsAreas(req, result.results);
189
- }
190
- if (query.get('choicesResults')) {
191
- result.choices = query.get('choicesResults');
192
- }
193
- if (query.get('countsResults')) {
194
- result.counts = query.get('countsResults');
195
- }
196
- return result;
197
- },
198
- async getOne(req, _id) {
199
- _id = self.inferIdLocaleAndMode(req, _id);
200
- self.publicApiCheck(req);
201
- const doc = await self.getRestQuery(req).and({ _id }).toObject();
202
- if (!doc) {
203
- throw self.apos.error('notfound');
204
- }
205
- if (self.apos.launder.boolean(req.query['render-areas']) === true) {
206
- await self.apos.area.renderDocsAreas(req, [ doc ]);
207
- }
208
- self.apos.attachment.all(doc, { annotate: true });
209
- return doc;
210
- },
211
- async post(req) {
212
- self.publicApiCheck(req);
213
- if (req.body._newInstance) {
214
- const newInstance = self.newInstance();
215
- newInstance._previewable = self.addUrlsViaModule && (await self.addUrlsViaModule.readyToAddUrlsToPieces(req, self.name));
216
- delete newInstance._url;
217
- return newInstance;
168
+ restApiRoutes(self) {
169
+ const { enableCacheOnDemand = true } = self.apos
170
+ .modules['@apostrophecms/express'].options;
171
+
172
+ return {
173
+ getAll: [
174
+ ...enableCacheOnDemand ? [ expressCacheOnDemand ] : [],
175
+ async (req) => {
176
+ self.publicApiCheck(req);
177
+ const query = self.getRestQuery(req);
178
+ if (!query.get('perPage')) {
179
+ query.perPage(
180
+ self.options.perPage
181
+ );
182
+ }
183
+ const result = {};
184
+ // Also populates totalPages when perPage is present
185
+ const count = await query.toCount();
186
+ if (self.apos.launder.boolean(req.query.count)) {
187
+ return {
188
+ count
189
+ };
190
+ }
191
+ result.pages = query.get('totalPages');
192
+ result.currentPage = query.get('page') || 1;
193
+ result.results = await query.toArray();
194
+ if (self.apos.launder.boolean(req.query['render-areas']) === true) {
195
+ await self.apos.area.renderDocsAreas(req, result.results);
196
+ }
197
+ if (query.get('choicesResults')) {
198
+ result.choices = query.get('choicesResults');
199
+ }
200
+ if (query.get('countsResults')) {
201
+ result.counts = query.get('countsResults');
202
+ }
203
+ return result;
204
+ }
205
+ ],
206
+ getOne: [
207
+ ...enableCacheOnDemand ? [ expressCacheOnDemand ] : [],
208
+ async (req, _id) => {
209
+ _id = self.inferIdLocaleAndMode(req, _id);
210
+ self.publicApiCheck(req);
211
+ const doc = await self.getRestQuery(req).and({ _id }).toObject();
212
+ if (!doc) {
213
+ throw self.apos.error('notfound');
214
+ }
215
+ if (self.apos.launder.boolean(req.query['render-areas']) === true) {
216
+ await self.apos.area.renderDocsAreas(req, [ doc ]);
217
+ }
218
+ self.apos.attachment.all(doc, { annotate: true });
219
+ return doc;
220
+ }
221
+ ],
222
+ async post(req) {
223
+ self.publicApiCheck(req);
224
+ if (req.body._newInstance) {
225
+ const newInstance = self.newInstance();
226
+ newInstance._previewable = self.addUrlsViaModule && (await self.addUrlsViaModule.readyToAddUrlsToPieces(req, self.name));
227
+ delete newInstance._url;
228
+ return newInstance;
229
+ }
230
+ return await self.convertInsertAndRefresh(req, req.body);
231
+ },
232
+ async put(req, _id) {
233
+ _id = self.inferIdLocaleAndMode(req, _id);
234
+ self.publicApiCheck(req);
235
+ return self.convertUpdateAndRefresh(req, req.body, _id);
236
+ },
237
+ async delete(req, _id) {
238
+ _id = self.inferIdLocaleAndMode(req, _id);
239
+ self.publicApiCheck(req);
240
+ const piece = await self.findOneForEditing(req, {
241
+ _id
242
+ });
243
+ return self.delete(req, piece);
244
+ },
245
+ async patch(req, _id) {
246
+ _id = self.inferIdLocaleAndMode(req, _id);
247
+ self.publicApiCheck(req);
248
+ return self.convertPatchAndRefresh(req, req.body, _id);
218
249
  }
219
- return await self.convertInsertAndRefresh(req, req.body);
220
- },
221
- async put(req, _id) {
222
- _id = self.inferIdLocaleAndMode(req, _id);
223
- self.publicApiCheck(req);
224
- return self.convertUpdateAndRefresh(req, req.body, _id);
225
- },
226
- async delete(req, _id) {
227
- _id = self.inferIdLocaleAndMode(req, _id);
228
- self.publicApiCheck(req);
229
- const piece = await self.findOneForEditing(req, {
230
- _id
231
- });
232
- return self.delete(req, piece);
233
- },
234
- async patch(req, _id) {
235
- _id = self.inferIdLocaleAndMode(req, _id);
236
- self.publicApiCheck(req);
237
- return self.convertPatchAndRefresh(req, req.body, _id);
238
- }
239
- }),
250
+ };
251
+
252
+ },
240
253
  apiRoutes(self) {
241
254
  return {
242
255
  get: {
@@ -65,7 +65,6 @@ export default () => {
65
65
  // parsed as JSON only if the content-type is application/json)
66
66
  // `headers` (an object containing header names and values)
67
67
  // `draft` (if true, always add aposMode=draft to the query string, creating one if needed)
68
- // `csrf` (unless explicitly set to `false`, send the X-XSRF-TOKEN header when talking to the same site)
69
68
  // `fullResponse` (if true, return an object with `status`, `headers` and `body`
70
69
  // properties, rather than returning the body directly; the individual `headers` are canonicalized
71
70
  // to lowercase names. If there are duplicate headers after canonicalization only the
@@ -140,10 +139,10 @@ export default () => {
140
139
 
141
140
  const busyName = options.busy === true ? 'busy' : options.busy;
142
141
  const xmlhttp = new XMLHttpRequest();
143
- const csrfToken = apos.csrfCookieName ? apos.util.getCookie(apos.csrfCookieName) : 'csrf-fallback';
144
142
  let data = options.body;
145
143
  let keys;
146
144
  let i;
145
+
147
146
  if (options.qs) {
148
147
  url = apos.http.addQueryToUrl(url, options.qs);
149
148
  }
@@ -166,11 +165,6 @@ export default () => {
166
165
  if (sendJson) {
167
166
  xmlhttp.setRequestHeader('Content-Type', 'application/json');
168
167
  }
169
- if (csrfToken && (options.csrf !== false)) {
170
- if (apos.util.sameSite(url)) {
171
- xmlhttp.setRequestHeader('X-XSRF-TOKEN', csrfToken);
172
- }
173
- }
174
168
  if (options.headers) {
175
169
  keys = Object.keys(options.headers);
176
170
  for (i = 0; (i < keys.length); i++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "3.13.0",
3
+ "version": "3.14.0",
4
4
  "description": "The Apostrophe Content Management System.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -61,6 +61,7 @@
61
61
  "eslint-plugin-promise": "^5.1.0",
62
62
  "express": "^4.16.4",
63
63
  "express-bearer-token": "^2.4.0",
64
+ "express-cache-on-demand": "^1.0.3",
64
65
  "express-session": "^1.17.1",
65
66
  "form-data": "^4.0.0",
66
67
  "fs-extra": "^7.0.1",
package/test/express.js CHANGED
@@ -39,37 +39,13 @@ describe('Express', function() {
39
39
  assert(body.toString() === 'ok');
40
40
  });
41
41
 
42
- it('should flunk a POST request with no X-XSRF-TOKEN header', async function() {
42
+ it('should flunk a POST request without the CSRF cookie', async function() {
43
43
  try {
44
44
  await apos.http.post('/tests/body', {
45
45
  body: {
46
46
  person: {
47
47
  age: '30'
48
48
  }
49
- },
50
- jar,
51
- csrf: false
52
- });
53
- assert(false);
54
- } catch (e) {
55
- assert(e);
56
- }
57
- });
58
-
59
- it('should flunk a POST request with the wrong CSRF token', async function() {
60
- const csrfToken = 'BOGOSITY';
61
-
62
- try {
63
- await apos.http.post('/tests/body', {
64
- body: {
65
- person: {
66
- age: '30'
67
- }
68
- },
69
- jar,
70
- csrf: false,
71
- headers: {
72
- 'X-XSRF-TOKEN': csrfToken
73
49
  }
74
50
  });
75
51
  assert(false);
@@ -78,7 +54,7 @@ describe('Express', function() {
78
54
  }
79
55
  });
80
56
 
81
- it('should use the extended bodyParser for submitted forms', async function() {
57
+ it('should use the extended bodyParser for submitted forms, and pass CSRF with the cookie', async function() {
82
58
 
83
59
  const response = await apos.http.post('/tests/body', {
84
60
  send: 'form',
package/test/http.js CHANGED
@@ -40,30 +40,6 @@ describe('Http', function() {
40
40
  assert(result.match(/logged out/));
41
41
  });
42
42
 
43
- it('should not be able to make an http POST request without csrf header', async () => {
44
- try {
45
- await apos.http.post('/csrf-test', {
46
- jar,
47
- csrf: false
48
- });
49
- assert(false);
50
- } catch (e) {
51
- assert(e.status === 403);
52
- }
53
- });
54
-
55
- it('should be able to make an http POST request with manually built csrf header', async () => {
56
- const response = await apos.http.post('/csrf-test?manual=1', {
57
- jar,
58
- headers: {
59
- 'X-XSRF-TOKEN': apos.http.getCookie(jar, '/', `${apos.options.shortName}.csrf`)
60
- },
61
- body: {},
62
- csrf: false
63
- });
64
- assert(response.ok === true);
65
- });
66
-
67
43
  it('should be able to make an http POST request with csrf header via default csrf convenience of http.post', async () => {
68
44
  const response = await apos.http.post('/csrf-test', {
69
45
  jar,
@@ -34,6 +34,24 @@ describe('Pieces Pages', function() {
34
34
  perPage: 10
35
35
  }
36
36
  },
37
+ home: {
38
+ extend: '@apostrophecms/piece-type',
39
+ options: {
40
+ name: 'home',
41
+ label: 'Home',
42
+ alias: 'home',
43
+ sort: { title: 1 }
44
+ }
45
+ },
46
+ 'home-page': {
47
+ extend: '@apostrophecms/piece-page-type',
48
+ options: {
49
+ name: 'homePiecePage',
50
+ label: 'Home Piece Page',
51
+ alias: 'homePiecePage',
52
+ perPage: 10
53
+ }
54
+ },
37
55
  '@apostrophecms/page': {
38
56
  options: {
39
57
  park: [
@@ -42,6 +60,12 @@ describe('Pieces Pages', function() {
42
60
  type: 'eventPage',
43
61
  slug: '/events',
44
62
  parkedId: 'events'
63
+ },
64
+ {
65
+ title: 'Home piece page',
66
+ type: 'homePiecePage',
67
+ slug: '/',
68
+ parkedId: 'home'
45
69
  }
46
70
  ]
47
71
  }
@@ -81,6 +105,36 @@ describe('Pieces Pages', function() {
81
105
  return apos.doc.db.insertMany(testItems);
82
106
  });
83
107
 
108
+ it('should be able to use db to insert test "home" pieces', async function() {
109
+ assert(apos.modules.home);
110
+ const testItems = [];
111
+ const total = 100;
112
+ for (let i = 1; (i <= total); i++) {
113
+ const paddedInt = apos.launder.padInteger(i, 3);
114
+
115
+ testItems.push({
116
+ _id: 'home' + paddedInt,
117
+ slug: 'home-' + paddedInt,
118
+ visibility: 'public',
119
+ type: 'home',
120
+ title: 'Home ' + paddedInt,
121
+ titleSortified: 'home ' + paddedInt,
122
+ body: {
123
+ metaType: 'area',
124
+ _id: apos.util.generateId(),
125
+ items: [
126
+ {
127
+ metaType: 'widget',
128
+ type: '@apostrophecms/rich-text',
129
+ content: '<p>This is some content.</p>'
130
+ }
131
+ ]
132
+ }
133
+ });
134
+ }
135
+
136
+ return apos.doc.db.insertMany(testItems);
137
+ });
84
138
  it('should populate the ._url property of pieces in any docs query', async function() {
85
139
  const piece = await apos.doc.find(apos.task.getAnonReq(), {
86
140
  type: 'event',
@@ -104,6 +158,15 @@ describe('Pieces Pages', function() {
104
158
  assert((!piece._url) || (piece._url.match(/undefined/)));
105
159
  });
106
160
 
161
+ it('should not create a double-slashed _url on a piece-page-type set as the homepage', async function() {
162
+ const piece = await apos.doc.find(apos.task.getAnonReq(), {
163
+ type: 'home',
164
+ title: 'Home 001'
165
+ }).toObject();
166
+ assert(piece);
167
+ assert(piece._url === '/home-001');
168
+ });
169
+
107
170
  it('should correctly populate the ._url property of pieces in a docs query if _url itself is "projected"', async function() {
108
171
  const piece = await apos.doc.find(apos.task.getAnonReq(), {
109
172
  type: 'event',