Files
projectsend/app/Modules/Files/Http/Controllers/FileThumbnailController.php
T
ignacionelson 88c182cf3b Preview video, audio and PDF, not only images
v1 could preview four kinds of file in a modal — images, video, audio and
PDF. v2 previewed only images, and not by decision: preview shipped as part
of the image *thumbnail* work (1c68aa1), so "previewable" quietly became a
synonym for "GD can decode it". FileThumbnailController::preview() gated on
ThumbnailGenerator::SUPPORTED_MIME_TYPES, the frontend mirrored the same
four types, and the dialog was a hardcoded <img>.

Rather than widen that list — it drives pathFor(), extensionFor(),
generate() and FileDiskCleanup, and a video reaching getimagesize() is a
500 — this separates the two questions. PreviewKind now answers "may these
bytes be served inline, and what element renders them?", while
ThumbnailGenerator keeps answering the narrower "can this app decode it
itself?", which is what renditions, the cache and the watermark hook
actually depend on. Image delegates to it so the two cannot drift.

The allowlist stays a security boundary: mime_type is sniffed from the
bytes, so text/html and image/svg+xml remain excluded, and PreviewKind is
deliberately narrower than "formats a browser might cope with" — no
quicktime, avi or matroska, because an embedded player for those shows a
black rectangle. Those still download exactly as before.

docs/security-audit-2026-08-05.md finding 1 recorded that adding
application/pdf "should be a conscious decision". This is that decision,
and three things were measured rather than assumed:

- An <iframe sandbox> cannot be used. Chrome refuses to run its PDF viewer
  in a sandboxed frame at all (ERR_BLOCKED_BY_CLIENT, with or without
  allow-same-origin) — the attribute removes the feature, it does not
  harden it.
- nginx's `Content-Security-Policy: sandbox; default-src 'none'` on
  /protected-files/ does work (a <video> frame lands in an opaque origin),
  but Chrome exempts its PDF viewer from it, so it is not what protects
  the PDF case.
- What does is the allowlist plus the browser's own PDF sandbox, where PDF
  JavaScript has no DOM and no cookies.

Range requests were verified end to end: 206 with a correct Content-Range,
a byte-perfect file reassembled from three ranges, and a real browser
seeking to 10s of a 20s clip. nginx drops the upstream Content-Length on
the X-Accel path, so there is no collision.

Two settings, both defaulting on so no installation loses what it has:
clients_can_preview_files and public_listing_preview_enabled. Staff are
never gated. The anonymous side needed a route of its own — there was no
public preview endpoint — with its own throttle bucket, since a bare
throttle: shares one counter across that whole block.

A preview now logs at most one FilePreviewed per viewer per file per five
minutes: a <video> turns one deliberate act into a long tail of Range
requests, and a row each would bury the log.

Also fixes a layout bug the tests could never catch. A portal file row was
flex justify-between with three children — name, comment trigger, download
— so the middle one settled wherever the name happened to end and the
comment icon sat at a different place on every row. The name block now
takes the slack and every action lives in one trailing group, with the
comment trigger in a fixed-width slot so the icons form a column. And
because half the previewable files have no thumbnail to click — a PDF, an
mp3 and an mp4 all render as a generic icon — every row gains an explicit
PreviewAction beside DownloadAction, matching whatever style that theme
gives its download control.
2026-08-21 14:14:23 -03:00

264 lines
10 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\Audit\ActivityLogger;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Delivery\InlineFileResponse;
use App\Modules\Files\Models\File;
use App\Modules\Files\Preview\PreviewKind;
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
use App\Modules\Files\Thumbnails\ImageAudience;
use App\Modules\Files\Thumbnails\ImageRendition;
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\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
/**
* Two inline (never `attachment`) views of a file, same X-Accel-Redirect
* pattern as FileDownloadController: 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 ActivityLogger $activity,
private readonly DownloadAllowance $allowance,
private readonly InlineFileResponse $inline,
private readonly Settings $settings,
) {}
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);
$this->logPreview($file, $request);
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->inline->make($file);
}
/**
* One log row per viewer per file per five minutes.
*
* Watching a video is a single deliberate act that the browser turns
* into dozens of Range requests against this route, and each one
* arrives here indistinguishable from someone clicking preview again.
* Cache::add is the whole mechanism: it writes only if the key is
* absent, so the first request through the window logs and the rest
* are silent, without a read-then-write race between two of them.
*
* Keyed by viewer, so one client's playback never suppresses another
* person's preview of the same file. Anonymous viewers do not reach
* this route at all — see PublicGroupsController::preview.
*/
private function logPreview(File $file, Request $request): void
{
$key = 'file-preview-logged:'.$file->id.':'.($request->user()->id ?? 'guest');
if (Cache::add($key, true, now()->addMinutes(5))) {
$this->activity->log(Action::FilePreviewed, subject: $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');
if ($disk->exists($path)) {
return $path;
}
$disk->makeDirectory(dirname($path));
$sourcePath = $this->localSourcePathFor($file);
try {
$this->thumbnails->generate($sourcePath, $disk->path($path), $file->mime_type, $audience, $rendition);
} finally {
if ($file->disk !== 'files') {
@unlink($sourcePath);
}
}
return $path;
}
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),
]);
}
/**
* A local-disk file's real path (fast path). Anything else is
* stream-copied to a temp file first — the caller unlinks it once
* rendering is done.
*/
private function localSourcePathFor(File $file): string
{
if ($file->disk === 'files') {
return Storage::disk('files')->path($file->path);
}
$tempPath = tempnam(sys_get_temp_dir(), 'thumb-src-');
if ($tempPath === false) {
throw new \RuntimeException('Could not create a temp file for '.$file->original_name);
}
$stream = Storage::disk($file->disk)->readStream($file->path);
$out = fopen($tempPath, 'wb');
if ($stream === null || $out === false) {
throw new \RuntimeException('Could not read '.$file->original_name.' from its storage disk.');
}
stream_copy_to_stream($stream, $out);
fclose($out);
if (is_resource($stream)) {
fclose($stream);
}
return $tempPath;
}
}