Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <meta name="description"> and og:description on all pages.
# KITTYSHARE_META_DESCRIPTION=KittyShare: self-hosted private file sharing.
Expand Down
7 changes: 7 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions apache-vhost.conf
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
FallbackResource /index.php
</Directory>

# Enables the mod_xsendfile to allow for the delegation of sending static
# files to the Apache Webserver.
<IfModule mod_xsendfile.c>
XSendFile On
XSendFilePath /data/files
# XSendFilePath /
</IfModule>

ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>
4 changes: 2 additions & 2 deletions src/Controller/ShareController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions src/Http/FileResponseFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace KittyShare\Http;

use KittyShare\Manager\ConfigManager;

/**
* Creates the configured file-download response.
*/
final class FileResponseFactory
{
/**
* @param string $file The canonical absolute path to send.
* @param string $contentType The MIME type of the file.
* @param positive-int $statusCode The HTTP status code to send.
* @param positive-int $chunkSize Bytes per chunk for PHP streaming.
*/
public static function forFile(
string $file,
string $contentType,
int $statusCode = 200,
int $chunkSize = 8192,
): Response {
if (ConfigManager::get()->fileServer === FileServerType::XSendfile) {
return new XSendfileResponse($file, $contentType, $statusCode);
}

return new FileResponse($file, $contentType, $statusCode, $chunkSize);
}
}
18 changes: 18 additions & 0 deletions src/Http/FileServerType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace KittyShare\Http;

/**
* Selectable backend used to send shared files to the client.
*/
enum FileServerType: string
{
/** Php streams the file through the PHP process (works everywhere). */
case Php = 'php';

/**
* XSendfile delegates the transfer to Apache via mod_xsendfile
* (`X-Sendfile` header).
*/
case XSendfile = 'x-sendfile';
}
49 changes: 49 additions & 0 deletions src/Http/XSendfileResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace KittyShare\Http;

/**
* A response that delegates the file transfer to Apache via mod_xsendfile.
*/
final class XSendfileResponse implements Response
{
/**
* @param string $file The canonical absolute path to send.
* @param string $contentType The MIME type of the file.
* @param positive-int $statusCode The HTTP status code to send.
*/
public function __construct(
private string $file,
private string $contentType,
private int $statusCode = 200,
) {
}

public function send(): void
{
if (!is_file($this->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);
}
}
27 changes: 26 additions & 1 deletion src/Manager/ConfigManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -38,6 +40,7 @@ private static function constructConfig(): Config
),
metaDescription: self::metaDescription(),
metaOgMode: self::metaOgMode(),
fileServer: self::fileServer(),
);
}

Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/Model/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace KittyShare\Model;

use KittyShare\Http\FileServerType;


final readonly class Config
{
Expand All @@ -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. */
Expand Down Expand Up @@ -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,
) {
}
}
Loading