StaticFiles.php
PHP
Path: src/Static/StaticFiles.php
<?php
namespace mini\Static;
use mini\Http\Message\Response;
use mini\Http\Message\Stream;
use mini\Mini;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Static File Serving Middleware
*
* Serves static files from _static/ directories using PathRegistry.
* Files in _static/path/to/file.js are accessible at /path/to/file.js.
*
* Resolution order:
* 1. Application: _static/ (or MINI_STATIC_ROOT)
* 2. Framework: vendor/fubber/mini/_static/
*
* If file not found in static registry, passes request to next handler (router).
*/
class StaticFiles implements MiddlewareInterface
{
private array $mimeTypes;
public function __construct()
{
$this->mimeTypes = Mini::$mini->loadConfig('mimeTypes.php');
}
/**
* Process request - serve static file or pass to next handler
*
* @param ServerRequestInterface $request
* @param RequestHandlerInterface $handler Next handler (router)
* @return ResponseInterface
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
// Extract path from request URI (strips query string)
$path = parse_url($request->getRequestTarget(), PHP_URL_PATH);
// Strip base URL path prefix if configured (e.g., /assistant/ -> /)
$baseUrlPath = parse_url(Mini::$mini->baseUrl, PHP_URL_PATH);
if ($baseUrlPath && str_starts_with($path, $baseUrlPath)) {
$path = substr($path, strlen($baseUrlPath));
}
$path = ltrim($path, '/');
// Try to find static file
$filePath = $this->findAsset($path);
// If not found, pass to next handler (router)
if ($filePath === null) {
return $handler->handle($request);
}
// Serve the static file
return $this->serveFile($filePath, $request);
}
/**
* Find static asset in PathRegistry
*
* @param string $assetPath Relative path (e.g., "logo.png" or "css/style.css")
* @return string|null Full file system path, or null if not found
*/
public function findAsset(string $assetPath): ?string
{
$filePath = Mini::$mini->paths->static->findFirst($assetPath);
if ($filePath === null || !is_file($filePath)) {
return null;
}
return $filePath;
}
/**
* Get MIME type for file
*
* @param string $file File path or filename with extension
* @return string MIME type (defaults to 'application/octet-stream')
*/
public function getMimeType(string $file): string
{
$extension = strtolower(pathinfo($file, PATHINFO_EXTENSION));
return $this->mimeTypes[$extension] ?? 'application/octet-stream';
}
/**
* Serve static file with proper headers
*
* @param string $filePath Full file system path
* @param ServerRequestInterface $request Original request
* @return ResponseInterface
*/
private function serveFile(string $filePath, ServerRequestInterface $request): ResponseInterface
{
$mimeType = $this->getMimeType($filePath);
$fileSize = filesize($filePath);
$lastModified = filemtime($filePath);
$etag = '"' . md5($filePath . $lastModified . $fileSize) . '"';
// Check conditional request headers (304 Not Modified)
$ifNoneMatch = $request->getHeaderLine('If-None-Match');
$ifModifiedSince = $request->getHeaderLine('If-Modified-Since');
if ($ifNoneMatch === $etag) {
// ETag matches - return 304
return new Response('', [
'ETag' => $etag,
'Cache-Control' => 'public, max-age=31536000, immutable',
], 304);
}
if ($ifModifiedSince && strtotime($ifModifiedSince) >= $lastModified) {
// File not modified since client's cached version - return 304
return new Response('', [
'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified) . ' GMT',
'Cache-Control' => 'public, max-age=31536000, immutable',
], 304);
}
// Open file stream
$stream = Stream::cast(fopen($filePath, 'rb'));
// Return response with file content and proper headers
return new Response($stream, [
'Content-Type' => $mimeType,
'Content-Length' => (string)$fileSize,
'Cache-Control' => 'public, max-age=31536000, immutable',
'ETag' => $etag,
'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified) . ' GMT',
], 200);
}
}