apostrophe 4.31.1-beta.1 → 4.32.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +43 -2
  2. package/claude-tools/run-mocha-file.sh +29 -0
  3. package/modules/@apostrophecms/area/index.js +14 -5
  4. package/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js +72 -3
  5. package/modules/@apostrophecms/asset/index.js +170 -21
  6. package/modules/@apostrophecms/asset/lib/build/external-module-api.js +7 -16
  7. package/modules/@apostrophecms/asset/lib/build/internals.js +1 -3
  8. package/modules/@apostrophecms/asset/lib/build/task.js +7 -13
  9. package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +21 -1
  10. package/modules/@apostrophecms/doc-type/index.js +73 -15
  11. package/modules/@apostrophecms/http/index.js +175 -139
  12. package/modules/@apostrophecms/image/index.js +16 -7
  13. package/modules/@apostrophecms/image/ui/apos/components/AposMediaManager.vue +9 -1
  14. package/modules/@apostrophecms/job/index.js +3 -1
  15. package/modules/@apostrophecms/oembed/index.js +12 -45
  16. package/modules/@apostrophecms/page/index.js +17 -1
  17. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +10 -1
  18. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapInsertItem.vue +3 -14
  19. package/modules/@apostrophecms/rich-text-widget/ui/apos/lib/remove-slash.js +18 -0
  20. package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +37 -6
  21. package/modules/@apostrophecms/url/index.js +54 -0
  22. package/package.json +11 -12
  23. package/test/asset-external.js +3 -0
  24. package/test/asset-lock-cache.js +243 -0
  25. package/test/assets.js +25 -2
  26. package/test/config-cascade-merge.js +73 -0
  27. package/test/files.js +10 -11
  28. package/test/image-tags.js +103 -0
  29. package/test/modules/collision-piece/index.js +23 -0
  30. package/test/oembed.js +0 -98
  31. package/test/pages-move-permissions.js +317 -0
  32. package/test/pages.js +12 -15
  33. package/test/pieces-public-api-relationship-choices.js +245 -0
  34. package/test/pieces.js +12 -15
  35. package/test-lib/test.js +32 -0
@@ -1621,6 +1621,50 @@ module.exports = {
1621
1621
  return doc;
1622
1622
  },
1623
1623
 
1624
+ // Resolve a `choices` / `counts` filter (i.e. a query builder name) to
1625
+ // the schema field whose distinct values that builder would expose, or
1626
+ // `undefined` when it does not correspond to a schema field.
1627
+ //
1628
+ // Most field types register a query builder named exactly after the
1629
+ // field, so an exact-name match is enough. Relationship fields are the
1630
+ // exception: they register additional builders whose names differ from
1631
+ // the schema field name — the `And` variant of the field name
1632
+ // (`_authorAnd`) and the "slug" builders that drop the leading
1633
+ // underscore (`author`, `authorAnd`). All of those run
1634
+ // `toDistinct(field.idsStorage)` against the host collection, so they
1635
+ // must resolve back to the underlying relationship field to be gated
1636
+ // exactly like the field itself. Dot-notated sub-field builders resolve
1637
+ // to their top-level schema field.
1638
+ choicesFilterField(filter) {
1639
+ if (typeof filter !== 'string') {
1640
+ return undefined;
1641
+ }
1642
+ // Exact match: covers every non-relationship field type, plus the
1643
+ // relationship field's own `field.name` builder.
1644
+ const exact = self.schema.find(f => f.name === filter);
1645
+ if (exact) {
1646
+ return exact;
1647
+ }
1648
+ // Dot-notated filters are gated by their top-level schema field.
1649
+ const topLevel = filter.split('.')[0];
1650
+ if (topLevel !== filter) {
1651
+ const parent = self.schema.find(f => f.name === topLevel);
1652
+ if (parent) {
1653
+ return parent;
1654
+ }
1655
+ }
1656
+ // Relationship alias builders. Strip an optional trailing `And`
1657
+ // operator suffix, then match either the relationship field name
1658
+ // (`_author`, reached via the `_authorAnd` operation builder) or the
1659
+ // field name without its leading underscore (`author`, reached via
1660
+ // the slug builders `author` / `authorAnd`).
1661
+ const base = filter.replace(/And$/, '');
1662
+ return self.schema.find(f =>
1663
+ f.type === 'relationship' &&
1664
+ (f.name === base || f.name.replace(/^_/, '') === base)
1665
+ );
1666
+ },
1667
+
1624
1668
  // Returns true if the named filter is permitted to expose distinct
