apostrophe 4.31.0 → 4.31.1-beta.1
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 +7 -1
- package/modules/@apostrophecms/oembed/index.js +45 -12
- package/package.json +8 -8
- package/test/oembed.js +98 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.31.1-beta.1
|
|
4
|
+
|
|
5
|
+
### Fixes
|
|
6
|
+
|
|
7
|
+
The `@apostrophecms/oembed` module now caches failed lookups in addition to successful ones. Previously only successes were cached, so a bad or removed URL (such as a deleted YouTube video) could be requested over and over, triggering provider rate-limiting (e.g. YouTube 429 errors) and temporary lockouts. Cached entries written by older versions of Apostrophe are still tolerated, so no cache clearing is required when upgrading. In addition, the underlying error is now logged (via structured logging) before the higher-level "Video URL invalid" error is thrown, making the original cause easier to diagnose.
|
|
8
|
+
|
|
3
9
|
## 4.31.0 (2026-06-10)
|
|
4
10
|
|
|
5
11
|
### Adds
|
|
@@ -12,7 +18,7 @@
|
|
|
12
18
|
### Fixes
|
|
13
19
|
|
|
14
20
|
- Fixed an issue where using the Tab key to navigate within modals could incorrectly jump focus to a wrong element instead of the next input field.
|
|
15
|
-
Fixed Tab navigation escaping out of modals when the form contained hidden sections or elements that became disabled after editing.
|
|
21
|
+
Fixed Tab navigation escaping out of modals when the form contained hidden sections or elements that became disabled after editing.
|
|
16
22
|
- Fixed adding or removing an area field from a schema breaking existing documents on an external front such as Astro.
|
|
17
23
|
- For Astro: `AposArea` now renders only schema-backed areas. A missing area no longer throws, and an area orphaned by removing its field from the schema (while its content remains in the document) renders nothing instead of breaking sibling areas in edit mode. Logged-in editors get a diagnostic message in place of an orphaned area; anonymous visitors see nothing.
|
|
18
24
|
- Editable documents sent to an external front (Asgtro) now materialize empty area objects for schema area fields added after the document was created, so they can be edited in context.
|
|
@@ -86,7 +86,9 @@ module.exports = {
|
|
|
86
86
|
// The `options` object is passed on to `oembetter.fetch`.
|
|
87
87
|
//
|
|
88
88
|
// Responses are automatically cached, by default for one hour. See the
|
|
89
|
-
// cacheLifetime option to the module.
|
|
89
|
+
// cacheLifetime option to the module. Failures are cached as well, so
|
|
90
|
+
// that a bad or removed URL does not lead to repeated requests that can
|
|
91
|
+
// trigger rate-limiting (e.g. YouTube 429 errors and lockouts).
|
|
90
92
|
async query(req, url, options) {
|
|
91
93
|
if (!options) {
|
|
92
94
|
options = {};
|
|
@@ -112,18 +114,44 @@ module.exports = {
|
|
|
112
114
|
throw self.apos.error('invalid', req.t('apostrophe:oembedVideoUrlInvalid'));
|
|
113
115
|
}
|
|
114
116
|
const key = url + ':' + JSON.stringify(options);
|
|
115
|
-
|
|
116
|
-
if (
|
|
117
|
-
|
|
117
|
+
const cached = await self.apos.cache.get('@apostrophecms/oembed', key);
|
|
118
|
+
if (cached !== undefined) {
|
|
119
|
+
// Cache entries are wrapped so that both successes and failures can
|
|
120
|
+
// be remembered. Caching failures prevents a bad or removed URL
|
|
121
|
+
// (such as a deleted YouTube video) from being requested over and
|
|
122
|
+
// over, which can trigger 429 rate-limiting and temporary lockouts.
|
|
123
|
+
//
|
|
124
|
+
// Legacy entries written before this wrapping existed are the raw
|
|
125
|
+
// oembed response object, so tolerate them by returning them as-is.
|
|
126
|
+
if (cached && cached.aposOembedCache) {
|
|
127
|
+
if (cached.error) {
|
|
128
|
+
throw self.apos.error('invalid', req.t('apostrophe:oembedVideoUrlInvalid'));
|
|
129
|
+
}
|
|
130
|
+
return cached.response;
|
|
131
|
+
}
|
|
132
|
+
return cached;
|
|
118
133
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
134
|
+
let response;
|
|
135
|
+
try {
|
|
136
|
+
if (options.alwaysIframe) {
|
|
137
|
+
response = await self.iframe(req, url, options);
|
|
138
|
+
} else {
|
|
123
139
|
response = await require('util').promisify(self.oembetter.fetch)(url, options);
|
|
124
|
-
} catch (err) {
|
|
125
|
-
throw self.apos.error('invalid', req.t('apostrophe:oembedVideoUrlInvalid'));
|
|
126
140
|
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
// Log the underlying cause first, since the higher-level error
|
|
143
|
+
// thrown below would otherwise obscure it.
|
|
144
|
+
self.logError(req, 'query-failed', err.message, {
|
|
145
|
+
url,
|
|
146
|
+
stack: err.stack
|
|
147
|
+
});
|
|
148
|
+
// Cache the failure too, so we don't keep hammering the provider
|
|
149
|
+
// for a URL that is currently failing.
|
|
150
|
+
await self.apos.cache.set('@apostrophecms/oembed', key, {
|
|
151
|
+
aposOembedCache: true,
|
|
152
|
+
error: true
|
|
153
|
+
}, self.options.cacheLifetime);
|
|
154
|
+
throw self.apos.error('invalid', req.t('apostrophe:oembedVideoUrlInvalid'));
|
|
127
155
|
}
|
|
128
156
|
// Make non-secure URLs protocol relative and
|
|
129
157
|
// let the browser upgrade them to https if needed
|
|
@@ -137,8 +165,13 @@ module.exports = {
|
|
|
137
165
|
if (response.html) {
|
|
138
166
|
response.html = makeProtocolRelative(response.html);
|
|
139
167
|
}
|
|
140
|
-
//
|
|
141
|
-
|
|
168
|
+
// Cache the successful response (by default for one hour; see the
|
|
169
|
+
// cacheLifetime option).
|
|
170
|
+
await self.apos.cache.set('@apostrophecms/oembed', key, {
|
|
171
|
+
aposOembedCache: true,
|
|
172
|
+
error: false,
|
|
173
|
+
response
|
|
174
|
+
}, self.options.cacheLifetime);
|
|
142
175
|
return response;
|
|
143
176
|
},
|
|
144
177
|
// Not currently used. Present for backwards compatibility
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apostrophe",
|
|
3
|
-
"version": "4.31.
|
|
3
|
+
"version": "4.31.1-beta.1",
|
|
4
4
|
"description": "The Apostrophe Content Management System.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"repository": {
|
|
@@ -120,15 +120,15 @@
|
|
|
120
120
|
"webpack": "^5.106.1",
|
|
121
121
|
"webpack-merge": "^5.7.3",
|
|
122
122
|
"xregexp": "^2.0.0",
|
|
123
|
-
"broadband": "^1.1.0",
|
|
124
|
-
"launder": "^1.7.1",
|
|
125
123
|
"@apostrophecms/db-connect": "^1.0.1",
|
|
126
|
-
"
|
|
124
|
+
"broadband": "^1.1.0",
|
|
127
125
|
"boring": "^1.1.1",
|
|
128
126
|
"express-cache-on-demand": "^1.0.4",
|
|
127
|
+
"launder": "^1.7.1",
|
|
128
|
+
"postcss-viewport-to-container-toggle": "^2.3.0",
|
|
129
|
+
"oembetter": "^1.2.0",
|
|
129
130
|
"sanitize-html": "^2.17.5",
|
|
130
|
-
"uploadfs": "^1.26.1"
|
|
131
|
-
"postcss-viewport-to-container-toggle": "^2.3.0"
|
|
131
|
+
"uploadfs": "^1.26.1"
|
|
132
132
|
},
|
|
133
133
|
"devDependencies": {
|
|
134
134
|
"chai": "^4.3.10",
|
|
@@ -137,8 +137,8 @@
|
|
|
137
137
|
"mocha": "^11.7.5",
|
|
138
138
|
"nyc": "^17.1.0",
|
|
139
139
|
"stylelint": "^16.5.0",
|
|
140
|
-
"
|
|
141
|
-
"
|
|
140
|
+
"stylelint-config-apostrophe": "^4.4.0",
|
|
141
|
+
"eslint-config-apostrophe": "^6.0.2"
|
|
142
142
|
},
|
|
143
143
|
"browserslist": [
|
|
144
144
|
"ie >= 10"
|
package/test/oembed.js
CHANGED
|
@@ -53,4 +53,102 @@ describe('Oembed', function() {
|
|
|
53
53
|
// const data = JSON.parse(response.body);
|
|
54
54
|
// assert(data.type === 'video');
|
|
55
55
|
// });
|
|
56
|
+
|
|
57
|
+
it('should cache successful responses and not re-fetch them', async function() {
|
|
58
|
+
const req = apos.task.getReq();
|
|
59
|
+
const url = 'https://example.com/good-video';
|
|
60
|
+
let calls = 0;
|
|
61
|
+
const original = apos.oembed.oembetter.fetch;
|
|
62
|
+
apos.oembed.oembetter.fetch = (fetchUrl, options, cb) => {
|
|
63
|
+
calls++;
|
|
64
|
+
return cb(null, {
|
|
65
|
+
type: 'video',
|
|
66
|
+
title: 'Success',
|
|
67
|
+
html: '<iframe src="//example.com/embed"></iframe>'
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const first = await apos.oembed.query(req, url);
|
|
73
|
+
assert.strictEqual(first.title, 'Success');
|
|
74
|
+
assert.strictEqual(calls, 1);
|
|
75
|
+
|
|
76
|
+
// Second call must be served from the cache, not re-fetched
|
|
77
|
+
const second = await apos.oembed.query(req, url);
|
|
78
|
+
assert.strictEqual(second.title, 'Success');
|
|
79
|
+
assert.strictEqual(calls, 1);
|
|
80
|
+
} finally {
|
|
81
|
+
apos.oembed.oembetter.fetch = original;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should cache failures so a bad URL is not requested repeatedly, and log the original error', async function() {
|
|
86
|
+
const req = apos.task.getReq();
|
|
87
|
+
const url = 'https://example.com/bad-video';
|
|
88
|
+
let calls = 0;
|
|
89
|
+
const original = apos.oembed.oembetter.fetch;
|
|
90
|
+
apos.oembed.oembetter.fetch = (fetchUrl, options, cb) => {
|
|
91
|
+
calls++;
|
|
92
|
+
return cb(new Error('simulated provider failure'));
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Capture structured log output to confirm the original error is logged
|
|
96
|
+
const originalLogError = apos.oembed.logError;
|
|
97
|
+
const logged = [];
|
|
98
|
+
apos.oembed.logError = (...args) => logged.push(args);
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
await assert.rejects(
|
|
102
|
+
() => apos.oembed.query(req, url),
|
|
103
|
+
{ name: 'invalid' }
|
|
104
|
+
);
|
|
105
|
+
assert.strictEqual(calls, 1);
|
|
106
|
+
|
|
107
|
+
// The underlying cause is logged before the higher-level error is thrown
|
|
108
|
+
assert.strictEqual(logged.length, 1);
|
|
109
|
+
const [ loggedReq, eventType, message ] = logged[0];
|
|
110
|
+
assert.strictEqual(loggedReq, req);
|
|
111
|
+
assert.strictEqual(eventType, 'query-failed');
|
|
112
|
+
assert.strictEqual(message, 'simulated provider failure');
|
|
113
|
+
|
|
114
|
+
// Second call must be served from the cached failure, not re-fetched
|
|
115
|
+
await assert.rejects(
|
|
116
|
+
() => apos.oembed.query(req, url),
|
|
117
|
+
{ name: 'invalid' }
|
|
118
|
+
);
|
|
119
|
+
assert.strictEqual(calls, 1);
|
|
120
|
+
} finally {
|
|
121
|
+
apos.oembed.oembetter.fetch = original;
|
|
122
|
+
apos.oembed.logError = originalLogError;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should tolerate legacy (unwrapped) cache entries without crashing', async function() {
|
|
127
|
+
const req = apos.task.getReq();
|
|
128
|
+
const url = 'https://example.com/legacy-video';
|
|
129
|
+
const legacyResponse = {
|
|
130
|
+
type: 'video',
|
|
131
|
+
title: 'Legacy',
|
|
132
|
+
html: '<iframe src="//example.com/legacy"></iframe>'
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Simulate a raw response cached by an older version of this module
|
|
136
|
+
const originalGet = apos.cache.get;
|
|
137
|
+
apos.cache.get = async () => legacyResponse;
|
|
138
|
+
|
|
139
|
+
// Fetching must not happen when the cache already has data
|
|
140
|
+
const originalFetch = apos.oembed.oembetter.fetch;
|
|
141
|
+
apos.oembed.oembetter.fetch = (fetchUrl, options, cb) => {
|
|
142
|
+
return cb(new Error('should not be called for a cache hit'));
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const result = await apos.oembed.query(req, url);
|
|
147
|
+
assert.strictEqual(result.title, 'Legacy');
|
|
148
|
+
assert.strictEqual(result.type, 'video');
|
|
149
|
+
} finally {
|
|
150
|
+
apos.cache.get = originalGet;
|
|
151
|
+
apos.oembed.oembetter.fetch = originalFetch;
|
|
152
|
+
}
|
|
153
|
+
});
|
|
56
154
|
});
|