domma-cms 1.8.13 → 1.8.15

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 (37) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/CLAUDE.md +29 -1
  3. package/admin/js/api.js +1 -1
  4. package/admin/js/views/plugin-code.js +3 -3
  5. package/admin/js/views/plugins.js +8 -8
  6. package/docs/DommaFeatures.md +2 -1
  7. package/docs/configuration.md +2 -1
  8. package/package.json +1 -1
  9. package/server/config.js +3 -2
  10. package/server/env.js +12 -0
  11. package/server/routes/api/collections.js +9 -0
  12. package/server/routes/api/forms.js +9 -4
  13. package/server/routes/api/plugin-marketplace.js +1 -2
  14. package/server/routes/api/plugins.js +25 -3
  15. package/server/server.js +1 -1
  16. package/server/services/actions.js +5 -2
  17. package/server/services/adapters/FileAdapter.js +2 -1
  18. package/server/services/adapters/MongoAdapter.js +22 -6
  19. package/server/services/codePolicy.js +111 -0
  20. package/server/services/collections.js +2 -1
  21. package/server/services/components.js +3 -2
  22. package/server/services/connectionManager.js +8 -0
  23. package/server/services/content.js +1 -1
  24. package/server/services/contextMenus.js +2 -1
  25. package/server/services/filterEngine.js +3 -1
  26. package/server/services/forms.js +4 -3
  27. package/server/services/menus.js +2 -1
  28. package/server/services/mongoQueryGuard.js +193 -0
  29. package/server/services/outbound.js +394 -0
  30. package/server/services/pluginFiles.js +8 -1
  31. package/server/services/pluginInstaller.js +16 -13
  32. package/server/services/pluginScaffold.js +3 -0
  33. package/server/services/safePath.js +35 -0
  34. package/server/services/staffAccess.js +2 -1
  35. package/server/services/versions.js +1 -1
  36. package/server/services/views.js +17 -4
  37. package/server/services/wpImport/fetch.js +12 -181
@@ -16,9 +16,9 @@ import {readFileSync} from 'fs';
16
16
  import path from 'path';
17
17
  import zlib from 'zlib';
18
18
  import {fileURLToPath} from 'url';
19
- import {getConfig} from '../config.js';
20
19
  import {discoverPlugins, pluginRoleHooks, unregisterAllRolesForPlugin, validatePluginDir} from './plugins.js';
21
20
  import {allowInGitignore, manifestContentPaths, pluginContentRules} from './siteGitignore.js';
21
+ import {signedOnly, signedOnlyError} from './codePolicy.js';
22
22
 
23
23
  // ---------------------------------------------------------------------------
24
24
  // Paths
@@ -74,18 +74,14 @@ MCowBQYDK2VwAyEAqAlx1lfHeCaNiwJmoXZMDY56yafU2GlpNG3VInw9Q14=
74
74
  * Does this site refuse anything the marketplace has not signed?
75
75
  *
76
76
  * Default false, because a CMS whose own scaffolder produces unsigned plugins
77
- * cannot refuse unsigned plugins without refusing its user's own work. A fleet
78
- * site should set it true: everything it receives arrives by push-copy, and
79
- * nothing legitimate is ever uploaded there.
77
+ * cannot refuse unsigned plugins without refusing its user's own work. A hosted
78
+ * site has it on (services/codePolicy.js - the one place that decides, and the
79
+ * other doors it closes).
80
80
  *
81
81
  * @returns {boolean}
82
82
  */
83
83
  function requireSignedPlugins() {
84
- try {
85
- return getConfig('server')?.requireSignedPlugins === true;
86
- } catch {
87
- return false;
88
- }
84
+ return signedOnly();
89
85
  }
90
86
 
