@magland/mochi 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +108 -0
  3. package/dist/ansi.js +174 -0
  4. package/dist/api/admin.js +416 -0
  5. package/dist/api/auth.js +166 -0
  6. package/dist/api/backup.js +598 -0
  7. package/dist/api/ci.js +336 -0
  8. package/dist/api/contents.js +339 -0
  9. package/dist/api/issues.js +165 -0
  10. package/dist/api/pulls.js +244 -0
  11. package/dist/api/releases.js +83 -0
  12. package/dist/api/repos.js +156 -0
  13. package/dist/api/write.js +518 -0
  14. package/dist/api.js +326 -0
  15. package/dist/assets.js +29 -0
  16. package/dist/atom.js +32 -0
  17. package/dist/atomic.js +171 -0
  18. package/dist/avatar.js +81 -0
  19. package/dist/browse.js +630 -0
  20. package/dist/build-info.json +4 -0
  21. package/dist/ci/actionref.js +86 -0
  22. package/dist/ci/api.js +829 -0
  23. package/dist/ci/artifacts.js +201 -0
  24. package/dist/ci/dispatch.js +30 -0
  25. package/dist/ci/engine.js +1321 -0
  26. package/dist/ci/expr.js +526 -0
  27. package/dist/ci/manual.js +199 -0
  28. package/dist/ci/present.js +82 -0
  29. package/dist/ci/protocol.js +6 -0
  30. package/dist/ci/runners.js +256 -0
  31. package/dist/ci/runs.js +208 -0
  32. package/dist/ci/trigger.js +28 -0
  33. package/dist/ci/views.js +441 -0
  34. package/dist/ci/wake.js +194 -0
  35. package/dist/ci/web.js +617 -0
  36. package/dist/ci/workflow.js +436 -0
  37. package/dist/cli/admin-cmd.js +324 -0
  38. package/dist/cli/api-cmd.js +128 -0
  39. package/dist/cli/backup-cmd.js +1500 -0
  40. package/dist/cli/exit.js +69 -0
  41. package/dist/cli/input.js +64 -0
  42. package/dist/cli/issue-cmd.js +243 -0
  43. package/dist/cli/output.js +93 -0
  44. package/dist/cli/parse.js +317 -0
  45. package/dist/cli/pr-cmd.js +289 -0
  46. package/dist/cli/release-cmd.js +171 -0
  47. package/dist/cli/repo-cmd.js +763 -0
  48. package/dist/cli/repo.js +101 -0
  49. package/dist/cli/run-cmd.js +438 -0
  50. package/dist/cli/target.js +54 -0
  51. package/dist/cli-api.js +84 -0
  52. package/dist/compare.js +111 -0
  53. package/dist/config.js +212 -0
  54. package/dist/credentials.js +235 -0
  55. package/dist/deploy-cli.js +859 -0
  56. package/dist/deploy-runner-cli.js +592 -0
  57. package/dist/diff.js +171 -0
  58. package/dist/discussion.js +253 -0
  59. package/dist/egress.js +559 -0
  60. package/dist/filecache.js +68 -0
  61. package/dist/find.js +162 -0
  62. package/dist/forms.js +737 -0
  63. package/dist/git.js +547 -0
  64. package/dist/githttp.js +428 -0
  65. package/dist/html.js +87 -0
  66. package/dist/icons.js +101 -0
  67. package/dist/import-cli.js +316 -0
  68. package/dist/index.js +752 -0
  69. package/dist/issues.js +308 -0
  70. package/dist/issueweb.js +447 -0
  71. package/dist/job-cli.js +197 -0
  72. package/dist/jobtoken.js +96 -0
  73. package/dist/languages.js +383 -0
  74. package/dist/layout.js +100 -0
  75. package/dist/lfs.js +438 -0
  76. package/dist/lfsstore.js +425 -0
  77. package/dist/limit.js +259 -0
  78. package/dist/logo.js +61 -0
  79. package/dist/markdown.js +382 -0
  80. package/dist/migrate.js +334 -0
  81. package/dist/multipart.js +90 -0
  82. package/dist/ops.js +869 -0
  83. package/dist/pagescript.js +465 -0
  84. package/dist/perms.js +370 -0
  85. package/dist/pointer.js +55 -0
  86. package/dist/profile.js +106 -0
  87. package/dist/pulls.js +320 -0
  88. package/dist/pullweb.js +461 -0
  89. package/dist/redirects.js +455 -0
  90. package/dist/releases.js +435 -0
  91. package/dist/render.js +233 -0
  92. package/dist/runner/actions.js +448 -0
  93. package/dist/runner/client.js +428 -0
  94. package/dist/runner/context.js +247 -0
  95. package/dist/runner/docker.js +197 -0
  96. package/dist/runner/externals.js +175 -0
  97. package/dist/runner/job.js +290 -0
  98. package/dist/runner/manual-run.js +272 -0
  99. package/dist/runner/overrides.js +554 -0
  100. package/dist/runner/steps.js +571 -0
  101. package/dist/runner/wake.js +84 -0
  102. package/dist/runner-cli.js +405 -0
  103. package/dist/scan.js +231 -0
  104. package/dist/server.js +424 -0
  105. package/dist/session.js +267 -0
  106. package/dist/site.js +259 -0
  107. package/dist/siteshost.js +94 -0
  108. package/dist/source.js +90 -0
  109. package/dist/style.js +1295 -0
  110. package/dist/themes.js +369 -0
  111. package/dist/vault.js +442 -0
  112. package/dist/version.js +88 -0
  113. package/dist/views.js +1007 -0
  114. package/dist/web.js +182 -0
  115. package/dist/webops.js +1402 -0
  116. package/package.json +71 -0
