apostrophe 3.14.2 → 3.16.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 +19 -0
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBar.vue +2 -2
- package/modules/@apostrophecms/admin-bar/ui/src/index.js +26 -0
- package/modules/@apostrophecms/i18n/i18n/en.json +2 -0
- package/modules/@apostrophecms/login/index.js +171 -34
- package/modules/@apostrophecms/login/ui/apos/components/TheAposLogin.vue +8 -3
- package/modules/@apostrophecms/module/index.js +26 -0
- package/modules/@apostrophecms/page/index.js +19 -1
- package/modules/@apostrophecms/piece-type/index.js +10 -0
- package/modules/@apostrophecms/task/index.js +6 -0
- package/modules/@apostrophecms/template/index.js +1 -0
- package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +1 -0
- package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuDialog.vue +1 -0
- package/modules/@apostrophecms/user/index.js +6 -1
- package/package.json +1 -1
- package/test/login-requirements.js +76 -6
- package/test/login.js +104 -1
- package/test/modules/@apostrophecms/home-page/views/page.html +1 -1
- package/test/modules/@apostrophecms/user/index.js +7 -0
- package/test/pages-public-api.js +33 -0
- package/test/pages-rest.js +37 -1
- package/test/pages.js +177 -1
- package/test/pieces-public-api.js +33 -0
- package/test/pieces.js +118 -0
- package/test/templates.js +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.16.0 (2022-03-18)
|
|
4
|
+
|
|
5
|
+
### Adds
|
|
6
|
+
|
|
7
|
+
* Offers a simple way to set a Cache-Control max-age for Apostrophe page and GET REST API responses for pieces and pages.
|
|
8
|
+
* API keys and bearer tokens "win" over session cookies when both are present. Since API keys and bearer tokens are explicitly added to the request at hand, it never makes sense to ignore them in favor of a cookie, which is implicit. This also simplifies automated testing.
|
|
9
|
+
* `data-apos-test=""` selectors for certain elements frequently selected in QA tests, such as `data-apos-test="adminBar"`.
|
|
10
|
+
* To speed up functional tests, an `insecurePasswords` option has been added to the login module. This option is deliberately named to discourage use for any purpose other than functional tests in which repeated password hashing would unduly limit performance. Normally password hashing is intentionally difficult to slow down brute force attacks, especially if a database is compromised.
|
|
11
|
+
|
|
12
|
+
### Fixes
|
|
13
|
+
|
|
14
|
+
* `POST`ing a new child page with `_targetId: '_home'` now works properly in combination with `_position: 'lastChild'`.
|
|
15
|
+
|
|
16
|
+
## 3.15.0 (2022-03-02)
|
|
17
|
+
|
|
18
|
+
### Adds
|
|
19
|
+
|
|
20
|
+
* Adds throttle system based on username (even when not existing), on initial login route. Also added for each late login requirement, e.g. for 2FA attempts.
|
|
21
|
+
|
|
3
22
|
## 3.14.2 (2022-02-27)
|
|
4
23
|
|
|
5
24
|
* Hotfix: fixed a bug introduced by 3.14.1 in which non-parked pages could throw an error during the migration to fix replication issues.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="apos-admin-bar-wrapper" :class="themeClass">
|
|
2
|
+
<div data-apos-test="adminBar" class="apos-admin-bar-wrapper" :class="themeClass">
|
|
3
3
|
<div class="apos-admin-bar-spacer" ref="spacer" />
|
|
4
4
|
<nav class="apos-admin-bar" ref="adminBar">
|
|
5
5
|
<div class="apos-admin-bar__row">
|
|
6
6
|
<AposLogoPadless class="apos-admin-bar__logo" />
|
|
7
7
|
<TheAposAdminBarMenu :items="items" />
|
|
8
8
|
<TheAposAdminBarLocale v-if="hasLocales()" />
|
|
9
|
-
<TheAposAdminBarUser class="apos-admin-bar__user" />
|
|
9
|
+
<TheAposAdminBarUser data-apos-test="authenticatedUserMenuTrigger" class="apos-admin-bar__user" />
|
|
10
10
|
</div>
|
|
11
11
|
<TheAposContextBar @mounted="setSpacer" />
|
|
12
12
|
</nav>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* If the page delivers a logged-out content but we know from session storage that a user is logged-in,
|
|
3
|
+
* we force-refresh the page to bypass the cache, in order to get the logged-in content (with admin UI).
|
|
4
|
+
*/
|
|
5
|
+
export default function() {
|
|
6
|
+
const isLoggedOutPageContent = !(apos.login && apos.login.user);
|
|
7
|
+
const isLoggedInCookie = apos.util.getCookie(`${self.apos.shortName}.loggedIn`) === 'true';
|
|
8
|
+
|
|
9
|
+
if (!isLoggedOutPageContent || !isLoggedInCookie) {
|
|
10
|
+
sessionStorage.setItem('aposRefreshedPages', '{}');
|
|
11
|
+
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const refreshedPages = JSON.parse(sessionStorage.aposRefreshedPages || '{}');
|
|
16
|
+
|
|
17
|
+
// Avoid potential refresh loops
|
|
18
|
+
if (!refreshedPages[location.href]) {
|
|
19
|
+
refreshedPages[location.href] = true;
|
|
20
|
+
sessionStorage.setItem('aposRefreshedPages', JSON.stringify(refreshedPages));
|
|
21
|
+
|
|
22
|
+
console.info('Received logged-out content from cache while logged-in, refreshing the page');
|
|
23
|
+
|
|
24
|
+
location.reload();
|
|
25
|
+
}
|
|
26
|
+
};
|
|
@@ -178,6 +178,8 @@
|
|
|
178
178
|
"manageDocType": "Manage {{ type }}",
|
|
179
179
|
"manageDraftSubmissions": "Manage Draft Submissions",
|
|
180
180
|
"managePages": "Manage Pages",
|
|
181
|
+
"loginMaxAttemptsReached": "Too many attempts. You may try again in a minute.",
|
|
182
|
+
"loginMaxAttemptsReached_plural": "Too many attempts. You may try again in {{ count }} minutes.",
|
|
181
183
|
"maxLabel": "Max:",
|
|
182
184
|
"maxUi": "Max: {{ number }}",
|
|
183
185
|
"mediaCreatedDate": "Uploaded: {{ createdDate }}",
|
|
@@ -44,6 +44,9 @@ const Promise = require('bluebird');
|
|
|
44
44
|
const cuid = require('cuid');
|
|
45
45
|
const expressSession = require('express-session');
|
|
46
46
|
|
|
47
|
+
const loginAttemptsNamespace = '@apostrophecms/loginAttempt';
|
|
48
|
+
const loggedInCookieName = 'loggedIn';
|
|
49
|
+
|
|
47
50
|
module.exports = {
|
|
48
51
|
cascades: [ 'requirements' ],
|
|
49
52
|
options: {
|
|
@@ -53,7 +56,12 @@ module.exports = {
|
|
|
53
56
|
csrfExceptions: [
|
|
54
57
|
'login'
|
|
55
58
|
],
|
|
56
|
-
bearerTokens: true
|
|
59
|
+
bearerTokens: true,
|
|
60
|
+
throttle: {
|
|
61
|
+
allowedAttempts: 3,
|
|
62
|
+
perMinutes: 1,
|
|
63
|
+
lockoutMinutes: 1
|
|
64
|
+
}
|
|
57
65
|
},
|
|
58
66
|
async init(self) {
|
|
59
67
|
self.passport = new Passport();
|
|
@@ -144,6 +152,9 @@ module.exports = {
|
|
|
144
152
|
expireCookie.expires = new Date(0);
|
|
145
153
|
const name = self.apos.modules['@apostrophecms/express'].sessionOptions.name;
|
|
146
154
|
req.res.header('set-cookie', expireCookie.serialize(name, 'deleted'));
|
|
155
|
+
|
|
156
|
+
// TODO: get cookie name from config
|
|
157
|
+
req.res.cookie(`${self.apos.shortName}.${loggedInCookieName}`, 'false');
|
|
147
158
|
}
|
|
148
159
|
},
|
|
149
160
|
// invokes the `props(req, user)` function for the requirement specified by
|
|
@@ -166,8 +177,16 @@ module.exports = {
|
|
|
166
177
|
},
|
|
167
178
|
async requirementVerify(req) {
|
|
168
179
|
const name = self.apos.launder.string(req.body.name);
|
|
180
|
+
const loginNamespace = `${loginAttemptsNamespace}/${name}`;
|
|
169
181
|
|
|
170
|
-
const { user } = await self.findIncompleteTokenAndUser(
|
|
182
|
+
const { user } = await self.findIncompleteTokenAndUser(
|
|
183
|
+
req,
|
|
184
|
+
req.body.incompleteToken
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
if (!user) {
|
|
188
|
+
throw self.apos.error('invalid');
|
|
189
|
+
}
|
|
171
190
|
|
|
172
191
|
const requirement = self.requirements[name];
|
|
173
192
|
|
|
@@ -179,7 +198,15 @@ module.exports = {
|
|
|
179
198
|
throw self.apos.error('invalid', 'You must provide a verify method in your requirement');
|
|
180
199
|
}
|
|
181
200
|
|
|
201
|
+
const { cachedAttempts, reached } = await self
|
|
202
|
+
.checkLoginAttempts(user.username, loginNamespace);
|
|
203
|
+
|
|
204
|
+
if (reached) {
|
|
205
|
+
throw self.apos.error('invalid', req.t('apostrophe:loginMaxAttemptsReached'));
|
|
206
|
+
}
|
|
207
|
+
|
|
182
208
|
try {
|
|
209
|
+
|
|
183
210
|
await requirement.verify(req, req.body.value, user);
|
|
184
211
|
|
|
185
212
|
const token = await self.bearerTokens.findOne({
|
|
@@ -198,8 +225,16 @@ module.exports = {
|
|
|
198
225
|
$pull: { requirementsToVerify: name }
|
|
199
226
|
});
|
|
200
227
|
|
|
228
|
+
await self.clearLoginAttempts(user.username, loginNamespace);
|
|
229
|
+
|
|
201
230
|
return {};
|
|
202
231
|
} catch (err) {
|
|
232
|
+
await self.addLoginAttempt(
|
|
233
|
+
user.username,
|
|
234
|
+
cachedAttempts,
|
|
235
|
+
loginNamespace
|
|
236
|
+
);
|
|
237
|
+
|
|
203
238
|
err.data = err.data || {};
|
|
204
239
|
err.data.requirement = name;
|
|
205
240
|
throw err;
|
|
@@ -553,48 +588,64 @@ module.exports = {
|
|
|
553
588
|
// Implementation detail of the login route. Log in the user, or if there are
|
|
554
589
|
// `requirements` that require password verification occur first, return an incomplete token.
|
|
555
590
|
async initialLogin(req) {
|
|
556
|
-
// Initial login step
|
|
557
591
|
const username = self.apos.launder.string(req.body.username);
|
|
558
592
|
const password = self.apos.launder.string(req.body.password);
|
|
593
|
+
|
|
559
594
|
if (!(username && password)) {
|
|
560
595
|
throw self.apos.error('invalid', req.t('apostrophe:loginPageBothRequired'));
|
|
561
596
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
throw e;
|
|
570
|
-
}
|
|
597
|
+
|
|
598
|
+
const { cachedAttempts, reached } = await self.checkLoginAttempts(username);
|
|
599
|
+
|
|
600
|
+
if (reached) {
|
|
601
|
+
throw self.apos.error('invalid', req.t('apostrophe:loginMaxAttemptsReached', {
|
|
602
|
+
count: self.options.throttle.lockoutMinutes
|
|
603
|
+
}));
|
|
571
604
|
}
|
|
572
|
-
|
|
573
|
-
|
|
605
|
+
|
|
606
|
+
try {
|
|
607
|
+
// Initial login step
|
|
608
|
+
const { earlyRequirements, lateRequirements } = self.filterRequirements();
|
|
609
|
+
for (const [ name, requirement ] of Object.entries(earlyRequirements)) {
|
|
610
|
+
try {
|
|
611
|
+
await requirement.verify(req, req.body.requirements && req.body.requirements[name]);
|
|
612
|
+
} catch (e) {
|
|
613
|
+
e.data = e.data || {};
|
|
614
|
+
e.data.requirement = name;
|
|
615
|
+
throw e;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
const user = await self.apos.login.verifyLogin(username, password);
|
|
619
|
+
if (!user) {
|
|
574
620
|
// For security reasons we may not tell the user which case applies
|
|
575
|
-
|
|
576
|
-
|
|
621
|
+
throw self.apos.error('invalid', req.t('apostrophe:loginPageBadCredentials'));
|
|
622
|
+
}
|
|
577
623
|
|
|
578
|
-
|
|
624
|
+
const requirementsToVerify = Object.keys(lateRequirements);
|
|
579
625
|
|
|
580
|
-
|
|
581
|
-
|
|
626
|
+
if (requirementsToVerify.length) {
|
|
627
|
+
const token = cuid();
|
|
628
|
+
|
|
629
|
+
await self.bearerTokens.insert({
|
|
630
|
+
_id: token,
|
|
631
|
+
userId: user._id,
|
|
632
|
+
requirementsToVerify,
|
|
633
|
+
// Default lifetime of 1 hour is generous to permit situations like
|
|
634
|
+
// installing a TOTP app for the first time
|
|
635
|
+
expires: new Date(new Date().getTime() + (self.options.incompleteLifetime || 60 * 60 * 1000))
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
await self.clearLoginAttempts(user.username);
|
|
639
|
+
|
|
640
|
+
return {
|
|
641
|
+
incompleteToken: token
|
|
642
|
+
};
|
|
643
|
+
}
|
|
582
644
|
|
|
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
645
|
const session = self.apos.launder.boolean(req.body.session);
|
|
596
646
|
if (session) {
|
|
597
647
|
await self.passportLogin(req, user);
|
|
648
|
+
await self.clearLoginAttempts(user.username);
|
|
598
649
|
} else {
|
|
599
650
|
const token = cuid();
|
|
600
651
|
await self.bearerTokens.insert({
|
|
@@ -602,17 +653,30 @@ module.exports = {
|
|
|
602
653
|
userId: user._id,
|
|
603
654
|
expires: new Date(new Date().getTime() + (self.options.bearerTokens.lifetime || (86400 * 7 * 2)) * 1000)
|
|
604
655
|
});
|
|
656
|
+
|
|
657
|
+
await self.clearLoginAttempts(user.username);
|
|
658
|
+
|
|
605
659
|
return {
|
|
606
660
|
token
|
|
607
661
|
};
|
|
608
662
|
}
|
|
663
|
+
} catch (err) {
|
|
664
|
+
await self.addLoginAttempt(username, cachedAttempts);
|
|
665
|
+
|
|
666
|
+
throw err;
|
|
609
667
|
}
|
|
610
668
|
},
|
|
611
669
|
|
|
612
670
|
filterRequirements() {
|
|
613
671
|
return {
|
|
614
|
-
earlyRequirements: Object.fromEntries(
|
|
615
|
-
|
|
672
|
+
earlyRequirements: Object.fromEntries(
|
|
673
|
+
Object.entries(self.requirements)
|
|
674
|
+
.filter(([ _, requirement ]) => requirement.phase === 'beforeSubmit')
|
|
675
|
+
),
|
|
676
|
+
lateRequirements: Object.fromEntries(
|
|
677
|
+
Object.entries(self.requirements)
|
|
678
|
+
.filter(([ _, requirement ]) => requirement.phase === 'afterPasswordVerified')
|
|
679
|
+
)
|
|
616
680
|
};
|
|
617
681
|
},
|
|
618
682
|
|
|
@@ -624,6 +688,63 @@ module.exports = {
|
|
|
624
688
|
})(user);
|
|
625
689
|
};
|
|
626
690
|
await passportLogin(user);
|
|
691
|
+
},
|
|
692
|
+
|
|
693
|
+
async addLoginAttempt (
|
|
694
|
+
username,
|
|
695
|
+
attempts,
|
|
696
|
+
namespace = loginAttemptsNamespace
|
|
697
|
+
) {
|
|
698
|
+
if (typeof attempts !== 'number') {
|
|
699
|
+
await self.apos.cache.set(namespace,
|
|
700
|
+
username,
|
|
701
|
+
1,
|
|
702
|
+
self.options.throttle.perMinutes * 60
|
|
703
|
+
);
|
|
704
|
+
} else {
|
|
705
|
+
await self.apos.cache.cacheCollection.updateOne(
|
|
706
|
+
{
|
|
707
|
+
namespace,
|
|
708
|
+
key: username
|
|
709
|
+
},
|
|
710
|
+
{
|
|
711
|
+
$inc: {
|
|
712
|
+
value: 1
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
},
|
|
718
|
+
|
|
719
|
+
async checkLoginAttempts (username, namespace = loginAttemptsNamespace) {
|
|
720
|
+
const cachedAttempts = await self.apos.cache.get(namespace, username);
|
|
721
|
+
const { allowedAttempts } = self.options.throttle;
|
|
722
|
+
|
|
723
|
+
if (!cachedAttempts || cachedAttempts < allowedAttempts) {
|
|
724
|
+
return { cachedAttempts };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// When this is the first time we reach the limit
|
|
728
|
+
// we set the lifetime only once with lockoutMinutes
|
|
729
|
+
if (cachedAttempts === allowedAttempts) {
|
|
730
|
+
await self.apos.cache.set(namespace,
|
|
731
|
+
username,
|
|
732
|
+
cachedAttempts + 1,
|
|
733
|
+
self.options.throttle.lockoutMinutes * 60
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
return {
|
|
738
|
+
cachedAttempts,
|
|
739
|
+
reached: true
|
|
740
|
+
};
|
|
741
|
+
},
|
|
742
|
+
|
|
743
|
+
async clearLoginAttempts (username, namespace = loginAttemptsNamespace) {
|
|
744
|
+
await self.apos.cache.cacheCollection.deleteOne({
|
|
745
|
+
namespace,
|
|
746
|
+
key: username
|
|
747
|
+
});
|
|
627
748
|
}
|
|
628
749
|
};
|
|
629
750
|
},
|
|
@@ -672,7 +793,12 @@ module.exports = {
|
|
|
672
793
|
},
|
|
673
794
|
passportSession: {
|
|
674
795
|
before: '@apostrophecms/i18n',
|
|
675
|
-
middleware:
|
|
796
|
+
middleware: (() => {
|
|
797
|
+
// Wrap the passport middleware so that if the apikey or bearer token
|
|
798
|
+
// middleware already supplied req.user, that wins (explicit wins over implicit)
|
|
799
|
+
const passportSession = self.passport.session();
|
|
800
|
+
return (req, res, next) => req.user ? next() : passportSession(req, res, next);
|
|
801
|
+
})()
|
|
676
802
|
},
|
|
677
803
|
honorLoginInvalidBefore: {
|
|
678
804
|
before: '@apostrophecms/i18n',
|
|
@@ -695,6 +821,17 @@ module.exports = {
|
|
|
695
821
|
return next();
|
|
696
822
|
}
|
|
697
823
|
}
|
|
824
|
+
},
|
|
825
|
+
addLoggedInCookie: {
|
|
826
|
+
before: '@apostrophecms/i18n',
|
|
827
|
+
middleware(req, res, next) {
|
|
828
|
+
// TODO: get cookie name from config
|
|
829
|
+
const cookieName = `${self.apos.shortName}.${loggedInCookieName}`;
|
|
830
|
+
if (req.user && req.cookies[cookieName] !== 'true') {
|
|
831
|
+
res.cookie(cookieName, 'true');
|
|
832
|
+
}
|
|
833
|
+
return next();
|
|
834
|
+
}
|
|
698
835
|
}
|
|
699
836
|
};
|
|
700
837
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
<transition name="fade-stage">
|
|
3
3
|
<div
|
|
4
4
|
class="apos-login apos-theme-dark"
|
|
5
|
+
data-apos-test="loginForm"
|
|
5
6
|
v-show="loaded"
|
|
6
7
|
:class="themeClass"
|
|
7
8
|
>
|
|
@@ -35,6 +36,7 @@
|
|
|
35
36
|
<!-- TODO -->
|
|
36
37
|
<!-- <a href="#" class="apos-login__link">Forgot Password</a> -->
|
|
37
38
|
<AposButton
|
|
39
|
+
data-apos-test="loginSubmit"
|
|
38
40
|
:busy="busy"
|
|
39
41
|
:disabled="disabled"
|
|
40
42
|
type="primary"
|
|
@@ -128,7 +130,8 @@ export default {
|
|
|
128
130
|
return this.mounted && this.beforeCreateFinished;
|
|
129
131
|
},
|
|
130
132
|
disabled() {
|
|
131
|
-
return this.doc.hasErrors ||
|
|
133
|
+
return this.doc.hasErrors ||
|
|
134
|
+
!!this.beforeSubmitRequirements.find(requirement => !requirement.done);
|
|
132
135
|
},
|
|
133
136
|
beforeSubmitRequirements() {
|
|
134
137
|
return this.requirements.filter(requirement => requirement.phase === 'beforeSubmit');
|
|
@@ -274,12 +277,14 @@ export default {
|
|
|
274
277
|
location.assign(`${apos.prefix}/`);
|
|
275
278
|
},
|
|
276
279
|
async requirementBlock(requirementBlock) {
|
|
277
|
-
const requirement = this.requirements
|
|
280
|
+
const requirement = this.requirements
|
|
281
|
+
.find(requirement => requirement.name === requirementBlock.name);
|
|
278
282
|
requirement.done = false;
|
|
279
283
|
requirement.value = undefined;
|
|
280
284
|
},
|
|
281
285
|
async requirementDone(requirementDone, value) {
|
|
282
|
-
const requirement = this.requirements
|
|
286
|
+
const requirement = this.requirements
|
|
287
|
+
.find(requirement => requirement.name === requirementDone.name);
|
|
283
288
|
|
|
284
289
|
if (requirement.phase === 'beforeSubmit') {
|
|
285
290
|
requirement.done = true;
|
|
@@ -177,6 +177,11 @@ module.exports = {
|
|
|
177
177
|
return async function(req, res) {
|
|
178
178
|
try {
|
|
179
179
|
const result = await fn(req);
|
|
180
|
+
|
|
181
|
+
if (req.method === 'GET' && req.user) {
|
|
182
|
+
res.header('Cache-Control', 'no-store');
|
|
183
|
+
}
|
|
184
|
+
|
|
180
185
|
res.status(200);
|
|
181
186
|
res.send(result);
|
|
182
187
|
} catch (err) {
|
|
@@ -446,6 +451,27 @@ module.exports = {
|
|
|
446
451
|
);
|
|
447
452
|
},
|
|
448
453
|
|
|
454
|
+
setMaxAge(req, maxAge) {
|
|
455
|
+
if (typeof maxAge !== 'number') {
|
|
456
|
+
self.apos.util.warnDev(`"maxAge" property must be defined as a number in the "${self.__meta.name}" module's cache options"`);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// A cookie in session doesn't mean we can't cache, nor an empty flash or passport object.
|
|
461
|
+
// Other session properties must be assumed to be specific to the user, with a possible
|
|
462
|
+
// impact on the response, and thus mean this request must not be cached.
|
|
463
|
+
// Same rule as in [express-cache-on-demand](https://github.com/apostrophecms/express-cache-on-demand/blob/master/index.js#L102)
|
|
464
|
+
const isSessionClearForCaching = Object.entries(req.session).every(([ key, val ]) =>
|
|
465
|
+
key === 'cookie' || (
|
|
466
|
+
(key === 'flash' || key === 'passport') && _.isEmpty(val)
|
|
467
|
+
)
|
|
468
|
+
);
|
|
469
|
+
const isSafeToCache = !req.user && isSessionClearForCaching;
|
|
470
|
+
const cacheControlValue = isSafeToCache ? `max-age=${maxAge}` : 'no-store';
|
|
471
|
+
|
|
472
|
+
req.res.header('Cache-Control', cacheControlValue);
|
|
473
|
+
},
|
|
474
|
+
|
|
449
475
|
// Call from init once if this module implements the `getBrowserData` method.
|
|
450
476
|
// The data returned by `getBrowserData(req)` will then be available on
|
|
451
477
|
// `apos.modules['your-module-name']` in the browser.
|
|
@@ -119,6 +119,10 @@ module.exports = {
|
|
|
119
119
|
project: self.getAllProjection()
|
|
120
120
|
}).toObject();
|
|
121
121
|
|
|
122
|
+
if (self.options.cache && self.options.cache.api) {
|
|
123
|
+
self.setMaxAge(req, self.options.cache.api.maxAge);
|
|
124
|
+
}
|
|
125
|
+
|
|
122
126
|
if (!page) {
|
|
123
127
|
throw self.apos.error('notfound');
|
|
124
128
|
}
|
|
@@ -137,6 +141,11 @@ module.exports = {
|
|
|
137
141
|
}
|
|
138
142
|
} else {
|
|
139
143
|
const result = await self.getRestQuery(req).and({ level: 0 }).toObject();
|
|
144
|
+
|
|
145
|
+
if (self.options.cache && self.options.cache.api) {
|
|
146
|
+
self.setMaxAge(req, self.options.cache.api.maxAge);
|
|
147
|
+
}
|
|
148
|
+
|
|
140
149
|
if (!result) {
|
|
141
150
|
throw self.apos.error('notfound');
|
|
142
151
|
}
|
|
@@ -168,6 +177,11 @@ module.exports = {
|
|
|
168
177
|
self.publicApiCheck(req);
|
|
169
178
|
const criteria = self.getIdCriteria(_id);
|
|
170
179
|
const result = await self.getRestQuery(req).and(criteria).toObject();
|
|
180
|
+
|
|
181
|
+
if (self.options.cache && self.options.cache.api) {
|
|
182
|
+
self.setMaxAge(req, self.options.cache.api.maxAge);
|
|
183
|
+
}
|
|
184
|
+
|
|
171
185
|
if (!result) {
|
|
172
186
|
throw self.apos.error('notfound');
|
|
173
187
|
}
|
|
@@ -220,7 +234,7 @@ module.exports = {
|
|
|
220
234
|
}
|
|
221
235
|
|
|
222
236
|
return self.withLock(req, async () => {
|
|
223
|
-
const targetPage = await self.findForEditing(req, targetId ?
|
|
237
|
+
const targetPage = await self.findForEditing(req, targetId ? self.getIdCriteria(targetId) : { level: 0 }).ancestors(true).permission('edit').toObject();
|
|
224
238
|
if (!targetPage) {
|
|
225
239
|
throw self.apos.error('notfound');
|
|
226
240
|
}
|
|
@@ -1415,6 +1429,10 @@ database.`);
|
|
|
1415
1429
|
await self.emit('serveQuery', query);
|
|
1416
1430
|
req.data.bestPage = await query.toObject();
|
|
1417
1431
|
self.evaluatePageMatch(req);
|
|
1432
|
+
|
|
1433
|
+
if (self.options.cache && self.options.cache.page) {
|
|
1434
|
+
self.setMaxAge(req, self.options.cache.page.maxAge);
|
|
1435
|
+
}
|
|
1418
1436
|
},
|
|
1419
1437
|
// Normalize req.slug to account for unneeded trailing whitespace,
|
|
1420
1438
|
// trailing slashes other than the root, and double slash based open
|
|
@@ -200,6 +200,11 @@ module.exports = {
|
|
|
200
200
|
if (query.get('countsResults')) {
|
|
201
201
|
result.counts = query.get('countsResults');
|
|
202
202
|
}
|
|
203
|
+
|
|
204
|
+
if (self.options.cache && self.options.cache.api) {
|
|
205
|
+
self.setMaxAge(req, self.options.cache.api.maxAge);
|
|
206
|
+
}
|
|
207
|
+
|
|
203
208
|
return result;
|
|
204
209
|
}
|
|
205
210
|
],
|
|
@@ -209,6 +214,11 @@ module.exports = {
|
|
|
209
214
|
_id = self.inferIdLocaleAndMode(req, _id);
|
|
210
215
|
self.publicApiCheck(req);
|
|
211
216
|
const doc = await self.getRestQuery(req).and({ _id }).toObject();
|
|
217
|
+
|
|
218
|
+
if (self.options.cache && self.options.cache.api) {
|
|
219
|
+
self.setMaxAge(req, self.options.cache.api.maxAge);
|
|
220
|
+
}
|
|
221
|
+
|
|
212
222
|
if (!doc) {
|
|
213
223
|
throw self.apos.error('notfound');
|
|
214
224
|
}
|
|
@@ -432,7 +432,12 @@ module.exports = {
|
|
|
432
432
|
|
|
433
433
|
// Initialize the [credential](https://npmjs.org/package/credential) module.
|
|
434
434
|
initializeCredential() {
|
|
435
|
-
self.pw = credential(
|
|
435
|
+
self.pw = credential({
|
|
436
|
+
// For efficient unit tests only. Reducing the work factor
|
|
437
|
+
// for actual credentials increases the speed of brute force attacks
|
|
438
|
+
// if the database is ever compromised
|
|
439
|
+
work: self.options.insecurePasswords ? 0.01 : 1
|
|
440
|
+
});
|
|
436
441
|
},
|
|
437
442
|
|
|
438
443
|
// Implement the `@apostrophecms/user:add` command line task.
|