From 8d97b28c7fe47eea80e9e2eea2573fee658592a6 Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Wed, 23 Sep 2026 00:50:24 +0200 Subject: [PATCH] Add a second file streaming method The default file streaming method is still the PHP filestreaming method. However as this method does have the issue of hogging up resources that can be used by other PHP processes, the method can now be delegated to the Apache Web Server. When activated, the response will only contain a header entry that states that the Apache Web Server should serve the file and as such delegating. However as this requires an Apache Web Server with the mod_xsendfile extension enabled, it is not on by default and must be enabled using an environment variable. --- .env.example | 8 +++++ Dockerfile | 7 +++++ README.md | 9 +++++- apache-vhost.conf | 8 +++++ src/Controller/ShareController.php | 4 +-- src/Http/FileResponseFactory.php | 30 ++++++++++++++++++ src/Http/FileServerType.php | 18 +++++++++++ src/Http/XSendfileResponse.php | 49 ++++++++++++++++++++++++++++++ src/Manager/ConfigManager.php | 27 +++++++++++++++- src/Model/Config.php | 6 ++++ 10 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 src/Http/FileResponseFactory.php create mode 100644 src/Http/FileServerType.php create mode 100644 src/Http/XSendfileResponse.php 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, ) { } }