mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-03 21:03:17 +00:00
Send downloads the way the web server in front of us understands
Uploads live outside the web root, so PHP authorizes every download and then hands the file to the web server with a header naming it. Four routes decided that for themselves and all four hard-coded nginx's spelling. On Apache or LiteSpeed nothing acts on the header, so the empty body PHP sent goes to the visitor: files upload fine, thumbnails are broken images, and downloads arrive as 0 bytes, with every other page working. Reported as #1765 from an Apache 2.4 install, and before that as #1266, #1215, #870 and #1271. It is also a regression from v1, which had a download_method setting -- php, apache_xsendfile, litespeed, nginx_xaccel -- defaulting to php. v1 therefore worked on any server out of the box and v2 did not, and a v1 Apache user migrating lost every download with nothing to tell them why. So the four sites now go through one FileDelivery, and it picks: auto (default) nginx when SERVER_SOFTWARE says nginx, else php nginx X-Accel-Redirect, a URL path via the internal location xsendfile X-Sendfile, an absolute path (Apache mod_xsendfile, LiteSpeed) php BinaryFileResponse Defaulting to auto rather than nginx is the point of the change: a default that assumes nginx leaves an Apache install exactly as broken as it is today until somebody reads INSTALL.md. Slow beats empty. Auto never picks xsendfile, even where the module is loaded. mod_xsendfile also needs XSendFilePath to allow the storage directory, which cannot be seen from here, and choosing it on the strength of the module being present would trade a silent failure an administrator can diagnose from the dashboard for one nobody can. BinaryFileResponse rather than a readfile loop because it answers Range requests. nginx does that itself on the fast path, so hand-rolling it would have broken seeking through a video on exactly the installations this fallback exists for. Verified end to end: 206 with the right Content-Range through the live stack. Two guards. Every method checks the path cannot climb out of the storage area -- nginx resolves `..` in the URL it is handed as happily as PHP would -- and the two methods that hand over a filesystem path resolve it and prove it lands inside the root. Callers pass paths from rows they just authorized, so this is a backstop; it is here because the cost of being wrong once is handing over any file the web server can read. The dashboard's System panel names the method, with a warning icon and a dialog when PHP is doing the sending: what is happening, what it costs (one worker held for the whole of each download, so a few large simultaneous ones can occupy every worker while the processor sits idle), why it is set that way, and the three ways out. Written to be accurate rather than reassuring -- nothing is broken, it does not scale -- and the notice stays even when php was chosen deliberately, because the trade-off is the same either way. /system/settings/downloads repeats it, which is where somebody coming from v1 goes looking for the dropdown. An environment variable rather than a stored setting: it describes the server this installation runs on, not a preference, and a value in the database travels to a different server in a restore and is wrong there. Read only in config/projectsend.php, so config:cache cannot blank it. The suite pins itself to nginx. Left at auto it would detect no server at all, fall back to php, and quietly retire the coverage of the mechanism most installations actually use.
This commit is contained in:
@@ -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.
|
||||
|
||||
+105
-52
@@ -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
|
||||
|
||||
@@ -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<string, string|int|bool|array<string, string|null>|null>
|
||||
* @return array<string, array<string, bool|string|null>|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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Delivery;
|
||||
|
||||
/**
|
||||
* How a file's bytes get from this server's disk to the visitor.
|
||||
*
|
||||
* Uploads live outside the web root, so every download passes through a
|
||||
* permission check in PHP first. What differs is what happens after that
|
||||
* check passes: PHP can read the file and write it out itself, or it can
|
||||
* answer with an empty body and a header telling the web server to send
|
||||
* the file instead.
|
||||
*
|
||||
* The header is the fast path and it is not portable — each server reads
|
||||
* a different one, and a server reading none of them serves the empty
|
||||
* body, which is how an installation ends up handing out 0-byte
|
||||
* downloads while every other page works. ProjectSend v1 had this as a
|
||||
* four-way setting with PHP as the default; v2 hard-coded nginx's
|
||||
* spelling for its first releases, which is
|
||||
* https://github.com/projectsend/projectsend/issues/1765.
|
||||
*/
|
||||
enum DeliveryMethod: string
|
||||
{
|
||||
/**
|
||||
* nginx: `X-Accel-Redirect`, carrying a *URL path* that the
|
||||
* `location /protected-files/` block maps back onto the storage
|
||||
* directory. That block is marked `internal`, which is what stops a
|
||||
* visitor requesting the path directly.
|
||||
*/
|
||||
case Nginx = 'nginx';
|
||||
|
||||
/**
|
||||
* Apache with `mod_xsendfile`, and LiteSpeed, which reads the same
|
||||
* header: `X-Sendfile`, carrying an *absolute filesystem path*.
|
||||
*
|
||||
* Never chosen automatically. The module also needs `XSendFilePath`
|
||||
* to whitelist the storage directory, and there is no way to detect
|
||||
* that from here — picking this on the strength of the module being
|
||||
* loaded would trade one silent failure for another.
|
||||
*/
|
||||
case XSendFile = 'xsendfile';
|
||||
|
||||
/**
|
||||
* PHP reads the file and streams it.
|
||||
*
|
||||
* Works on every server, and costs a worker process for the duration
|
||||
* of each download — a handful of large concurrent downloads can
|
||||
* occupy every worker while the CPU sits idle. That is why it is the
|
||||
* fallback rather than the default, and why an installation using it
|
||||
* says so on the dashboard rather than being quietly slow.
|
||||
*/
|
||||
case Php = 'php';
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Delivery;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
/**
|
||||
* Puts a file that lives on this server's local disk on the wire.
|
||||
*
|
||||
* The single place that knows how the bytes travel. Four routes used to
|
||||
* decide that for themselves and all four hard-coded nginx's header, so
|
||||
* an Apache or LiteSpeed installation served four different flavours of
|
||||
* empty response — uploads worked, thumbnails were broken images, and
|
||||
* downloads arrived as 0 bytes. Callers now say *what* to send and this
|
||||
* decides *how*.
|
||||
*
|
||||
* It authorizes nothing. Every caller has already done that its own way
|
||||
* — a policy, a share token, a public-listing check — and the path it
|
||||
* passes is always derived from a row it just authorized, never from the
|
||||
* request. That is load-bearing: `serve()` will send any file under the
|
||||
* storage root, so a caller that passed user input would have built a
|
||||
* file-disclosure bug. The root check below is the backstop, not the
|
||||
* rule.
|
||||
*
|
||||
* ### Choosing the method
|
||||
*
|
||||
* `PROJECTSEND_FILE_DELIVERY` picks one explicitly. Left at `auto` — the
|
||||
* default — nginx gets its own fast path and everything else gets PHP
|
||||
* streaming.
|
||||
*
|
||||
* Auto deliberately never chooses `xsendfile`. Apache's `mod_xsendfile`
|
||||
* needs `XSendFilePath` to whitelist the storage directory as well as
|
||||
* being loaded, and nothing here can see whether it does; choosing it
|
||||
* because the module is present would swap a silent failure anybody can
|
||||
* diagnose from the dashboard for one nobody can. So it stays something
|
||||
* an operator turns on having configured it.
|
||||
*
|
||||
* A value that is not a method falls back to auto rather than throwing.
|
||||
* A typo in an environment variable should cost speed, not every
|
||||
* download on the installation.
|
||||
*/
|
||||
class FileDelivery
|
||||
{
|
||||
/**
|
||||
* The disk uploads live on. Named rather than injected because the
|
||||
* whole class is about the local-disk case: a file on S3 never
|
||||
* reaches here, it is a signed redirect from StoredFileResponse.
|
||||
*/
|
||||
private const DISK = 'files';
|
||||
|
||||
/** The internal nginx location that maps back onto the storage root. */
|
||||
private const NGINX_LOCATION = '/protected-files/';
|
||||
|
||||
public function __construct(private readonly Request $request) {}
|
||||
|
||||
/**
|
||||
* The method in force, and whether it was detected or stated.
|
||||
*
|
||||
* @return array{method: DeliveryMethod, detected: bool}
|
||||
*/
|
||||
public function resolve(): array
|
||||
{
|
||||
$configured = config('projectsend.file_delivery');
|
||||
$explicit = is_string($configured) ? DeliveryMethod::tryFrom($configured) : null;
|
||||
|
||||
if ($explicit !== null) {
|
||||
return ['method' => $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<string, string> $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;
|
||||
}
|
||||
}
|
||||
@@ -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 <video> seeking through
|
||||
* an hour of footage issues a long tail of Range requests, and nginx's
|
||||
* static handler answers those with 206s on its own, dropping the
|
||||
* Content-Length below in favour of the range it actually served.
|
||||
* an hour of footage issues a long tail of Range requests. Every local
|
||||
* delivery method answers those — nginx's static handler on the fast
|
||||
* path, BinaryFileResponse when PHP is streaming — each dropping the
|
||||
* Content-Length passed here in favour of the range actually served.
|
||||
*
|
||||
* Callers of inline() must have established that the mime type is
|
||||
* inline-safe first; PreviewKind is the allowlist, and the reason there
|
||||
@@ -36,6 +37,8 @@ use Illuminate\Support\Facades\Storage;
|
||||
*/
|
||||
class StoredFileResponse
|
||||
{
|
||||
public function __construct(private readonly FileDelivery $delivery) {}
|
||||
|
||||
/** Shown in place — a preview. */
|
||||
public function inline(File $file): Response|RedirectResponse
|
||||
{
|
||||
@@ -60,11 +63,6 @@ class StoredFileResponse
|
||||
return redirect()->away($url);
|
||||
}
|
||||
|
||||
return response('', 200, [
|
||||
'X-Accel-Redirect' => '/protected-files/'.$file->path,
|
||||
'Content-Type' => $file->mime_type,
|
||||
'Content-Disposition' => $disposition,
|
||||
'Content-Length' => (string) $file->size,
|
||||
]);
|
||||
return $this->delivery->serve($file->path, $file->mime_type, $disposition, $file->size);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Modules\Files\Http\Controllers;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Delivery\FileDelivery;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -24,12 +25,21 @@ class DownloadSettingsController extends Controller
|
||||
public function __construct(
|
||||
private readonly Settings $settings,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly FileDelivery $delivery,
|
||||
) {}
|
||||
|
||||
public function edit(): Response
|
||||
{
|
||||
return Inertia::render('system/settings/downloads', [
|
||||
'max_zip_download_size_mb' => $this->settings->get(Setting::MaxZipDownloadSizeMb),
|
||||
// Not a setting, and shown here because this is where somebody
|
||||
// coming from v1 looks for one: v1 had a "Download method"
|
||||
// dropdown on its uploads options screen. It is an environment
|
||||
// variable now rather than a stored setting, because it
|
||||
// describes the server the installation is running on rather
|
||||
// than a preference — a value in the database can be restored
|
||||
// onto a different server and be wrong there.
|
||||
'file_delivery' => $this->delivery->describe(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,15 +12,16 @@ use App\Modules\Files\Delivery\StoredFileResponse;
|
||||
use App\Modules\Files\Models\File;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Authorized downloads without the bytes ever traversing PHP: the app
|
||||
* checks the policy, and StoredFileResponse answers with either an
|
||||
* X-Accel-Redirect for nginx to stream from the protected location
|
||||
* (brief §3) or a presigned URL when the file lives on external storage,
|
||||
* since nginx has no way to serve bytes it doesn't have on disk.
|
||||
* Authorized downloads: the app checks the policy, and StoredFileResponse
|
||||
* decides how the bytes travel — a presigned URL when the file lives on
|
||||
* external storage, and otherwise whichever local delivery method this
|
||||
* installation's web server understands (see FileDelivery). On nginx that
|
||||
* is an X-Accel-Redirect and the bytes never traverse PHP at all; on a
|
||||
* server with no such header PHP streams them, which is slower and works.
|
||||
*/
|
||||
class FileDownloadController extends Controller
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Modules\Files\Http\Controllers;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Delivery\FileDelivery;
|
||||
use App\Modules\Files\Delivery\StoredFileResponse;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Preview\PreviewKind;
|
||||
@@ -21,14 +22,14 @@ use App\Modules\Platform\Settings\Settings;
|
||||
use App\Support\ContentDisposition;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Two inline (never `attachment`) views of a file, same X-Accel-Redirect
|
||||
* pattern as FileDownloadController: a bounded thumbnail for listing rows,
|
||||
* Two inline (never `attachment`) views of a file, delivered the same way
|
||||
* FileDownloadController delivers one: a bounded thumbnail for listing rows,
|
||||
* and a larger view opened in a new tab when a thumbnail is clicked.
|
||||
* `thumbnail()` stays unlogged — it fires automatically as an `<img src>`
|
||||
* for every row on every listing render, not a deliberate action, and
|
||||
@@ -76,6 +77,7 @@ class FileThumbnailController extends Controller
|
||||
private readonly StoredFileResponse $bytes,
|
||||
private readonly LocalSourceFile $source,
|
||||
private readonly Settings $settings,
|
||||
private readonly FileDelivery $delivery,
|
||||
) {}
|
||||
|
||||
public function thumbnail(Request $request, File $file): Response
|
||||
@@ -211,10 +213,12 @@ class FileThumbnailController extends Controller
|
||||
|
||||
private function serve(File $file, string $path): Response
|
||||
{
|
||||
return response('', 200, [
|
||||
'X-Accel-Redirect' => '/protected-files/'.$path,
|
||||
'Content-Type' => $file->mime_type,
|
||||
'Content-Disposition' => ContentDisposition::inline($file->original_name),
|
||||
]);
|
||||
// No Content-Length: this is the rendition's size, not the
|
||||
// original file's, and $file->size is the wrong number for it.
|
||||
return $this->delivery->serve(
|
||||
$path,
|
||||
$file->mime_type,
|
||||
ContentDisposition::inline($file->original_name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ use App\Modules\Files\Models\Category;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\ShareLink;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response as InertiaResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* The public, unauthenticated side of a share link: no Gate/policy is
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Delivery\FileDelivery;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Access\ViewableFileScope;
|
||||
use App\Modules\Files\Jobs\BuildZipDownloadJob;
|
||||
@@ -21,10 +22,10 @@ use App\Support\ContentDisposition;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Number;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* A folder's "Download as zip" button and the file listing's multi-select
|
||||
@@ -45,6 +46,7 @@ class ZipDownloadsController extends Controller
|
||||
private readonly ViewableFileScope $viewable,
|
||||
private readonly DownloadAllowance $allowance,
|
||||
private readonly Settings $settings,
|
||||
private readonly FileDelivery $delivery,
|
||||
) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
@@ -186,12 +188,12 @@ class ZipDownloadsController extends Controller
|
||||
|
||||
$size = Storage::disk('files')->size($path);
|
||||
|
||||
return response('', 200, [
|
||||
'X-Accel-Redirect' => '/protected-files/'.$path,
|
||||
'Content-Type' => 'application/zip',
|
||||
'Content-Disposition' => ContentDisposition::attachment($this->filenameFor($zipDownload)),
|
||||
'Content-Length' => (string) $size,
|
||||
]);
|
||||
return $this->delivery->serve(
|
||||
$path,
|
||||
'application/zip',
|
||||
ContentDisposition::attachment($this->filenameFor($zipDownload)),
|
||||
$size,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,9 +14,10 @@ use App\Modules\Files\Thumbnails\ImageRendition;
|
||||
*
|
||||
* A thumbnail never asks: it is a rendering by definition, nothing else
|
||||
* would fit in a listing row. A preview is the case with two valid
|
||||
* answers. Serving the stored file is far cheaper — an X-Accel-Redirect
|
||||
* with no PHP in the path at all, or a redirect straight to external
|
||||
* storage — and it is what this app has always done. Decoding and
|
||||
* answers. Serving the stored file is far cheaper — handed to the web
|
||||
* server with no PHP in the path at all where that is possible, or a
|
||||
* redirect straight to external storage — and it is what this app has
|
||||
* always done. Decoding and
|
||||
* re-encoding a full-size photograph instead is only worth it when
|
||||
* something actually intends to change what the viewer sees.
|
||||
*
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Comments\CommentingRules;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Delivery\FileDelivery;
|
||||
use App\Modules\Files\Delivery\StoredFileResponse;
|
||||
use App\Modules\Files\Models\Category;
|
||||
use App\Modules\Files\Models\File;
|
||||
@@ -33,12 +34,12 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response as InertiaResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* The guest-facing side of a public group: no Gate/policy involved (same
|
||||
@@ -86,6 +87,7 @@ class PublicGroupsController extends Controller
|
||||
private readonly CommentingRules $commenting,
|
||||
private readonly StoredFileResponse $bytes,
|
||||
private readonly LocalSourceFile $source,
|
||||
private readonly FileDelivery $delivery,
|
||||
) {}
|
||||
|
||||
public function index(Request $request, string $publicSlug): InertiaResponse|RedirectResponse
|
||||
@@ -276,11 +278,11 @@ class PublicGroupsController extends Controller
|
||||
));
|
||||
}
|
||||
|
||||
return response('', 200, [
|
||||
'X-Accel-Redirect' => '/protected-files/'.$thumbnailPath,
|
||||
'Content-Type' => $file->mime_type,
|
||||
'Content-Disposition' => ContentDisposition::inline($file->original_name),
|
||||
]);
|
||||
return $this->delivery->serve(
|
||||
$thumbnailPath,
|
||||
$file->mime_type,
|
||||
ContentDisposition::inline($file->original_name),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -86,6 +86,28 @@ return [
|
||||
'parts_path' => env('UPLOAD_PARTS_PATH'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| How downloads leave the server
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Uploads live outside the web root, so PHP authorizes every download
|
||||
| before any byte moves. What differs is what happens next: PHP can
|
||||
| stream the file itself, or hand the web server a header naming the
|
||||
| file and let it do the work. The header is faster and each server
|
||||
| spells it differently — a server that does not recognise the one it
|
||||
| is sent serves the empty body instead, which is a 0-byte download.
|
||||
|
|
||||
| 'auto' (the default) uses nginx's X-Accel-Redirect when the server
|
||||
| says it is nginx, and PHP streaming otherwise. 'nginx', 'xsendfile'
|
||||
| (Apache with mod_xsendfile, or LiteSpeed) and 'php' state it
|
||||
| outright. Read here rather than through env() elsewhere, so that
|
||||
| `php artisan config:cache` does not silently blank it.
|
||||
|
|
||||
*/
|
||||
|
||||
'file_delivery' => env('PROJECTSEND_FILE_DELIVERY', 'auto'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Chunked upload part size (MB)
|
||||
|
||||
@@ -26,6 +26,13 @@
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="PROJECTSEND_EDITION" value="community"/>
|
||||
<!-- The suite describes the nginx fast path, which is what Docker
|
||||
and every documented install runs. Left at "auto" the tests
|
||||
would detect no web server at all and fall back to PHP
|
||||
streaming, quietly retiring the coverage of the mechanism
|
||||
most installations actually use. Tests for the other methods
|
||||
set the config themselves. -->
|
||||
<env name="PROJECTSEND_FILE_DELIVERY" value="nginx"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { type SharedData } from '@/types';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { AlertTriangle, ArrowUpCircle, HardDrive } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { FileDeliveryDialog, type FileDelivery } from '@/components/file-delivery-dialog';
|
||||
import { UpdateInstructions, type InstallKind } from '@/components/update-instructions';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
import { formatBytes } from '@/lib/format-bytes';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface StorageDurability {
|
||||
level: 'durable' | 'docker_volume' | 'ephemeral' | 'unknown';
|
||||
@@ -29,6 +32,22 @@ export interface SystemInfo {
|
||||
storage_durability: StorageDurability | null;
|
||||
/** Decides which upgrade instructions this card prints. */
|
||||
install_kind: InstallKind;
|
||||
/** How downloads leave the server — see FileDeliveryDialog. */
|
||||
file_delivery: FileDelivery;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the delivery row says, per method.
|
||||
*
|
||||
* Named after the mechanism rather than graded good/bad: an administrator
|
||||
* reading "Web server (nginx)" can check it against what they configured,
|
||||
* which "Optimized" would not let them do.
|
||||
*/
|
||||
function deliveryLabel(method: FileDelivery['method'], t: (key: string) => string): string {
|
||||
if (method === 'nginx') return t('Web server (nginx)');
|
||||
if (method === 'xsendfile') return t('Web server (X-Sendfile)');
|
||||
|
||||
return t('PHP');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +106,10 @@ export function SystemWidget({ system, onViewReleaseNotes }: { system: SystemInf
|
||||
const { t } = useTranslation();
|
||||
const { update_notice } = usePage<SharedData>().props;
|
||||
const durability = system.storage_durability;
|
||||
const [deliveryOpen, setDeliveryOpen] = useState(false);
|
||||
// Only PHP is worth flagging. The other two are the file being handed
|
||||
// to the web server, which is the outcome this is watching for.
|
||||
const deliveryNeedsAttention = system.file_delivery.method === 'php';
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -149,6 +172,39 @@ export function SystemWidget({ system, onViewReleaseNotes }: { system: SystemInf
|
||||
<dd>{formatBytes(system.storage_free_bytes)}</dd>
|
||||
</div>
|
||||
)}
|
||||
{/* Same reasoning as the durability row below: stated
|
||||
always, flagged only when it is the slow one. The icon
|
||||
is a button rather than a tooltip because the
|
||||
explanation does not fit in one, and "not optimized"
|
||||
without the why is not worth putting on a dashboard. */}
|
||||
<div className="flex justify-between gap-2">
|
||||
{/* The label carries the colour too, not just the icon.
|
||||
A muted label beside a small amber triangle reads as
|
||||
decoration; the row has to look different from the
|
||||
five plain facts above it to be worth a second
|
||||
glance. */}
|
||||
<dt
|
||||
className={cn(
|
||||
deliveryNeedsAttention ? 'font-medium text-amber-600 dark:text-amber-500' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{t('Downloads sent by')}
|
||||
</dt>
|
||||
<dd className="flex items-center gap-1.5">
|
||||
{deliveryLabel(system.file_delivery.method, t)}
|
||||
{deliveryNeedsAttention && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeliveryOpen(true)}
|
||||
className="text-amber-600 hover:text-amber-700 dark:text-amber-500 dark:hover:text-amber-400"
|
||||
aria-label={t('Why downloads are not being handed to the web server')}
|
||||
title={t('Downloads are not being handed to the web server')}
|
||||
>
|
||||
<AlertTriangle className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
{/* Stated even when everything is correct: "my files are on a
|
||||
host directory" is worth being able to confirm at a glance,
|
||||
not only worth warning about when it is false. */}
|
||||
@@ -163,6 +219,7 @@ export function SystemWidget({ system, onViewReleaseNotes }: { system: SystemInf
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
<FileDeliveryDialog delivery={system.file_delivery} open={deliveryOpen} onOpenChange={setDeliveryOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
|
||||
export type DeliveryMethod = 'nginx' | 'xsendfile' | 'php';
|
||||
|
||||
export interface FileDelivery {
|
||||
method: DeliveryMethod;
|
||||
/** True when nobody set PROJECTSEND_FILE_DELIVERY and the server was detected. */
|
||||
detected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What "downloads are handled by PHP" actually means, for an administrator
|
||||
* who has just been told it and reasonably wants to know whether it
|
||||
* matters.
|
||||
*
|
||||
* Written to be accurate rather than reassuring, and specific rather than
|
||||
* simplified. The reader is somebody who installed a PHP application on a
|
||||
* web server; they can be told what a worker process is. What they must
|
||||
* not be told is a vague "performance may be affected", which gives them
|
||||
* nothing to decide with, nor an alarming "downloads are broken", which is
|
||||
* false — everything works, it just does not scale.
|
||||
*
|
||||
* The three ways out are given in the order most installations should
|
||||
* consider them, and each says what it costs.
|
||||
*/
|
||||
export function FileDeliveryDialog({
|
||||
delivery,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
delivery: FileDelivery;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Downloads are being sent by PHP')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Everything works. This is about how much load your server can take, not about anything being broken.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 text-sm">
|
||||
<section className="space-y-2">
|
||||
<h3 className="font-medium">{t('What is happening')}</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
'Your uploaded files are stored outside the web root, so no one can reach them by guessing a URL. Every download therefore goes through ProjectSend first, which checks that the person asking is allowed to have the file.',
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
'After that check passes, the file still has to be sent. Right now PHP is doing that itself: it opens the file and writes it out to the visitor. The alternative is for PHP to tell your web server "this person may have this file, you send it" and finish immediately.',
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="font-medium">{t('Why that is worth changing')}</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
'Your server runs a fixed number of PHP worker processes. While PHP is sending a file, one of those workers is busy for the entire download — three minutes for a large file on a slow connection is three minutes that worker cannot answer any other request.',
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
'A handful of people downloading large files at once can therefore occupy every worker you have, and the whole site stops responding — including for people who are only trying to log in. The processor is not busy, and memory is not full; the workers are simply all waiting on network transfers.',
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
'Web servers send files far better than PHP can. They use the operating system call meant for it, handle resuming an interrupted download and seeking through a video, and one process can serve many transfers at once.',
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="font-medium">{t('Why it is set this way')}</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
'Handing a file to the web server needs a response header, and each server reads a different one. ProjectSend only sends that header when it knows the server will understand it, because a server that does not recognise it sends an empty response instead — which arrives as a 0-byte file.',
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{delivery.detected
|
||||
? t(
|
||||
'PHP sending the file is the option that works everywhere, so it is what ProjectSend falls back to when it does not recognise the web server in front of it.',
|
||||
)
|
||||
: t(
|
||||
'This installation also has PROJECTSEND_FILE_DELIVERY set to php, so ProjectSend is sending files itself because it was told to, not because it could not tell what the server was.',
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-medium">{t('How to change it')}</h3>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{t('Run ProjectSend behind nginx')}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
'The configuration in INSTALL.md includes an internal location block that serves your storage directory. Nothing else needs setting: ProjectSend detects nginx on its own.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{t('Or enable X-Sendfile on Apache or LiteSpeed')}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
'Apache needs the mod_xsendfile module installed and an XSendFilePath directive allowing your storage directory; LiteSpeed reads the same header without a module. Then set PROJECTSEND_FILE_DELIVERY=xsendfile in your .env file.',
|
||||
)}{' '}
|
||||
{t(
|
||||
'ProjectSend will not turn this on by itself, because it cannot see whether the directory has been allowed — and guessing wrong would produce empty downloads rather than slow ones.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{t('Or store files in object storage')}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
'With S3-compatible or Google Cloud storage configured, downloads become a temporary link straight to the storage provider and your server is not involved in the transfer at all.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="text-muted-foreground border-t pt-3 text-xs">
|
||||
{t(
|
||||
'This notice stays while PHP is sending files, including when that was chosen deliberately — the trade-off is the same either way, and a server that grows busier later will meet it without warning.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { FormEventHandler } from 'react';
|
||||
import { FormEventHandler, useState } from 'react';
|
||||
|
||||
import { FileDeliveryDialog, type FileDelivery } from '@/components/file-delivery-dialog';
|
||||
import Heading from '@/components/heading';
|
||||
import InputError from '@/components/input-error';
|
||||
import { SaveButton } from '@/components/save-button';
|
||||
@@ -12,10 +13,12 @@ import AppLayout from '@/layouts/app-layout';
|
||||
|
||||
interface DownloadSettingsProps {
|
||||
max_zip_download_size_mb: number;
|
||||
file_delivery: FileDelivery;
|
||||
}
|
||||
|
||||
export default function DownloadSettings({ max_zip_download_size_mb }: DownloadSettingsProps) {
|
||||
export default function DownloadSettings({ max_zip_download_size_mb, file_delivery }: DownloadSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [deliveryOpen, setDeliveryOpen] = useState(false);
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{ title: t('Settings'), href: '/system/settings' },
|
||||
@@ -60,6 +63,40 @@ export default function DownloadSettings({ max_zip_download_size_mb }: DownloadS
|
||||
|
||||
<SaveButton processing={processing} recentlySuccessful={recentlySuccessful} />
|
||||
</form>
|
||||
|
||||
{/* Not a setting, and here because this is where somebody
|
||||
coming from v1 looks for one — v1 had a "Download
|
||||
method" dropdown. Read-only on purpose: it describes
|
||||
the server this installation is running on, and a value
|
||||
kept in the database would travel to a different server
|
||||
in a restore and be wrong there. */}
|
||||
<div className="mt-8 max-w-xl border-t pt-6">
|
||||
<h2 className="text-sm font-medium">{t('How downloads are sent')}</h2>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{file_delivery.method === 'nginx' &&
|
||||
t('Files are handed to nginx, which sends them without holding a PHP process open. This is the fastest option and needs nothing further.')}
|
||||
{file_delivery.method === 'xsendfile' &&
|
||||
t('Files are handed to your web server with the X-Sendfile header, which sends them without holding a PHP process open.')}
|
||||
{file_delivery.method === 'php' &&
|
||||
t('PHP is reading each file and sending it. That works on every server, but it occupies a PHP worker process for the whole of each download.')}
|
||||
</p>
|
||||
{file_delivery.method === 'php' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeliveryOpen(true)}
|
||||
className="mt-2 text-sm underline hover:no-underline"
|
||||
>
|
||||
{t('Why this matters, and how to change it')}
|
||||
</button>
|
||||
)}
|
||||
<p className="text-muted-foreground mt-3 text-xs">
|
||||
{file_delivery.detected
|
||||
? t('Detected from the web server. Set PROJECTSEND_FILE_DELIVERY in your .env file to choose explicitly.')
|
||||
: t('Set explicitly by PROJECTSEND_FILE_DELIVERY in your .env file.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FileDeliveryDialog delivery={file_delivery} open={deliveryOpen} onOpenChange={setDeliveryOpen} />
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Models\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* How a file's bytes leave the server.
|
||||
*
|
||||
* The fast path answers with an empty body and a header telling the web
|
||||
* server to send the file. A server that does not recognise that header
|
||||
* sends the empty body instead, which is a 0-byte download with every
|
||||
* other page working perfectly — the shape of
|
||||
* https://github.com/projectsend/projectsend/issues/1765, where an Apache
|
||||
* install had broken thumbnails and empty downloads.
|
||||
*
|
||||
* So these are mostly about the *body*: the assertion that catches the
|
||||
* bug is "the bytes actually arrived", not "the header said the right
|
||||
* thing".
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Storage::fake('files');
|
||||
$this->admin = User::factory()->create();
|
||||
});
|
||||
|
||||
/** A file whose bytes are really on the fake disk. */
|
||||
function storedFile(string $contents = 'the actual bytes'): File
|
||||
{
|
||||
$file = File::factory()->create(['size' => strlen($contents), 'mime_type' => 'application/pdf']);
|
||||
Storage::disk('files')->put($file->path, $contents);
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
function deliverAs(string $method): void
|
||||
{
|
||||
config(['projectsend.file_delivery' => $method]);
|
||||
}
|
||||
|
||||
test('nginx is handed the file and PHP sends no bytes', function () {
|
||||
deliverAs('nginx');
|
||||
$file = storedFile();
|
||||
|
||||
$response = $this->actingAs($this->admin)->get("/files/{$file->id}/download");
|
||||
|
||||
$response->assertOk()->assertHeader('X-Accel-Redirect', '/protected-files/'.$file->path);
|
||||
expect($response->getContent())->toBe('');
|
||||
});
|
||||
|
||||
test('PHP streaming actually sends the bytes', function () {
|
||||
// The regression that matters. Before this existed, an installation
|
||||
// whose server did not understand X-Accel-Redirect served this empty.
|
||||
deliverAs('php');
|
||||
$file = storedFile('the actual bytes');
|
||||
|
||||
$response = $this->actingAs($this->admin)->get("/files/{$file->id}/download");
|
||||
|
||||
$response->assertOk()
|
||||
->assertHeaderMissing('X-Accel-Redirect')
|
||||
->assertHeader('Content-Disposition', 'attachment; filename="'.$file->original_name.'"');
|
||||
|
||||
expect($response->streamedContent())->toBe('the actual bytes');
|
||||
});
|
||||
|
||||
test('PHP streaming answers a range request rather than resending the file', function () {
|
||||
// What makes seeking through a long video work. nginx does this for
|
||||
// itself on the fast path, so a hand-rolled readfile here would break
|
||||
// scrubbing on exactly the installations this fallback exists for.
|
||||
deliverAs('php');
|
||||
$file = storedFile('0123456789');
|
||||
|
||||
$response = $this->actingAs($this->admin)
|
||||
->get("/files/{$file->id}/download", ['Range' => 'bytes=2-5']);
|
||||
|
||||
expect($response->getStatusCode())->toBe(206)
|
||||
->and($response->streamedContent())->toBe('2345');
|
||||
});
|
||||
|
||||
test('X-Sendfile is handed an absolute path, not a URL path', function () {
|
||||
// The two headers are not interchangeable: nginx maps a URL through an
|
||||
// internal location, Apache and LiteSpeed open a filesystem path.
|
||||
// Renaming the header without changing the value is the obvious way to
|
||||
// "add Apache support" and produces a second broken install.
|
||||
deliverAs('xsendfile');
|
||||
$file = storedFile();
|
||||
|
||||
$response = $this->actingAs($this->admin)->get("/files/{$file->id}/download");
|
||||
|
||||
$response->assertOk()->assertHeaderMissing('X-Accel-Redirect');
|
||||
|
||||
expect($response->headers->get('X-Sendfile'))
|
||||
->toBe(Storage::disk('files')->path($file->path))
|
||||
->and($response->getContent())->toBe('');
|
||||
});
|
||||
|
||||
test('auto uses the fast path when the server says it is nginx', function () {
|
||||
deliverAs('auto');
|
||||
$file = storedFile();
|
||||
|
||||
$response = $this->actingAs($this->admin)
|
||||
->withServerVariables(['SERVER_SOFTWARE' => 'nginx/1.24.0'])
|
||||
->get("/files/{$file->id}/download");
|
||||
|
||||
$response->assertOk()->assertHeader('X-Accel-Redirect', '/protected-files/'.$file->path);
|
||||
});
|
||||
|
||||
test('auto falls back to PHP on a server it cannot hand files to', function () {
|
||||
// Apache, and the reason the issue was filed. Slow beats empty.
|
||||
deliverAs('auto');
|
||||
$file = storedFile('apache bytes');
|
||||
|
||||
$response = $this->actingAs($this->admin)
|
||||
->withServerVariables(['SERVER_SOFTWARE' => 'Apache/2.4.62 (AlmaLinux)'])
|
||||
->get("/files/{$file->id}/download");
|
||||
|
||||
$response->assertOk()->assertHeaderMissing('X-Accel-Redirect');
|
||||
expect($response->streamedContent())->toBe('apache bytes');
|
||||
});
|
||||
|
||||
test('auto never chooses X-Sendfile on its own', function () {
|
||||
// mod_xsendfile also needs XSendFilePath to allow the storage
|
||||
// directory, which cannot be seen from here. Choosing it because the
|
||||
// module might be loaded would swap a silent failure an administrator
|
||||
// can diagnose from the dashboard for one nobody can.
|
||||
deliverAs('auto');
|
||||
$file = storedFile();
|
||||
|
||||
$response = $this->actingAs($this->admin)
|
||||
->withServerVariables(['SERVER_SOFTWARE' => 'Apache/2.4.62'])
|
||||
->get("/files/{$file->id}/download");
|
||||
|
||||
$response->assertHeaderMissing('X-Sendfile');
|
||||
});
|
||||
|
||||
test('an unrecognised setting falls back to detection rather than breaking every download', function () {
|
||||
// A typo in an environment variable should cost speed, not the
|
||||
// installation's downloads.
|
||||
deliverAs('nginx-x-accel-redirect');
|
||||
$file = storedFile('still works');
|
||||
|
||||
$response = $this->actingAs($this->admin)
|
||||
->withServerVariables(['SERVER_SOFTWARE' => 'Apache/2.4.62'])
|
||||
->get("/files/{$file->id}/download");
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->streamedContent())->toBe('still works');
|
||||
});
|
||||
|
||||
test('a path trying to climb out of the storage directory is refused', function () {
|
||||
deliverAs('nginx');
|
||||
$file = storedFile();
|
||||
// Paths come from rows this application wrote, so this cannot happen
|
||||
// today. It is refused anyway because the cost of being wrong once is
|
||||
// handing over any file the web server can read — and nginx resolves
|
||||
// `..` in the URL it is given exactly as happily as PHP would.
|
||||
$file->forceFill(['path' => '../../../../etc/passwd'])->save();
|
||||
|
||||
$this->actingAs($this->admin)->get("/files/{$file->id}/download")->assertNotFound();
|
||||
});
|
||||
|
||||
test('PHP streaming reports a missing file as missing rather than failing', function () {
|
||||
deliverAs('php');
|
||||
$file = File::factory()->create();
|
||||
|
||||
$this->actingAs($this->admin)->get("/files/{$file->id}/download")->assertNotFound();
|
||||
});
|
||||
|
||||
test('previews, thumbnails and zips travel the same way downloads do', function () {
|
||||
// The four routes that hand over bytes each used to decide this for
|
||||
// themselves, and all four hard-coded nginx. Centralising them is the
|
||||
// fix; this is what stops one drifting back out.
|
||||
deliverAs('php');
|
||||
|
||||
$image = File::factory()->create(['mime_type' => 'image/png', 'original_name' => 'shot.png']);
|
||||
Storage::disk('files')->put($image->path, base64_decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
|
||||
));
|
||||
|
||||
$response = $this->actingAs($this->admin)->get("/files/{$image->id}/preview");
|
||||
|
||||
$response->assertOk()->assertHeaderMissing('X-Accel-Redirect');
|
||||
expect(strlen($response->streamedContent()))->toBeGreaterThan(0);
|
||||
});
|
||||
Reference in New Issue
Block a user