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.
- package/CHANGELOG.md +43 -2
- package/claude-tools/run-mocha-file.sh +29 -0
- package/modules/@apostrophecms/area/index.js +14 -5
- package/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js +72 -3
- package/modules/@apostrophecms/asset/index.js +170 -21
- package/modules/@apostrophecms/asset/lib/build/external-module-api.js +7 -16
- package/modules/@apostrophecms/asset/lib/build/internals.js +1 -3
- package/modules/@apostrophecms/asset/lib/build/task.js +7 -13
- package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +21 -1
- package/modules/@apostrophecms/doc-type/index.js +73 -15
- package/modules/@apostrophecms/http/index.js +175 -139
- package/modules/@apostrophecms/image/index.js +16 -7
- package/modules/@apostrophecms/image/ui/apos/components/AposMediaManager.vue +9 -1
- package/modules/@apostrophecms/job/index.js +3 -1
- package/modules/@apostrophecms/oembed/index.js +12 -45
- package/modules/@apostrophecms/page/index.js +17 -1
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +10 -1
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapInsertItem.vue +3 -14
- package/modules/@apostrophecms/rich-text-widget/ui/apos/lib/remove-slash.js +18 -0
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +37 -6
- package/modules/@apostrophecms/url/index.js +54 -0
- package/package.json +11 -12
- package/test/asset-external.js +3 -0
- package/test/asset-lock-cache.js +243 -0
- package/test/assets.js +25 -2
- package/test/config-cascade-merge.js +73 -0
- package/test/files.js +10 -11
- package/test/image-tags.js +103 -0
- package/test/modules/collision-piece/index.js +23 -0
- package/test/oembed.js +0 -98
- package/test/pages-move-permissions.js +317 -0
- package/test/pages.js +12 -15
- package/test/pieces-public-api-relationship-choices.js +245 -0
- package/test/pieces.js +12 -15
- 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
|
-
//
|
|
1639
|
-
//
|
|
1640
|
-
|
|
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
|
-
|
|
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:
|
|
1650
|
-
return Boolean(projection[
|
|
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:
|
|
1654
|
-
return
|
|
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
|
|
1660
|
-
//
|
|
1661
|
-
//
|
|
1662
|
-
//
|
|
1663
|
-
//
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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 (
|
|
232
|
-
|
|
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
|
|
253
|
+
return form.getLength(callback);
|
|
235
254
|
})();
|
|
236
|
-
options.headers =
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
//
|
|
252
|
-
// this header
|
|
253
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|