mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 09:05:08 +00:00
d6fd5a917d
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.
225 lines
9.4 KiB
PHP
225 lines
9.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
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;
|
|
use App\Modules\Files\Preview\PreviewLog;
|
|
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
|
|
use App\Modules\Files\Thumbnails\ImageAudience;
|
|
use App\Modules\Files\Thumbnails\ImageRendition;
|
|
use App\Modules\Files\Thumbnails\LocalSourceFile;
|
|
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use App\Support\ContentDisposition;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
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, 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
|
|
* logging it would flood the activity log with non-events. `preview()`
|
|
* logs Action::FilePreviewed, since a user explicitly chose to view the
|
|
* file's contents — a real, audit-worthy action, just not a "download."
|
|
*
|
|
* SECURITY: both methods serve bytes inline, from this app's own origin,
|
|
* with the File's stored mime type, so both are restricted to an
|
|
* allowlist — but not the same one, because they are asking different
|
|
* questions. `thumbnail()` is bounded by
|
|
* ThumbnailGenerator::SUPPORTED_MIME_TYPES, the raster formats this app
|
|
* decodes and re-encodes itself, since a thumbnail *is* a rendition.
|
|
* `preview()` is bounded by PreviewKind, which additionally admits the
|
|
* video, audio and PDF types a browser plays natively and this app never
|
|
* touches. PreviewKind's docblock carries the rule in full; the short
|
|
* version is that neither list may ever grow a type a browser executes
|
|
* script from, and neither may be derived from the upload
|
|
* allowed-extensions setting, which matches on the *extension* while
|
|
* mime_type is detected from the *bytes*
|
|
* (ChunkedUploadsController::complete).
|
|
*
|
|
* Serving media inline is also why `preview()` logs at most one
|
|
* Action::FilePreviewed per viewer per file per five minutes: a `<video>`
|
|
* seeking through a recording issues a long tail of Range requests
|
|
* against this same URL, and one row each would bury the log under a
|
|
* single deliberate act.
|
|
*
|
|
* Renditions always cache on the local "files" disk regardless of where
|
|
* the source file lives — they're a derived artifact, not the original,
|
|
* so there's no reason to push them to external storage too. Generating
|
|
* one from a source on a non-local disk needs a temp local copy first,
|
|
* since ThumbnailGenerator needs a real path to read from.
|
|
*
|
|
* Both methods cache one file per ImageAudience, because both routes are
|
|
* reached by the staff file manager and the client portal alike and a
|
|
* RenderingImage listener may render the two differently.
|
|
*/
|
|
class FileThumbnailController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly ThumbnailGenerator $thumbnails,
|
|
private readonly PreviewLog $previews,
|
|
private readonly DownloadAllowance $allowance,
|
|
private readonly StoredFileResponse $bytes,
|
|
private readonly LocalSourceFile $source,
|
|
private readonly Settings $settings,
|
|
private readonly FileDelivery $delivery,
|
|
) {}
|
|
|
|
public function thumbnail(Request $request, File $file): Response
|
|
{
|
|
Gate::authorize('view', $file);
|
|
|
|
// This one route serves both the staff file manager and the client
|
|
// portal — the same URL, told apart only by who is asking. A client
|
|
// and a staff member looking at the same file get different cached
|
|
// bytes; see ImageAudience.
|
|
$audience = ImageAudience::forViewer($request->user());
|
|
|
|
$path = $this->render($file, $audience, ImageRendition::Thumbnail);
|
|
|
|
abort_if($path === null, 404);
|
|
|
|
return $this->serve($file, $path);
|
|
}
|
|
|
|
/**
|
|
* A file opened to be looked at.
|
|
*
|
|
* For an image, a preview is not the file — it is a rendered view of
|
|
* it, which is why it may be decorated at all. But rendering one is
|
|
* expensive (decoding and re-encoding a full-size photograph) where
|
|
* serving the stored bytes is nearly free, so core only pays that
|
|
* cost when a listener says this particular viewer must be served a
|
|
* rendering: ResolvingImageRendering asks, and defaults to no. On an
|
|
* installation that watermarks, a client gets a bounded, watermarked
|
|
* render and staff get the original; on one that does not, everyone
|
|
* gets exactly what this endpoint has always returned.
|
|
*
|
|
* For video, audio and PDF there is no rendering to resolve — this
|
|
* app cannot decode any of them, so it has no rendition to cache, no
|
|
* watermark to stamp, and nothing to ask about. Those go straight to
|
|
* the bytes.
|
|
*/
|
|
public function preview(Request $request, File $file): Response|RedirectResponse
|
|
{
|
|
Gate::authorize('view', $file);
|
|
|
|
// The inline allowlist. See the class docblock and PreviewKind —
|
|
// the stored mime type is sniffed from the bytes, so an allowed
|
|
// extension is not evidence of a safe-to-render payload.
|
|
$kind = PreviewKind::forMime($file->mime_type);
|
|
|
|
abort_if($kind === null, 404);
|
|
|
|
// Staff are never gated: this switch exists so an installation can
|
|
// decide what its *clients* may do with a file short of taking it.
|
|
// 404 rather than 403 because with the setting off the endpoint is
|
|
// not a thing that exists for this viewer.
|
|
abort_if(
|
|
$request->user()?->isStaff() !== true && ! $this->settings->get(Setting::ClientsCanPreviewFiles),
|
|
404,
|
|
);
|
|
|
|
// A preview is not counted as a download, but it is refused once
|
|
// the download limit is spent — because unless a listener asks
|
|
// for a rendering (nothing does by default, and nothing ever does
|
|
// for media), the branches below serve the *original bytes* at
|
|
// full size. Without this a cap would be one URL away from
|
|
// meaningless for every previewable file on the install.
|
|
// thumbnail() needs no such guard: a 300px rendition is not the
|
|
// file.
|
|
abort_unless($this->allowance->allows($file, $request->user()), 403);
|
|
|
|
// Debounced, because a browser turns one video into dozens of
|
|
// Range requests — see PreviewLog, which the anonymous twin in
|
|
// PublicGroupsController::preview shares.
|
|
$this->previews->record(Action::FilePreviewed, $file, $request->user());
|
|
|
|
if ($kind === PreviewKind::Image) {
|
|
$audience = ImageAudience::forViewer($request->user());
|
|
|
|
$decision = new ResolvingImageRendering($audience, ImageRendition::Preview, $file->mime_type);
|
|
Event::dispatch($decision);
|
|
|
|
if ($decision->required) {
|
|
$path = $this->render($file, $audience, ImageRendition::Preview);
|
|
|
|
abort_if($path === null, 404);
|
|
|
|
return $this->serve($file, $path);
|
|
}
|
|
}
|
|
|
|
return $this->bytes->inline($file);
|
|
}
|
|
|
|
/**
|
|
* The cached rendition's path on the local disk, generating it first
|
|
* if this is the first time anyone has asked for it. Null only when
|
|
* the mime type has no rendition at all.
|
|
*/
|
|
private function render(File $file, ImageAudience $audience, ImageRendition $rendition): ?string
|
|
{
|
|
$path = ThumbnailGenerator::pathFor($file->id, $file->mime_type, $audience, $rendition);
|
|
|
|
if ($path === null) {
|
|
return null;
|
|
}
|
|
|
|
$disk = Storage::disk('files');
|
|
|
|
// Existence is the cache, and an empty file is not a rendition: it
|
|
// is what a render that died before writing anything leaves behind,
|
|
// and serving it hands the viewer a broken image for as long as the
|
|
// file lives — nothing invalidates a rendition once it is there.
|
|
// ThumbnailGenerator writes through a temporary file now, so this
|
|
// state can no longer be created here; it can still be inherited
|
|
// from an installation that ran an older version.
|
|
if ($disk->exists($path)) {
|
|
if ($disk->size($path) > 0) {
|
|
return $path;
|
|
}
|
|
|
|
$disk->delete($path);
|
|
}
|
|
|
|
$disk->makeDirectory(dirname($path));
|
|
|
|
$this->source->use($file, fn (string $sourcePath) => $this->thumbnails->generate(
|
|
$sourcePath,
|
|
$disk->path($path),
|
|
$file->mime_type,
|
|
$audience,
|
|
$rendition,
|
|
));
|
|
|
|
return $path;
|
|
}
|
|
|
|
private function serve(File $file, string $path): Response
|
|
{
|
|
// 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),
|
|
);
|
|
}
|
|
}
|