laravel-workers 0.4.0 → 0.4.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.
- package/LICENSE +21 -21
- package/bin/laravel-workers.mjs +141 -141
- package/build.mjs +2599 -2599
- package/compiler/bin/controller.mjs +92 -92
- package/kernel/cache.mjs +405 -405
- package/kernel/db.mjs +703 -703
- package/kernel/encrypter.mjs +1 -1
- package/kernel/hash.mjs +113 -113
- package/kernel/multipart.mjs +161 -161
- package/kernel/router.mjs +276 -276
- package/kernel/scheduler.mjs +236 -236
- package/kernel/str.mjs +445 -445
- package/kernel/tests/cache.test.mjs +290 -290
- package/kernel/tests/db.test.mjs +1068 -1068
- package/kernel/tests/encrypter.test.mjs +1 -1
- package/kernel/tests/hash.test.mjs +149 -149
- package/kernel/tests/http.test.mjs +1182 -1182
- package/kernel/tests/php-json.test.mjs +263 -263
- package/kernel/tests/router.test.mjs +281 -281
- package/kernel/tests/services.test.mjs +474 -474
- package/kernel/tests/str.test.mjs +83 -83
- package/kernel/tests/validation.test.mjs +340 -340
- package/kernel/validation.mjs +123 -123
- package/package.json +69 -69
- package/tools/schema-dump.php +158 -158
package/kernel/encrypter.mjs
CHANGED
|
@@ -103,4 +103,4 @@ export async function decryptString(payloadBase64, keyBase64) {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
// Convenience sync-ish wrappers not possible (crypto.subtle is async). Export async only.
|
|
106
|
-
export default { encryptString, decryptString, timingSafeEqual };
|
|
106
|
+
export default { encryptString, decryptString, timingSafeEqual };
|
package/kernel/hash.mjs
CHANGED
|
@@ -1,113 +1,113 @@
|
|
|
1
|
-
// kernel/hash.mjs � Hash (bcrypt)
|
|
2
|
-
// Uses bcryptjs (pure JS, no native).
|
|
3
|
-
import bcrypt from 'bcryptjs';
|
|
4
|
-
|
|
5
|
-
// constant-time string/Uint8Array compare � does not early-return on first mismatch
|
|
6
|
-
export function timingSafeEqual(a, b) {
|
|
7
|
-
const toBytes = (v) => {
|
|
8
|
-
if (v instanceof Uint8Array) return v;
|
|
9
|
-
if (typeof v === 'string') return new TextEncoder().encode(v);
|
|
10
|
-
if (typeof Buffer !== 'undefined' && v && Buffer.isBuffer(v)) return new Uint8Array(v);
|
|
11
|
-
return new TextEncoder().encode(String(v));
|
|
12
|
-
};
|
|
13
|
-
const aBytes = toBytes(a);
|
|
14
|
-
const bBytes = toBytes(b);
|
|
15
|
-
if (aBytes.length !== bBytes.length) return false;
|
|
16
|
-
try {
|
|
17
|
-
const f = globalThis.crypto && globalThis.crypto.timingSafeEqual;
|
|
18
|
-
if (typeof f === 'function' && typeof Buffer !== 'undefined') {
|
|
19
|
-
return f(Buffer.from(aBytes), Buffer.from(bBytes));
|
|
20
|
-
}
|
|
21
|
-
} catch {}
|
|
22
|
-
let diff = 0;
|
|
23
|
-
for (let i = 0; i < aBytes.length; i++) diff |= aBytes[i] ^ bBytes[i];
|
|
24
|
-
return diff === 0;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export async function make(value, { rounds = 10 } = {}) {
|
|
28
|
-
const salt = await bcrypt.genSalt(rounds);
|
|
29
|
-
const hash = await bcrypt.hash(String(value), salt);
|
|
30
|
-
// PHP 8.3 password_hash() emits `$2y$`; bcryptjs emits `$2a$`. Store the
|
|
31
|
-
// PHP prefix (like makeSync below) so password_get_info()-shaped checks
|
|
32
|
-
// (isHashed, verifyConfiguration, needsRehash) treat our own hashes as
|
|
33
|
-
// bcrypt - exactly like BcryptHasher.
|
|
34
|
-
return hash.replace(/^\$2[abxy]\$/, '$2y$');
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Synchronous bcrypt for Eloquent's `hashed` cast. Laravel's
|
|
38
|
-
// castAttributeAsHashedString() runs Hash::make() inside setAttribute(), which is
|
|
39
|
-
// synchronous in PHP, so the compiled Model#set() must hash before returning.
|
|
40
|
-
// bcryptjs' hashSync costs the same CPU as the async form (measured in
|
|
41
|
-
// kernel/tests/budget.test.mjs); callers on the free plan should expect the
|
|
42
|
-
// configured cost (12 by default) per filled password.
|
|
43
|
-
export function makeSync(value, { rounds = 12 } = {}) {
|
|
44
|
-
const salt = bcrypt.genSaltSync(Number(rounds));
|
|
45
|
-
const hash = bcrypt.hashSync(String(value), salt);
|
|
46
|
-
// PHP 8.3 password_hash() emits `$2y$`; bcryptjs emits `$2a$`. Store the
|
|
47
|
-
// PHP prefix so password_get_info()-shaped checks (verifyConfiguration,
|
|
48
|
-
// needsRehash) treat our own hashes as bcrypt, exactly like BcryptHasher.
|
|
49
|
-
return hash.replace(/^\$2[abxy]\$/, '$2y$');
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// PHP 8.3 password_get_info() as measured against the vendor oracle: only a
|
|
53
|
-
// full `$2y$NN$` + 53-char digest is bcrypt; `$2a$`/`$2b$` and truncated
|
|
54
|
-
// strings report algo null; any `$argon2(id|i|d)$` prefix is argon2.
|
|
55
|
-
const BCRYPT_2Y_60_RE = /^\$2y\$(\d{2})\$[./A-Za-z0-9]{53}$/;
|
|
56
|
-
const ARGON2_PREFIX_RE = /^\$argon2(id|i|d)\$/;
|
|
57
|
-
|
|
58
|
-
export function passwordInfo(value) {
|
|
59
|
-
if (typeof value !== 'string') return { algo: null, algoName: 'unknown', options: [] };
|
|
60
|
-
const m = BCRYPT_2Y_60_RE.exec(value);
|
|
61
|
-
if (m) {
|
|
62
|
-
return { algo: '2y', algoName: 'bcrypt', options: { cost: parseInt(m[1], 10) } };
|
|
63
|
-
}
|
|
64
|
-
const a = ARGON2_PREFIX_RE.exec(value);
|
|
65
|
-
if (a) {
|
|
66
|
-
return { algo: 'argon2' + a[1], algoName: 'argon2' + a[1], options: [] };
|
|
67
|
-
}
|
|
68
|
-
return { algo: null, algoName: 'unknown', options: [] };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Laravel 13.37 HashManager::isHashed(): `driver()->info($value)['algo'] !== null`.
|
|
72
|
-
// The `hashed` cast must use this exact check (a `$2a$` digest is re-hashed by
|
|
73
|
-
// Laravel on PHP 8.3, while `$2y$` passes through).
|
|
74
|
-
export function isHashedDigest(value) {
|
|
75
|
-
return passwordInfo(value).algo !== null;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Laravel 13.37 BcryptHasher::verifyConfiguration():
|
|
79
|
-
// isUsingCorrectAlgorithm($value) && isUsingValidOptions($value)
|
|
80
|
-
// where PHP's password_get_info() identifies ONLY `$2y$NN$` as bcrypt, and the
|
|
81
|
-
// cost must be an integer <= the configured rounds (a lower cost is valid).
|
|
82
|
-
export function verifyConfiguration(hashed, { rounds = 12 } = {}) {
|
|
83
|
-
const info = passwordInfo(hashed);
|
|
84
|
-
if (info.algoName !== 'bcrypt') return false;
|
|
85
|
-
return info.options.cost <= Number(rounds);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export async function check(value, hashed) {
|
|
89
|
-
if (!hashed || typeof hashed !== 'string') return false;
|
|
90
|
-
const ok = await bcrypt.compare(String(value), hashed);
|
|
91
|
-
return timingSafeEqual(ok ? '1' : '0', '1');
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export function needsRehash(hashed, { rounds = 10 } = {}) {
|
|
95
|
-
if (!hashed || typeof hashed !== 'string') return true;
|
|
96
|
-
try {
|
|
97
|
-
const r = bcrypt.getRounds(hashed);
|
|
98
|
-
return r !== rounds;
|
|
99
|
-
} catch {
|
|
100
|
-
return true;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Laravel `Hash::isHashed()` (HashManager.php:97-100): `$this->driver()->info(
|
|
105
|
-
// $value)['algo'] !== null` - the same rule as password_get_info(). The kernel
|
|
106
|
-
// used a loose regex, so a `$2a$`/`$2b$` digest or a TRUNCATED one counted as
|
|
107
|
-
// hashed where PHP says it is not (Laravel's own isHashedDigest() is the
|
|
108
|
-
// faithful rule - the driver checks the digest, not its shape).
|
|
109
|
-
export function isHashed(value) {
|
|
110
|
-
return isHashedDigest(value);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export default { make, makeSync, check, needsRehash, isHashed, isHashedDigest, passwordInfo, verifyConfiguration, timingSafeEqual };
|
|
1
|
+
// kernel/hash.mjs � Hash (bcrypt)
|
|
2
|
+
// Uses bcryptjs (pure JS, no native).
|
|
3
|
+
import bcrypt from 'bcryptjs';
|
|
4
|
+
|
|
5
|
+
// constant-time string/Uint8Array compare � does not early-return on first mismatch
|
|
6
|
+
export function timingSafeEqual(a, b) {
|
|
7
|
+
const toBytes = (v) => {
|
|
8
|
+
if (v instanceof Uint8Array) return v;
|
|
9
|
+
if (typeof v === 'string') return new TextEncoder().encode(v);
|
|
10
|
+
if (typeof Buffer !== 'undefined' && v && Buffer.isBuffer(v)) return new Uint8Array(v);
|
|
11
|
+
return new TextEncoder().encode(String(v));
|
|
12
|
+
};
|
|
13
|
+
const aBytes = toBytes(a);
|
|
14
|
+
const bBytes = toBytes(b);
|
|
15
|
+
if (aBytes.length !== bBytes.length) return false;
|
|
16
|
+
try {
|
|
17
|
+
const f = globalThis.crypto && globalThis.crypto.timingSafeEqual;
|
|
18
|
+
if (typeof f === 'function' && typeof Buffer !== 'undefined') {
|
|
19
|
+
return f(Buffer.from(aBytes), Buffer.from(bBytes));
|
|
20
|
+
}
|
|
21
|
+
} catch {}
|
|
22
|
+
let diff = 0;
|
|
23
|
+
for (let i = 0; i < aBytes.length; i++) diff |= aBytes[i] ^ bBytes[i];
|
|
24
|
+
return diff === 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function make(value, { rounds = 10 } = {}) {
|
|
28
|
+
const salt = await bcrypt.genSalt(rounds);
|
|
29
|
+
const hash = await bcrypt.hash(String(value), salt);
|
|
30
|
+
// PHP 8.3 password_hash() emits `$2y$`; bcryptjs emits `$2a$`. Store the
|
|
31
|
+
// PHP prefix (like makeSync below) so password_get_info()-shaped checks
|
|
32
|
+
// (isHashed, verifyConfiguration, needsRehash) treat our own hashes as
|
|
33
|
+
// bcrypt - exactly like BcryptHasher.
|
|
34
|
+
return hash.replace(/^\$2[abxy]\$/, '$2y$');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Synchronous bcrypt for Eloquent's `hashed` cast. Laravel's
|
|
38
|
+
// castAttributeAsHashedString() runs Hash::make() inside setAttribute(), which is
|
|
39
|
+
// synchronous in PHP, so the compiled Model#set() must hash before returning.
|
|
40
|
+
// bcryptjs' hashSync costs the same CPU as the async form (measured in
|
|
41
|
+
// kernel/tests/budget.test.mjs); callers on the free plan should expect the
|
|
42
|
+
// configured cost (12 by default) per filled password.
|
|
43
|
+
export function makeSync(value, { rounds = 12 } = {}) {
|
|
44
|
+
const salt = bcrypt.genSaltSync(Number(rounds));
|
|
45
|
+
const hash = bcrypt.hashSync(String(value), salt);
|
|
46
|
+
// PHP 8.3 password_hash() emits `$2y$`; bcryptjs emits `$2a$`. Store the
|
|
47
|
+
// PHP prefix so password_get_info()-shaped checks (verifyConfiguration,
|
|
48
|
+
// needsRehash) treat our own hashes as bcrypt, exactly like BcryptHasher.
|
|
49
|
+
return hash.replace(/^\$2[abxy]\$/, '$2y$');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// PHP 8.3 password_get_info() as measured against the vendor oracle: only a
|
|
53
|
+
// full `$2y$NN$` + 53-char digest is bcrypt; `$2a$`/`$2b$` and truncated
|
|
54
|
+
// strings report algo null; any `$argon2(id|i|d)$` prefix is argon2.
|
|
55
|
+
const BCRYPT_2Y_60_RE = /^\$2y\$(\d{2})\$[./A-Za-z0-9]{53}$/;
|
|
56
|
+
const ARGON2_PREFIX_RE = /^\$argon2(id|i|d)\$/;
|
|
57
|
+
|
|
58
|
+
export function passwordInfo(value) {
|
|
59
|
+
if (typeof value !== 'string') return { algo: null, algoName: 'unknown', options: [] };
|
|
60
|
+
const m = BCRYPT_2Y_60_RE.exec(value);
|
|
61
|
+
if (m) {
|
|
62
|
+
return { algo: '2y', algoName: 'bcrypt', options: { cost: parseInt(m[1], 10) } };
|
|
63
|
+
}
|
|
64
|
+
const a = ARGON2_PREFIX_RE.exec(value);
|
|
65
|
+
if (a) {
|
|
66
|
+
return { algo: 'argon2' + a[1], algoName: 'argon2' + a[1], options: [] };
|
|
67
|
+
}
|
|
68
|
+
return { algo: null, algoName: 'unknown', options: [] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Laravel 13.37 HashManager::isHashed(): `driver()->info($value)['algo'] !== null`.
|
|
72
|
+
// The `hashed` cast must use this exact check (a `$2a$` digest is re-hashed by
|
|
73
|
+
// Laravel on PHP 8.3, while `$2y$` passes through).
|
|
74
|
+
export function isHashedDigest(value) {
|
|
75
|
+
return passwordInfo(value).algo !== null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Laravel 13.37 BcryptHasher::verifyConfiguration():
|
|
79
|
+
// isUsingCorrectAlgorithm($value) && isUsingValidOptions($value)
|
|
80
|
+
// where PHP's password_get_info() identifies ONLY `$2y$NN$` as bcrypt, and the
|
|
81
|
+
// cost must be an integer <= the configured rounds (a lower cost is valid).
|
|
82
|
+
export function verifyConfiguration(hashed, { rounds = 12 } = {}) {
|
|
83
|
+
const info = passwordInfo(hashed);
|
|
84
|
+
if (info.algoName !== 'bcrypt') return false;
|
|
85
|
+
return info.options.cost <= Number(rounds);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function check(value, hashed) {
|
|
89
|
+
if (!hashed || typeof hashed !== 'string') return false;
|
|
90
|
+
const ok = await bcrypt.compare(String(value), hashed);
|
|
91
|
+
return timingSafeEqual(ok ? '1' : '0', '1');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function needsRehash(hashed, { rounds = 10 } = {}) {
|
|
95
|
+
if (!hashed || typeof hashed !== 'string') return true;
|
|
96
|
+
try {
|
|
97
|
+
const r = bcrypt.getRounds(hashed);
|
|
98
|
+
return r !== rounds;
|
|
99
|
+
} catch {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Laravel `Hash::isHashed()` (HashManager.php:97-100): `$this->driver()->info(
|
|
105
|
+
// $value)['algo'] !== null` - the same rule as password_get_info(). The kernel
|
|
106
|
+
// used a loose regex, so a `$2a$`/`$2b$` digest or a TRUNCATED one counted as
|
|
107
|
+
// hashed where PHP says it is not (Laravel's own isHashedDigest() is the
|
|
108
|
+
// faithful rule - the driver checks the digest, not its shape).
|
|
109
|
+
export function isHashed(value) {
|
|
110
|
+
return isHashedDigest(value);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export default { make, makeSync, check, needsRehash, isHashed, isHashedDigest, passwordInfo, verifyConfiguration, timingSafeEqual };
|
package/kernel/multipart.mjs
CHANGED
|
@@ -1,161 +1,161 @@
|
|
|
1
|
-
// kernel/multipart.mjs — the multipart/form-data parser (the edge has no PHP).
|
|
2
|
-
//
|
|
3
|
-
// The body is the raw bytes; the parts are split on the boundary. A part with a
|
|
4
|
-
// `filename` is an uploaded file (the bytes as they are), the other parts are
|
|
5
|
-
// the form fields.
|
|
6
|
-
const enc = new TextEncoder();
|
|
7
|
-
const dec = new TextDecoder('utf-8', { fatal: false });
|
|
8
|
-
|
|
9
|
-
function indexOfBytes(hay, needle, from) {
|
|
10
|
-
outer: for (let i = Math.max(0, from); i + needle.length <= hay.length; i++) {
|
|
11
|
-
for (let j = 0; j < needle.length; j++) {
|
|
12
|
-
if (hay[i + j] !== needle[j]) continue outer;
|
|
13
|
-
}
|
|
14
|
-
return i;
|
|
15
|
-
}
|
|
16
|
-
return -1;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function parseMultipart(bytes, contentType) {
|
|
20
|
-
if (!(bytes instanceof Uint8Array)) {
|
|
21
|
-
throw new Error('multipart/form-data: the body must be the raw bytes');
|
|
22
|
-
}
|
|
23
|
-
const m = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType || '');
|
|
24
|
-
if (!m) throw new Error('multipart/form-data: the boundary is missing');
|
|
25
|
-
const boundary = (m[1] || m[2] || '').trim();
|
|
26
|
-
const delim = enc.encode('--' + boundary);
|
|
27
|
-
const fields = {};
|
|
28
|
-
const files = {};
|
|
29
|
-
// PHP's rfc1867 parser (ext/standard/rfc1867.c): the delimiter is
|
|
30
|
-
// CRLF + "--boundary" - a file whose CONTENT contains the boundary bytes
|
|
31
|
-
// must not end the part (the edge searched the bare bytes, so such a file
|
|
32
|
-
// truncated the upload). The FIRST boundary starts the body (no CRLF before
|
|
33
|
-
// it).
|
|
34
|
-
const findDelimiter = (from) => {
|
|
35
|
-
let at = from;
|
|
36
|
-
for (;;) {
|
|
37
|
-
const found = indexOfBytes(bytes, delim, at);
|
|
38
|
-
if (found === -1) return -1;
|
|
39
|
-
if (found === 0 || (bytes[found - 1] === 10 && bytes[found - 2] === 13)) return found;
|
|
40
|
-
at = found + 1;
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
|
-
let pos = findDelimiter(0);
|
|
44
|
-
if (pos === -1) throw new Error('multipart/form-data: the body has no boundary');
|
|
45
|
-
pos += delim.length;
|
|
46
|
-
for (;;) {
|
|
47
|
-
// the closing delimiter is "--boundary--"
|
|
48
|
-
if (bytes[pos] === 45 && bytes[pos + 1] === 45) break;
|
|
49
|
-
if (bytes[pos] === 13 && bytes[pos + 1] === 10) pos += 2;
|
|
50
|
-
const headEnd = indexOfBytes(bytes, enc.encode('\r\n\r\n'), pos);
|
|
51
|
-
if (headEnd === -1) break;
|
|
52
|
-
const head = dec.decode(bytes.subarray(pos, headEnd));
|
|
53
|
-
const bodyStart = headEnd + 4;
|
|
54
|
-
const next = findDelimiter(bodyStart);
|
|
55
|
-
if (next === -1) break;
|
|
56
|
-
// the part's body ends with a CRLF before the delimiter
|
|
57
|
-
let end = next;
|
|
58
|
-
if (end >= 2 && bytes[end - 1] === 10 && bytes[end - 2] === 13) end -= 2;
|
|
59
|
-
const content = bytes.subarray(bodyStart, end);
|
|
60
|
-
let name = null;
|
|
61
|
-
let filename = null;
|
|
62
|
-
let mime = 'application/octet-stream';
|
|
63
|
-
for (const line of head.split('\r\n')) {
|
|
64
|
-
const colon = line.indexOf(':');
|
|
65
|
-
if (colon === -1) continue;
|
|
66
|
-
const key = line.slice(0, colon).trim().toLowerCase();
|
|
67
|
-
const value = line.slice(colon + 1).trim();
|
|
68
|
-
if (key === 'content-disposition') {
|
|
69
|
-
const nm = /name="([^"]*)"/i.exec(value);
|
|
70
|
-
if (nm) name = nm[1];
|
|
71
|
-
const fn = /filename="([^"]*)"/i.exec(value);
|
|
72
|
-
if (fn) filename = fn[1];
|
|
73
|
-
} else if (key === 'content-type') {
|
|
74
|
-
mime = value;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
if (name !== null) {
|
|
78
|
-
if (filename !== null) {
|
|
79
|
-
setFile(files, name, {
|
|
80
|
-
name,
|
|
81
|
-
filename,
|
|
82
|
-
mime,
|
|
83
|
-
bytes: content,
|
|
84
|
-
size: content.length,
|
|
85
|
-
__part: true,
|
|
86
|
-
});
|
|
87
|
-
} else {
|
|
88
|
-
fields[name] = dec.decode(content);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
pos = next + delim.length;
|
|
92
|
-
}
|
|
93
|
-
return { fields, files };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// PHP's $_FILES naming: 'photos[]' is an append, 'photos[0]' is an index,
|
|
97
|
-
// 'a[b]' is a key. The parser writes the files into the same shape.
|
|
98
|
-
function setFile(files, name, desc) {
|
|
99
|
-
const m = /^([^[\]]*)((?:\[[^[\]]*\])*)$/.exec(name);
|
|
100
|
-
const base = m ? m[1] : name;
|
|
101
|
-
const brackets = m ? m[2] : '';
|
|
102
|
-
if (!brackets) {
|
|
103
|
-
if (base === '') files[''] = desc;
|
|
104
|
-
else files[base] = desc;
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
// The path: the base + every bracket key ('' means "append").
|
|
108
|
-
const keys = brackets.slice(1, -1).split('][');
|
|
109
|
-
let node = files;
|
|
110
|
-
let container = base;
|
|
111
|
-
for (let i = 0; i < keys.length; i++) {
|
|
112
|
-
const key = keys[i];
|
|
113
|
-
if (container === '') {
|
|
114
|
-
container = '0';
|
|
115
|
-
if (!node[container] || typeof node[container] !== 'object') node[container] = [];
|
|
116
|
-
}
|
|
117
|
-
let child = node[container];
|
|
118
|
-
if (!child || typeof child !== 'object' || child.__uploadedFile || child.__part) {
|
|
119
|
-
// The next key decides the shape: '' or a number is a list, else a map.
|
|
120
|
-
const next = keys[i + 1];
|
|
121
|
-
child = next === undefined || next === '' || /^\d+$/.test(next) ? [] : {};
|
|
122
|
-
node[container] = child;
|
|
123
|
-
}
|
|
124
|
-
if (Array.isArray(child)) {
|
|
125
|
-
if (key === '') {
|
|
126
|
-
// The last segment: append the file.
|
|
127
|
-
if (i === keys.length - 1) {
|
|
128
|
-
child.push(desc);
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
const created = [];
|
|
132
|
-
child.push(created);
|
|
133
|
-
node = created;
|
|
134
|
-
container = '';
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
const idx = Number(key);
|
|
138
|
-
if (!Number.isInteger(idx) || idx < 0) continue;
|
|
139
|
-
if (i === keys.length - 1) {
|
|
140
|
-
child[idx] = desc;
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
if (!child[idx] || typeof child[idx] !== 'object') child[idx] = [];
|
|
144
|
-
node = child[idx];
|
|
145
|
-
container = '';
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
if (i === keys.length - 1) {
|
|
149
|
-
child[key] = desc;
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
const next = keys[i + 1];
|
|
153
|
-
if (!child[key] || typeof child[key] !== 'object') {
|
|
154
|
-
child[key] = next === '' || /^\d+$/.test(next) ? [] : {};
|
|
155
|
-
}
|
|
156
|
-
node = child[key];
|
|
157
|
-
container = key;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
export default { parseMultipart };
|
|
1
|
+
// kernel/multipart.mjs — the multipart/form-data parser (the edge has no PHP).
|
|
2
|
+
//
|
|
3
|
+
// The body is the raw bytes; the parts are split on the boundary. A part with a
|
|
4
|
+
// `filename` is an uploaded file (the bytes as they are), the other parts are
|
|
5
|
+
// the form fields.
|
|
6
|
+
const enc = new TextEncoder();
|
|
7
|
+
const dec = new TextDecoder('utf-8', { fatal: false });
|
|
8
|
+
|
|
9
|
+
function indexOfBytes(hay, needle, from) {
|
|
10
|
+
outer: for (let i = Math.max(0, from); i + needle.length <= hay.length; i++) {
|
|
11
|
+
for (let j = 0; j < needle.length; j++) {
|
|
12
|
+
if (hay[i + j] !== needle[j]) continue outer;
|
|
13
|
+
}
|
|
14
|
+
return i;
|
|
15
|
+
}
|
|
16
|
+
return -1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseMultipart(bytes, contentType) {
|
|
20
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
21
|
+
throw new Error('multipart/form-data: the body must be the raw bytes');
|
|
22
|
+
}
|
|
23
|
+
const m = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType || '');
|
|
24
|
+
if (!m) throw new Error('multipart/form-data: the boundary is missing');
|
|
25
|
+
const boundary = (m[1] || m[2] || '').trim();
|
|
26
|
+
const delim = enc.encode('--' + boundary);
|
|
27
|
+
const fields = {};
|
|
28
|
+
const files = {};
|
|
29
|
+
// PHP's rfc1867 parser (ext/standard/rfc1867.c): the delimiter is
|
|
30
|
+
// CRLF + "--boundary" - a file whose CONTENT contains the boundary bytes
|
|
31
|
+
// must not end the part (the edge searched the bare bytes, so such a file
|
|
32
|
+
// truncated the upload). The FIRST boundary starts the body (no CRLF before
|
|
33
|
+
// it).
|
|
34
|
+
const findDelimiter = (from) => {
|
|
35
|
+
let at = from;
|
|
36
|
+
for (;;) {
|
|
37
|
+
const found = indexOfBytes(bytes, delim, at);
|
|
38
|
+
if (found === -1) return -1;
|
|
39
|
+
if (found === 0 || (bytes[found - 1] === 10 && bytes[found - 2] === 13)) return found;
|
|
40
|
+
at = found + 1;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
let pos = findDelimiter(0);
|
|
44
|
+
if (pos === -1) throw new Error('multipart/form-data: the body has no boundary');
|
|
45
|
+
pos += delim.length;
|
|
46
|
+
for (;;) {
|
|
47
|
+
// the closing delimiter is "--boundary--"
|
|
48
|
+
if (bytes[pos] === 45 && bytes[pos + 1] === 45) break;
|
|
49
|
+
if (bytes[pos] === 13 && bytes[pos + 1] === 10) pos += 2;
|
|
50
|
+
const headEnd = indexOfBytes(bytes, enc.encode('\r\n\r\n'), pos);
|
|
51
|
+
if (headEnd === -1) break;
|
|
52
|
+
const head = dec.decode(bytes.subarray(pos, headEnd));
|
|
53
|
+
const bodyStart = headEnd + 4;
|
|
54
|
+
const next = findDelimiter(bodyStart);
|
|
55
|
+
if (next === -1) break;
|
|
56
|
+
// the part's body ends with a CRLF before the delimiter
|
|
57
|
+
let end = next;
|
|
58
|
+
if (end >= 2 && bytes[end - 1] === 10 && bytes[end - 2] === 13) end -= 2;
|
|
59
|
+
const content = bytes.subarray(bodyStart, end);
|
|
60
|
+
let name = null;
|
|
61
|
+
let filename = null;
|
|
62
|
+
let mime = 'application/octet-stream';
|
|
63
|
+
for (const line of head.split('\r\n')) {
|
|
64
|
+
const colon = line.indexOf(':');
|
|
65
|
+
if (colon === -1) continue;
|
|
66
|
+
const key = line.slice(0, colon).trim().toLowerCase();
|
|
67
|
+
const value = line.slice(colon + 1).trim();
|
|
68
|
+
if (key === 'content-disposition') {
|
|
69
|
+
const nm = /name="([^"]*)"/i.exec(value);
|
|
70
|
+
if (nm) name = nm[1];
|
|
71
|
+
const fn = /filename="([^"]*)"/i.exec(value);
|
|
72
|
+
if (fn) filename = fn[1];
|
|
73
|
+
} else if (key === 'content-type') {
|
|
74
|
+
mime = value;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (name !== null) {
|
|
78
|
+
if (filename !== null) {
|
|
79
|
+
setFile(files, name, {
|
|
80
|
+
name,
|
|
81
|
+
filename,
|
|
82
|
+
mime,
|
|
83
|
+
bytes: content,
|
|
84
|
+
size: content.length,
|
|
85
|
+
__part: true,
|
|
86
|
+
});
|
|
87
|
+
} else {
|
|
88
|
+
fields[name] = dec.decode(content);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
pos = next + delim.length;
|
|
92
|
+
}
|
|
93
|
+
return { fields, files };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// PHP's $_FILES naming: 'photos[]' is an append, 'photos[0]' is an index,
|
|
97
|
+
// 'a[b]' is a key. The parser writes the files into the same shape.
|
|
98
|
+
function setFile(files, name, desc) {
|
|
99
|
+
const m = /^([^[\]]*)((?:\[[^[\]]*\])*)$/.exec(name);
|
|
100
|
+
const base = m ? m[1] : name;
|
|
101
|
+
const brackets = m ? m[2] : '';
|
|
102
|
+
if (!brackets) {
|
|
103
|
+
if (base === '') files[''] = desc;
|
|
104
|
+
else files[base] = desc;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// The path: the base + every bracket key ('' means "append").
|
|
108
|
+
const keys = brackets.slice(1, -1).split('][');
|
|
109
|
+
let node = files;
|
|
110
|
+
let container = base;
|
|
111
|
+
for (let i = 0; i < keys.length; i++) {
|
|
112
|
+
const key = keys[i];
|
|
113
|
+
if (container === '') {
|
|
114
|
+
container = '0';
|
|
115
|
+
if (!node[container] || typeof node[container] !== 'object') node[container] = [];
|
|
116
|
+
}
|
|
117
|
+
let child = node[container];
|
|
118
|
+
if (!child || typeof child !== 'object' || child.__uploadedFile || child.__part) {
|
|
119
|
+
// The next key decides the shape: '' or a number is a list, else a map.
|
|
120
|
+
const next = keys[i + 1];
|
|
121
|
+
child = next === undefined || next === '' || /^\d+$/.test(next) ? [] : {};
|
|
122
|
+
node[container] = child;
|
|
123
|
+
}
|
|
124
|
+
if (Array.isArray(child)) {
|
|
125
|
+
if (key === '') {
|
|
126
|
+
// The last segment: append the file.
|
|
127
|
+
if (i === keys.length - 1) {
|
|
128
|
+
child.push(desc);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const created = [];
|
|
132
|
+
child.push(created);
|
|
133
|
+
node = created;
|
|
134
|
+
container = '';
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const idx = Number(key);
|
|
138
|
+
if (!Number.isInteger(idx) || idx < 0) continue;
|
|
139
|
+
if (i === keys.length - 1) {
|
|
140
|
+
child[idx] = desc;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (!child[idx] || typeof child[idx] !== 'object') child[idx] = [];
|
|
144
|
+
node = child[idx];
|
|
145
|
+
container = '';
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (i === keys.length - 1) {
|
|
149
|
+
child[key] = desc;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const next = keys[i + 1];
|
|
153
|
+
if (!child[key] || typeof child[key] !== 'object') {
|
|
154
|
+
child[key] = next === '' || /^\d+$/.test(next) ? [] : {};
|
|
155
|
+
}
|
|
156
|
+
node = child[key];
|
|
157
|
+
container = key;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export default { parseMultipart };
|