diff --git a/.env.example b/.env.example index c7e7dbd..e551ff7 100644 --- a/.env.example +++ b/.env.example @@ -24,8 +24,16 @@ KITTYSHARE_SHOW_DOTFILES=false KITTYSHARE_BASE_URL=https://files.example.com # Number of bytes sent per chunk when downloading files (default: 8192). +# Only used by the default PHP file server (KITTYSHARE_FILE_SERVER=php). KITTYSHARE_DOWNLOAD_CHUNK_SIZE=8192 +# File server backend: php or x-sendfile (default: php). +# - php: streams files through PHP, works everywhere. +# - x-sendfile (alias: apache): PHP authorizes the request, Apache sends +# the file via mod_xsendfile. Requires XSendFile On + XSendFilePath in +# the Apache vhost (see apache-vhost.conf). +KITTYSHARE_FILE_SERVER=php + # Generic meta description text (default: omitted). # When set, it is used for and og:description on all pages. # KITTYSHARE_META_DESCRIPTION=KittyShare: self-hosted private file sharing. diff --git a/Dockerfile b/Dockerfile index d0e4937..b9a078e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,12 @@ FROM php:8.5-apache AS final RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" +# Install mod_xsendfile extension +RUN apt-get update \ + && apt-get install -y --no-install-recommends libapache2-mod-xsendfile \ + && rm -rf /var/lib/apt/lists/* \ + && a2enmod xsendfile + COPY --from=deps /app/vendor/ /var/www/html/vendor COPY . /var/www/html @@ -26,5 +32,6 @@ COPY apache-vhost.conf /etc/apache2/sites-available/000-default.conf USER www-data +ENV KITTYSHARE_FILE_SERVER=x-sendfile ENV KITTYSHARE_DATABASE_PATH=/var/www/data/database.sqlite ENV KITTYSHARE_ROOT=/data/files diff --git a/README.md b/README.md index 7f89c67..d58f81c 100644 --- a/README.md +++ b/README.md @@ -97,10 +97,17 @@ file as a reference: | `KITTYSHARE_COOKIE_SECURE` | Whether to mark the session cookie as secure. | Automatic | | `KITTYSHARE_SHOW_DOTFILES` | Whether dotfiles should appear in file listings. | `false` | | `KITTYSHARE_BASE_URL` | Canonical base URL used when generating absolute links. | Relative links | -| `KITTYSHARE_DOWNLOAD_CHUNK_SIZE` | Number of bytes sent per download chunk. | `8192` | +| `KITTYSHARE_DOWNLOAD_CHUNK_SIZE` | Number of bytes sent per download chunk (PHP file server only). | `8192` | +| `KITTYSHARE_FILE_SERVER` | File server backend: `php` or `x-sendfile` (`apache` alias). | `php` | | `KITTYSHARE_META_DESCRIPTION` | Generic meta description text. Omitted when unset. | Omitted | | `KITTYSHARE_META_OG_MODE` | Open Graph tags: `none`, `minimal`, or `per-share`. | `none` | +Setting `KITTYSHARE_FILE_SERVER=x-sendfile` makes PHP authorize the share +request and then delegate the transfer to Apache via `mod_xsendfile`. This frees +PHP workers on large files and lets Apache handle range requests. It requires +`XSendFile On` plus a matching `XSendFilePath` (see +[`apache-vhost.conf`](apache-vhost.conf)). + Make sure the user running PHP can read the application files and has the necessary permissions to create or modify the SQLite database. The configured `KITTYSHARE_ROOT` directory must also be readable by the PHP process. diff --git a/apache-vhost.conf b/apache-vhost.conf index 696c432..609a8e5 100644 --- a/apache-vhost.conf +++ b/apache-vhost.conf @@ -11,6 +11,14 @@ FallbackResource /index.php + # Enables the mod_xsendfile to allow for the delegation of sending static + # files to the Apache Webserver. + + XSendFile On + XSendFilePath /data/files + # XSendFilePath / + + ErrorLog ${APACHE_LOG_DIR}/error.log CustomLog ${APACHE_LOG_DIR}/access.log combined diff --git a/src/Controller/ShareController.php b/src/Controller/ShareController.php index f6ea5be..2f80d3b 100644 --- a/src/Controller/ShareController.php +++ b/src/Controller/ShareController.php @@ -2,7 +2,7 @@ namespace KittyShare\Controller; -use KittyShare\Http\{Request, Response, TemplateResponse, FileResponse}; +use KittyShare\Http\{Request, Response, TemplateResponse, FileResponseFactory}; use KittyShare\Manager\ConfigManager; use KittyShare\Filesystem\DirectoryBrowser; use KittyShare\Model\Share; @@ -169,7 +169,7 @@ private function serveFile(string $path): Response { $contentType = mime_content_type($path); - return new FileResponse( + return FileResponseFactory::forFile( $path, $contentType !== false ? $contentType diff --git a/src/Http/FileResponseFactory.php b/src/Http/FileResponseFactory.php new file mode 100644 index 0000000..a05d323 --- /dev/null +++ b/src/Http/FileResponseFactory.php @@ -0,0 +1,30 @@ +fileServer === FileServerType::XSendfile) { + return new XSendfileResponse($file, $contentType, $statusCode); + } + + return new FileResponse($file, $contentType, $statusCode, $chunkSize); + } +} diff --git a/src/Http/FileServerType.php b/src/Http/FileServerType.php new file mode 100644 index 0000000..beb33c8 --- /dev/null +++ b/src/Http/FileServerType.php @@ -0,0 +1,18 @@ +file)) { + http_response_code(404); + + return; + } + + while (ob_get_level() > 0) { + ob_end_clean(); + } + + if (headers_sent()) { + return; + } + + http_response_code($this->statusCode); + + header("Content-Type: {$this->contentType}"); + + // Prevent MIME sniffing and script execution when the browser + // renders a shared HTML/SVG file inline. + header('X-Content-Type-Options: nosniff'); + header('Content-Security-Policy: sandbox'); + + header('X-Sendfile: ' . $this->file); + } +} diff --git a/src/Manager/ConfigManager.php b/src/Manager/ConfigManager.php index 7af20ec..6ead34d 100644 --- a/src/Manager/ConfigManager.php +++ b/src/Manager/ConfigManager.php @@ -3,8 +3,10 @@ namespace KittyShare\Manager; use KittyShare\Model\Config; +use KittyShare\Http\FileServerType; -final class ConfigManager { +final class ConfigManager +{ private static ?Config $config = null; public static function get(): Config @@ -38,6 +40,7 @@ private static function constructConfig(): Config ), metaDescription: self::metaDescription(), metaOgMode: self::metaOgMode(), + fileServer: self::fileServer(), ); } @@ -218,6 +221,28 @@ private static function metaOgMode(): string }; } + /** + * Reads the file-serving backend. + * + * Accepts `php` (default, streams through PHP) and `x-sendfile` + * (delegates to Apache via mod_xsendfile). `apache` is accepted as + * an alias of `x-sendfile`. Unknown or unset values fall back to `php`. + */ + private static function fileServer(): FileServerType + { + $value = self::env('KITTYSHARE_FILE_SERVER'); + + if ($value === null) { + return FileServerType::Php; + } + + return match (strtolower(trim($value))) { + 'php', 'php-stream' => FileServerType::Php, + 'apache', 'x-sendfile' => FileServerType::XSendfile, + default => FileServerType::Php, + }; + } + /** * Returns the path portion of the base URL for use as an internal route * prefix. diff --git a/src/Model/Config.php b/src/Model/Config.php index f2396a3..8d8ceaa 100644 --- a/src/Model/Config.php +++ b/src/Model/Config.php @@ -2,6 +2,8 @@ namespace KittyShare\Model; +use KittyShare\Http\FileServerType; + final readonly class Config { @@ -18,6 +20,7 @@ * @param positive-int $downloadChunkSize * @param ?string $metaDescription * @param 'none'|'minimal'|'per-share' $metaOgMode + * @param FileServerType $fileServer */ public function __construct( /** Path to the SQLite database file. */ @@ -61,6 +64,9 @@ public function __construct( * - 'per-share' additionally allows per-share og:title. */ public string $metaOgMode, + + /** Backend used to send shared files. */ + public FileServerType $fileServer, ) { } }