91
87
  /**
@@ -348,12 +344,12 @@ export async function installPlugin(slug, { tarball, signature, publicKeyId, all
348
344
  // Step 1 - Verify signature, or establish that there is deliberately none.
349
345
  const unsigned = !signature;
350
346
  if (unsigned) {
347
+ // Signed-only first: asking the admin to confirm something the site will
348
+ // refuse anyway would be a question with no right answer.
349
+ if (requireSignedPlugins()) throw signedOnlyError();
351
350
  if (!allowUnsigned) {
352
351
  throw new Error('This plugin is unsigned. Installing it runs its code on this server, so it has to be confirmed.');
353
352
  }
354
- if (requireSignedPlugins()) {
355
- throw new Error('This site accepts signed plugins only (server.json: requireSignedPlugins).');
356
- }
357
353
  }
358
354
 
359
355
  const pubKeyPem = unsigned ? null : trustedKey(publicKeyId, 'bundle');
@@ -396,7 +392,14 @@ export async function installPlugin(slug, { tarball, signature, publicKeyId, all
396
392
  throw err;
397
393
  }
398
394
 
399
- // Step 4 - Validate manifest
395
+ // Step 4 - Validate manifest. discoverPlugins() names a plugin after its
396
+ // folder, so it cannot tell a bundle installed under someone else's slug -
397
+ // the manifest inside must name this slug itself (as an upgrade checks).
398
+ const own = await validatePluginDir(destDir, slug);
399
+ if (!own.ok) {
400
+ await fs.rm(destDir, { recursive: true, force: true });
401
+ throw new Error(`Plugin manifest validation failed after extraction: ${slug} (${own.reason})`);
402
+ }
400
403
  const manifests = await discoverPlugins();
401
404
  const manifest = manifests.find(m => m.name === slug);
402
405
  if (!manifest) {
@@ -12,6 +12,7 @@
12
12
  import fs from 'fs/promises';
13
13
  import path from 'path';
14
14
  import {allowInGitignore, sitePluginRules} from './siteGitignore.js';
15
+ import {assertMayChangeCode} from './codePolicy.js';
15
16
 
16
17
  const PLUGINS_DIR = path.resolve('plugins');
17
18
  const TEMPLATE_DIR = path.join(PLUGINS_DIR, '_template');
@@ -71,6 +72,8 @@ export function validateScaffoldInput(input = {}) {
71
72
  * @returns {Promise<{name: string, files: string[]}>} Created plugin name and relative file list
72
73
  */
73
74
  export async function scaffoldPlugin(input) {
75
+ // A new plugin is new server code: a signed-only site makes none (services/codePolicy.js).
76
+ assertMayChangeCode();
74
77
  const { slug, displayName, description, author, icon } = validateScaffoldInput(input);
75
78
 
76
79
  try {
@@ -0,0 +1,35 @@
1
+ /**
2
+ * One name, one file, inside one folder.
3
+ *
4
+ * Fastify DECODES route params, so `/api/forms/..%2F..%2Fconfig%2Fserver`
5
+ * arrives as the slug `../../config/server`. A store that builds its file as
6
+ * `path.join(DIR, `${slug}.json`)` then reads and writes `config/server.json` -
7
+ * which is how a site admin could rewrite the host's own settings (switching
8
+ * `requireSignedPlugins` off, unlocking a Tool) through an ordinary form save.
9
+ * Routes check slugs in most places; this is the check at the file, so a route
10
+ * that forgets is still safe.
11
+ *
12
+ * @module services/safePath
13
+ */
14
+ import path from 'path';
15
+
16
+ /**
17
+ * The file `<dir>/<name><ext>`, refusing any name that is not one plain path
18
+ * segment (a separator, `.`/`..`, a NUL, empty, or not a string).
19
+ *
20
+ * @param {string} dir
21
+ * @param {string} name
22
+ * @param {string} [ext] e.g. '.json'; '' for a folder
23
+ * @returns {string}
24
+ * @throws {Error} statusCode 404 - to a caller, a name like that names nothing
25
+ */
26
+ export function fileIn(dir, name, ext = '') {
27
+ const root = path.resolve(dir);
28
+ const ok = typeof name === 'string' && name !== '' && name !== '.' && name !== '..'
29
+ && !/[\\/\0]/.test(name);
30
+ const file = ok ? path.join(root, `${name}${ext}`) : null;
31
+ if (!file || path.dirname(file) !== root) {
32
+ throw Object.assign(new Error(`Not found: ${String(name).slice(0, 80)}`), {statusCode: 404, code: 'ENOENT'});
33
+ }
34
+ return file;
35
+ }
@@ -24,7 +24,8 @@
24
24
  *
25
25
  * Anyone who can read the site's JWT_SECRET (shell access, or code running in
26
26
  * the site's process - which includes a level-0 user installing an unsigned
27
- * plugin, unless server.json `requireSignedPlugins` is on) can mint either;
27
+ * plugin or editing one, unless the site is signed-only - services/codePolicy.js,
28
+ * which hosted sites are) can mint either;
28
29
  * that is true of every session. The session is the site's full top role for
