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 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
- let response = await self.apos.cache.get('@apostrophecms/oembed', key);
116
- if (response !== undefined) {
117
- return response;
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
- if (options.alwaysIframe) {
120
- response = await self.iframe(req, url, options);
121
- } else {
122
- try {
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
- // cache oembed responses for one hour
141
- await self.apos.cache.set('@apostrophecms/oembed', key, response, self.options.cacheLifetime);
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.0",
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
- "oembetter": "^1.2.0",
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
- "eslint-config-apostrophe": "^6.0.2",
141
- "stylelint-config-apostrophe": "^4.4.0"
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
  });