apostrophe 3.13.0 → 3.14.2-alpha.20220401

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.
@@ -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.2-alpha.20220401",
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",
@@ -136,4 +137,4 @@
136
137
  "browserslist": [
137
138
  "ie >= 10"
138
139
  ]
139
- }
140
+ }
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,
@@ -4,6 +4,8 @@ const assert = require('assert');
4
4
  let apos;
5
5
 
6
6
  describe('Login', function() {
7
+ const extraSecretErr = 'extra secret incorrect';
8
+ const captchaErr = 'captcha code incorrect';
7
9
 
8
10
  this.timeout(20000);
9
11
 
@@ -30,7 +32,7 @@ describe('Login', function() {
30
32
  },
31
33
  async verify(req, data) {
32
34
  if (data !== 'xyz') {
33
- throw self.apos.error('invalid', 'captcha code incorrect');
35
+ throw self.apos.error('invalid', captchaErr);
34
36
  }
35
37
  }
36
38
  },
@@ -45,7 +47,7 @@ describe('Login', function() {
45
47
  },
46
48
  async verify(req, data, user) {
47
49
  if (data !== user.extraSecret) {
48
- throw self.apos.error('invalid', 'extra secret incorrect');
50
+ throw self.apos.error('invalid', extraSecretErr);
49
51
  }
50
52
  }
51
53
  }
@@ -119,7 +121,7 @@ describe('Login', function() {
119
121
  assert(false);
120
122
  } catch (e) {
121
123
  assert(e.status === 400);
122
- assert.strictEqual(e.body.message, 'captcha code incorrect');
124
+ assert.strictEqual(e.body.message, captchaErr);
123
125
  assert.strictEqual(e.body.data.requirement, 'WeakCaptcha');
124
126
  }
125
127
 
@@ -167,7 +169,7 @@ describe('Login', function() {
167
169
  assert(false);
168
170
  } catch (e) {
169
171
  assert(e.status === 400);
170
- assert.strictEqual(e.body.message, 'captcha code incorrect');
172
+ assert.strictEqual(e.body.message, captchaErr);
171
173
  assert.strictEqual(e.body.data.requirement, 'WeakCaptcha');
172
174
  }
173
175
 
@@ -182,7 +184,10 @@ describe('Login', function() {
182
184
  assert(page.match(/logged out/));
183
185
  });
184
186
 
185
- it('initial login should produce an incompleteToken, convertible with the afterPasswordVerified requirements', async function() {
187
+ it('should throttle requirements verify attemps and show a proper error when the limit is reached', async function () {
188
+ const loginModule = apos.modules['@apostrophecms/login'];
189
+ const { allowedAttempts } = loginModule.options.throttle;
190
+ const namespace = '@apostrophecms/loginAttempt/ExtraSecret';
186
191
 
187
192
  const jar = apos.http.jar();
188
193
 
@@ -224,6 +229,71 @@ describe('Login', function() {
224
229
 
225
230
  assert(page.match(/logged out/));
226
231
 
232
+ const token = result.incompleteToken;
233
+
234
+ for (let index = 0; index <= allowedAttempts; index++) {
235
+ try {
236
+ await apos.http.post('/api/v1/@apostrophecms/login/requirement-verify', {
237
+ body: {
238
+ incompleteToken: token,
239
+ session: true,
240
+ name: 'ExtraSecret',
241
+ value: 'roll-off'
242
+ },
243
+ jar
244
+ });
245
+ } catch ({ status, body }) {
246
+ if (index < allowedAttempts) {
247
+ assert(body.message === extraSecretErr);
248
+ } else {
249
+ assert(body.message === 'Too many attempts. You may try again in a minute.');
250
+ }
251
+ }
252
+ }
253
+
254
+ await loginModule.clearLoginAttempts('HarryPutter', namespace);
255
+ });
256
+
257
+ it('initial login should produce an incompleteToken, convertible with the afterPasswordVerified requirements', async function() {
258
+
259
+ const jar = apos.http.jar();
260
+
261
+ // establish session
262
+ let page = await apos.http.get(
263
+ '/',
264
+ {
265
+ jar
266
+ }
267
+ );
268
+
269
+ const result = await apos.http.post(
270
+ '/api/v1/@apostrophecms/login/login',
271
+ {
272
+ method: 'POST',
273
+ body: {
274
+ username: 'HarryPutter',
275
+ password: 'crookshanks',
276
+ session: true,
277
+ requirements: {
278
+ WeakCaptcha: 'xyz'
279
+ }
280
+ },
281
+ jar
282
+ }
283
+ );
284
+
285
+ assert(result.incompleteToken);
286
+
287
+ // Make sure it did not create a login session prematurely
288
+ page = await apos.http.get(
289
+ '/',
290
+ {
291
+ jar
292
+ }
293
+ );
294
+
295
+ assert(page.match(/logged out/));
296
+
227
297
  // Make sure it won't convert with an incorrect ExtraSecret
228
298
 
229
299
  const token = result.incompleteToken;
@@ -240,7 +310,7 @@ describe('Login', function() {
240
310
  });
241
311
  } catch ({ status, body }) {
242
312
  assert(status === 400);
243
- assert.strictEqual(body.message, 'extra secret incorrect');
313
+ assert.strictEqual(body.message, extraSecretErr);
244
314
  assert.strictEqual(body.data.requirement, 'ExtraSecret');
245
315
  }
246
316
 
package/test/login.js CHANGED
@@ -41,6 +41,48 @@ describe('Login', function() {
41
41
  assert(doc._id);
42
42
  });
43
43
 
44
+ it('should throttle login attempts and show a proper error when the limit is reached', async function () {
45
+ const loginModule = apos.modules['@apostrophecms/login'];
46
+ const { allowedAttempts } = loginModule.options.throttle;
47
+ const jar = apos.http.jar();
48
+ const username = 'HarryPutter';
49
+ // establish session
50
+ const page = await apos.http.get(
51
+ '/',
52
+ {
53
+ jar
54
+ }
55
+ );
56
+
57
+ assert(page.match(/logged out/));
58
+
59
+ for (let index = 0; index <= allowedAttempts; index++) {
60
+ try {
61
+ await apos.http.post(
62
+ '/api/v1/@apostrophecms/login/login',
63
+ {
64
+ method: 'POST',
65
+ body: {
66
+ username,
67
+ password: 'badpassword',
68
+ session: true
69
+ },
70
+ jar
71
+ }
72
+ );
73
+
74
+ } catch ({ body }) {
75
+ if (index < allowedAttempts) {
76
+ assert(body.message === 'Your credentials are incorrect, or there is no such user');
77
+ } else {
78
+ assert(body.message === 'Too many attempts. You may try again in a minute.');
79
+ }
80
+ }
81
+ }
82
+
83
+ await loginModule.clearLoginAttempts(username);
84
+ });
85
+
44
86
  it('should be able to login a user with their username', async function() {
45
87
 
46
88
  const jar = apos.http.jar();
@@ -1,7 +1,36 @@
1
1
  const t = require('../test-lib/test.js');
2
2
  const assert = require('assert');
3
3
 
4
- let apos, apos2;
4
+ let apos, apos2, apos3, apos4, apos5, apos6;
5
+
6
+ const park2 = [
7
+ {
8
+ slug: '/',
9
+ parkedId: 'home',
10
+ _defaults: {
11
+ title: 'Home',
12
+ type: 'default-page'
13
+ },
14
+ _children: [
15
+ {
16
+ slug: '/default1',
17
+ parkedId: 'default1',
18
+ _defaults: {
19
+ type: 'default-page',
20
+ title: 'Default 1'
21
+ }
22
+ },
23
+ {
24
+ slug: '/default2',
25
+ parkedId: 'default2',
26
+ _defaults: {
27
+ type: 'default-page',
28
+ title: 'Default 2'
29
+ }
30
+ }
31
+ ]
32
+ }
33
+ ];
5
34
 
6
35
  describe('Parked Pages', function() {
7
36
 
@@ -10,12 +39,21 @@ describe('Parked Pages', function() {
10
39
  after(async function() {
11
40
  await t.destroy(apos);
12
41
  await t.destroy(apos2);
42
+ await t.destroy(apos3);
43
+ await t.destroy(apos4);
44
+ await t.destroy(apos5);
45
+ await t.destroy(apos6);
13
46
  });
14
47
 
15
- it('standard parked pages should be as expected', async function() {
48
+ it('standard and custom parked pages should be as expected', async function() {
49
+ this.timeout(20000);
16
50
  apos = await t.create({
17
51
  root: module,
18
- modules: {}
52
+ modules: {
53
+ 'default-page': {
54
+ extend: '@apostrophecms/page-type'
55
+ }
56
+ }
19
57
  });
20
58
  const req = apos.task.getReq();
21
59
  const home = await apos.page.find(req, { slug: '/' }).toObject();
@@ -29,21 +67,13 @@ describe('Parked Pages', function() {
29
67
  });
30
68
 
31
69
  it('overridden home page should work without disturbing archive', async function() {
70
+ this.timeout(20000);
32
71
  apos2 = await t.create({
33
72
  root: module,
34
73
  modules: {
35
74
  '@apostrophecms/page': {
36
75
  options: {
37
- park: [
38
- {
39
- slug: '/',
40
- parkedId: 'home',
41
- _defaults: {
42
- title: 'Home',
43
- type: 'default-page'
44
- }
45
- }
46
- ]
76
+ park: park2
47
77
  }
48
78
  },
49
79
  'default-page': {}
@@ -58,5 +88,246 @@ describe('Parked Pages', function() {
58
88
  assert(archive);
59
89
  assert(archive.parkedId === 'archive');
60
90
  assert(archive.type === '@apostrophecms/archive-page');
91
+ await apos2.db.collection('verify').insertOne({
92
+ checkSameDb: true
93
+ });
94
+ });
95
+
96
+ it('all pages should have consistent aposDocId across draft and published', async function() {
97
+ this.timeout(20000);
98
+ await validate(apos2, [ '/', '/archive', '/default1', '/default2' ]);
99
+ });
100
+
101
+ it('third apos object should park a third child correctly when spinning up later on existing db', async function() {
102
+ this.timeout(20000);
103
+ apos3 = await t.create({
104
+ root: module,
105
+ modules: {
106
+ '@apostrophecms/page': {
107
+ options: {
108
+ park: [
109
+ ...park2,
110
+ {
111
+ slug: '/default3',
112
+ parkedId: 'default3',
113
+ _defaults: {
114
+ type: 'default-page',
115
+ title: 'Default 3'
116
+ }
117
+ }
118
+ ]
119
+ }
120
+ },
121
+ 'default-page': {}
122
+ },
123
+ shortName: apos2.options.shortName
124
+ });
125
+ // prove apos2 and apos3 are talking to the same db and it hasn't been erased
126
+ assert(await apos3.db.collection('verify').findOne({
127
+ checkSameDb: true
128
+ }));
61
129
  });
130
+
131
+ it('all pages should have consistent aposDocId across draft and published', async function() {
132
+ await validate(apos3, [ '/', '/archive', '/default1', '/default2', '/default3' ]);
133
+ // Should be same db, make sure of that
134
+ await validate(apos2, [ '/', '/archive', '/default1', '/default2', '/default3' ]);
135
+ });
136
+
137
+ it('nested parked children work', async function() {
138
+ this.timeout(20000);
139
+ apos4 = await t.create({
140
+ root: module,
141
+ modules: {
142
+ '@apostrophecms/page': {
143
+ options: {
144
+ park: [
145
+ ...park2,
146
+ {
147
+ slug: '/default3',
148
+ parkedId: 'default3',
149
+ _defaults: {
150
+ type: 'default-page',
151
+ title: 'Default 3'
152
+ },
153
+ _children: [
154
+ {
155
+ slug: '/default3/child1',
156
+ parkedId: 'default3child1',
157
+ _defaults: {
158
+ type: 'default-page',
159
+ title: 'Default 3 Child 1'
160
+ }
161
+ }
162
+ ]
163
+ }
164
+ ]
165
+ }
166
+ },
167
+ 'default-page': {}
168
+ },
169
+ shortName: apos2.options.shortName
170
+ });
171
+ await validate(apos4, [ '/', '/archive', '/default1', '/default2', '/default3', '/default3/child1' ]);
172
+ });
173
+
174
+ it('nested parked children work across locales if locales are added later', async function() {
175
+ this.timeout(20000);
176
+ apos5 = await t.create({
177
+ root: module,
178
+ modules: {
179
+ '@apostrophecms/i18n': {
180
+ options: {
181
+ locales: {
182
+ en: {
183
+ label: 'English',
184
+ hostname: 'en'
185
+ },
186
+ fr: {
187
+ label: 'French',
188
+ hostname: 'fr'
189
+ }
190
+ }
191
+ }
192
+ },
193
+ '@apostrophecms/page': {
194
+ options: {
195
+ park: [
196
+ ...park2,
197
+ {
198
+ slug: '/default3',
199
+ parkedId: 'default3',
200
+ _defaults: {
201
+ type: 'default-page',
202
+ title: 'Default 3'
203
+ },
204
+ _children: [
205
+ {
206
+ slug: '/default3/child1',
207
+ parkedId: 'default3child1',
208
+ _defaults: {
209
+ type: 'default-page',
210
+ title: 'Default 3 Child 1'
211
+ }
212
+ }
213
+ ]
214
+ }
215
+ ]
216
+ }
217
+ },
218
+ 'default-page': {}
219
+ },
220
+ shortName: apos4.options.shortName
221
+ });
222
+ await validate(apos5, [ '/', '/archive', '/default1', '/default2', '/default3', '/default3/child1' ]);
223
+ });
224
+ });
225
+
226
+ it('nested parked children work across locales if locales are present from the start', async function() {
227
+ this.timeout(20000);
228
+ apos6 = await t.create({
229
+ root: module,
230
+ modules: {
231
+ '@apostrophecms/i18n': {
232
+ options: {
233
+ locales: {
234
+ en: {
235
+ label: 'English',
236
+ hostname: 'en'
237
+ },
238
+ fr: {
239
+ label: 'French',
240
+ hostname: 'fr'
241
+ }
242
+ }
243
+ }
244
+ },
245
+ '@apostrophecms/page': {
246
+ options: {
247
+ park: [
248
+ ...park2,
249
+ {
250
+ slug: '/default3',
251
+ parkedId: 'default3',
252
+ _defaults: {
253
+ type: 'default-page',
254
+ title: 'Default 3'
255
+ },
256
+ _children: [
257
+ {
258
+ slug: '/default3/child1',
259
+ parkedId: 'default3child1',
260
+ _defaults: {
261
+ type: 'default-page',
262
+ title: 'Default 3 Child 1'
263
+ }
264
+ }
265
+ ]
266
+ }
267
+ ]
268
+ }
269
+ },
270
+ 'default-page': {}
271
+ }
272
+ });
273
+ await validate(apos6, [ '/', '/archive', '/default1', '/default2', '/default3', '/default3/child1' ]);
62
274
  });
275
+
276
+ async function validate(apos, expected) {
277
+ const locales = Object.keys(apos.i18n.locales);
278
+ const slugs = await apos.doc.db.distinct('slug', {
279
+ slug: /^\//
280
+ });
281
+ slugs.sort();
282
+ assert.deepStrictEqual(slugs, expected);
283
+ const pages = await apos.doc.db.find({
284
+ slug: /^\//
285
+ }).toArray();
286
+ assert(pages.length === slugs.length * 2 * locales.length);
287
+ for (const slug of slugs) {
288
+ const matches = pages.filter(page => page.slug === slug);
289
+ matches.sort((p1, p2) => {
290
+ if (p1.aposLocale < p2.aposLocale) {
291
+ return -1;
292
+ } else if (p1.aposLocale > p2.aposLocale) {
293
+ return 1;
294
+ } else {
295
+ return 0;
296
+ }
297
+ });
298
+ assert.strictEqual(matches.length, 2 * locales.length);
299
+ let i = 0;
300
+ let aposDocId;
301
+ for (const locale of locales) {
302
+ const draft = i;
303
+ const published = i + 1;
304
+ assert.strictEqual(matches[draft].aposLocale, `${locale}:draft`);
305
+ assert.strictEqual(matches[published].aposLocale, `${locale}:published`);
306
+ assert(matches[draft].aposDocId);
307
+ assert.strictEqual(matches[draft].aposDocId, matches[published].aposDocId);
308
+ if (!aposDocId) {
309
+ aposDocId = matches[draft].aposDocId;
310
+ } else {
311
+ assert.strictEqual(matches[draft].aposDocId, aposDocId);
312
+ }
313
+ assert.strictEqual(matches[draft]._id, `${aposDocId}:${locale}:draft`);
314
+ assert.strictEqual(matches[published]._id, `${aposDocId}:${locale}:published`);
315
+ i += 2;
316
+ }
317
+ }
318
+ const home = await apos.page.find(apos.task.getReq(), {
319
+ slug: '/'
320
+ }).children({
321
+ depth: 2
322
+ }).toObject();
323
+ const children = expected.filter(slug => slug.startsWith('/default') && !slug.match(/\/.*\//));
324
+ assert.deepStrictEqual(home._children.map(child => child.slug), children);
325
+ const grandkids = expected.filter(slug => slug.match(/\/.*\//));
326
+ for (const grandkid of grandkids) {
327
+ const parentSlug = grandkid.replace(/\/[^/]+$/, '');
328
+ const parent = home._children.find(child => child.slug === parentSlug);
329
+ assert(parent);
330
+ assert(parent._children);
331
+ assert(parent._children.find(child => child.slug === grandkid));
332
+ }
333
+ }