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,
) {
}
}