mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +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.
139 lines
5.4 KiB
PHP
139 lines
5.4 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\Models\File;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
|
|
beforeEach(function () {
|
|
Storage::fake('files');
|
|
$this->admin = User::factory()->create();
|
|
});
|
|
|
|
function uploadPdfFile(User $as, string $name = 'contract.pdf'): File
|
|
{
|
|
test()->actingAs($as)->post('/files', [
|
|
'file' => UploadedFile::fake()->create($name, 4, 'application/pdf'),
|
|
'name' => '',
|
|
'description' => '',
|
|
]);
|
|
|
|
return File::query()->latest('id')->firstOrFail();
|
|
}
|
|
|
|
test('requesting a thumbnail generates and caches it on disk', function () {
|
|
$file = uploadImageFile($this->admin);
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/thumbnail")
|
|
->assertOk()
|
|
->assertHeader('Content-Type', 'image/jpeg')
|
|
->assertHeader('Content-Disposition', 'inline; filename="photo.jpg"');
|
|
|
|
expect(Storage::disk('files')->exists("thumbnails/{$file->id}.jpg"))->toBeTrue();
|
|
});
|
|
|
|
test('a second request reuses the cached thumbnail instead of regenerating it', function () {
|
|
$file = uploadImageFile($this->admin);
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/thumbnail")->assertOk();
|
|
|
|
// If the controller tried to regenerate from source it would now fail
|
|
// (the original is gone) — a passing second request proves the cache
|
|
// was reused instead.
|
|
Storage::disk('files')->delete($file->path);
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/thumbnail")->assertOk();
|
|
});
|
|
|
|
test('a non-image file 404s when a thumbnail is requested', function () {
|
|
$file = uploadPdfFile($this->admin);
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/thumbnail")->assertNotFound();
|
|
});
|
|
|
|
test('the preview endpoint serves the original file inline and logs a preview, not a download', function () {
|
|
$file = uploadImageFile($this->admin);
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/preview")
|
|
->assertOk()
|
|
->assertHeader('X-Accel-Redirect', '/protected-files/'.$file->path)
|
|
->assertHeader('Content-Disposition', 'inline; filename="photo.jpg"');
|
|
|
|
expect(ActivityLog::query()->where('action', Action::FileDownloaded)->where('subject_id', $file->id)->exists())->toBeFalse();
|
|
|
|
$entry = ActivityLog::query()->where('action', Action::FilePreviewed)->where('subject_id', $file->id)->sole();
|
|
expect($entry->actor_id)->toBe($this->admin->id);
|
|
});
|
|
|
|
// A PDF has no thumbnail (nothing here decodes one) but does have a
|
|
// preview, which is the whole reason the two allowlists are separate.
|
|
test('a file with no thumbnail can still be previewed', function () {
|
|
$file = uploadPdfFile($this->admin);
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/preview")
|
|
->assertOk()
|
|
->assertHeader('Content-Type', 'application/pdf')
|
|
->assertHeader('X-Accel-Redirect', '/protected-files/'.$file->path);
|
|
});
|
|
|
|
test('a file of a type no browser plays 404s when a preview is requested', function () {
|
|
$file = File::factory()->create([
|
|
'uploaded_by' => $this->admin->id,
|
|
'name' => 'archive',
|
|
'original_name' => 'archive.zip',
|
|
'path' => '2026/08/'.Str::uuid()->toString().'.zip',
|
|
'mime_type' => 'application/zip',
|
|
'size' => 64,
|
|
]);
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/preview")->assertNotFound();
|
|
});
|
|
|
|
// The mime type is detected from the bytes, not the filename, so an
|
|
// extension on the upload allowlist is no guarantee of a safe payload: a
|
|
// .txt holding HTML is stored as text/html. Serving that inline from this
|
|
// app's origin would execute script with the viewer's session, so the
|
|
// preview allowlist — not the upload allowlist — has to be what stops it.
|
|
test('a file stored with a script-executing mime type is never served inline', function (string $mimeType, string $extension) {
|
|
$file = File::factory()->create([
|
|
'uploaded_by' => $this->admin->id,
|
|
'name' => 'notes',
|
|
'original_name' => 'notes.'.$extension,
|
|
'path' => '2026/08/'.Str::uuid()->toString().'.'.$extension,
|
|
'mime_type' => $mimeType,
|
|
'size' => 64,
|
|
]);
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/preview")->assertNotFound();
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/thumbnail")->assertNotFound();
|
|
|
|
// A refused preview is not a preview — nothing to audit.
|
|
expect(ActivityLog::query()->where('action', Action::FilePreviewed)->where('subject_id', $file->id)->exists())->toBeFalse();
|
|
})->with([
|
|
'html disguised as a text file' => ['text/html', 'txt'],
|
|
'svg disguised as a text file' => ['image/svg+xml', 'txt'],
|
|
'xml' => ['application/xml', 'xml'],
|
|
]);
|
|
|
|
test('requesting a thumbnail does not create any activity log entry', function () {
|
|
$file = uploadImageFile($this->admin);
|
|
$countBeforeThumbnail = ActivityLog::query()->count();
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/thumbnail")->assertOk();
|
|
|
|
expect(ActivityLog::query()->count())->toBe($countBeforeThumbnail);
|
|
});
|
|
|
|
test('a client cannot view the thumbnail or preview of a file not shared with them', function () {
|
|
$client = User::factory()->client()->create();
|
|
$file = uploadImageFile($this->admin);
|
|
|
|
$this->actingAs($client)->get("/files/{$file->id}/thumbnail")->assertForbidden();
|
|
$this->actingAs($client)->get("/files/{$file->id}/preview")->assertForbidden();
|
|
});
|