mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-03 12:54:18 +00:00
a150dc4955
Downloads, previews and public links from managed storage have failed in every browser since late August with the bucket's AccessDenied XML. The platform pins each instance's R2 key to our servers' IP (portal 0125be7, 2026-08-26). Core started redirecting to signed URLs at about the same time (57540164,d6fd5a91). A signed URL carries every restriction of the key that signed it, so it worked from the server and nowhere else. A disk can now name another disk in `signing_disk`, and links are signed with that one. The platform gives it a read-only key without the IP pin. The read-write key stays pinned. A name that points at no configured disk is ignored, and the file's own disk signs as before. Uploads are unaffected: they go through the server, and nothing else signs.
128 lines
5.3 KiB
PHP
128 lines
5.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Files\Delivery;
|
|
|
|
use App\Modules\Files\Models\File;
|
|
use App\Support\ContentDisposition;
|
|
use Illuminate\Http\RedirectResponse;
|
|
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
|
|
* on.
|
|
*
|
|
* Every route that hands over a file reaches this after authorizing in
|
|
* its own way — a policy, a share token, a public-listing check. It
|
|
* authorizes nothing itself, and deliberately knows nothing about who is
|
|
* 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: 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. 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.
|
|
*
|
|
* The two paths are not equally revocable, which is why the lifetimes
|
|
* below differ. Every local delivery method authorises one response and
|
|
* no more — nginx's X-Accel-Redirect, Apache's X-Sendfile, or PHP
|
|
* streaming the bytes itself: these bytes, now, to this request, and
|
|
* nothing that outlives it. A presigned URL is a bearer
|
|
* credential — whoever holds it can fetch the file without passing the
|
|
* caller's checks again, and it outlives them: a download cap that is
|
|
* spent in the meantime, an expires_at that falls in between, an
|
|
* assignment that is withdrawn. Nothing here can revoke one, so the only
|
|
* dial is how long it lasts.
|
|
*
|
|
* A download needs to survive being followed, which is a redirect and a
|
|
* request: a minute is generous. A preview is held by the player for as
|
|
* long as somebody watches, and each seek outside the buffer is a fresh
|
|
* Range request against the same URL, so it keeps the hour. That is the
|
|
* trade, stated rather than left in a single number.
|
|
*
|
|
* Callers of inline() must have established that the mime type is
|
|
* inline-safe first; PreviewKind is the allowlist, and the reason there
|
|
* is one.
|
|
*/
|
|
class StoredFileResponse
|
|
{
|
|
/**
|
|
* Long enough for a browser, a download manager or a queued transfer
|
|
* to follow the redirect and start the request. An object store
|
|
* checks the signature when the request arrives, not while it runs,
|
|
* so a transfer that begins inside this window finishes however long
|
|
* it takes.
|
|
*/
|
|
private const DOWNLOAD_LINK_SECONDS = 60;
|
|
|
|
/**
|
|
* A preview is watched, not fetched: the player holds this URL and
|
|
* issues a Range request every time somebody seeks past the buffer,
|
|
* so it has to outlive the viewing rather than the redirect.
|
|
*/
|
|
private const PREVIEW_LINK_SECONDS = 3600;
|
|
|
|
public function __construct(private readonly FileDelivery $delivery) {}
|
|
|
|
/** Shown in place — a preview. */
|
|
public function inline(File $file): Response|RedirectResponse
|
|
{
|
|
return $this->make($file, ContentDisposition::inline($file->original_name), self::PREVIEW_LINK_SECONDS);
|
|
}
|
|
|
|
/** Handed over — a download. */
|
|
public function attachment(File $file): Response|RedirectResponse
|
|
{
|
|
return $this->make($file, ContentDisposition::attachment($file->original_name), self::DOWNLOAD_LINK_SECONDS);
|
|
}
|
|
|
|
private function make(File $file, string $disposition, int $linkSeconds): Response|RedirectResponse
|
|
{
|
|
if ($file->disk !== 'files') {
|
|
$url = Storage::disk($this->signingDisk($file->disk))->temporaryUrl(
|
|
$file->path,
|
|
now()->addSeconds($linkSeconds),
|
|
['ResponseContentDisposition' => $disposition],
|
|
);
|
|
|
|
return redirect()->away($url);
|
|
}
|
|
|
|
return $this->delivery->serve($file->path, $file->mime_type, $disposition, $file->size);
|
|
}
|
|
|
|
/**
|
|
* The disk whose credentials sign the link: the file's own, unless
|
|
* that disk names another in `signing_disk`.
|
|
*
|
|
* A signed URL carries every restriction of the key that signed it.
|
|
* A hosted instance's read-write key only works from our own servers,
|
|
* which is right for the key and wrong for a link a browser follows:
|
|
* every download, preview and public link got AccessDenied from the
|
|
* bucket. So a platform can give the disk a second, read-only key,
|
|
* free of that restriction and used for nothing but signing. The
|
|
* signing disk must point at the same bucket and prefix. The platform
|
|
* that configures one is also responsible for that.
|
|
*
|
|
* A name that points at no configured disk is ignored rather than
|
|
* obeyed. Failing every download over a typo would be worse than
|
|
* signing with the key the file was stored with.
|
|
*/
|
|
private function signingDisk(string $disk): string
|
|
{
|
|
$signing = config("filesystems.disks.{$disk}.signing_disk");
|
|
|
|
return is_string($signing) && $signing !== '' && is_array(config("filesystems.disks.{$signing}"))
|
|
? $signing
|
|
: $disk;
|
|
}
|
|
}
|