From d6fd5a917da548ac5a82c1f9b691778ce0ad1cde Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Mon, 31 Aug 2026 22:25:13 -0300 Subject: [PATCH] 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. --- .env.example | 9 + INSTALL.md | 157 ++++++++---- .../Http/Controllers/DashboardController.php | 10 +- app/Modules/Files/Delivery/DeliveryMethod.php | 55 ++++ app/Modules/Files/Delivery/FileDelivery.php | 236 ++++++++++++++++++ .../Files/Delivery/StoredFileResponse.php | 26 +- .../DownloadSettingsController.php | 10 + .../Controllers/FileDownloadController.php | 13 +- .../Controllers/FileThumbnailController.php | 20 +- .../Controllers/PublicShareController.php | 2 +- .../Controllers/ZipDownloadsController.php | 16 +- .../Events/ResolvingImageRendering.php | 7 +- .../Controllers/PublicGroupsController.php | 14 +- config/projectsend.php | 22 ++ phpunit.xml | 7 + .../dashboard-widgets/system-widget.tsx | 57 +++++ .../js/components/file-delivery-dialog.tsx | 143 +++++++++++ .../js/pages/system/settings/downloads.tsx | 41 ++- tests/Feature/Files/FileDeliveryTest.php | 185 ++++++++++++++ 19 files changed, 930 insertions(+), 100 deletions(-) create mode 100644 app/Modules/Files/Delivery/DeliveryMethod.php create mode 100644 app/Modules/Files/Delivery/FileDelivery.php create mode 100644 resources/js/components/file-delivery-dialog.tsx create mode 100644 tests/Feature/Files/FileDeliveryTest.php 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