@@ -23,6 +23,25 @@ export type RewriteRule = {
23
23
replacement : string ;
24
24
} ;
25
25
26
+ export type FileNotFoundToResponse = {
27
+ type : 'response' ;
28
+ response : PHPResponse ;
29
+ } ;
30
+ export type FileNotFoundToInternalRedirect = {
31
+ type : 'internal-redirect' ;
32
+ uri : string ;
33
+ } ;
34
+ export type FileNotFoundTo404 = { type : '404' } ;
35
+
36
+ export type FileNotFoundAction =
37
+ | FileNotFoundToResponse
38
+ | FileNotFoundToInternalRedirect
39
+ | FileNotFoundTo404 ;
40
+
41
+ export type FileNotFoundGetActionCallback = (
42
+ relativePath : string
43
+ ) => FileNotFoundAction ;
44
+
26
45
interface BaseConfiguration {
27
46
/**
28
47
* The directory in the PHP filesystem where the server will look
@@ -38,6 +57,12 @@ interface BaseConfiguration {
38
57
* Rewrite rules
39
58
*/
40
59
rewriteRules ?: RewriteRule [ ] ;
60
+
61
+ /**
62
+ * A callback that decides how to handle a file-not-found condition for a
63
+ * given request URI.
64
+ */
65
+ getFileNotFoundAction ?: FileNotFoundGetActionCallback ;
41
66
}
42
67
43
68
export type PHPRequestHandlerFactoryArgs = PHPFactoryOptions & {
@@ -137,6 +162,7 @@ export class PHPRequestHandler {
137
162
#cookieStore: HttpCookieStore ;
138
163
rewriteRules : RewriteRule [ ] ;
139
164
processManager : PHPProcessManager ;
165
+ getFileNotFoundAction : FileNotFoundGetActionCallback ;
140
166
141
167
/**
142
168
* The request handler needs to decide whether to serve a static asset or
@@ -154,6 +180,7 @@ export class PHPRequestHandler {
154
180
documentRoot = '/www/' ,
155
181
absoluteUrl = typeof location === 'object' ? location ?. href : '' ,
156
182
rewriteRules = [ ] ,
183
+ getFileNotFoundAction = ( ) => ( { type : '404' } ) ,
157
184
} = config ;
158
185
if ( 'processManager' in config ) {
159
186
this . processManager = config . processManager ;
@@ -194,6 +221,7 @@ export class PHPRequestHandler {
194
221
this . #PATHNAME,
195
222
] . join ( '' ) ;
196
223
this . rewriteRules = rewriteRules ;
224
+ this . getFileNotFoundAction = getFileNotFoundAction ;
197
225
}
198
226
199
227
async getPrimaryPhp ( ) {
@@ -306,14 +334,94 @@ export class PHPRequestHandler {
306
334
) ,
307
335
this . rewriteRules
308
336
) ;
309
- const fsPath = joinPaths ( this . #DOCROOT, normalizedRequestedPath ) ;
310
- if ( ! seemsLikeAPHPRequestHandlerPath ( fsPath ) ) {
311
- return this . #serveStaticFile(
312
- await this . processManager . getPrimaryPhp ( ) ,
313
- fsPath
337
+
338
+ const primaryPhp = await this . getPrimaryPhp ( ) ;
339
+
340
+ let fsPath = joinPaths ( this . #DOCROOT, normalizedRequestedPath ) ;
341
+
342
+ if ( primaryPhp . isDir ( fsPath ) ) {
343
+ // Ensure directory URIs have a trailing slash. Otherwise,
344
+ // relative URIs in index.php or index.html files are relative
345
+ // to the next directory up.
346
+ //
347
+ // Example:
348
+ // For an index page served for URI "/settings", we naturally expect
349
+ // links to be relative to "/settings", but without the trailing
350
+ // slash, a relative link "edit.php" resolves to "/edit.php"
351
+ // rather than "/settings/edit.php".
352
+ //
353
+ // This treatment of relative links is correct behavior for the browser:
354
+ // https://www.rfc-editor.org/rfc/rfc3986#section-5.2.3
355
+ //
356
+ // But user intent for `/settings/index.php` is that its relative
357
+ // URIs are relative to `/settings/`. So we redirect to add a
358
+ // trailing slash to directory URIs to meet this expecatation.
359
+ //
360
+ // This behavior is also necessary for WordPress to function properly.
361
+ // Otherwise, when viewing the WP admin dashboard at `/wp-admin`,
362
+ // links to other admin pages like `edit.php` will incorrectly
363
+ // resolve to `/edit.php` rather than `/wp-admin/edit.php`.
364
+ if ( ! fsPath . endsWith ( '/' ) ) {
365
+ return new PHPResponse (
366
+ 301 ,
367
+ { Location : [ `${ requestedUrl . pathname } /` ] } ,
368
+ new Uint8Array ( 0 )
369
+ ) ;
370
+ }
371
+
372
+ // We can only satisfy requests for directories with a default file
373
+ // so let's first resolve to a default path when available.
374
+ for ( const possibleIndexFile of [ 'index.php' , 'index.html' ] ) {
375
+ const possibleIndexPath = joinPaths ( fsPath , possibleIndexFile ) ;
376
+ if ( primaryPhp . isFile ( possibleIndexPath ) ) {
377
+ fsPath = possibleIndexPath ;
378
+ break ;
379
+ }
380
+ }
381
+ }
382
+
383
+ if ( ! primaryPhp . isFile ( fsPath ) ) {
384
+ const fileNotFoundAction = this . getFileNotFoundAction (
385
+ normalizedRequestedPath
314
386
) ;
387
+ switch ( fileNotFoundAction . type ) {
388
+ case 'response' :
389
+ return fileNotFoundAction . response ;
390
+ case 'internal-redirect' :
391
+ fsPath = joinPaths ( this . #DOCROOT, fileNotFoundAction . uri ) ;
392
+ break ;
393
+ case '404' :
394
+ return PHPResponse . forHttpCode ( 404 ) ;
395
+ default :
396
+ throw new Error (
397
+ 'Unsupported file-not-found action type: ' +
398
+ // Cast because TS asserts the remaining possibility is `never`
399
+ `'${
400
+ ( fileNotFoundAction as FileNotFoundAction ) . type
401
+ } '`
402
+ ) ;
403
+ }
404
+ }
405
+
406
+ // We need to confirm that the current target file exists because
407
+ // file-not-found fallback actions may redirect to non-existent files.
408
+ if ( primaryPhp . isFile ( fsPath ) ) {
409
+ if ( fsPath . endsWith ( '.php' ) ) {
410
+ const effectiveRequest : PHPRequest = {
411
+ ...request ,
412
+ // Pass along URL with the #fragment filtered out
413
+ url : requestedUrl . toString ( ) ,
414
+ } ;
415
+ return this . #spawnPHPAndDispatchRequest(
416
+ effectiveRequest ,
417
+ fsPath
418
+ ) ;
419
+ } else {
420
+ return this . #serveStaticFile( primaryPhp , fsPath ) ;
421
+ }
422
+ } else {
423
+ return PHPResponse . forHttpCode ( 404 ) ;
315
424
}
316
- return this . #spawnPHPAndDispatchRequest( request , requestedUrl ) ;
317
425
}
318
426
319
427
/**
@@ -323,17 +431,6 @@ export class PHPRequestHandler {
323
431
* @returns The response.
324
432
*/
325
433
#serveStaticFile( php : PHP , fsPath : string ) : PHPResponse {
326
- if ( ! php . fileExists ( fsPath ) ) {
327
- return new PHPResponse (
328
- 404 ,
329
- // Let the service worker know that no static file was found
330
- // and that it's okay to issue a real fetch() to the server.
331
- {
332
- 'x-file-type' : [ 'static' ] ,
333
- } ,
334
- new TextEncoder ( ) . encode ( '404 File not found' )
335
- ) ;
336
- }
337
434
const arrayBuffer = php . readFileAsBuffer ( fsPath ) ;
338
435
return new PHPResponse (
339
436
200 ,
@@ -355,7 +452,7 @@ export class PHPRequestHandler {
355
452
*/
356
453
async #spawnPHPAndDispatchRequest(
357
454
request : PHPRequest ,
358
- requestedUrl : URL
455
+ scriptPath : string
359
456
) : Promise < PHPResponse > {
360
457
let spawnedPHP : SpawnedPHP | undefined = undefined ;
361
458
try {
@@ -371,7 +468,7 @@ export class PHPRequestHandler {
371
468
return await this . #dispatchToPHP(
372
469
spawnedPHP . php ,
373
470
request ,
374
- requestedUrl
471
+ scriptPath
375
472
) ;
376
473
} finally {
377
474
spawnedPHP . reap ( ) ;
@@ -388,7 +485,7 @@ export class PHPRequestHandler {
388
485
async #dispatchToPHP(
389
486
php : PHP ,
390
487
request : PHPRequest ,
391
- requestedUrl : URL
488
+ scriptPath : string
392
489
) : Promise < PHPResponse > {
393
490
let preferredMethod : PHPRunOptions [ 'method' ] = 'GET' ;
394
491
@@ -406,20 +503,10 @@ export class PHPRequestHandler {
406
503
headers [ 'content-type' ] = contentType ;
407
504
}
408
505
409
- let scriptPath ;
410
- try {
411
- scriptPath = this . #resolvePHPFilePath(
412
- php ,
413
- decodeURIComponent ( requestedUrl . pathname )
414
- ) ;
415
- } catch ( error ) {
416
- return PHPResponse . forHttpCode ( 404 ) ;
417
- }
418
-
419
506
try {
420
507
const response = await php . run ( {
421
508
relativeUri : ensurePathPrefix (
422
- toRelativeUrl ( requestedUrl ) ,
509
+ toRelativeUrl ( new URL ( request . url ) ) ,
423
510
this . #PATHNAME
424
511
) ,
425
512
protocol : this . #PROTOCOL,
@@ -447,45 +534,6 @@ export class PHPRequestHandler {
447
534
throw error ;
448
535
}
449
536
}
450
-
451
- /**
452
- * Resolve the requested path to the filesystem path of the requested PHP file.
453
- *
454
- * Fall back to index.php as if there was a url rewriting rule in place.
455
- *
456
- * @param requestedPath - The requested pathname.
457
- * @throws {Error } If the requested path doesn't exist.
458
- * @returns The resolved filesystem path.
459
- */
460
- #resolvePHPFilePath( php : PHP , requestedPath : string ) : string {
461
- let filePath = removePathPrefix ( requestedPath , this . #PATHNAME) ;
462
- filePath = applyRewriteRules ( filePath , this . rewriteRules ) ;
463
-
464
- if ( filePath . includes ( '.php' ) ) {
465
- // If the path mentions a .php extension, that's our file's path.
466
- filePath = filePath . split ( '.php' ) [ 0 ] + '.php' ;
467
- } else if ( php . isDir ( `${ this . #DOCROOT} ${ filePath } ` ) ) {
468
- if ( ! filePath . endsWith ( '/' ) ) {
469
- filePath = `${ filePath } /` ;
470
- }
471
- // If the path is a directory, let's assume the file is index.php
472
- filePath = `${ filePath } index.php` ;
473
- } else {
474
- // Otherwise, let's assume the file is /index.php
475
- filePath = '/index.php' ;
476
- }
477
-
478
- let resolvedFsPath = `${ this . #DOCROOT} ${ filePath } ` ;
479
- // If the requested PHP file doesn't exist, let's fall back to /index.php
480
- // as the request may need to be rewritten.
481
- if ( ! php . fileExists ( resolvedFsPath ) ) {
482
- resolvedFsPath = `${ this . #DOCROOT} /index.php` ;
483
- }
484
- if ( php . fileExists ( resolvedFsPath ) ) {
485
- return resolvedFsPath ;
486
- }
487
- throw new Error ( `File not found: ${ resolvedFsPath } ` ) ;
488
- }
489
537
}
490
538
491
539
/**
@@ -503,35 +551,6 @@ function inferMimeType(path: string): string {
503
551
return mimeTypes [ extension ] || mimeTypes [ '_default' ] ;
504
552
}
505
553
506
- /**
507
- * Guesses whether the given path looks like a PHP file.
508
- *
509
- * @example
510
- * ```js
511
- * seemsLikeAPHPRequestHandlerPath('/index.php') // true
512
- * seemsLikeAPHPRequestHandlerPath('/index.php') // true
513
- * seemsLikeAPHPRequestHandlerPath('/index.php/foo/bar') // true
514
- * seemsLikeAPHPRequestHandlerPath('/index.html') // false
515
- * seemsLikeAPHPRequestHandlerPath('/index.html/foo/bar') // false
516
- * seemsLikeAPHPRequestHandlerPath('/') // true
517
- * ```
518
- *
519
- * @param path The path to check.
520
- * @returns Whether the path seems like a PHP server path.
521
- */
522
- export function seemsLikeAPHPRequestHandlerPath ( path : string ) : boolean {
523
- return seemsLikeAPHPFile ( path ) || seemsLikeADirectoryRoot ( path ) ;
524
- }
525
-
526
- function seemsLikeAPHPFile ( path : string ) {
527
- return path . endsWith ( '.php' ) || path . includes ( '.php/' ) ;
528
- }
529
-
530
- function seemsLikeADirectoryRoot ( path : string ) {
531
- const lastSegment = path . split ( '/' ) . pop ( ) ;
532
- return ! lastSegment ! . includes ( '.' ) ;
533
- }
534
-
535
554
/**
536
555
* Applies the given rewrite rules to the given path.
537
556
*
0 commit comments