diff --git a/.env.example b/.env.example index 28ae2f23..fff9c7a4 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,15 @@ PROJECTSEND_EDITION=community # configured at /system/settings/captcha. # PROJECTSEND_CAPTCHA_DISABLED=true +# How downloads leave the server. Left unset (or "auto"), ProjectSend hands +# files to nginx when it is running behind nginx, and streams them through +# PHP on anything else -- which works everywhere but holds a PHP worker for +# the whole of each download. Set "xsendfile" for Apache with mod_xsendfile +# (or LiteSpeed) once XSendFilePath allows storage/app/files, "nginx" when +# an nginx proxy in front is the one serving /protected-files/, or "php" to +# stream deliberately. The dashboard's System panel shows which is in use. +# PROJECTSEND_FILE_DELIVERY=auto + # Optional: uid/gid the app/web containers' internal user runs as, so the # bind-mounted repo needs no permission fixes. Defaults to 1000; override # if your host user's `id -u`/`id -g` differ. diff --git a/INSTALL.md b/INSTALL.md index 648a8c40..b39e6d10 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -21,7 +21,7 @@ to create a database — this is not an install you can do over FTP alone. | **PHP** | 8.4 or newer, both the command-line PHP and PHP-FPM | | **PHP extensions** | `bcmath` `ctype` `curl` `dom` `fileinfo` `filter` `gd` `iconv` `intl` `json` `ldap` `mbstring` `openssl` `pcntl` `pdo_mysql` `session` `simplexml` `tokenizer` `zip` | | **Database** | MySQL 8.0 or newer (we test on 8.4 LTS) | -| **Web server** | **nginx**, with PHP-FPM — see the note below | +| **Web server** | Any, with PHP-FPM. **nginx is strongly recommended** — see the note below | | **Disk space** | The app itself is small; plan for whatever your users will upload | A few notes on that list: @@ -29,66 +29,113 @@ A few notes on that list: - **`ldap` is required even if you never use LDAP.** One of the libraries ProjectSend depends on declares it, so PHP will refuse to start the app without it. On Debian/Ubuntu it is `php8.4-ldap`; on RHEL-family systems, `php-ldap`. -- **nginx is not a preference, it is a requirement.** See [Why nginx](#why-nginx) — it is worth - two minutes of reading before you commit to a server, because Apache cannot be made to work by - configuring it differently. +- **nginx is recommended, not required.** ProjectSend runs on Apache and LiteSpeed too, and + downloads work on them out of the box. What differs is *how* the bytes are sent: on nginx the + web server sends them, and everywhere else PHP does, which costs a worker process for the + duration of every download. See [How downloads are sent](#how-downloads-are-sent) before you + commit to a server — it is a capacity decision, not a compatibility one. - **Redis is optional.** The Docker setup uses it, but a manual install works fine with the database for sessions, cache and queues. If you already have Redis, see [Optional extras](#optional-extras) below. -### Why nginx +### How downloads are sent Your uploaded files do not live under `public/`. They sit in `storage/app/files/`, outside the web root, where no URL can reach them — which is the whole point: a file is only yours to download if ProjectSend says so, and a file sitting in a guessable public folder has already lost that argument. -So every download has to pass through a permission check. The obvious way to do that is to let PHP -read the file and echo it back to the browser, and that is what most PHP applications do. It works, -and it is a bad idea at any real size: a single 5 GB download occupies a PHP process for its entire -duration, so a handful of people downloading at once can exhaust every worker your server has while -the CPU sits idle. Resumable downloads, byte ranges and progress bars all have to be reimplemented -by hand, usually incorrectly. +So every download has to pass through a permission check in PHP first. What happens *after* that +check passes is the thing this section is about, and ProjectSend can do it two ways. -ProjectSend does the other thing. PHP checks permissions, logs the download, and then answers with -an empty response carrying a header that says *"nginx, please send this file."* nginx streams the -bytes with the same code it uses for any static file — sendfile, byte ranges, resume support, no -PHP process held open — and the visitor never sees the real path. The header is -`X-Accel-Redirect`, and the matching `location /protected-files/` block in -[step 6](#step-6--point-your-web-server-at-it) is marked `internal`, which is what stops anyone -from requesting that path directly. +**PHP sends the file.** It opens the file and writes it out to the visitor. This works on every +web server and needs no configuration, which is why it is what ProjectSend falls back to. The cost +is that one PHP worker process is occupied for the whole of each download — three minutes for a +large file on a slow connection is three minutes that worker cannot answer anything else. A +handful of concurrent large downloads can therefore occupy every worker you have and the site +stops responding, with the processor idle and the workers all waiting on network transfers. -**Apache has no equivalent that ProjectSend can use.** Apache's closest feature, `mod_xsendfile`, -reads a differently-named header (`X-Sendfile`) that ProjectSend does not send, and it is not -installed by default anyway. LiteSpeed has its own third spelling. On any of them the application -installs fine and every page works — you can log in, upload, manage clients, browse the library — -but **every download returns an empty response or a 404**, because nothing is listening for the -instruction PHP just gave. There is no setting to change; the header names simply do not match. +**The web server sends the file.** PHP answers with an empty response and a header naming the +file, and finishes immediately; the web server streams the bytes with the same code it uses for +any static file — `sendfile`, byte ranges, resume support, no PHP process held open — and the +visitor never sees the real path. This is what you want on anything busy. -Two ways out, if nginx really is impossible on your hosting: +The second option needs a header, and **each web server reads a different one**, which is why +ProjectSend has to know which one it is talking to. It works this out from the server itself and +you can override it. -- Put nginx in front of Apache as a reverse proxy, serving `/protected-files/` itself. This works - but is more moving parts than just using nginx. Give the proxy some header headroom while you are - there — the same headroom the reference configuration in Step 6 gives PHP-FPM, in the directives a - proxy uses instead: +| Your server | What ProjectSend does | What you need to configure | +|---|---|---| +| nginx | `X-Accel-Redirect` | The `location /protected-files/` block in [step 6](#step-6--point-your-web-server-at-it). Detected automatically | +| Apache | PHP sends the file, unless you enable `mod_xsendfile` | See below | +| LiteSpeed / OpenLiteSpeed | PHP sends the file, unless you turn on X-Sendfile | See below | +| Anything else | PHP sends the file | Nothing | - ```nginx - proxy_buffer_size 32k; - proxy_buffers 8 32k; - proxy_busy_buffers_size 64k; - ``` +**The dashboard tells you which one is in use.** The System panel has a "Downloads sent by" line, +with a warning icon and an explanation whenever PHP is doing the sending. You do not have to +remember to check this file. - nginx buffers a response's headers into a single block that defaults to one memory page — 4 KB on - most systems — and answers `502 Bad Gateway` with `upstream sent too big header` when they do not - fit. The page that goes over is not always the same one, so it presents as an intermittent fault - rather than as a misconfiguration. This applies to any proxy in front of ProjectSend, not just - this one: Nginx Proxy Manager, Traefik and a hand-written nginx vhost all ship the same default. - ([#1664](https://github.com/projectsend/projectsend/issues/1664)) -- Store your files in object storage instead — S3-compatible or Google Cloud Storage (see - [Storing files somewhere other than this server](#storing-files-somewhere-other-than-this-server)). - Files kept there are never on your server's disk, so downloads become a signed, expiring redirect - to the storage provider and the web server is not involved at all. This is a genuine, supported - path — just decide it before people start uploading, not after. +#### Enabling X-Sendfile on Apache or LiteSpeed + +Apache needs [`mod_xsendfile`](https://github.com/nmaier/mod_xsendfile) installed and enabled, and +a directive allowing it to serve your storage directory: + +```apache +XSendFile On +XSendFilePath /home/projectsend/storage/app/files +``` + +LiteSpeed and OpenLiteSpeed read the same header without an extra module; enable it in the server +configuration. + +Then tell ProjectSend to use it, in `.env`: + +```dotenv +PROJECTSEND_FILE_DELIVERY=xsendfile +``` + +**ProjectSend will not switch this on by itself**, even when it can see the module is loaded, +because it cannot see whether `XSendFilePath` allows the storage directory. Guessing wrong there +produces empty downloads rather than slow ones, and an empty download is a much worse failure than +a slow one — so this stays something you turn on having configured it. + +#### Choosing explicitly + +`PROJECTSEND_FILE_DELIVERY` accepts: + +| Value | Meaning | +|---|---| +| `auto` | The default. nginx if the server says it is nginx, PHP otherwise | +| `nginx` | Always `X-Accel-Redirect`. Use this if nginx is proxying another server | +| `xsendfile` | Always `X-Sendfile`, for Apache with `mod_xsendfile`, or LiteSpeed | +| `php` | Always PHP. Correct and slow, and never wrong | + +The one case `auto` gets wrong is **nginx reverse-proxying Apache**: PHP is talking to Apache, so +it picks PHP streaming, and downloads work but do not use the nginx in front. Set +`PROJECTSEND_FILE_DELIVERY=nginx` and make sure the front nginx serves `/protected-files/`. While +you are there, give the proxy some header headroom — the same headroom the reference configuration +in Step 6 gives PHP-FPM, in the directives a proxy uses instead: + +```nginx +proxy_buffer_size 32k; +proxy_buffers 8 32k; +proxy_busy_buffers_size 64k; +``` + +nginx buffers a response's headers into a single block that defaults to one memory page — 4 KB on +most systems — and answers `502 Bad Gateway` with `upstream sent too big header` when they do not +fit. The page that goes over is not always the same one, so it presents as an intermittent fault +rather than as a misconfiguration. This applies to any proxy in front of ProjectSend, not just +this one: Nginx Proxy Manager, Traefik and a hand-written nginx vhost all ship the same default. +([#1664](https://github.com/projectsend/projectsend/issues/1664)) + +#### Or take your server out of it entirely + +Store your files in object storage — S3-compatible or Google Cloud Storage (see +[Storing files somewhere other than this server](#storing-files-somewhere-other-than-this-server)). +Files kept there are never on your server's disk, so downloads become a signed, expiring redirect +to the storage provider and the web server is not involved at all. Decide this before people start +uploading, not after. --- @@ -216,10 +263,10 @@ FILES_WEB_SERVER_READABLE=true Uploaded files are written `0600` inside `0700` directories, readable only by the user that wrote them. That is deliberate, and on a same-user server it is the safer setting. But a download is not -served by PHP: PHP checks permissions and then hands the web server the path with `X-Accel-Redirect` -(see [Why nginx](#why-nginx)), so the web server has to open a file PHP owns. When it cannot, **the -whole site works and only downloads fail** — the browser reports `ERR_INVALID_RESPONSE` and the -nginx error log says: +served by PHP on nginx: PHP checks permissions and then hands the web server the path with +`X-Accel-Redirect` (see [How downloads are sent](#how-downloads-are-sent)), so the web server has +to open a file PHP owns. When it cannot, **the whole site works and only downloads fail** — the +browser reports `ERR_INVALID_RESPONSE` and the nginx error log says: ``` open() ".../storage/app/files/..." failed (13: Permission denied) @@ -546,9 +593,15 @@ That is correct behaviour until the first administrator exists. Finish step 7. I created one and it still happens, ProjectSend cannot reach your database — check `storage/logs/`. **Pages load but downloads give a 404, or download a 0-byte file.** -The `/protected-files/` block is missing from your nginx config, or its `alias` path does not match -where you installed ProjectSend. It must point at `storage/app/files/` and end with a slash. If you -are on Apache or LiteSpeed, no configuration will fix this — see [Why nginx](#why-nginx). +On nginx, the `/protected-files/` block is missing from your config, or its `alias` path does not +match where you installed ProjectSend. It must point at `storage/app/files/` and end with a slash. + +On any server, check the "Downloads sent by" line in the dashboard's System panel against the +server you are actually running. A 0-byte download means ProjectSend sent a header the server did +not act on — most often `PROJECTSEND_FILE_DELIVERY` set to `nginx` or `xsendfile` on a server that +is neither, or set to `xsendfile` without `XSendFilePath` allowing the storage directory. Setting +`PROJECTSEND_FILE_DELIVERY=php` always works and is the quickest way to confirm that is the +problem. See [How downloads are sent](#how-downloads-are-sent). **Uploads fail partway through.** `client_max_body_size` in nginx, or `upload_max_filesize` / `post_max_size` in `php.ini`, is diff --git a/app/Modules/Audit/Http/Controllers/DashboardController.php b/app/Modules/Audit/Http/Controllers/DashboardController.php index fb63f522..1e49a9f9 100644 --- a/app/Modules/Audit/Http/Controllers/DashboardController.php +++ b/app/Modules/Audit/Http/Controllers/DashboardController.php @@ -14,6 +14,7 @@ use App\Modules\Audit\ActivityPresenter; use App\Modules\Audit\DashboardWidgetPreferences; use App\Modules\Clients\ClientStorageUsage; use App\Modules\Files\Access\StaffLibraryScope; +use App\Modules\Files\Delivery\FileDelivery; use App\Modules\Files\Models\File; use App\Modules\Groups\Models\Group; use App\Modules\Identity\UserType; @@ -51,6 +52,7 @@ class DashboardController extends Controller private readonly Settings $settings, private readonly ApiUsage $apiUsage, private readonly StorageDurability $storageDurability, + private readonly FileDelivery $fileDelivery, private readonly Installation $installation, private readonly TimezoneRegistry $timezones, private readonly SystemEnvironment $environment, @@ -477,7 +479,7 @@ class DashboardController extends Controller } /** - * @return array|null> + * @return array|bool|int|string|null> */ private function systemInfo(): array { @@ -502,6 +504,12 @@ class DashboardController extends Controller // Installation. Always present, unlike storage_durability, which // is null whenever the durability question does not apply. 'install_kind' => $this->installation->kind()->value, + // How downloads leave the server, and whether that was + // detected or stated. Reported even when it is the fast path: + // "my downloads are handed to the web server" is worth being + // able to confirm at a glance, not only worth warning about + // when it is false — the same reasoning as storage_durability. + 'file_delivery' => $this->fileDelivery->describe(), ]; } diff --git a/app/Modules/Files/Delivery/DeliveryMethod.php b/app/Modules/Files/Delivery/DeliveryMethod.php new file mode 100644 index 00000000..5ffcf1b8 --- /dev/null +++ b/app/Modules/Files/Delivery/DeliveryMethod.php @@ -0,0 +1,55 @@ + $explicit, 'detected' => false]; + } + + return ['method' => $this->detect(), 'detected' => true]; + } + + public function method(): DeliveryMethod + { + return $this->resolve()['method']; + } + + /** + * The same answer as a plain array, for a screen or a probe. + * + * Spelled out rather than leaning on a backed enum encoding itself, + * because this shape is read by the dashboard and by whatever watches + * the installation from outside, and neither should change meaning if + * the enum ever grows a JsonSerializable of its own. + * + * @return array{method: string, detected: bool} + */ + public function describe(): array + { + $resolved = $this->resolve(); + + return [ + 'method' => $resolved['method']->value, + // True when nobody said which to use. The distinction matters + // to the reader: a detected `php` is an installation that + // could be faster, a stated one is somebody's decision. + 'detected' => $resolved['detected'], + ]; + } + + /** + * What the server says it is. + * + * `SERVER_SOFTWARE` is set by the web server itself through the + * FastCGI parameters, so it describes the process actually holding + * the connection to PHP. That is the right thing to ask: the header + * has to be understood by *that* server, not by whatever sits in + * front of it. + * + * The known-wrong case is nginx reverse-proxying Apache, which + * INSTALL.md offers as a way to keep an existing Apache. This reads + * Apache and picks PHP streaming, so downloads work and are slower + * than they need to be — the safe direction, and the reason the + * override exists. + */ + private function detect(): DeliveryMethod + { + $software = $this->request->server('SERVER_SOFTWARE'); + $software = strtolower(is_string($software) ? $software : ''); + + return str_contains($software, 'nginx') ? DeliveryMethod::Nginx : DeliveryMethod::Php; + } + + /** + * @param string $path disk-relative, and always derived from an + * already-authorized row — never from the request + * @param int|null $length when the caller already knows it; PHP + * streaming ignores it and measures the file + */ + public function serve(string $path, string $mimeType, string $disposition, ?int $length = null): Response|BinaryFileResponse + { + $this->assertRelative($path); + + $headers = array_filter([ + 'Content-Type' => $mimeType, + 'Content-Disposition' => $disposition, + 'Content-Length' => $length === null ? null : (string) $length, + ], static fn (?string $value): bool => $value !== null); + + return match ($this->method()) { + DeliveryMethod::Nginx => response('', 200, [ + 'X-Accel-Redirect' => self::NGINX_LOCATION.$path, + ...$headers, + ]), + DeliveryMethod::XSendFile => response('', 200, [ + // An absolute filesystem path, unlike nginx's URL path. + // Renaming the header without changing the value is the + // obvious way to "add Apache support" and produces a + // second broken install. + 'X-Sendfile' => $this->absolutePathWithin($path), + ...$headers, + ]), + DeliveryMethod::Php => $this->stream($this->absolutePathWithin($path), $headers), + }; + } + + /** + * @param array $headers + */ + private function stream(string $absolute, array $headers): BinaryFileResponse + { + // A large download can outlive max_execution_time, and the visitor + // sees a truncated file rather than an error. The web server is + // not holding this one open for us. + if (function_exists('set_time_limit')) { + @set_time_limit(0); + } + + // BinaryFileResponse rather than a hand-written readfile loop: it + // answers Range requests, which is what makes seeking through a + // long video work. nginx does that for itself on the fast path, so + // rolling our own here would break preview scrubbing on exactly + // the installations this fallback exists for. + // + // Content-Length is deliberately dropped from the headers: the + // response sets its own from the file, and a caller's figure that + // disagrees — a stale `files.size`, or a range being served — + // truncates the download. + unset($headers['Content-Length']); + + return new BinaryFileResponse($absolute, 200, $headers); + } + + /** + * The path must stay a path *inside* the storage area. + * + * Checked for every method, and without touching the filesystem, + * because nginx resolves `..` in the URL it is handed just as + * happily as a filesystem call would. Callers pass paths from rows + * they authorized rather than from the request, so this is a + * backstop; it is here because the cost of being wrong about that, + * once, is handing over any file the web server can read. + */ + private function assertRelative(string $path): void + { + abort_if( + $path === '' + || str_starts_with($path, '/') + || preg_match('#(^|/)\.\.(/|$)#', $path) === 1, + 404, + ); + } + + /** + * The absolute path, proven to resolve inside the storage root. + * + * Only the two methods that hand over a *filesystem* path need this, + * and only they can afford it: it resolves symlinks, so it answers + * the question `assertRelative()` cannot — whether the file is really + * where the path says it is. + * + * It also requires the file to exist, which is why nginx does not go + * through it. On that path PHP never opens the file, and adding a + * stat to every download to discover something nginx is about to + * discover anyway would be a cost with no answer attached. + */ + private function absolutePathWithin(string $path): string + { + $disk = Storage::disk(self::DISK); + + $absolute = realpath($disk->path($path)); + $root = realpath($disk->path('')); + + abort_if( + $absolute === false || $root === false || ! str_starts_with($absolute, rtrim($root, '/').'/'), + 404, + ); + + return $absolute; + } +} diff --git a/app/Modules/Files/Delivery/StoredFileResponse.php b/app/Modules/Files/Delivery/StoredFileResponse.php index 3db34524..7d797fce 100644 --- a/app/Modules/Files/Delivery/StoredFileResponse.php +++ b/app/Modules/Files/Delivery/StoredFileResponse.php @@ -7,8 +7,8 @@ namespace App\Modules\Files\Delivery; use App\Modules\Files\Models\File; use App\Support\ContentDisposition; use Illuminate\Http\RedirectResponse; -use Illuminate\Http\Response; use Illuminate\Support\Facades\Storage; +use Symfony\Component\HttpFoundation\Response; /** * A stored file's own bytes, put on the wire for whichever disk it lives @@ -20,15 +20,16 @@ use Illuminate\Support\Facades\Storage; * asking. The one thing it knows is the thing each caller kept getting * wrong on its own: that `$file->disk` decides how the bytes travel. * - * Local disk: X-Accel-Redirect, so nginx streams the file and PHP never - * touches the bytes. Anything else — S3, GCS and friends — gets a - * short-lived presigned URL carrying the disposition, which an object - * store ranges just as well. + * Local disk: handed to FileDelivery, which decides whether the web + * server sends the bytes or PHP does. Anything else — S3, GCS and + * friends — gets a short-lived presigned URL carrying the disposition, + * which an object store ranges just as well. * * That distinction matters most for inline(): a