-
Notifications
You must be signed in to change notification settings - Fork 244
/
php-request-handler.ts
618 lines (588 loc) · 16.3 KB
/
php-request-handler.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
import { joinPaths } from '@php-wasm/util';
import {
ensurePathPrefix,
toRelativeUrl,
removePathPrefix,
DEFAULT_BASE_URL,
} from './urls';
import {
BasePHP,
PHPExecutionFailureError,
normalizeHeaders,
} from './base-php';
import { PHPResponse } from './php-response';
import { PHPRequest, PHPRunOptions } from './universal-php';
import { encodeAsMultipart } from './encode-as-multipart';
import {
MaxPhpInstancesError,
PHPFactoryOptions,
PHPProcessManager,
SpawnedPHP,
} from './php-process-manager';
import { HttpCookieStore } from './http-cookie-store';
export type RewriteRule = {
match: RegExp;
replacement: string;
};
interface BaseConfiguration {
/**
* The directory in the PHP filesystem where the server will look
* for the files to serve. Default: `/var/www`.
*/
documentRoot?: string;
/**
* Request Handler URL. Used to populate $_SERVER details like HTTP_HOST.
*/
absoluteUrl?: string;
/**
* Rewrite rules
*/
rewriteRules?: RewriteRule[];
}
export type PHPRequestHandlerFactoryArgs<PHP extends BasePHP> =
PHPFactoryOptions & {
requestHandler: PHPRequestHandler<PHP>;
};
export type PHPRequestHandlerConfiguration<PHP extends BasePHP> =
BaseConfiguration &
(
| {
/**
* PHPProcessManager is required because the request handler needs
* to make a decision for each request.
*
* Static assets are served using the primary PHP's filesystem, even
* when serving 100 static files concurrently. No new PHP interpreter
* is ever created as there's no need for it.
*
* Dynamic PHP requests, however, require grabbing an available PHP
* interpreter, and that's where the PHPProcessManager comes in.
*/
processManager: PHPProcessManager<PHP>;
}
| {
phpFactory: (
requestHandler: PHPRequestHandlerFactoryArgs<PHP>
) => Promise<PHP>;
/**
* The maximum number of PHP instances that can exist at
* the same time.
*/
maxPhpInstances?: number;
}
);
/**
* Handles HTTP requests using PHP runtime as a backend.
*
* @public
* @example Use PHPRequestHandler implicitly with a new PHP instance:
* ```js
* import { PHP } from '@php-wasm/web';
*
* const php = await PHP.load( '7.4', {
* requestHandler: {
* // PHP FS path to serve the files from:
* documentRoot: '/www',
*
* // Used to populate $_SERVER['SERVER_NAME'] etc.:
* absoluteUrl: 'http://127.0.0.1'
* }
* } );
*
* php.mkdirTree('/www');
* php.writeFile('/www/index.php', '<?php echo "Hi from PHP!"; ');
*
* const response = await php.request({ path: '/index.php' });
* console.log(response.text);
* // "Hi from PHP!"
* ```
*
* @example Explicitly create a PHPRequestHandler instance and run a PHP script:
* ```js
* import {
* loadPHPRuntime,
* PHP,
* PHPRequestHandler,
* getPHPLoaderModule,
* } from '@php-wasm/web';
*
* const runtime = await loadPHPRuntime( await getPHPLoaderModule('7.4') );
* const php = new PHP( runtime );
*
* php.mkdirTree('/www');
* php.writeFile('/www/index.php', '<?php echo "Hi from PHP!"; ');
*
* const server = new PHPRequestHandler(php, {
* // PHP FS path to serve the files from:
* documentRoot: '/www',
*
* // Used to populate $_SERVER['SERVER_NAME'] etc.:
* absoluteUrl: 'http://127.0.0.1'
* });
*
* const response = server.request({ path: '/index.php' });
* console.log(response.text);
* // "Hi from PHP!"
* ```
*/
export class PHPRequestHandler<PHP extends BasePHP> {
#DOCROOT: string;
#PROTOCOL: string;
#HOSTNAME: string;
#PORT: number;
#HOST: string;
#PATHNAME: string;
#ABSOLUTE_URL: string;
#cookieStore: HttpCookieStore;
rewriteRules: RewriteRule[];
processManager: PHPProcessManager<PHP>;
/**
* The request handler needs to decide whether to serve a static asset or
* run the PHP interpreter. For static assets it should just reuse the primary
* PHP even if there's 50 concurrent requests to serve. However, for
* dynamic PHP requests, it needs to grab an available interpreter.
* Therefore, it cannot just accept PHP as an argument as serving requests
* requires access to ProcessManager.
*
* @param php - The PHP instance.
* @param config - Request Handler configuration.
*/
constructor(config: PHPRequestHandlerConfiguration<PHP>) {
const {
documentRoot = '/www/',
absoluteUrl = typeof location === 'object' ? location?.href : '',
rewriteRules = [],
} = config;
if ('processManager' in config) {
this.processManager = config.processManager;
} else {
this.processManager = new PHPProcessManager({
phpFactory: async (info) => {
const php = await config.phpFactory!({
...info,
requestHandler: this,
});
// @TODO: Decouple PHP and request handler
(php as any).requestHandler = this;
return php;
},
maxPhpInstances: config.maxPhpInstances,
});
}
this.#cookieStore = new HttpCookieStore();
this.#DOCROOT = documentRoot;
const url = new URL(absoluteUrl);
this.#HOSTNAME = url.hostname;
this.#PORT = url.port
? Number(url.port)
: url.protocol === 'https:'
? 443
: 80;
this.#PROTOCOL = (url.protocol || '').replace(':', '');
const isNonStandardPort = this.#PORT !== 443 && this.#PORT !== 80;
this.#HOST = [
this.#HOSTNAME,
isNonStandardPort ? `:${this.#PORT}` : '',
].join('');
this.#PATHNAME = url.pathname.replace(/\/+$/, '');
this.#ABSOLUTE_URL = [
`${this.#PROTOCOL}://`,
this.#HOST,
this.#PATHNAME,
].join('');
this.rewriteRules = rewriteRules;
}
async getPrimaryPhp() {
return await this.processManager.getPrimaryPhp();
}
/**
* Converts a path to an absolute URL based at the PHPRequestHandler
* root.
*
* @param path The server path to convert to an absolute URL.
* @returns The absolute URL.
*/
pathToInternalUrl(path: string): string {
return `${this.absoluteUrl}${path}`;
}
/**
* Converts an absolute URL based at the PHPRequestHandler to a relative path
* without the server pathname and scope.
*
* @param internalUrl An absolute URL based at the PHPRequestHandler root.
* @returns The relative path.
*/
internalUrlToPath(internalUrl: string): string {
const url = new URL(internalUrl);
if (url.pathname.startsWith(this.#PATHNAME)) {
url.pathname = url.pathname.slice(this.#PATHNAME.length);
}
return toRelativeUrl(url);
}
/**
* The absolute URL of this PHPRequestHandler instance.
*/
get absoluteUrl() {
return this.#ABSOLUTE_URL;
}
/**
* The directory in the PHP filesystem where the server will look
* for the files to serve. Default: `/var/www`.
*/
get documentRoot() {
return this.#DOCROOT;
}
/**
* Serves the request – either by serving a static file, or by
* dispatching it to the PHP runtime.
*
* The request() method mode behaves like a web server and only works if
* the PHP was initialized with a `requestHandler` option (which the online version
* of WordPress Playground does by default).
*
* In the request mode, you pass an object containing the request information
* (method, headers, body, etc.) and the path to the PHP file to run:
*
* ```ts
* const php = PHP.load('7.4', {
* requestHandler: {
* documentRoot: "/www"
* }
* })
* php.writeFile("/www/index.php", `<?php echo file_get_contents("php://input");`);
* const result = await php.request({
* method: "GET",
* headers: {
* "Content-Type": "text/plain"
* },
* body: "Hello world!",
* path: "/www/index.php"
* });
* // result.text === "Hello world!"
* ```
*
* The `request()` method cannot be used in conjunction with `cli()`.
*
* @example
* ```js
* const output = await php.request({
* method: 'GET',
* url: '/index.php',
* headers: {
* 'X-foo': 'bar',
* },
* body: {
* foo: 'bar',
* },
* });
* console.log(output.stdout); // "Hello world!"
* ```
*
* @param request - PHP Request data.
*/
async request(request: PHPRequest): Promise<PHPResponse> {
const isAbsolute =
request.url.startsWith('http://') ||
request.url.startsWith('https://');
const requestedUrl = new URL(
// Remove the hash part of the URL as it's not meant for the server.
request.url.split('#')[0],
isAbsolute ? undefined : DEFAULT_BASE_URL
);
const normalizedRequestedPath = applyRewriteRules(
removePathPrefix(
decodeURIComponent(requestedUrl.pathname),
this.#PATHNAME
),
this.rewriteRules
);
const fsPath = joinPaths(this.#DOCROOT, normalizedRequestedPath);
if (!seemsLikeAPHPRequestHandlerPath(fsPath)) {
return this.#serveStaticFile(
await this.processManager.getPrimaryPhp(),
fsPath
);
}
return this.#spawnPHPAndDispatchRequest(request, requestedUrl);
}
/**
* Serves a static file from the PHP filesystem.
*
* @param fsPath - Absolute path of the static file to serve.
* @returns The response.
*/
#serveStaticFile(php: BasePHP, fsPath: string): PHPResponse {
if (!php.fileExists(fsPath)) {
return new PHPResponse(
404,
// Let the service worker know that no static file was found
// and that it's okay to issue a real fetch() to the server.
{
'x-file-type': ['static'],
},
new TextEncoder().encode('404 File not found')
);
}
const arrayBuffer = php.readFileAsBuffer(fsPath);
return new PHPResponse(
200,
{
'content-length': [`${arrayBuffer.byteLength}`],
// @TODO: Infer the content-type from the arrayBuffer instead of the file path.
// The code below won't return the correct mime-type if the extension
// was tampered with.
'content-type': [inferMimeType(fsPath)],
'accept-ranges': ['bytes'],
'cache-control': ['public, max-age=0'],
},
arrayBuffer
);
}
/**
* Spawns a new PHP instance and dispatches a request to it.
*/
async #spawnPHPAndDispatchRequest(
request: PHPRequest,
requestedUrl: URL
): Promise<PHPResponse> {
let spawnedPHP: SpawnedPHP<PHP> | undefined = undefined;
try {
spawnedPHP = await this.processManager!.acquirePHPInstance();
} catch (e) {
if (e instanceof MaxPhpInstancesError) {
return PHPResponse.forHttpCode(502);
} else {
return PHPResponse.forHttpCode(500);
}
}
try {
return await this.#dispatchToPHP(
spawnedPHP.php,
request,
requestedUrl
);
} finally {
spawnedPHP.reap();
}
}
/**
* Runs the requested PHP file with all the request and $_SERVER
* superglobals populated.
*
* @param request - The request.
* @returns The response.
*/
async #dispatchToPHP(
php: BasePHP,
request: PHPRequest,
requestedUrl: URL
): Promise<PHPResponse> {
let preferredMethod: PHPRunOptions['method'] = 'GET';
const headers: Record<string, string> = {
host: this.#HOST,
...normalizeHeaders(request.headers || {}),
cookie: this.#cookieStore.getCookieRequestHeader(),
};
let body = request.body;
if (typeof body === 'object' && !(body instanceof Uint8Array)) {
preferredMethod = 'POST';
const { bytes, contentType } = await encodeAsMultipart(body);
body = bytes;
headers['content-type'] = contentType;
}
let scriptPath;
try {
scriptPath = this.#resolvePHPFilePath(
php,
decodeURIComponent(requestedUrl.pathname)
);
} catch (error) {
return PHPResponse.forHttpCode(404);
}
try {
const response = await php.run({
relativeUri: ensurePathPrefix(
toRelativeUrl(requestedUrl),
this.#PATHNAME
),
protocol: this.#PROTOCOL,
method: request.method || preferredMethod,
$_SERVER: {
REMOTE_ADDR: '127.0.0.1',
DOCUMENT_ROOT: this.#DOCROOT,
HTTPS: this.#ABSOLUTE_URL.startsWith('https://')
? 'on'
: '',
},
body,
scriptPath,
headers,
});
this.#cookieStore.rememberCookiesFromResponseHeaders(
response.headers
);
return response;
} catch (error) {
const executionError = error as PHPExecutionFailureError;
if (executionError?.response) {
return executionError.response;
}
throw error;
}
}
/**
* Resolve the requested path to the filesystem path of the requested PHP file.
*
* Fall back to index.php as if there was a url rewriting rule in place.
*
* @param requestedPath - The requested pathname.
* @throws {Error} If the requested path doesn't exist.
* @returns The resolved filesystem path.
*/
#resolvePHPFilePath(php: BasePHP, requestedPath: string): string {
let filePath = removePathPrefix(requestedPath, this.#PATHNAME);
filePath = applyRewriteRules(filePath, this.rewriteRules);
if (filePath.includes('.php')) {
// If the path mentions a .php extension, that's our file's path.
filePath = filePath.split('.php')[0] + '.php';
} else if (php.isDir(`${this.#DOCROOT}${filePath}`)) {
if (!filePath.endsWith('/')) {
filePath = `${filePath}/`;
}
// If the path is a directory, let's assume the file is index.php
filePath = `${filePath}index.php`;
} else {
// Otherwise, let's assume the file is /index.php
filePath = '/index.php';
}
const resolvedFsPath = `${this.#DOCROOT}${filePath}`;
if (php.fileExists(resolvedFsPath)) {
return resolvedFsPath;
}
throw new Error(`File not found: ${resolvedFsPath}`);
}
}
/**
* Naively infer a file mime type from its path.
*
* @todo Infer the mime type based on the file contents.
* A naive function like this one can be inaccurate
* and potentially have negative security consequences.
*
* @param path - The file path
* @returns The inferred mime type.
*/
function inferMimeType(path: string): string {
const extension = path.split('.').pop();
switch (extension) {
case 'css':
return 'text/css';
case 'js':
return 'application/javascript';
case 'png':
return 'image/png';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'gif':
return 'image/gif';
case 'svg':
return 'image/svg+xml';
case 'woff':
return 'font/woff';
case 'woff2':
return 'font/woff2';
case 'ttf':
return 'font/ttf';
case 'otf':
return 'font/otf';
case 'eot':
return 'font/eot';
case 'ico':
return 'image/x-icon';
case 'html':
return 'text/html';
case 'json':
return 'application/json';
case 'xml':
return 'application/xml';
case 'txt':
case 'md':
return 'text/plain';
case 'pdf':
return 'application/pdf';
case 'webp':
return 'image/webp';
case 'mp3':
return 'audio/mpeg';
case 'mp4':
return 'video/mp4';
case 'csv':
return 'text/csv';
case 'xls':
return 'application/vnd.ms-excel';
case 'xlsx':
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
case 'doc':
return 'application/msword';
case 'docx':
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
case 'ppt':
return 'application/vnd.ms-powerpoint';
case 'pptx':
return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
case 'zip':
return 'application/zip';
case 'rar':
return 'application/x-rar-compressed';
case 'tar':
return 'application/x-tar';
case 'gz':
return 'application/gzip';
case '7z':
return 'application/x-7z-compressed';
default:
return 'application-octet-stream';
}
}
/**
* Guesses whether the given path looks like a PHP file.
*
* @example
* ```js
* seemsLikeAPHPRequestHandlerPath('/index.php') // true
* seemsLikeAPHPRequestHandlerPath('/index.php') // true
* seemsLikeAPHPRequestHandlerPath('/index.php/foo/bar') // true
* seemsLikeAPHPRequestHandlerPath('/index.html') // false
* seemsLikeAPHPRequestHandlerPath('/index.html/foo/bar') // false
* seemsLikeAPHPRequestHandlerPath('/') // true
* ```
*
* @param path The path to check.
* @returns Whether the path seems like a PHP server path.
*/
export function seemsLikeAPHPRequestHandlerPath(path: string): boolean {
return seemsLikeAPHPFile(path) || seemsLikeADirectoryRoot(path);
}
function seemsLikeAPHPFile(path: string) {
return path.endsWith('.php') || path.includes('.php/');
}
function seemsLikeADirectoryRoot(path: string) {
const lastSegment = path.split('/').pop();
return !lastSegment!.includes('.');
}
/**
* Applies the given rewrite rules to the given path.
*
* @param path The path to apply the rules to.
* @param rules The rules to apply.
* @returns The path with the rules applied.
*/
export function applyRewriteRules(path: string, rules: RewriteRule[]): string {
for (const rule of rules) {
if (new RegExp(rule.match).test(path)) {
return path.replace(rule.match, rule.replacement);
}
}
return path;
}