1625
1669
  // values through the `choices` / `counts` query builders given the
1626
1670
  // publicApiProjection. When no publicApiProjection is in effect
@@ -1635,35 +1679,49 @@ module.exports = {
1635
1679
  if (!projection || !Object.keys(projection).length) {
1636
1680
  return true;
1637
1681
  }
1638
- // Builders that aren't named after a top-level schema field are
1639
- // not gated by the projection.
1640
- const field = self.schema.find(f => f.name === filter);
1682
+ // Resolve the builder to its underlying schema field, mapping
1683
+ // relationship alias builders back to the relationship field so the
1684
+ // projection check cannot be bypassed by requesting the alias name.
1685
+ // Builders not backed by a schema field are not gated.
1686
+ const field = self.choicesFilterField(filter);
1641
1687
  if (!field) {
1642
1688
  return true;
1643
1689
  }
1644
- const topLevel = filter.split('.')[0];
1690
+ // Projection keys that would expose this field's values to the client.
1691
+ // For a relationship, exposure of either the relationship property
1692
+ // itself or its `idsStorage` (the property MongoDB's `distinct`
1693
+ // actually reads) permits the choices.
1694
+ const keys = [ field.name ];
1695
+ if (field.idsStorage) {
1696
+ keys.push(field.idsStorage);
1697
+ }
1645
1698
  const values = Object.values(projection);
1646
1699
  const hasInclusion = values.some(v => v && v !== 0);
1647
1700
  const hasExclusion = values.some(v => v === 0 || v === false);
1648
1701
  if (hasInclusion) {
1649
- // Inclusion projection: field must be explicitly included.
1650
- return Boolean(projection[topLevel]);
1702
+ // Inclusion projection: at least one relevant key must be included.
1703
+ return keys.some(key => Boolean(projection[key]));
1651
1704
  }
1652
1705
  if (hasExclusion) {
1653
- // Exclusion projection: field must not be explicitly excluded.
1654
- return projection[topLevel] !== 0 && projection[topLevel] !== false;
1706
+ // Exclusion projection: no relevant key may be explicitly excluded.
1707
+ return keys.every(
1708
+ key => projection[key] !== 0 && projection[key] !== false
1709
+ );
1655
1710
  }
1656
1711
  return true;
1657
1712
  },
1658
1713
 