@@ -0,0 +1,334 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.migrateLayout = migrateLayout;
37
+ exports.migratePermissions = migratePermissions;
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const layout_1 = require("./layout");
41
+ const perms_1 = require("./perms");
42
+ const scan_1 = require("./scan");
43
+ const vault_1 = require("./vault");
44
+ /**
45
+ * Moving a vault from the older layout, where collections sat directly in the
46
+ * vault directory and repositories directly in a collection, to the current
47
+ * one, where both sit one level down (see src/layout.ts).
48
+ *
49
+ * This runs on startup, before anything is served, and is renames only: no
50
+ * file is read, written, or copied, so the cost does not depend on how large
51
+ * the repositories are, and a vault on a full disk migrates as well as one on
52
+ * an empty disk. The server refuses to serve a vault it could not migrate,
53
+ * rather than serving what would look like an empty vault.
54
+ */
55
+ /** Where a collection is parked while its own contents are being rearranged. */
56
+ const STAGING_DIR = '.collections-migrating';
57
+ /**
58
+ * Whether a directory is a collection under the old layout: it holds a bare
59
+ * repository, or one of the directories a repository keeps beside it. An empty
60
+ * collection matches neither, and is decided by its company - see oldCollections.
61
+ */
62
+ function looksLikeOldCollection(dir) {
63
+ let entries;
64
+ try {
65
+ entries = fs.readdirSync(dir, { withFileTypes: true });
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ return entries.some((e) => {
71
+ if (!e.isDirectory())
72
+ return false;
73
+ const lower = e.name.toLowerCase();
74
+ if (scan_1.repoSiblingSuffixes.some((s) => lower.endsWith(s)) || lower.endsWith('.lfs'))
75
+ return true;
76
+ return (0, scan_1.isBareRepo)(path.join(dir, e.name));
77
+ });
78
+ }
79
+ /**
80
+ * Whether a `collections` directory in the vault root is this layout's
81
+ * container rather than a collection that happens to be named that. It is the
82
+ * container when it is empty, or when something in it has a `repos` directory,
83
+ * which is the shape only the current layout produces.
84
+ */
85
+ function isCollectionsContainer(dir) {
86
+ let entries;
87
+ try {
88
+ entries = fs.readdirSync(dir, { withFileTypes: true });
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ if (entries.length === 0)
94
+ return true;
95
+ return entries.some((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, layout_1.REPOS_DIR)));
96
+ }
97
+ /**
98
+ * The collections to move, or an empty list if the vault is already laid out
99
+ * the current way.
100
+ *
101
+ * A directory in the vault root is a collection to move when it holds
102
+ * something a repository would put there. An empty directory holds nothing to
103
+ * judge by, so it is taken as a collection only when some other directory
104
+ * proves the vault is on the old layout; in a vault already migrated, an empty
105
+ * stray directory is left where it is rather than being adopted as a
106
+ * collection nobody made.
107
+ *
108
+ * `collections` is the one ambiguous name, since a vault on the old layout may
109
+ * have a collection called that. Asked of it is the narrower question above:
110
+ * a `collections` directory holding this layout is the container and is left
111
+ * alone, and one holding repositories is an ordinary collection and is moved,
112
+ * ending up at `collections/collections`.
113
+ */
114
+ function oldCollections(root) {
115
+ let entries;
116
+ try {
117
+ entries = fs.readdirSync(root, { withFileTypes: true });
118
+ }
119
+ catch {
120
+ return [];
121
+ }
122
+ const candidates = entries
123
+ .filter((e) => e.isDirectory() && !e.isSymbolicLink() && (0, scan_1.isValidName)(e.name))
124
+ .map((e) => e.name)
125
+ .filter((name) => name !== layout_1.COLLECTIONS_DIR || !isCollectionsContainer(path.join(root, name)))
126
+ .sort();
127
+ const definite = candidates.filter((name) => looksLikeOldCollection(path.join(root, name)));
128
+ if (definite.length === 0)
129
+ return [];
130
+ return candidates;
131
+ }
132
+ /** Move everything in a collection's directory into its `repos` subdirectory. */
133
+ function nestRepos(dir) {
134
+ const repos = path.join(dir, layout_1.REPOS_DIR);
135
+ const entries = fs.readdirSync(dir);
136
+ // Already nested, by an earlier run that was interrupted after this
137
+ // collection and before the last step. A collection may hold a repository
138
+ // named `repos`, so this asks what the directory is rather than assuming
139
+ // from its name: a bare repository there is a repository to move, not a
140
+ // collection already done.
141
+ if (entries.length === 1 && entries[0] === layout_1.REPOS_DIR && !(0, scan_1.isBareRepo)(repos))
142
+ return;
143
+ // Everything moves into a directory under a temporary name, which is renamed
144
+ // into place at the end. A collection is therefore never left holding a
145
+ // half-filled repos directory beside the repositories still to go, and a
146
+ // repository that is itself called `repos` moves like any other.
147
+ //
148
+ // A scratch directory from an interrupted run is filled the rest of the way
149
+ // rather than cleared: what is in it are repositories that were moved and
150
+ // not yet renamed into place, and nothing in this file deletes those.
151
+ const scratchName = `.${layout_1.REPOS_DIR}-incoming`;
152
+ const scratch = path.join(dir, scratchName);
153
+ if (!fs.existsSync(scratch))
154
+ fs.mkdirSync(scratch);
155
+ for (const name of entries) {
156
+ if (name === scratchName)
157
+ continue;
158
+ fs.renameSync(path.join(dir, name), path.join(scratch, name));
159
+ }
160
+ fs.renameSync(scratch, repos);
161
+ }
162
+ /** Move each entry of `from` into `to`, refusing rather than overwriting. */
163
+ function drain(from, to) {
164
+ const moved = [];
165
+ for (const name of fs.readdirSync(from).sort()) {
166
+ const dest = path.join(to, name);
167
+ if (fs.existsSync(dest)) {
168
+ throw new Error(`cannot move ${name} into ${layout_1.COLLECTIONS_DIR}/: a collection of that name is already there`);
169
+ }
170
+ fs.renameSync(path.join(from, name), dest);
171
+ moved.push(name);
172
+ }
173
+ return moved;
174
+ }
175
+ /**
176
+ * Bring a vault up to the current layout, returning what was moved, or null if
177
+ * there was nothing to move. Safe to call on every start: a vault already laid
178
+ * out this way is read once and left alone.
179
+ *
180
+ * The order is what makes an interrupted run recoverable. Each collection is
181
+ * moved into a staging directory and rearranged there, so a collection is
182
+ * either where it was or finished, never half-nested in place; the staging
183
+ * directory then becomes `collections/` in a single rename, or is drained into
184
+ * it if one already exists. A run interrupted before that last step leaves the
185
+ * staging directory behind, and the next start finds it and finishes.
186
+ */
187
+ function migrateLayout(root) {
188
+ const staging = path.join(root, STAGING_DIR);
189
+ const collections = (0, layout_1.collectionsDir)(root);
190
+ const stale = fs.existsSync(staging);
191
+ const names = oldCollections(root);
192
+ if (names.length === 0 && !stale)
193
+ return null;
194
+ try {
195
+ if (!stale)
196
+ fs.mkdirSync(staging);
197
+ for (const name of names) {
198
+ fs.renameSync(path.join(root, name), path.join(staging, name));
199
+ }
200
+ for (const name of fs.readdirSync(staging)) {
201
+ nestRepos(path.join(staging, name));
202
+ }
203
+ let moved;
204
+ if (fs.existsSync(collections)) {
205
+ moved = drain(staging, collections);
206
+ fs.rmdirSync(staging);
207
+ }
208
+ else {
209
+ moved = fs.readdirSync(staging).sort();
210
+ fs.renameSync(staging, collections);
211
+ }
212
+ return { collections: moved };
213
+ }
214
+ catch (e) {
215
+ // Said in full, because this is printed instead of a vault starting, and
216
+ // the next thing the reader does depends on which of its parts is true.
217
+ throw new Error(`This vault is laid out the older way, with its collections in ${root} rather than ` +
218
+ `in ${layout_1.COLLECTIONS_DIR}/, and moving them failed: ${e instanceof Error ? e.message : String(e)}. ` +
219
+ 'Nothing was deleted, and what was moved is under ' +
220
+ `${STAGING_DIR}/ or ${layout_1.COLLECTIONS_DIR}/; starting again finishes the move once the ` +
221
+ 'cause is fixed. The vault is not served meanwhile, since serving it would show an ' +
222
+ 'empty vault while every repository is still on disk.');
223
+ }
224
+ }
225
+ // ---- glob scopes to roles ----
226
+ /**
227
+ * Translating a pre-roles vault.json into the role model: glob scopes on
228
+ * users become the site-admin bit, collection owners, and repository
229
+ * collaborators. This runs on startup, like the layout migration above, and
230
+ * the original file is copied to vault.json.pre-roles first, so the
231
+ * translation can be audited or undone by hand.
232
+ *
233
+ * The two models do not line up exactly, and where they differ the
234
+ * translation rounds up to the nearest role rather than dropping access:
235
+ *
236
+ * - Push scope over a whole collection (`alice/*`) becomes ownership of the
237
+ * collection, which also carries the admin role there. There is no
238
+ * collection-wide write role to round down to.
239
+ * - Admin scope whose collection part is itself a pattern (`*`) becomes the
240
+ * site-admin bit.
241
+ * - Push scope whose collection part is a pattern becomes ownership of every
242
+ * collection the pattern matches today; a collection created later is not
243
+ * covered, where the glob would have covered it.
244
+ *
245
+ * Each rounding is reported in the returned notes, which the server prints on
246
+ * the migration run. Scope over a single repository (`alice/webapp`) has an
247
+ * exact counterpart and is translated silently: write for push scope, admin
248
+ * for admin scope. A glob naming a repository that does not exist is dropped
249
+ * with a note, since collaborators are recorded in the repository itself.
250
+ */
251
+ function migratePermissions(root) {
252
+ const state = (0, vault_1.loadVault)(root);
253
+ if (state.status !== 'ok' || !state.vault.legacy)
254
+ return null;
255
+ const file = (0, vault_1.vaultFilePath)(root);
256
+ const backup = `${file}.pre-roles`;
257
+ if (!fs.existsSync(backup))
258
+ fs.copyFileSync(file, backup);
259
+ const notes = [`translated glob scopes to roles; the old file is kept at ${path.basename(backup)}`];
260
+ const siteAdmins = [];
261
+ const collections = (0, scan_1.listCollections)(root).map((c) => c.name);
262
+ const collectionOf = (glob) => (glob.includes('/') ? glob.slice(0, glob.indexOf('/')) : glob);
263
+ const repoOf = (glob) => (glob.includes('/') ? glob.slice(glob.indexOf('/') + 1) : '*');
264
+ const isPattern = (s) => /[*?]/.test(s);
265
+ const makeOwner = (username, collection) => {
266
+ if (collection === username)
267
+ return; // implicit
268
+ (0, perms_1.addCollectionOwner)(root, collection, username);
269
+ notes.push(`${username}: now an owner of ${collection}`);
270
+ };
271
+ const makeCollaborator = (username, collection, repoName, role) => {
272
+ const repo = (0, scan_1.findRepo)(root, collection, repoName);
273
+ if (!repo) {
274
+ notes.push(`${username}: scope over ${collection}/${repoName} dropped; no such repository`);
275
+ return;
276
+ }
277
+ (0, perms_1.setCollaborator)(repo.dir, username, role);
278
+ };
279
+ for (const [username, user] of Object.entries(state.vault.users)) {
280
+ const legacy = user.legacy ?? { scope: [], admin: [] };
281
+ for (const kind of ['admin', 'scope']) {
282
+ // A site admin holds the admin role everywhere, so once the admin pass
283
+ // has granted the bit there is nothing the push globs could add.
284
+ if (siteAdmins.includes(username))
285
+ break;
286
+ const role = kind === 'admin' ? 'admin' : 'write';
287
+ for (const glob of legacy[kind]) {
288
+ const c = collectionOf(glob);
289
+ const r = repoOf(glob);
290
+ if (isPattern(c)) {
291
+ if (kind === 'admin') {
292
+ if (!siteAdmins.includes(username)) {
293
+ siteAdmins.push(username);
294
+ notes.push(`${username}: admin scope ${glob} becomes site admin`);
295
+ }
296
+ }
297
+ else {
298
+ for (const name of collections.filter((n) => (0, vault_1.globMatch)(c, n))) {
299
+ if (r === '*')
300
+ makeOwner(username, name);
301
+ else
302
+ for (const d of (0, scan_1.listRepoDirs)(root, name)
303
+ .map(scan_1.displayName)
304
+ .filter((n) => (0, vault_1.globMatch)(r, n))) {
305
+ makeCollaborator(username, name, d, role);
306
+ }
307
+ }
308
+ notes.push(`${username}: push scope ${glob} covers only what exists today, as ownership or write`);
309
+ }
310
+ continue;
311
+ }
312
+ if (r === '*') {
313
+ makeOwner(username, c);
314
+ if (kind === 'scope' && c !== username) {
315
+ notes.push(`${username}: push scope ${glob} rounds up to ownership of ${c}`);
316
+ }
317
+ continue;
318
+ }
319
+ if (isPattern(r)) {
320
+ for (const d of (0, scan_1.listRepoDirs)(root, c)
321
+ .map(scan_1.displayName)
322
+ .filter((n) => (0, vault_1.globMatch)(r, n))) {
323
+ makeCollaborator(username, c, d, role);
324
+ }
325
+ notes.push(`${username}: scope ${glob} covers only the repositories that exist today`);
326
+ continue;
327
+ }
328
+ makeCollaborator(username, c, r, role);
329
+ }
330
+ }
331
+ }
332
+ (0, vault_1.finishPermsMigration)(root, siteAdmins);
333
+ return notes;
334
+ }
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ // Parsing multipart/form-data, which is how a browser sends files.
3
+ //
4
+ // mochi parses this itself rather than taking a dependency, for the same
5
+ // reason it renders its own HTML: the format is small, the surface it is
6
+ // exposed to is one form, and a parser in the tree is one that can be read.
7
+ // The body arrives whole (express.raw with a cap) rather than as a stream,
8
+ // which is what lets the parser be forty lines: an upload here is bounded by
9
+ // the same cap either way, and streaming would buy nothing but complexity.
10
+ //
11
+ // The grammar, from RFC 7578 and RFC 2046: parts are separated by a line
12
+ // `--<boundary>`, each part has headers, then a blank line, then its bytes,
13
+ // and the epilogue begins at `--<boundary>--`. Every separator is preceded by
14
+ // CRLF that belongs to the separator, not to the part's content.
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.boundaryOf = boundaryOf;
17
+ exports.parseMultipart = parseMultipart;
18
+ exports.partField = partField;
19
+ exports.partFiles = partFiles;
20
+ /** The boundary out of a Content-Type header, or null if there is not one. */
21
+ function boundaryOf(contentType) {
22
+ if (!contentType)
23
+ return null;
24
+ const m = contentType.match(/^\s*multipart\/form-data\s*;(.*)$/i);
25
+ if (!m)
26
+ return null;
27
+ const b = m[1].match(/boundary=(?:"([^"]+)"|([^;\s]+))/i);
28
+ if (!b)
29
+ return null;
30
+ return b[1] ?? b[2] ?? null;
31
+ }
32
+ function headerValue(headers, name) {
33
+ const m = headers.match(new RegExp(`^${name}:\\s*(.*)$`, 'im'));
34
+ return m ? m[1].trim() : undefined;
35
+ }
36
+ // Content-Disposition parameters. A filename may be quoted and may contain
37
+ // escaped quotes; anything else is taken up to the next semicolon.
38
+ function dispositionParam(disposition, key) {
39
+ const m = disposition.match(new RegExp(`${key}=(?:"((?:[^"\\\\]|\\\\.)*)"|([^;]*))`, 'i'));
40
+ if (!m)
41
+ return undefined;
42
+ return (m[1] !== undefined ? m[1].replace(/\\(.)/g, '$1') : (m[2] ?? '')).trim();
43
+ }
44
+ /**
45
+ * Split a body into its parts. Anything malformed yields the parts that were
46
+ * well formed rather than an exception: a caller checks for what it needs.
47
+ */
48
+ function parseMultipart(body, boundary) {
49
+ const parts = [];
50
+ const sep = Buffer.from(`--${boundary}`);
51
+ let index = body.indexOf(sep);
52
+ if (index === -1)
53
+ return parts;
54
+ while (index !== -1) {
55
+ const after = index + sep.length;
56
+ // `--` here closes the whole body; anything else is a CRLF and a part.
57
+ if (body.length >= after + 2 && body[after] === 0x2d && body[after + 1] === 0x2d)
58
+ break;
59
+ const headerStart = after + 2;
60
+ const blank = body.indexOf('\r\n\r\n', headerStart);
61
+ const next = body.indexOf(sep, headerStart);
62
+ if (blank === -1 || next === -1 || blank > next)
63
+ break;
64
+ const headers = body.subarray(headerStart, blank).toString('utf8');
65
+ // The CRLF before the next separator belongs to the separator.
66
+ const data = body.subarray(blank + 4, Math.max(blank + 4, next - 2));
67
+ const disposition = headerValue(headers, 'content-disposition') ?? '';
68
+ const name = dispositionParam(disposition, 'name');
69
+ if (name !== undefined) {
70
+ const filename = dispositionParam(disposition, 'filename');
71
+ parts.push({
72
+ name,
73
+ filename: filename === undefined || filename === '' ? undefined : filename,
74
+ contentType: headerValue(headers, 'content-type'),
75
+ data: Buffer.from(data),
76
+ });
77
+ }
78
+ index = next;
79
+ }
80
+ return parts;
81
+ }
82
+ /** The value of a plain (non-file) field. */
83
+ function partField(parts, name) {
84
+ const part = parts.find((p) => p.name === name && p.filename === undefined);
85
+ return part ? part.data.toString('utf8') : '';
86
+ }
87
+ /** Every part that carries a file, in the order the browser sent them. */
88
+ function partFiles(parts, name) {
89
+ return parts.filter((p) => p.name === name && p.filename !== undefined && p.data.length > 0);
90
+ }