29
30
  * an hour (the Manager can already act as it), with no `sid` to revoke it.
30
31
  *
@@ -37,7 +37,7 @@ const PAGES_DIR = path.join(config.content.contentDir, 'pages');
37
37
  function versionsDirForPath(urlPath) {
38
38
  const normalised = urlPath === '/' || !urlPath ? '_index' : urlPath.replace(/^\//, '').replace(/\/$/, '');
39
39
  const resolved = path.resolve(path.join(VERSIONS_DIR, normalised));
40
- if (!resolved.startsWith(path.resolve(VERSIONS_DIR))) {
40
+ if (resolved !== path.resolve(VERSIONS_DIR) && !resolved.startsWith(path.resolve(VERSIONS_DIR) + path.sep)) {
41
41
  throw new Error('Invalid path: outside versions directory');
42
42
  }
43
43
  return resolved;
@@ -24,6 +24,11 @@
24
24
  *
25
25
  * Forbidden stage types:
26
26
  * $out, $merge, $function, $accumulator, $graphLookup
27
+ *
28
+ * Stage CONFIGS are checked too, at any depth (mongoQueryGuard.js): no
29
+ * `$where`/`$function`/`$accumulator` hidden in a `$match`, `$group` or
30
+ * `$lookup` sub-pipeline, and a `$lookup` only into this site's `cms_<slug>`
31
+ * collections. Checked on save and again before MongoDB runs the pipeline.
27
32
  */
28
33
  import fs from 'fs/promises';
29
34
  import path from 'path';
@@ -34,6 +39,8 @@ import {checkVisibility} from '../middleware/auth.js';
34
39
  import {getEffectiveLevel} from './userRoles.js';
35
40
  import {executePipeline} from './viewPipeline.js';
36
41
  import * as cache from './cache/index.js';
42
+ import {fileIn} from './safePath.js';
43
+ import {assertSafeMongoQuery, toSafeMongoPipeline} from './mongoQueryGuard.js';
37
44
 
38
45
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
46
  const ROOT = path.resolve(__dirname, '..', '..');
@@ -87,6 +94,7 @@ function validateStages(stages) {
87
94
  if (!ALLOWED_STAGES.has(stage.type)) {
88
95
  throw new Error(`Stage type "${stage.type}" is not in the allowed list`);
89
96
  }
97
+ assertSafeMongoQuery({[stage.type]: stage.config});
90
98
  }
91
99
  }
92
100
 
@@ -122,7 +130,7 @@ export async function ensureViewsDir() {
122
130
  * @throws {Error} If the view file does not exist or cannot be parsed.
123
131
  */
124
132
  export async function readView(slug) {
125
- const file = path.join(VIEWS_DIR, `${slug}.json`);
133
+ const file = fileIn(VIEWS_DIR, slug, '.json');
126
134
  return JSON.parse(await fs.readFile(file, 'utf8'));
127
135
  }
128
136
 
@@ -135,7 +143,7 @@ export async function readView(slug) {
135
143
  */
136
144
  export async function writeView(slug, view) {
137
145
  await ensureViewsDir();
138
- const file = path.join(VIEWS_DIR, `${slug}.json`);
146
+ const file = fileIn(VIEWS_DIR, slug, '.json');
139
147
  await fs.writeFile(file, JSON.stringify(view, null, 4) + '\n', 'utf8');
140
148
  await cache.invalidateTags([`view:${slug}`]);
141
149
  }
@@ -368,7 +376,7 @@ export async function deleteView(slug) {
368
376
  throw new Error(`View "${slug}" not found`);
369
377
  }
370
378
  try {
371
- await fs.unlink(path.join(VIEWS_DIR, `${slug}.json`));
379
+ await fs.unlink(fileIn(VIEWS_DIR, slug, '.json'));
372
380
  } catch {
373
381
  throw new Error(`View "${slug}" not found`);
374
382
  }
@@ -388,7 +396,12 @@ async function executeMongoView(view, { page, limit, user }) {
388
396
  const db = getDb(view.connection || 'default');
389
397
 
390
398
  const sourceCollection = `${COLLECTION_PREFIX}${view.pipeline.source}`;
391
- const pipeline = buildPipelineStages(view.pipeline?.stages || []);
399
+ // The one translation of a view for MongoDB: refuses code-running
400
+ // operators at any depth and a $lookup outside this site's collections.
401
+ const {getCollection} = await import('./collections.js');
402
+ const pipeline = await toSafeMongoPipeline(view.pipeline?.stages || [], {
403
+ collectionExists: async (slug) => !!(await getCollection(slug).catch(() => null))
404
+ });
392
405
 
393
406
  // Prepend row-level $match if configured
394
407
  const rowLevel = view.access?.rowLevel;
@@ -21,25 +21,15 @@
21
21
  *
22
22
  * @module services/wpImport/fetch
23
23
  */
24
- import dns from 'node:dns';
25
- import http from 'node:http';
26
- import https from 'node:https';
27
- import net from 'node:net';
28
- import os from 'node:os';
24
+ import {FETCH_PORTS, FetchRefused, isBlockedAddress, ownAddresses, pinnedRequest, portOf, resolvePublic} from '../outbound.js';
29
25
 
30
- export const FETCH_PORTS = Object.freeze([80, 443]);
31
-
32
- /** The port an address would be fetched on. */
33
- export function portOf(raw) {
34
- try { const u = new URL(raw); return u.port ? Number(u.port) : (u.protocol === 'https:' ? 443 : 80); } catch { return 0; }
35
- }
26
+ // The address rules and the pinned connection are shared with every other
27
+ // outbound request (services/outbound.js); this module adds the WordPress
28
+ // site's own host list, the 50 MB cap and "only a 200 is a file".
29
+ export {FETCH_PORTS, FetchRefused, isBlockedAddress, ownAddresses, portOf, resolvePublic};
36
30
 
37
31
  export const FETCH_LIMITS = Object.freeze({maxBytes: 50 * 1024 * 1024, timeoutMs: 60_000, maxRedirects: 3});
38
32
 
39
- export class FetchRefused extends Error {
40
- constructor(message) { super(message); this.code = 'EREFUSED_SSRF'; }
41
- }
42
-
43
33
  /**
44
34
  * The test-only switch: private addresses are allowed only outside
45
35
  * production AND with WP_IMPORT_ALLOW_PRIVATE_HOSTS=1 set.
@@ -48,121 +38,6 @@ export function testHostsAllowed(env = process.env) {
48
38
  return env.NODE_ENV !== 'production' && env.WP_IMPORT_ALLOW_PRIVATE_HOSTS === '1';
49
39
  }
50
40
 
51
- const V4_BLOCKED = [
52
- ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
53
- ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15],
54
- ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4]
55
- ];
56
-
57
- function v4ToInt(ip) {
58
- const p = ip.split('.').map(Number);
59
- if (p.length !== 4 || p.some(n => !Number.isInteger(n) || n < 0 || n > 255)) return null;
60
- return ((p[0] << 24) >>> 0) + (p[1] << 16) + (p[2] << 8) + p[3];
61
- }
62
-
63
- function v4Blocked(ip) {
64
- const n = v4ToInt(ip);
65
- if (n === null) return true;
66
- return V4_BLOCKED.some(([base, bits]) => {
67
- const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
68
- return ((n & mask) >>> 0) === ((v4ToInt(base) & mask) >>> 0);
69
- });
70
- }
71
-
72
- /** Expand an IPv6 address to 8 groups of numbers (null when it is not one). */
73
- function v6Groups(ip) {
74
- let s = ip.toLowerCase().split('%')[0];
75
- // A dotted IPv4 tail (::ffff:1.2.3.4).
76
- const tail = /(\d+\.\d+\.\d+\.\d+)$/.exec(s);
77
- if (tail) {
78
- const n = v4ToInt(tail[1]);
79
- if (n === null) return null;
80
- s = s.slice(0, -tail[1].length) + `${(n >>> 16).toString(16)}:${(n & 0xffff).toString(16)}`;
81
- }
82
- const halves = s.split('::');
83
- if (halves.length > 2) return null;
84
- const left = halves[0] ? halves[0].split(':') : [];
85
- const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
86
- const fill = halves.length === 2 ? 8 - left.length - right.length : 0;
87
- const groups = [...left, ...Array(Math.max(0, fill)).fill('0'), ...right].map(g => Number.parseInt(g || '0', 16));
88
- return groups.length === 8 && groups.every(g => Number.isInteger(g) && g >= 0 && g <= 0xffff) ? groups : null;
89
- }
90
-
91
- /**
92
- * Is this address one a server-side fetch must never reach?
93
- *
94
- * @param {string} ip
95
- * @returns {boolean}
96
- */
97
- export function isBlockedAddress(ip) {
98
- const kind = net.isIP(String(ip || ''));
99
- if (kind === 4) return v4Blocked(ip);
100
- if (kind !== 6) return true;
101
- const g = v6Groups(ip);
102
- if (!g) return true;
103
- const embeddedV4 = () => `${g[6] >> 8}.${g[6] & 255}.${g[7] >> 8}.${g[7] & 255}`;
104
- if (g.every(x => x === 0)) return true; // ::
105
- if (g.slice(0, 7).every(x => x === 0) && g[7] === 1) return true; // ::1
106
- if (g.slice(0, 5).every(x => x === 0) && g[5] === 0xffff) return v4Blocked(embeddedV4()); // ::ffff:a.b.c.d
107
- if (g.slice(0, 4).every(x => x === 0) && g[4] === 0xffff && g[5] === 0) return true; // ::ffff:0:0/96 (SIIT)
108
- if (g[0] === 0x64 && g[1] === 0xff9b && g[2] === 1) return true; // 64:ff9b:1::/48 (local NAT64)
109
- if (g[0] === 0x100 && g[1] === 0 && g[2] === 0 && g[3] === 0) return true; // 100::/64 (discard)
110
- if (g.slice(0, 6).every(x => x === 0)) return true; // ::a.b.c.d (deprecated compat)
111
- if (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every(x => x === 0)) return v4Blocked(embeddedV4()); // NAT64
112
- if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local
113
- if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
114
- if ((g[0] & 0xffc0) === 0xfec0) return true; // fec0::/10 site-local
115
- if ((g[0] & 0xff00) === 0xff00) return true; // multicast
116
- if (g[0] === 0x2001 && g[1] === 0x0db8) return true; // documentation
117
- if (g[0] === 0x2002) return v4Blocked(`${g[1] >> 8}.${g[1] & 255}.${g[2] >> 8}.${g[2] & 255}`); // 6to4
118
- if (g[0] === 0x2001 && g[1] === 0) return true; // Teredo
119
- return false;
120
- }
121
-
122
- /** Canonical text for an address, so the two spellings of one IPv6 address compare equal. */
123
- function canon(ip) {
124
- const s = String(ip || '').toLowerCase().split('%')[0];
125
- if (net.isIP(s) !== 6) return s;
126
- const g = v6Groups(s);
127
- if (g && g.slice(0, 5).every(x => x === 0) && g[5] === 0xffff) return `${g[6] >> 8}.${g[6] & 255}.${g[7] >> 8}.${g[7] & 255}`;
128
- return g ? g.map(x => x.toString(16)).join(':') : s;
129
- }
130
-
131
- /**
132
- * This machine's own addresses - every interface, public ones included, plus
133
- * the unspecified ones. A server must never fetch from itself, whatever its
134
- * public address resolves as.
135
- */
136
- export function ownAddresses(interfaces = os.networkInterfaces()) {
137
- const out = new Set(['0.0.0.0', canon('::')]);
138
- for (const list of Object.values(interfaces || {})) {
139
- for (const a of list || []) if (a?.address) out.add(canon(a.address));
140
- }
141
- return out;
142
- }
143
-
144
- /**
145
- * Resolve a host and check every address.
146
- *
147
- * @returns {Promise<{address: string, family: number}>} the address to connect to
148
- */
149
- export async function resolvePublic(hostname, {allowPrivate = false, lookup = dns.promises.lookup, own = ownAddresses()} = {}) {
150
- const host = String(hostname || '').replace(/^\[|\]$/g, '');
151
- let addrs;
152
- if (net.isIP(host)) addrs = [{address: host, family: net.isIP(host)}];
153
- else {
154
- try { addrs = await lookup(host, {all: true, verbatim: true}); } catch (err) { throw new FetchRefused(`${host} could not be found (${err.code || err.message}).`); }
155
- }
156
- if (!addrs?.length) throw new FetchRefused(`${host} could not be found.`);
157
- if (!allowPrivate) {
158
- const bad = addrs.find(a => isBlockedAddress(a.address));
159
- if (bad) throw new FetchRefused(`${host} points at a private or reserved address (${bad.address}) - refused.`);
160
- const self = addrs.find(a => own.has(canon(a.address)));
161
- if (self) throw new FetchRefused(`${host} points at this server itself (${self.address}) - refused.`);
162
- }
163
- return addrs[0];
164
- }
165
-
166
41
  /** May this URL be fetched at all (before any DNS)? Returns the URL, or throws. */
167
42
  export function checkUrl(raw, {hosts = [], allowPrivate = false} = {}) {
168
43
  let u;
@@ -209,56 +84,12 @@ export async function fetchMedia(url, opts = {}) {
209
84
  throw new FetchRefused('Too many redirects.');
210
85
  }
211
86
 
212
- function request(u, {address, family, deadline, maxBytes, signal}) {
213
- return new Promise((resolve, reject) => {
214
- const lib = u.protocol === 'https:' ? https : http;
215
- let done = false;
216
- const finish = (fn, v) => { if (!done) { done = true; clearTimeout(timer); fn(v); } };
217
- const req = lib.request({
218
- protocol: u.protocol,
219
- hostname: u.hostname,
220
- port: u.port || undefined,
221
- path: `${u.pathname}${u.search}`,
222
- method: 'GET',
223
- headers: {'user-agent': 'DommaCMS-WordPress-Import/1', accept: '*/*'},
224
- // Connect to the address that was checked - not to a fresh lookup.
225
- lookup: (_host, options, cb) => {
226
- if (options?.all) cb(null, [{address, family}]);
227
- else cb(null, address, family);
228
- },
229
- agent: false,
230
- signal
231
- }, (res) => {
232
- const status = res.statusCode || 0;
233
- if (status >= 300 && status < 400 && res.headers.location) {
234
- res.resume();
235
- return finish(resolve, {redirect: String(res.headers.location)});
236
- }
237
- if (status !== 200) {
238
- res.resume();
239
- return finish(reject, Object.assign(new Error(`The old site answered ${status}.`), {code: 'EHTTP', status}));
240
- }
241
- const declared = Number(res.headers['content-length']);
242
- if (Number.isFinite(declared) && declared > maxBytes) {
243
- res.destroy();
244
- return finish(reject, new FetchRefused(`The file is ${Math.round(declared / 1048576)} MB - over the ${Math.round(maxBytes / 1048576)} MB limit.`));
245
- }
246
- const chunks = [];
247
- let size = 0;
248
- res.on('data', (c) => {
249
- size += c.length;
250
- if (size > maxBytes) {
251
- res.destroy();
252
- finish(reject, new FetchRefused(`The file is over the ${Math.round(maxBytes / 1048576)} MB limit.`));
253
- return;
254
- }
255
- chunks.push(c);
256
- });
257
- res.on('end', () => finish(resolve, {buffer: Buffer.concat(chunks), contentType: String(res.headers['content-type'] || '')}));
258
- res.on('error', (err) => finish(reject, err));
259
- });
260
- const timer = setTimeout(() => { req.destroy(); finish(reject, Object.assign(new Error('The download took too long.'), {code: 'ETIMEDOUT'})); }, Math.max(1, deadline - Date.now()));
261
- req.on('error', (err) => finish(reject, err));
262
- req.end();
87
+ async function request(u, {address, family, deadline, maxBytes, signal}) {
88
+ const res = await pinnedRequest(u, {
89
+ address, family, deadline, maxBytes, signal,
90
+ headers: {'user-agent': 'DommaCMS-WordPress-Import/1', accept: '*/*'}
263
91
  });
92
+ if (res.redirect) return {redirect: res.redirect};
93
+ if (res.status !== 200) throw Object.assign(new Error(`The old site answered ${res.status}.`), {code: 'EHTTP', status: res.status});
94
+ return {buffer: res.buffer, contentType: String(res.headers['content-type'] || '')};
264
95
  }