1659
- // Returns true if the current user is permitted to view the named
1660
- // schema field according to any schema-level `viewPermission`.
1661
- // Non-schema filters are permitted. A dot-notated filter is matched
1662
- // against the top-level schema field, since `viewPermission` is
1663
- // declared on top-level fields (see `removeForbiddenFields`).
1714
+ // Returns true if the current user is permitted to view the field
1715
+ // exposed by the named filter according to any schema-level
1716
+ // `viewPermission`. Filters that don't resolve to a schema field are
1717
+ // permitted. Relationship alias builders and dot-notated sub-field
1718
+ // builders resolve to their underlying top-level field (see
1719
+ // `choicesFilterField`), since `viewPermission` is declared on
1720
+ // top-level fields (see `removeForbiddenFields`) — matching only the
1721
+ // literal filter name would let the relationship alias builders bypass
1722
+ // this check the same way they bypassed the projection check.
1664
1723
  choicesFieldAllowedByViewPermission(req, filter) {
1665
- const topLevel = filter.split('.')[0];
1666
- const field = self.schema.find(f => f.name === topLevel);
1724
+ const field = self.choicesFilterField(filter);
1667
1725
  if (!field || !field.viewPermission) {
1668
1726
  return true;
1669
1727
  }
@@ -1,9 +1,9 @@
1
1
  const _ = require('lodash');
2
2
  const qs = require('qs');
3
- const fetch = require('node-fetch');
4
3
  const tough = require('tough-cookie');
5
4
  const escapeHost = require('../../../lib/escape-host.js');
6
5
  const util = require('util');
6
+ const { PassThrough } = require('node:stream');
7
7
 
8
8
  module.exports = {
9
9
  options: {
@@ -53,160 +53,151 @@ module.exports = {
53
53
  self.errors[name] = code;
54
54
  },
55
55
 
56
- // Fetch the given URL and return the response body. Accepts the
57
- // following options:
58
- //
59
- // `qs` (builds a query string with qs)
60
- // `jar` (pass in a cookie jar obtained from apos.http.jar())
61
- // `parse` (can be 'json` to always parse the response body as JSON,
62
- // otherwise the response body is parsed as JSON only if the content-type
63
- // is application/json) `headers` (an object containing header names and
64
- // values) `fullResponse` (if true, return an object with `status`,
65
- // `headers` and `body` properties, rather than returning the body
66
- // directly; the individual `headers` are canonicalized to lowercase
67
- // names. If a header appears multiple times an array is returned for it)
68
- //
69
- // If the status code is >= 400 an error is thrown. The error object will
70
- // be similar to a `fullResponse` object, with a `status` property.
71
- //
72
- // If the URL is site-relative (starts with /) it will be requested from
73
- // the apostrophe site itself.
56
+ /**
57
+ * Options accepted by the `apos.http.*` request methods. Any additional
58
+ * properties are passed through to the built-in `fetch` as-is (e.g.
59
+ * `redirect`, `signal`).
60
+ *
61
+ * @typedef {object} AposHttpOptions
62
+ * @property {object} [qs] Query-string parameters, serialized with `qs`
63
+ * and appended to the URL.
64
+ * @property {object} [jar] A cookie jar from `apos.http.jar()`. Its
65
+ * cookies are sent with the request, and any `Set-Cookie` in the
66
+ * response is stored back into the jar.
67
+ * @property {'json'|'form'} [send] Force body encoding: `'json'` sends
68
+ * `body` JSON-encoded, `'form'` sends it URL-encoded.
69
+ * @property {*} [body] Request body. A plain object or array is sent as
70
+ * JSON; a `FormData` (the global or a `form-data` instance) is sent as
71
+ * multipart; otherwise sent as-is. See `send` to force encoding.
72
+ * @property {'json'} [parse] Always parse the response body as JSON.
73
+ * Without it, the body is parsed as JSON only when the response
74
+ * content-type is `application/json`.
75
+ * @property {Object<string, string>} [headers] Request header names and
76
+ * values. Per the fetch standard a `Host` header cannot be set.
77
+ * @property {boolean} [fullResponse] Resolve with `{ status, headers,
78
+ * body }` instead of the body alone. Header names are lowercased; a
79
+ * header seen multiple times is comma-joined.
80
+ * @property {boolean} [originalResponse] Resolve with the raw built-in
81
+ * fetch `Response`. Its `body` is a WHATWG `ReadableStream` (use
82
+ * `Readable.fromWeb()` to consume it as a Node stream).
83
+ * @property {number} [timeout] Per-request timeout in milliseconds,
84
+ * applied via `AbortSignal.timeout`. A falsy value means no timeout.
85
+ * @property {AbortSignal} [signal] Abort signal; combined with `timeout`
86
+ * when both are given.
87
+ * @property {object} [dispatcher] An undici `Dispatcher` (e.g. a proxy or
88
+ * custom-TLS agent). Replaces the former node-fetch `agent` option.
89
+ */
74
90
 
91
+ /**
92
+ * Make a GET request and resolve with the response body.
93
+ *
94
+ * If the URL is site-relative (starts with `/`) it is requested from the
95
+ * apostrophe site itself. Throws if the status code is >= 400; the error
96
+ * resembles a `fullResponse` object with a `status` property.
97
+ *
98
+ * @param {string} url
99
+ * @param {AposHttpOptions} [options]
100
+ * @returns {Promise<*>} The response body, or a `fullResponse` /
101
+ * `originalResponse` object when those options are set.
102
+ */
75
103
  async get(url, options) {
76
104
  return self.remote('GET', url, options);
77
105
  },
78
106
 
79
- // Make a HEAD request for the given URL and return the response object,
80
- // which will include a `status` property as well as `headers`.
81
- //
82
- // Options:
83
- //
84
- // `qs` (builds a query string with qs)
85
- // `jar` (pass in a cookie jar obtained from apos.http.jar())
86
- // `headers` (an object containing header names and values)
87
- //
88
- // If the status code is >= 400 an error is thrown. The error object
89
- // will have a `status` property.
90
- //
91
- // If the URL is site-relative (starts with /) it will be requested from
92
- // the apostrophe site itself.
93
-
107
+ /**
108
+ * Make a HEAD request. Resolves with the (empty) response body; pass
109
+ * `fullResponse: true` to read the `status` and `headers`.
110
+ *
111
+ * If the URL is site-relative (starts with `/`) it is requested from the
112
+ * apostrophe site itself. Throws if the status code is >= 400.
113
+ *
114
+ * @param {string} url
115
+ * @param {AposHttpOptions} [options]
116
+ * @returns {Promise<*>}
117
+ */
94
118
  async head(url, options) {
95
119
  return self.remote('HEAD', url, options);
96
120
  },
97
121
 
98
- // Send a POST request to the given URL and return the response body.
99
- // Accepts the following options:
100
- //
101
- // `qs` (pass object; builds a query string with qs)
102
- // `jar` (pass in a cookie jar obtained from apos.http.jar())
103
- // `send` (can be 'json' to always send `options.body` JSON encoded, or
104
- // 'form' to send it URL-encoded) `body` (request body; if an object or
105
- // array, sent as JSON, otherwise sent as-is, unless the `send` option is
106
- // set) `parse` (can be 'json` to always parse the response body as JSON,
107
- // otherwise the response body is parsed as JSON only if the content-type
108
- // is application/json) `headers` (an object containing header names and
109
- // values) `fullResponse` (if true, return an object with `status`,
110
- // `headers` and `body` properties, rather than returning the body
111
- // directly; the individual `headers` are canonicalized to lowercase
112
- // names. If a header appears multiple times an array is returned for it)
113
- //
114
- // If the status code is >= 400 an error is thrown. The error object will
115
- // be similar to a `fullResponse` object, with a `status` property.
116
- //
117
- // If the URL is site-relative (starts with /) it will be requested from
118
- // the apostrophe site itself.
119
-
122
+ /**
123
+ * Send a POST request and resolve with the response body.
124
+ *
125
+ * If the URL is site-relative (starts with `/`) it is requested from the
126
+ * apostrophe site itself. Throws if the status code is >= 400; the error
127
+ * resembles a `fullResponse` object with a `status` property.
128
+ *
129
+ * @param {string} url
130
+ * @param {AposHttpOptions} [options]
131
+ * @returns {Promise<*>} The response body, or a `fullResponse` /
132
+ * `originalResponse` object when those options are set.
133
+ */
120
134
  async post(url, options) {
121
135
  return self.remote('POST', url, options);
122
136
  },
123
137
 
124
- // Send a DELETE request to the given URL and return the response body.
125
- // Accepts the following options:
126
- //
127
- // `qs` (pass object; builds a query string with qs)
128
- // `jar` (pass in a cookie jar obtained from apos.http.jar())
129
- // `send` (can be 'json' to always send `options.body` JSON encoded, or
130
- // 'form' to send it URL-encoded) `body` (request body; if an object or
131
- // array, sent as JSON, otherwise sent as-is, unless the `send` option is
132
- // set) `parse` (can be 'json` to always parse the response body as JSON,
133
- // otherwise the response body is parsed as JSON only if the content-type
134
- // is application/json) `headers` (an object containing header names and
135
- // values) `fullResponse` (if true, return an object with `status`,
136
- // `headers` and `body` properties, rather than returning the body
137
- // directly; the individual `headers` are canonicalized to lowercase
138
- // names. If a header appears multiple times an array is returned for it)
139
- //
140
- // If the status code is >= 400 an error is thrown. The error object will
141
- // be similar to a `fullResponse` object, with a `status` property.
142
- //
143
- // If the URL is site-relative (starts with /) it will be requested from
144
- // the apostrophe site itself.
145
-
138
+ /**
139
+ * Send a DELETE request and resolve with the response body.
140
+ *
141
+ * If the URL is site-relative (starts with `/`) it is requested from the
142
+ * apostrophe site itself. Throws if the status code is >= 400; the error
143
+ * resembles a `fullResponse` object with a `status` property.
144
+ *
145
+ * @param {string} url
146
+ * @param {AposHttpOptions} [options]
147
+ * @returns {Promise<*>} The response body, or a `fullResponse` /
148
+ * `originalResponse` object when those options are set.
149
+ */
146
150
  async delete(url, options) {
147
151
  return self.remote('DELETE', url, options);
148
152
  },
149
153
 
150
- // Send a PUT request to the given URL and return the response body.
151
- // Accepts the following options:
152
- //
153
- // `qs` (pass object; builds a query string with qs)
154
- // `jar` (pass in a cookie jar obtained from apos.http.jar())
155
- // `send` (can be 'json' to always send `options.body` JSON encoded, or
156
- // 'form' to send it URL-encoded) `body` (request body; if an object or
157
- // array, sent as JSON, otherwise sent as-is, unless the `send` option is
158
- // set) `parse` (can be 'json` to always parse the response body as JSON,
159
- // otherwise the response body is parsed as JSON only if the content-type
160
- // is application/json) `headers` (an object containing header names and
161
- // values) `fullResponse` (if true, return an object with `status`,
162
- // `headers` and `body` properties, rather than returning the body
163
- // directly; the individual `headers` are canonicalized to lowercase
164
- // names. If a header appears multiple times an array is returned for it)
165
- //
166
- // If the status code is >= 400 an error is thrown. The error object will
167
- // be similar to a `fullResponse` object, with a `status` property.
168
- //
169
- // If the URL is site-relative (starts with /) it will be requested from
170
- // the apostrophe site itself.
171
-
154
+ /**
155
+ * Send a PUT request and resolve with the response body.
156
+ *
157
+ * If the URL is site-relative (starts with `/`) it is requested from the
158
+ * apostrophe site itself. Throws if the status code is >= 400; the error
159
+ * resembles a `fullResponse` object with a `status` property.
160
+ *
161
+ * @param {string} url
162
+ * @param {AposHttpOptions} [options]
163
+ * @returns {Promise<*>} The response body, or a `fullResponse` /
164
+ * `originalResponse` object when those options are set.
165
+ */
172
166
  async put(url, options) {
173
167
  return self.remote('PUT', url, options);
174
168
  },
175
169
 
176
- // Send a PATCH request to the given URL and return the response body.
177
- // Accepts the following options:
178
- //
179
- // `qs` (pass object; builds a query string with qs)
180
- // `jar` (pass in a cookie jar obtained from apos.http.jar())
181
- // `send` (can be 'json' to always send `options.body` JSON encoded, or
182
- // 'form' to send it URL-encoded) `body` (request body; if an object or
183
- // array, sent as JSON, otherwise sent as-is, unless the `send` option is
184
- // set) `parse` (can be 'json` to always parse the response body as JSON,
185
- // otherwise the response body is parsed as JSON only if the content-type
186
- // is application/json) `headers` (an object containing header names and
187
- // values) `fullResponse` (if true, return an object with `status`,
188
- // `headers` and `body` properties, rather than returning the body
189
- // directly; the individual `headers` are canonicalized to lowercase
190
- // names. If a header appears multiple times an array is returned for it)
191
- // `originalResponse` (if true, return the response object exactly as it
192
- // is returned by node-fetch)
193
- //
194
- // If the status code is >= 400 an error is thrown. The error object will
195
- // be similar to a `fullResponse` object, with a `status` property.
196
- //
197
- // If the URL is site-relative (starts with /) it will be requested from
198
- // the apostrophe site itself.
199
-
170
+ /**
171
+ * Send a PATCH request and resolve with the response body.
172
+ *
173
+ * If the URL is site-relative (starts with `/`) it is requested from the
174
+ * apostrophe site itself. Throws if the status code is >= 400; the error
175
+ * resembles a `fullResponse` object with a `status` property.
176
+ *
177
+ * @param {string} url
178
+ * @param {AposHttpOptions} [options]
179
+ * @returns {Promise<*>} The response body, or a `fullResponse` /
180
+ * `originalResponse` object when those options are set.
181
+ */
200
182
  async patch(url, options) {
201
183
  return self.remote('PATCH', url, options);
202
184
  },
203
185
 
204
- // Invoke a remote HTTP API with the named method. Use .get, .post, etc.
205
- // This is an implementation method invoked by these
206
- //
207
- // If the URL is site-relative (starts with /) it will be requested from
208
- // the apostrophe site itself.
209
-
186
+ /**
187
+ * Invoke a remote HTTP API with the given method. The implementation
188
+ * behind `get`, `post`, `put`, `patch`, `delete` and `head` — prefer
189
+ * those.
190
+ *
191
+ * If the URL is site-relative (starts with `/`) it is requested from the
192
+ * apostrophe site itself. Throws if the status code is >= 400; the error
193
+ * resembles a `fullResponse` object with a `status` property.
194
+ *
195
+ * @param {string} method HTTP method, e.g. `'GET'`.
196
+ * @param {string} url
197
+ * @param {AposHttpOptions} [options]
198
+ * @returns {Promise<*>} The response body, or a `fullResponse` /
199
+ * `originalResponse` object when those options are set.
200
+ */
210
201
  async remote(method, url, options) {
211
202
  let awaitedBody = false;
212
203
  if (!options) {
@@ -216,6 +207,24 @@ module.exports = {
216
207
  ...options,
217
208
  method
218
209
  };
210
+ // `agent` was a node-fetch option with no equivalent in the built-in
211
+ // fetch: an http.Agent is not an undici Dispatcher. Fail loudly rather
212
+ // than silently ignore a proxy/TLS/keep-alive intention. Power users
213
+ // can pass an undici `dispatcher` instead, which fetch accepts as-is.
214
+ if (options.agent) {
215
+ throw new Error('apos.http no longer supports the `agent` option (the built-in fetch has no equivalent). Pass an undici `dispatcher` instead.');
216
+ }
217
+ // `timeout` was a node-fetch option; the built-in fetch uses an
218
+ // AbortSignal. Translate it to preserve the behavior, combining it with
219
+ // any caller-supplied signal. As with node-fetch, a falsy timeout (0,
220
+ // undefined) means "no timeout".
221
+ if (options.timeout) {
222
+ const timeoutSignal = AbortSignal.timeout(options.timeout);
223
+ options.signal = options.signal
224
+ ? AbortSignal.any([ options.signal, timeoutSignal ])
225
+ : timeoutSignal;
226
+ delete options.timeout;
227
+ }
219
228
  if (url.match(/^\//)) {
220
229
  url = `${self.getBase()}${url}`;
221
230
  }
@@ -228,14 +237,35 @@ module.exports = {
228
237
  options.headers = options.headers || {};
229
238
  options.headers.cookie = cookies.join('; ');
230
239
  }
231
- if (options.body && options.body.constructor && (options.body.constructor.name === 'FormData')) {
232
- // If we don't do this multiparty will not parse it properly
240
+ if (
241
+ options.body &&
242
+ (typeof options.body.getHeaders === 'function') &&
243
+ (typeof options.body.pipe === 'function')
244
+ ) {
245
+ // A `form-data` package instance. The built-in fetch does not
246
+ // recognize it (passed directly it is coerced to a useless string
247
+ // body), so we adapt it ourselves: take its multipart Content-Type
248
+ // (with boundary) from getHeaders(), set Content-Length so multiparty
249
+ // can parse it, and stream it through a PassThrough, which the
250
+ // built-in fetch does accept as a request body.
251
+ const form = options.body;
233
252
  const contentLength = await util.promisify((callback) => {
234
- return options.body.getLength(callback);
253
+ return form.getLength(callback);
235
254
  })();
236
- options.headers = options.headers || {};
237
- options.headers['Content-Length'] = contentLength;
238
- // node-fetch will set the Content-Type
255
+ options.headers = {
256
+ ...form.getHeaders(),
257
+ ...options.headers,
258
+ 'Content-Length': contentLength
259
+ };
260
+ const pass = new PassThrough();
261
+ form.pipe(pass);
262
+ options.body = pass;
263
+ // Required by the built-in fetch when the body is a stream.
264
+ options.duplex = 'half';
265
+ } else if (options.body instanceof FormData) {
266
+ // A native (WHATWG) FormData: the built-in fetch sets the multipart
267
+ // Content-Type (with boundary) and streams it. Nothing to do here,
268
+ // and we must not fall through to the JSON branch below.
239
269
  } else if (((options.body != null) && ((typeof options.body) === 'object')) || (options.send === 'json')) {
240
270
  options.body = JSON.stringify(options.body);
241
271
  options.headers = options.headers || {};
@@ -248,9 +278,9 @@ module.exports = {
248
278
  const res = await fetch(url, options);
249
279
  let body;
250
280
  if (options.jar) {
251
- // This is node-fetch specific, browsers limit what JS can see about
252
- // this header
253
- (res.headers.raw()['set-cookie'] || []).forEach(cookie => {
281
+ // getSetCookie() returns each Set-Cookie value separately; browsers
282
+ // limit what JS can see about this header
283
+ res.headers.getSetCookie().forEach(cookie => {
254
284
  options.jar.setCookieSync(cookie, url);
255
285
  });
256
286
  }
@@ -279,6 +309,12 @@ module.exports = {
279
309
  // Usually just a source of bugs
280
310
  headers[name] = value;
281
311
  });
312
+ // node-fetch resolved a redirect's Location to an absolute URL; the
313
+ // built-in fetch returns it as sent (often relative). Resolve it
314
+ // against the request URL to preserve the absolute form callers expect.
315
+ if ((res.status >= 300) && (res.status < 400) && headers.location) {
316
+ headers.location = new URL(headers.location, url).href;
317
+ }
282
318
  return {
283
319
  status: res.status,
284
320
  headers,
@@ -333,15 +333,24 @@ module.exports = {
333
333
  });
334
334
 
335
335
  const imageTagManager = self.apos.doc.getManager('@apostrophecms/image-tag');
336
- const tag = (operation === 'create')
337
- ? await imageTagManager.insert(
338
- req,
339
- {
336
+ let tag;
337
+ if (operation === 'create') {
338
+ // Reuse an existing tag with the same slug instead of inserting a
339
+ // duplicate.
340
+ const desiredSlug = self.apos.util.slugify(title);
341
+ const lockName = `@apostrophecms/image-tag:create:${req.locale}:${desiredSlug}`;
342
+ tag = await self.apos.lock.withLock(lockName, async () => {
343
+ const existing = await imageTagManager
344
+ .find(req, { slug: desiredSlug })
345
+ .toObject();
346
+ return existing || imageTagManager.insert(req, {
340
347
  ...imageTagManager.newInstance(),
341
348
  title
342
- }
343
- )
344
- : await imageTagManager.find(req, { slug }).toObject();
349
+ });
350
+ });
351
+ } else {
352
+ tag = await imageTagManager.find(req, { slug }).toObject();
353
+ }
345
354
 
346
355
  return self.apos.modules['@apostrophecms/job'].runBatch(
347
356
  req,
@@ -816,6 +816,13 @@ export default {
816
816
  return [];
817
817
  }
818
818
 
819
+ const { count } = await apos.http.get(action, {
820
+ draft: true,
821
+ qs: {
822
+ count: true
823
+ }
824
+ });
825
+
819
826
  const response = await apos.http.get(
820
827
  action,
821
828
  {
@@ -823,7 +830,8 @@ export default {
823
830
  qs: {
824
831
  sort: {
825
832
  title: 1
826
- }
833
+ },
834
+ perPage: Math.max(count, 1)
827
835
  }
828
836
  }
829
837
  );
@@ -110,7 +110,9 @@ module.exports = {
110
110
  // sends a response with a jobId to the browser
111
111
  job = await self.start(options);
112
112
 
113
- self.setTotal(job, ids.length);
113
+ // Persist the total before work begins so the completed notification
114
+ // and job document always report it, even if processing finishes fast.
115
+ await self.setTotal(job, ids.length);
114
116
  // Runs after response is already sent
115
117
  run();
116
118