mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 09:05:08 +00:00
88c182cf3b
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.
356 lines
14 KiB
PHP
356 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLog;
|
|
use App\Modules\Files\Thumbnails\Events\ImageRenderingChanged;
|
|
use App\Modules\Files\Thumbnails\Events\RenderingImage;
|
|
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
|
|
use App\Modules\Files\Thumbnails\ImageAudience;
|
|
use App\Modules\Files\Thumbnails\ImageRendition;
|
|
use App\Modules\Files\Thumbnails\RenderedImageCache;
|
|
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
/**
|
|
* The seam a package hooks to change what a rendered image looks like —
|
|
* the watermark in cloud-modules is its first consumer, and none of its
|
|
* code is reachable from this repo. What is asserted here is the
|
|
* contract that package is written against: that the hook fires with the
|
|
* image still in memory, that mutating it reaches the file on disk, that
|
|
* each audience and rendition is cached apart, that a preview only
|
|
* becomes a rendering when a listener says it must, and that announcing
|
|
* a rendering change clears the cache that would otherwise keep serving
|
|
* the old bytes forever.
|
|
*/
|
|
beforeEach(function () {
|
|
Storage::fake('files');
|
|
$this->admin = User::factory()->create();
|
|
});
|
|
|
|
test('generating a thumbnail dispatches the rendering hook before the image is written', function () {
|
|
$file = uploadImageFile($this->admin);
|
|
|
|
$seen = null;
|
|
|
|
Event::listen(RenderingImage::class, function (RenderingImage $event) use (&$seen): void {
|
|
$seen = $event;
|
|
});
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/thumbnail")->assertOk();
|
|
|
|
expect($seen)->not->toBeNull()
|
|
->and($seen->mimeType)->toBe('image/jpeg')
|
|
->and($seen->audience)->toBe(ImageAudience::Staff)
|
|
// Already scaled into the thumbnail box: a listener sizing a
|
|
// watermark against it is measuring the finished thumbnail, not
|
|
// the original upload (which is 200x100 here).
|
|
->and($seen->image->getWidth())->toBeLessThanOrEqual(300)
|
|
->and($seen->image->getHeight())->toBeLessThanOrEqual(300);
|
|
});
|
|
|
|
// The whole point of the audience: a listener has to be able to leave the
|
|
// staff file manager alone while marking what everyone else sees.
|
|
test('the hook is told which audience each thumbnail is for', function () {
|
|
$client = User::factory()->client()->create();
|
|
$file = uploadImageFile($this->admin);
|
|
shareFileWith($file, $client);
|
|
|
|
$seen = [];
|
|
|
|
Event::listen(RenderingImage::class, function (RenderingImage $event) use (&$seen): void {
|
|
$seen[] = $event->audience;
|
|
});
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/thumbnail")->assertOk();
|
|
$this->actingAs($client)->get("/files/{$file->id}/thumbnail")->assertOk();
|
|
|
|
expect($seen)->toBe([ImageAudience::Staff, ImageAudience::External]);
|
|
});
|
|
|
|
// Two viewers, one URL. Without separate cache entries the first request
|
|
// would decide what the second one saw for as long as the file existed.
|
|
test('staff and clients are served different cached files for the same thumbnail URL', function () {
|
|
$client = User::factory()->client()->create();
|
|
$file = uploadImageFile($this->admin);
|
|
shareFileWith($file, $client);
|
|
|
|
// A listener that only ever touches the external variant — the shape
|
|
// the watermark takes.
|
|
Event::listen(RenderingImage::class, function (RenderingImage $event): void {
|
|
if ($event->audience === ImageAudience::External) {
|
|
$event->image->resize(32, 32);
|
|
}
|
|
});
|
|
|
|
$staffPath = ThumbnailGenerator::pathFor($file->id, 'image/jpeg', ImageAudience::Staff, ImageRendition::Thumbnail);
|
|
$externalPath = ThumbnailGenerator::pathFor($file->id, 'image/jpeg', ImageAudience::External, ImageRendition::Thumbnail);
|
|
|
|
expect($staffPath)->not->toBe($externalPath);
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/thumbnail")
|
|
->assertOk()
|
|
->assertHeader('X-Accel-Redirect', '/protected-files/'.$staffPath);
|
|
|
|
$this->actingAs($client)->get("/files/{$file->id}/thumbnail")
|
|
->assertOk()
|
|
->assertHeader('X-Accel-Redirect', '/protected-files/'.$externalPath);
|
|
|
|
$disk = Storage::disk('files');
|
|
|
|
expect(getimagesize($disk->path($externalPath))[0])->toBe(32)
|
|
->and(getimagesize($disk->path($staffPath))[0])->toBeGreaterThan(32);
|
|
});
|
|
|
|
// Anonymous, so there is no user to read an audience off — it must still
|
|
// come out external rather than defaulting to the internal variant.
|
|
test('a public listing thumbnail is generated for the external audience', function () {
|
|
app(Settings::class)->set(Setting::PublicListingEnabled, true);
|
|
app(Settings::class)->set(Setting::PublicListingSlug, 'public');
|
|
|
|
$file = uploadImageFile($this->admin);
|
|
$file->update(['public' => true]);
|
|
|
|
$seen = null;
|
|
|
|
Event::listen(RenderingImage::class, function (RenderingImage $event) use (&$seen): void {
|
|
$seen = $event->audience;
|
|
});
|
|
|
|
$this->get(route('public.thumbnail', ['public', $file->fresh()->slug]))->assertOk();
|
|
|
|
expect($seen)->toBe(ImageAudience::External)
|
|
->and(Storage::disk('files')->exists(
|
|
ThumbnailGenerator::pathFor($file->id, 'image/jpeg', ImageAudience::External, ImageRendition::Thumbnail)
|
|
))->toBeTrue();
|
|
});
|
|
|
|
test('what a listener does to the image is what lands on disk', function () {
|
|
$file = uploadImageFile($this->admin);
|
|
|
|
Event::listen(RenderingImage::class, function (RenderingImage $event): void {
|
|
$event->image->resize(24, 24);
|
|
});
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/thumbnail")->assertOk();
|
|
|
|
$written = getimagesize(Storage::disk('files')->path("thumbnails/{$file->id}.jpg"));
|
|
|
|
expect($written[0])->toBe(24)->and($written[1])->toBe(24);
|
|
});
|
|
|
|
// A rendition is written once and then served from cache forever, so a
|
|
// package that changes how images render has no other way to make its
|
|
// change visible on files anyone has already looked at.
|
|
test('announcing a rendering change clears the cached renditions', function () {
|
|
$file = uploadImageFile($this->admin);
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/thumbnail")->assertOk();
|
|
expect(Storage::disk('files')->exists("thumbnails/{$file->id}.jpg"))->toBeTrue();
|
|
|
|
Event::dispatch(new ImageRenderingChanged);
|
|
|
|
expect(Storage::disk('files')->exists("thumbnails/{$file->id}.jpg"))->toBeFalse();
|
|
});
|
|
|
|
// The half of the seam a package can actually reach: it cannot construct
|
|
// a host class, so it dispatches the event by string class name — which
|
|
// only works because the event carries no payload.
|
|
test('the change can be announced by string class name, as a package must', function () {
|
|
$file = uploadImageFile($this->admin);
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/thumbnail")->assertOk();
|
|
|
|
Event::dispatch('App\Modules\Files\Thumbnails\Events\ImageRenderingChanged');
|
|
|
|
expect(Storage::disk('files')->exists("thumbnails/{$file->id}.jpg"))->toBeFalse();
|
|
});
|
|
|
|
test('flushing a cache that is not there is not an error', function () {
|
|
app(RenderedImageCache::class)->flush();
|
|
})->throwsNoExceptions();
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Previews
|
|
// ---------------------------------------------------------------------
|
|
|
|
// The fast path this endpoint has always taken, and still takes on every
|
|
// installation where nothing wants to decorate a preview: no decoding, no
|
|
// cached copy, just the stored file.
|
|
test('a preview serves the original file when no listener asks for a rendering', function () {
|
|
$file = uploadImageFile($this->admin);
|
|
|
|
$rendered = false;
|
|
Event::listen(RenderingImage::class, function () use (&$rendered): void {
|
|
$rendered = true;
|
|
});
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/preview")
|
|
->assertOk()
|
|
->assertHeader('X-Accel-Redirect', '/protected-files/'.$file->path);
|
|
|
|
expect($rendered)->toBeFalse()
|
|
->and(Storage::disk('files')->exists("previews/{$file->id}.jpg"))->toBeFalse();
|
|
});
|
|
|
|
test('core asks whether a preview must be rendered, and defaults to no', function () {
|
|
$client = User::factory()->client()->create();
|
|
$file = uploadImageFile($this->admin);
|
|
shareFileWith($file, $client);
|
|
|
|
$asked = [];
|
|
Event::listen(ResolvingImageRendering::class, function (ResolvingImageRendering $event) use (&$asked): void {
|
|
$asked[] = [$event->audience, $event->rendition, $event->required];
|
|
});
|
|
|
|
$this->actingAs($client)->get("/files/{$file->id}/preview")->assertOk();
|
|
|
|
expect($asked)->toBe([[ImageAudience::External, ImageRendition::Preview, false]]);
|
|
});
|
|
|
|
// The trap this closes: the watermark listener decides on audience
|
|
// alone, so if a PDF or a video reached the resolving hook it would come
|
|
// back "render this" — and ThumbnailGenerator has no rendition for
|
|
// either, which would 404 every non-image preview on a watermarking
|
|
// installation. Core never asks about a type it cannot render.
|
|
test('core does not ask about rendering a file it could never render', function () {
|
|
$client = User::factory()->client()->create();
|
|
$file = uploadDocumentFile($this->admin);
|
|
shareFileWith($file, $client);
|
|
|
|
$asked = [];
|
|
Event::listen(ResolvingImageRendering::class, function (ResolvingImageRendering $event) use (&$asked): void {
|
|
$asked[] = $event->mimeType;
|
|
$event->required = true;
|
|
});
|
|
|
|
$this->actingAs($client)->get("/files/{$file->id}/preview")
|
|
->assertOk()
|
|
->assertHeader('X-Accel-Redirect', '/protected-files/'.$file->path);
|
|
|
|
expect($asked)->toBe([]);
|
|
});
|
|
|
|
// A preview is a rendered view of a file, not the file — so a listener
|
|
// that intends to decorate it (the watermark) can have it rendered, and
|
|
// what the client then sees is the decorated copy rather than the
|
|
// original bytes. This is the whole feature.
|
|
test('a listener can turn a preview into a rendered, decorated copy', function () {
|
|
$client = User::factory()->client()->create();
|
|
$file = uploadImageFile($this->admin);
|
|
shareFileWith($file, $client);
|
|
|
|
Event::listen(ResolvingImageRendering::class, function (ResolvingImageRendering $event): void {
|
|
if ($event->audience === ImageAudience::External) {
|
|
$event->required = true;
|
|
}
|
|
});
|
|
|
|
Event::listen(RenderingImage::class, function (RenderingImage $event): void {
|
|
if ($event->rendition === ImageRendition::Preview) {
|
|
$event->image->resize(48, 48);
|
|
}
|
|
});
|
|
|
|
$previewPath = ThumbnailGenerator::pathFor($file->id, 'image/jpeg', ImageAudience::External, ImageRendition::Preview);
|
|
|
|
$this->actingAs($client)->get("/files/{$file->id}/preview")
|
|
->assertOk()
|
|
// Not the original's path: the client is served the rendering.
|
|
->assertHeader('X-Accel-Redirect', '/protected-files/'.$previewPath);
|
|
|
|
expect(getimagesize(Storage::disk('files')->path($previewPath))[0])->toBe(48);
|
|
|
|
// The same listener leaves staff alone, so staff still get the file.
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/preview")
|
|
->assertOk()
|
|
->assertHeader('X-Accel-Redirect', '/protected-files/'.$file->path);
|
|
});
|
|
|
|
// Thumbnails and previews are separate cache entries, or the 300px icon
|
|
// and the full-size view would overwrite each other.
|
|
test('a preview and a thumbnail of the same file are cached apart and bounded differently', function () {
|
|
$client = User::factory()->client()->create();
|
|
$file = uploadImageFile($this->admin, 'wide.jpg');
|
|
shareFileWith($file, $client);
|
|
|
|
Event::listen(ResolvingImageRendering::class, function (ResolvingImageRendering $event): void {
|
|
$event->required = true;
|
|
});
|
|
|
|
$bounds = [];
|
|
Event::listen(RenderingImage::class, function (RenderingImage $event) use (&$bounds): void {
|
|
$bounds[$event->rendition->value] = $event->rendition->maxDimension();
|
|
});
|
|
|
|
$this->actingAs($client)->get("/files/{$file->id}/thumbnail")->assertOk();
|
|
$this->actingAs($client)->get("/files/{$file->id}/preview")->assertOk();
|
|
|
|
expect($bounds)->toBe(['thumbnail' => 300, 'preview' => 1600])
|
|
->and(Storage::disk('files')->exists("thumbnails/external/{$file->id}.jpg"))->toBeTrue()
|
|
->and(Storage::disk('files')->exists("previews/external/{$file->id}.jpg"))->toBeTrue();
|
|
});
|
|
|
|
test('a rendered preview is cached, so a second request does not re-render', function () {
|
|
$client = User::factory()->client()->create();
|
|
$file = uploadImageFile($this->admin);
|
|
shareFileWith($file, $client);
|
|
|
|
Event::listen(ResolvingImageRendering::class, function (ResolvingImageRendering $event): void {
|
|
$event->required = true;
|
|
});
|
|
|
|
$renders = 0;
|
|
Event::listen(RenderingImage::class, function () use (&$renders): void {
|
|
$renders++;
|
|
});
|
|
|
|
$this->actingAs($client)->get("/files/{$file->id}/preview")->assertOk();
|
|
$this->actingAs($client)->get("/files/{$file->id}/preview")->assertOk();
|
|
|
|
expect($renders)->toBe(1);
|
|
});
|
|
|
|
test('flushing the cache drops rendered previews along with thumbnails', function () {
|
|
$client = User::factory()->client()->create();
|
|
$file = uploadImageFile($this->admin);
|
|
shareFileWith($file, $client);
|
|
|
|
Event::listen(ResolvingImageRendering::class, function (ResolvingImageRendering $event): void {
|
|
$event->required = true;
|
|
});
|
|
|
|
$this->actingAs($client)->get("/files/{$file->id}/thumbnail")->assertOk();
|
|
$this->actingAs($client)->get("/files/{$file->id}/preview")->assertOk();
|
|
|
|
Event::dispatch(new ImageRenderingChanged);
|
|
|
|
expect(Storage::disk('files')->exists("thumbnails/external/{$file->id}.jpg"))->toBeFalse()
|
|
->and(Storage::disk('files')->exists("previews/external/{$file->id}.jpg"))->toBeFalse();
|
|
});
|
|
|
|
// Previewing is an audit-worthy action however the bytes get to the
|
|
// viewer — the rendering branch must not quietly skip the log entry.
|
|
test('a rendered preview is still logged as a preview', function () {
|
|
$client = User::factory()->client()->create();
|
|
$file = uploadImageFile($this->admin);
|
|
shareFileWith($file, $client);
|
|
|
|
Event::listen(ResolvingImageRendering::class, function (ResolvingImageRendering $event): void {
|
|
$event->required = true;
|
|
});
|
|
|
|
$this->actingAs($client)->get("/files/{$file->id}/preview")->assertOk();
|
|
|
|
expect(ActivityLog::query()
|
|
->where('action', Action::FilePreviewed)
|
|
->where('subject_id', $file->id)
|
|
->where('actor_id', $client->id)
|
|
->exists())->toBeTrue();
|
|
});
|