@eeacms/volto-cca-policy 0.3.121 → 0.3.123

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
@@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
+ ### [0.3.123](https://github.com/eea/volto-cca-policy/compare/0.3.122...0.3.123) - 11 May 2026
8
+
9
+ #### :bug: Bug Fixes
10
+
11
+ - fix: improve error logging [kreafox - [`58bfd70`](https://github.com/eea/volto-cca-policy/commit/58bfd70ce2155de6f8a773b8bcf9482ceb704211)]
12
+ - fix: add error logging in RSS middleware [kreafox - [`048d715`](https://github.com/eea/volto-cca-policy/commit/048d7154a118367a1ce4c6660c71517f8f049d55)]
13
+ - fix: improve image URL resolution [kreafox - [`f3e2e32`](https://github.com/eea/volto-cca-policy/commit/f3e2e323b8d9c9a77e1656c1f2f8a09592733b86)]
14
+
15
+ ### [0.3.122](https://github.com/eea/volto-cca-policy/compare/0.3.121...0.3.122) - 8 May 2026
16
+
17
+ #### :bug: Bug Fixes
18
+
19
+ - fix: add image fallback & improvements in RSS middleware [kreafox - [`6e6091a`](https://github.com/eea/volto-cca-policy/commit/6e6091a2e72c53fd37489fc959f5c1083e99c84e)]
20
+
21
+ #### :house: Internal changes
22
+
23
+ - style: Automated code fix [eea-jenkins - [`278f8b4`](https://github.com/eea/volto-cca-policy/commit/278f8b49ae0c56ce7a705a2eea8a6ff3373e6dff)]
24
+
25
+ #### :house: Documentation changes
26
+
27
+ - docs: update README.md [kreafox - [`5d810e1`](https://github.com/eea/volto-cca-policy/commit/5d810e1385c22d481fd14977c8c69dcecd16f70e)]
28
+
7
29
  ### [0.3.121](https://github.com/eea/volto-cca-policy/compare/0.3.120...0.3.121) - 7 May 2026
8
30
 
9
31
  #### :rocket: New Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-cca-policy",
3
- "version": "0.3.121",
3
+ "version": "0.3.123",
4
4
  "description": "@eeacms/volto-cca-policy: Volto add-on",
5
5
  "main": "src/index.js",
6
6
  "author": "European Environment Agency: IDM2 A-Team",
@@ -1,3 +1,5 @@
1
- ## RSS sort_order customization
1
+ ## RSS middleware customization
2
2
 
3
3
  This customization patches the `volto-rss-provider` middleware to correctly handle reversed sorting in RSS Listing block queries.
4
+
5
+ It also fixes image handling so items with small lead images or missing `preview` scales do not break the entire feed; those items remain in the RSS output, but their image enclosure is skipped.
@@ -1,7 +1,7 @@
1
1
  import express from 'express';
2
2
  import superagent from 'superagent';
3
3
  import RSS from 'rss';
4
- import { findBlocks, toPublicURL } from '@plone/volto/helpers';
4
+ import { findBlocks } from '@plone/volto/helpers';
5
5
  import config from '@plone/volto/registry';
6
6
 
7
7
  /**
@@ -26,75 +26,83 @@ import config from '@plone/volto/registry';
26
26
  *
27
27
  * @function getRssFeedData
28
28
  * @param {string} apiPath - The base path for the API requests.
29
- * @param {string} APISUFIX - The suffix added to the API path depending on the environment.
29
+ * @param {string} APISUFFIX - The suffix added to the API path depending on the environment.
30
30
  * @param {Object} req - The incoming Express request object.
31
31
  * @param {Object} settings - Configuration settings for the application.
32
32
  * @return {Object} An object containing the query data, language, description, and title of the rss_feed.
33
33
  * @throws Will throw an error if no query data is found in the listing block or if the request fails.
34
34
  */
35
- async function getRssFeedData(apiPath, APISUFIX, req, settings) {
36
- try {
37
- const request = superagent
38
- .get(
39
- `${apiPath}${__DEVELOPMENT__ ? '' : APISUFIX}${req.path.replace(
40
- '/rss.xml',
41
- '',
42
- )}`,
43
- )
44
- .accept('json');
45
-
46
- const authToken = req.universalCookies.get('auth_token');
47
- if (authToken) {
48
- request.set('Authorization', `Bearer ${authToken}`);
49
- }
35
+ async function getRssFeedData(apiPath, APISUFFIX, req, settings) {
36
+ const url = `${apiPath}${__DEVELOPMENT__ ? '' : APISUFFIX}${req.path.replace(
37
+ '/rss.xml',
38
+ '',
39
+ )}`;
50
40
 
51
- const response = await request;
52
- const json = JSON.parse(JSON.stringify(response.body));
53
- const listingBlock = findBlocks(json.blocks, 'listing');
54
- let queryData = json.blocks[listingBlock]?.querystring;
55
- let language = json.language.token ?? 'en';
56
- let description = json.description ?? 'A Volto RSS Feed';
57
- let title = json.title;
58
- let subjects = json.subjects;
59
- let date = json.effective;
60
- let max_description_length = json.max_description_length;
61
- let max_title_length = json.max_title_length;
62
- if (!queryData) {
63
- throw new Error('No query data found in listing block');
64
- }
41
+ // eslint-disable-next-line no-console
42
+ console.error('[RSS] Fetching feed data from:', url);
65
43
 
66
- if (queryData?.sort_order !== null) {
67
- if (typeof queryData.sort_order === 'boolean') {
68
- queryData.sort_order = queryData.sort_order ? 'reverse' : 'ascending';
69
- }
44
+ const request = superagent.get(url).accept('json');
70
45
 
71
- if (queryData.sort_order === 'descending') {
72
- queryData.sort_order = 'reverse';
73
- }
46
+ const authToken = req.universalCookies?.get?.('auth_token');
47
+ if (authToken) {
48
+ request.set('Authorization', `Bearer ${authToken}`);
49
+ }
50
+
51
+ const response = await request;
52
+ const json = response.body;
53
+ const listingBlocks = findBlocks(json.blocks, 'listing');
54
+ const listingBlockId = Array.isArray(listingBlocks)
55
+ ? listingBlocks[0]
56
+ : listingBlocks;
57
+
58
+ const queryData = listingBlockId
59
+ ? json.blocks?.[listingBlockId]?.querystring
60
+ : null;
61
+ const language = json.language?.token ?? 'en';
62
+ const description = json.description ?? 'A Volto RSS Feed';
63
+ const title = json.title;
64
+ const subjects = json.subjects;
65
+ const date = json.effective;
66
+ const max_description_length = json.max_description_length;
67
+ const max_title_length = json.max_title_length;
68
+ if (!queryData) {
69
+ throw new Error('No query data found in listing block');
70
+ }
71
+
72
+ const normalizedQueryData = { ...queryData };
73
+
74
+ if (normalizedQueryData.sort_order != null) {
75
+ if (typeof normalizedQueryData.sort_order === 'boolean') {
76
+ normalizedQueryData.sort_order = normalizedQueryData.sort_order
77
+ ? 'reverse'
78
+ : 'ascending';
74
79
  }
75
80
 
76
- let query = {
77
- ...queryData,
78
- ...(!queryData.b_size && {
79
- b_size: settings.defaultPageSize,
80
- }),
81
- query: queryData?.query,
82
- metadata_fields: '_all',
83
- b_start: 0,
84
- };
85
- return {
86
- query,
87
- language,
88
- description,
89
- title,
90
- subjects,
91
- date,
92
- max_description_length,
93
- max_title_length,
94
- };
95
- } catch (err) {
96
- throw err;
81
+ if (normalizedQueryData.sort_order === 'descending') {
82
+ normalizedQueryData.sort_order = 'reverse';
83
+ }
97
84
  }
85
+
86
+ const query = {
87
+ ...normalizedQueryData,
88
+ ...(!normalizedQueryData.b_size && {
89
+ b_size: settings.defaultPageSize,
90
+ }),
91
+ query: normalizedQueryData?.query,
92
+ metadata_fields: '_all',
93
+ b_start: 0,
94
+ };
95
+
96
+ return {
97
+ query,
98
+ language,
99
+ description,
100
+ title,
101
+ subjects,
102
+ date,
103
+ max_description_length,
104
+ max_title_length,
105
+ };
98
106
  }
99
107
 
100
108
  /**
@@ -107,26 +115,44 @@ async function getRssFeedData(apiPath, APISUFIX, req, settings) {
107
115
  * @function fetchListingItems
108
116
  * @param {Object} query - The query data used for fetching items.
109
117
  * @param {string} apiPath - The base path for the API requests.
110
- * @param {string} APISUFIX - The suffix added to the API path depending on the environment.
118
+ * @param {string} APISUFFIX - The suffix added to the API path depending on the environment.
111
119
  * @param {string} authToken - The authentication token for authorized requests.
112
120
  * @return {Array} An array of items that match the query criteria.
113
121
  * @throws Will throw an error if the request fails.
114
122
  */
115
- async function fetchListingItems(query, apiPath, APISUFIX, authToken) {
123
+ async function fetchListingItems(query, apiPath, APISUFFIX, authToken) {
124
+ const request = superagent
125
+ .post(`${apiPath}${__DEVELOPMENT__ ? '' : APISUFFIX}/@querystring-search`)
126
+ .send(query)
127
+ .accept('json');
128
+
129
+ if (authToken) {
130
+ request.set('Authorization', `Bearer ${authToken}`);
131
+ }
132
+
133
+ const response = await request;
134
+ return response.body.items || [];
135
+ }
136
+
137
+ function normalizeFeedURL(url, publicURL, apiPath) {
138
+ if (!url) return publicURL;
139
+
116
140
  try {
117
- const request = superagent
118
- .post(`${apiPath}${__DEVELOPMENT__ ? '' : APISUFIX}/@querystring-search`)
119
- .send(query)
120
- .accept('json');
141
+ const parsedUrl = new URL(url, publicURL);
142
+
143
+ if (apiPath) {
144
+ const apiOrigin = new URL(apiPath).origin;
145
+ const publicOrigin = new URL(publicURL).origin;
121
146
 
122
- if (authToken) {
123
- request.set('Authorization', `Bearer ${authToken}`);
147
+ if (parsedUrl.origin === apiOrigin) {
148
+ parsedUrl.protocol = new URL(publicOrigin).protocol;
149
+ parsedUrl.host = new URL(publicOrigin).host;
150
+ }
124
151
  }
125
152
 
126
- const response = await request;
127
- return response.body.items;
128
- } catch (err) {
129
- throw err;
153
+ return parsedUrl.toString();
154
+ } catch {
155
+ return url;
130
156
  }
131
157
  }
132
158
 
@@ -137,8 +163,31 @@ async function fetchListingItems(query, apiPath, APISUFIX, authToken) {
137
163
  * @param {number} maxLength - The maximum length of the text.
138
164
  * @return {string} The truncated text.
139
165
  */
140
- function truncateText(text, maxLength) {
141
- return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
166
+ function truncateText(text = '', maxLength) {
167
+ if (!maxLength || typeof text !== 'string') return text || '';
168
+ return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
169
+ }
170
+
171
+ function safeDate(value) {
172
+ if (!value) return undefined;
173
+ const d = new Date(value);
174
+ if (Number.isNaN(d.getTime()) || d.getTime() <= 0) return undefined;
175
+ return d;
176
+ }
177
+
178
+ function resolveImageUrl(download, itemUrl, publicURL, apiPath) {
179
+ if (!download || !itemUrl) return undefined;
180
+
181
+ try {
182
+ const base = itemUrl.endsWith('/') ? itemUrl : `${itemUrl}/`;
183
+ return normalizeFeedURL(
184
+ new URL(download, base).toString(),
185
+ publicURL,
186
+ apiPath,
187
+ );
188
+ } catch {
189
+ return undefined;
190
+ }
142
191
  }
143
192
 
144
193
  /**
@@ -153,7 +202,7 @@ function truncateText(text, maxLength) {
153
202
  */
154
203
  function make_rssMiddleware() {
155
204
  const { settings } = config;
156
- const APISUFIX = settings.legacyTraverse ? '' : '/++api++';
205
+ const APISUFFIX = settings.legacyTraverse ? '' : '/++api++';
157
206
  let apiPath = '';
158
207
  const host = process.env.HOST || 'localhost';
159
208
  const port = process.env.PORT || '3000';
@@ -180,69 +229,80 @@ function make_rssMiddleware() {
180
229
  date,
181
230
  max_description_length,
182
231
  max_title_length,
183
- } = await getRssFeedData(apiPath, APISUFIX, req, settings);
232
+ } = await getRssFeedData(apiPath, APISUFFIX, req, settings);
184
233
  const items = await fetchListingItems(
185
234
  query,
186
235
  apiPath,
187
- APISUFIX,
188
- req.universalCookies.get('auth_token'),
236
+ APISUFFIX,
237
+ req.universalCookies?.get?.('auth_token'),
189
238
  );
190
239
  const feedOptions = {
191
240
  title: truncateText(title, max_title_length),
192
241
  description: truncateText(description, max_description_length),
193
- feed_url: `${settings.publicURL}${req.path}`,
242
+ feed_url: normalizeFeedURL(req.path, settings.publicURL, apiPath),
194
243
  site_url: settings.publicURL,
195
244
  generator: 'RSS Feed Generator',
196
245
  language: language,
197
- pubDate: new Date(date),
246
+ pubDate: safeDate(date),
247
+ categories: subjects || [],
198
248
  };
199
- if (subjects) {
200
- for (let i = 0; i < subjects.length; i++) {
201
- feedOptions.categories = subjects;
202
- }
203
- }
249
+
204
250
  const feed = new RSS(feedOptions);
205
251
 
206
252
  items.forEach((item) => {
207
- let link = toPublicURL(item['getURL']);
253
+ const link = normalizeFeedURL(item.getURL, settings.publicURL, apiPath);
208
254
  let enclosure = undefined;
209
- if (
210
- item.image_field &&
211
- item.image_scales &&
212
- item.image_scales[item.image_field]
213
- ) {
214
- const imageData = item.image_scales[item.image_field][0];
215
- const imageUrl = `${link
216
- .concat('/')
217
- .concat(
218
- item.image_scales[item.image_field][0].scales.preview.download,
219
- )}`;
220
- const mimeType = item['content-type'];
221
- const originalSize = imageData.size;
255
+
256
+ const imageData = item.image_scales?.[item.image_field]?.[0];
257
+ const previewScale = imageData?.scales?.preview;
258
+ const imageDownload = previewScale?.download || imageData?.download;
259
+ const imageSize = previewScale?.size || imageData?.size;
260
+ const imageType =
261
+ previewScale?.['content-type'] || imageData?.['content-type'];
262
+
263
+ const imageUrl = resolveImageUrl(
264
+ imageDownload,
265
+ link,
266
+ settings.publicURL,
267
+ apiPath,
268
+ );
269
+
270
+ const numericImageSize = Number(imageSize);
271
+
272
+ if (imageUrl && Number.isFinite(numericImageSize) && imageType) {
222
273
  enclosure = {
223
274
  url: imageUrl,
224
- type: mimeType,
225
- size: originalSize,
275
+ type: imageType,
276
+ size: numericImageSize,
226
277
  };
227
278
  }
228
279
  feed.item({
229
280
  title: truncateText(item.title, max_title_length),
230
- description: truncateText(item.description, max_description_length),
281
+ description: truncateText(
282
+ item.description || item.Description || '',
283
+ max_description_length,
284
+ ),
231
285
  url: link,
232
286
  guid: item.UID,
233
- date: new Date(item.effective),
234
- author: item['listCreators']
235
- ? item['listCreators'].map((creator) => creator).join(', ')
236
- : undefined,
287
+ date: safeDate(item.effective || item.created || item.modified),
288
+ author: item.listCreators?.join(', '),
237
289
  categories: item.Subject ? item.Subject : [],
238
290
  enclosure: enclosure || undefined,
239
291
  });
240
292
  });
241
293
 
242
294
  const xml = feed.xml({ indent: true });
243
- res.setHeader('content-type', 'application/atom+xml');
295
+ res.setHeader('Content-Type', 'application/rss+xml; charset=utf-8');
244
296
  res.send(xml);
245
297
  } catch (err) {
298
+ // eslint-disable-next-line no-console
299
+ console.error('[RSS] Failed', {
300
+ path: req.path,
301
+ apiPath,
302
+ APISUFFIX,
303
+ message: err.message,
304
+ stack: err.stack,
305
+ });
246
306
  if (err.response && err.response.status === 401) {
247
307
  // Handle unauthorized errors
248
308
  res.status(401).json({
@@ -275,7 +335,7 @@ function make_rssMiddleware() {
275
335
  export default function makeMiddlewares() {
276
336
  const middleware = express.Router();
277
337
  middleware.use(express.urlencoded({ extended: true }));
278
- middleware.all('**/rss.xml', make_rssMiddleware());
338
+ middleware.get('**/rss.xml', make_rssMiddleware());
279
339
 
280
340
  middleware.id = 'rss-middleware';
281
341