mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
a8b1987e2c
5754016 moved this controller's thumbnail() and preview() onto
StoredFileResponse and left download(), the last method in the same
class, building its own response:
'X-Accel-Redirect' => '/protected-files/'.$file->path,
That prefix is nginx's internal location for the local files disk, and
$file->disk is never consulted. On an install with external storage
switched on it names a path nothing ever wrote, so the public download
fails — while the same file downloads correctly from the file manager
and from a share link, and previews correctly from this very page,
because all three go through the object that knows the rule.
That commit's own message names the shape: the knowledge "was sitting in
a private method on one class and inline in another, so the next caller
could not inherit it and did not". It is an object now, and this is the
call site that was not moved onto it. StoredFileResponse is already
injected here as $this->bytes — preview(), two methods above, uses it —
and attachment() is the method FileDownloadController and
PublicShareController already call.
Nothing changes for a local install: attachment() emits the same four
headers this method wrote by hand, through the same ContentDisposition
call. The return type widens to Response|RedirectResponse because a
non-local disk answers with a redirect to a presigned URL, which is the
signature preview() already declares.
The regression test fails against the unfixed controller — checked in
both directions rather than assumed. The existing local-disk case grew
assertions for the other three headers, so "unchanged for local" is
pinned rather than argued: it passes before and after.
434 lines
20 KiB
PHP
434 lines
20 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\Category;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Files\Models\Folder;
|
|
use App\Modules\Groups\Models\Group;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Testing\TestResponse;
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
function publicPageProps(TestResponse $response): array
|
|
{
|
|
$page = json_decode(json_encode($response->viewData('page')), true);
|
|
|
|
return $page['props'];
|
|
}
|
|
|
|
beforeEach(function () {
|
|
Storage::fake('files');
|
|
|
|
// EnsureSetupIsComplete redirects every guest request to /setup until
|
|
// a staff account exists — an unrelated concern to these tests, but
|
|
// one they need to satisfy just like every other feature test does.
|
|
User::factory()->create();
|
|
|
|
app(Settings::class)->set(Setting::PublicListingEnabled, true);
|
|
app(Settings::class)->set(Setting::PublicListingSlug, 'public');
|
|
app(Settings::class)->set(Setting::Theme, 'default');
|
|
});
|
|
|
|
test('the directory 404s when disabled, but a specific public group page and its downloads still work', function () {
|
|
// PublicListingEnabled only gates the browsable directory (index()) —
|
|
// a public group's own page/downloads are independent of it, exactly
|
|
// like a share link isn't gated by any global toggle.
|
|
app(Settings::class)->set(Setting::PublicListingEnabled, false);
|
|
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
|
|
$staff = User::factory()->create();
|
|
$file = publicListingFile();
|
|
$this->actingAs($staff)->post("/files/{$file->id}/assignments", ['type' => 'group', 'id' => $group->id]);
|
|
auth()->logout();
|
|
|
|
$this->get('/public')->assertNotFound();
|
|
$this->get("/public/{$group->slug}")->assertOk();
|
|
$this->get("/public/files/{$file->slug}/download")->assertOk();
|
|
});
|
|
|
|
test('the wrong base slug 404s even when enabled', function () {
|
|
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
|
|
|
|
$this->get('/wrong-base')->assertNotFound();
|
|
$this->get("/wrong-base/{$group->slug}")->assertNotFound();
|
|
});
|
|
|
|
test('the index lists public groups and standalone public files without leaking private ones', function () {
|
|
$publicGroup = Group::query()->create(['name' => 'Open Group', 'public' => true]);
|
|
$privateGroup = Group::query()->create(['name' => 'Closed Group', 'public' => false]);
|
|
|
|
$standalone = publicListingFile(['name' => 'Standalone']);
|
|
$privateOnly = publicListingFile(['name' => 'Private Only', 'public' => false]);
|
|
|
|
// Assigned only to a private group but itself flagged public: still
|
|
// shows on the front page (the file flag is independent of any group).
|
|
$orphanOfPrivateGroup = publicListingFile(['name' => 'Orphan Of Private']);
|
|
$this->actingAs(User::factory()->create())
|
|
->post("/files/{$orphanOfPrivateGroup->id}/assignments", ['type' => 'group', 'id' => $privateGroup->id]);
|
|
|
|
$inPublicGroup = publicListingFile(['name' => 'In Public Group']);
|
|
$this->actingAs(User::factory()->create())
|
|
->post("/files/{$inPublicGroup->id}/assignments", ['type' => 'group', 'id' => $publicGroup->id]);
|
|
|
|
// actingAs() persists across requests within a test — logout so the
|
|
// listing is viewed as a genuine guest, not still the staff member.
|
|
auth()->logout();
|
|
$response = $this->get('/public');
|
|
|
|
$response->assertInertia(
|
|
fn ($page) => $page
|
|
->component('public/themes/default/index')
|
|
->has('groups', 1)
|
|
->where('groups.0.name', 'Open Group')
|
|
->has('files', 2),
|
|
);
|
|
|
|
$names = collect(publicPageProps($response)['files'])->pluck('name');
|
|
expect($names)->toContain('Standalone')
|
|
->toContain('Orphan Of Private')
|
|
->not->toContain('Private Only')
|
|
->not->toContain('In Public Group');
|
|
|
|
expect((string) $response->getContent())->not->toContain('Closed Group');
|
|
});
|
|
|
|
test('a public group page lists its directly assigned and folder-subtree files, filtered to public ones', function () {
|
|
$group = Group::query()->create(['name' => 'Design Team', 'public' => true]);
|
|
$staff = User::factory()->create();
|
|
|
|
$direct = publicListingFile(['name' => 'Direct']);
|
|
$this->actingAs($staff)->post("/files/{$direct->id}/assignments", ['type' => 'group', 'id' => $group->id]);
|
|
|
|
$notPublic = publicListingFile(['name' => 'Not Public', 'public' => false]);
|
|
$this->actingAs($staff)->post("/files/{$notPublic->id}/assignments", ['type' => 'group', 'id' => $group->id]);
|
|
|
|
$folder = Folder::query()->create(['name' => 'Shared Folder']);
|
|
$this->actingAs($staff)->post("/folders/{$folder->id}/assignments", ['type' => 'group', 'id' => $group->id]);
|
|
$viaFolder = publicListingFile(['name' => 'Via Folder', 'folder_id' => $folder->id]);
|
|
|
|
$elsewhere = publicListingFile(['name' => 'Elsewhere']);
|
|
|
|
// actingAs() persists across requests within a test — logout so the
|
|
// group page is viewed as a genuine guest, not still the staff member.
|
|
auth()->logout();
|
|
$response = $this->get("/public/{$group->slug}");
|
|
|
|
$response->assertInertia(
|
|
fn ($page) => $page->component('public/themes/default/group')->where('group.name', 'Design Team'),
|
|
);
|
|
|
|
$names = collect(publicPageProps($response)['files'])->pluck('name');
|
|
expect($names)->toContain('Direct')
|
|
->toContain('Via Folder')
|
|
->not->toContain('Not Public')
|
|
->not->toContain('Elsewhere');
|
|
});
|
|
|
|
test('selecting the compact theme renders the compact components', function () {
|
|
app(Settings::class)->set(Setting::Theme, 'compact');
|
|
|
|
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
|
|
$file = publicListingFile();
|
|
|
|
$this->get('/public')->assertInertia(fn ($page) => $page->component('public/themes/compact/index'));
|
|
$this->get("/public/{$group->slug}")->assertInertia(fn ($page) => $page->component('public/themes/compact/group'));
|
|
$this->get("/public/files/{$file->slug}")->assertInertia(fn ($page) => $page->component('public/themes/compact/file'));
|
|
});
|
|
|
|
test('selecting the drive theme renders the drive components with a mime_type per file', function () {
|
|
app(Settings::class)->set(Setting::Theme, 'drive');
|
|
|
|
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
|
|
$file = publicListingFile();
|
|
|
|
$indexResponse = $this->get('/public');
|
|
$indexResponse->assertInertia(fn ($page) => $page->component('public/themes/drive/index'));
|
|
expect(collect(publicPageProps($indexResponse)['files'])->firstWhere('name', $file->name)['mime_type'] ?? null)
|
|
->toBe('application/pdf');
|
|
|
|
$this->get("/public/{$group->slug}")->assertInertia(fn ($page) => $page->component('public/themes/drive/group'));
|
|
$this->get("/public/files/{$file->slug}")->assertInertia(fn ($page) => $page->component('public/themes/drive/file'));
|
|
});
|
|
|
|
test('an unknown or unavailable stored theme falls back to default rather than a broken page', function () {
|
|
app(Settings::class)->set(Setting::Theme, 'does-not-exist');
|
|
|
|
$this->get('/public')->assertInertia(fn ($page) => $page->component('public/themes/default/index'));
|
|
});
|
|
|
|
test('selecting the gallery theme renders the gallery components', function () {
|
|
app(Settings::class)->set(Setting::Theme, 'gallery');
|
|
|
|
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
|
|
$file = publicListingFile();
|
|
|
|
$this->get('/public')->assertInertia(fn ($page) => $page->component('public/themes/gallery/index'));
|
|
$this->get("/public/{$group->slug}")->assertInertia(fn ($page) => $page->component('public/themes/gallery/group'));
|
|
$this->get("/public/files/{$file->slug}")->assertInertia(fn ($page) => $page->component('public/themes/gallery/file'));
|
|
});
|
|
|
|
test('a private group 404s even with its correct slug', function () {
|
|
$group = Group::query()->create(['name' => 'Closed Group', 'public' => false]);
|
|
|
|
$this->get("/public/{$group->slug}")->assertNotFound();
|
|
});
|
|
|
|
test('download serves a public file and 404s a non-public one regardless of group state', function () {
|
|
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
|
|
$staff = User::factory()->create();
|
|
|
|
$public = publicListingFile(['name' => 'Downloadable']);
|
|
$this->actingAs($staff)->post("/files/{$public->id}/assignments", ['type' => 'group', 'id' => $group->id]);
|
|
|
|
$notPublic = publicListingFile(['name' => 'Not Downloadable', 'public' => false]);
|
|
$this->actingAs($staff)->post("/files/{$notPublic->id}/assignments", ['type' => 'group', 'id' => $group->id]);
|
|
|
|
// The whole header set, not only the path: local delivery is what this
|
|
// route used to build by hand, and moving it behind StoredFileResponse
|
|
// has to leave a local install's response exactly as it was.
|
|
$this->get("/public/files/{$public->slug}/download")
|
|
->assertOk()
|
|
->assertHeader('X-Accel-Redirect', '/protected-files/'.$public->path)
|
|
->assertHeader('Content-Type', 'application/pdf')
|
|
->assertHeader('Content-Disposition', 'attachment; filename="report.pdf"')
|
|
->assertHeader('Content-Length', (string) $public->size);
|
|
|
|
expect(ActivityLog::query()->where('action', Action::PublicFileDownloaded)->where('subject_name', 'Downloadable')->exists())->toBeTrue();
|
|
|
|
$this->get("/public/files/{$notPublic->slug}/download")->assertNotFound();
|
|
});
|
|
|
|
test('a public download of an externally stored file hands out a presigned url, not an nginx path', function () {
|
|
// The bug this covers: this route answered every download with
|
|
// X-Accel-Redirect regardless of the file's disk, so a public download
|
|
// of an externally stored file pointed nginx at a path nothing ever
|
|
// wrote. Its two neighbours on this same controller, thumbnail() and
|
|
// preview(), were moved onto the shared delivery object; download()
|
|
// was left building the response itself.
|
|
Storage::fake('files_external');
|
|
Storage::disk('files_external')->buildTemporaryUrlsUsing(
|
|
fn (string $path, $expiration, array $options) => 'https://storage.example.test/'.$path.'?disposition='.urlencode($options['ResponseContentDisposition'] ?? '')
|
|
);
|
|
|
|
$file = publicListingFile(['name' => 'Externally Stored', 'disk' => 'files_external']);
|
|
|
|
$response = $this->get(route('public.download', ['public', $file->slug]));
|
|
|
|
$response->assertRedirect();
|
|
$response->assertHeaderMissing('X-Accel-Redirect');
|
|
|
|
$target = $response->headers->get('Location');
|
|
expect($target)->toStartWith('https://storage.example.test/'.$file->path)
|
|
// The filename has to survive into the signed URL, or the download
|
|
// arrives named after the storage key.
|
|
->and(urldecode((string) $target))->toContain('attachment; filename="report.pdf"');
|
|
|
|
// Delivery moved; the checks in front of it did not. The download is
|
|
// still logged, which is also what the download limit counts.
|
|
expect(ActivityLog::query()->where('action', Action::PublicFileDownloaded)->where('subject_name', 'Externally Stored')->exists())->toBeTrue();
|
|
});
|
|
|
|
test('an expired public file 404s on its detail, thumbnail, and download routes, and drops out of the standalone listing', function () {
|
|
$expired = publicListingFile(['name' => 'Expired', 'expires_at' => now()->subDay()]);
|
|
|
|
$this->get(route('public.file', ['public', $expired->slug]))->assertNotFound();
|
|
$this->get(route('public.thumbnail', ['public', $expired->slug]))->assertNotFound();
|
|
$this->get(route('public.download', ['public', $expired->slug]))->assertNotFound();
|
|
|
|
$this->get('/public')->assertInertia(
|
|
fn ($page) => $page->where('files', fn ($files) => collect($files)->pluck('name')->doesntContain('Expired')),
|
|
);
|
|
});
|
|
|
|
test('an expired file also drops out of its public group\'s page', function () {
|
|
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
|
|
$staff = User::factory()->create();
|
|
|
|
$expired = publicListingFile(['name' => 'Expired In Group', 'expires_at' => now()->subDay()]);
|
|
$this->actingAs($staff)->post("/files/{$expired->id}/assignments", ['type' => 'group', 'id' => $group->id]);
|
|
|
|
$this->get("/public/{$group->slug}")->assertInertia(
|
|
fn ($page) => $page->has('files', 0),
|
|
);
|
|
});
|
|
|
|
test('the file listing rows on the directory and group pages link to a details page', function () {
|
|
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
|
|
$staff = User::factory()->create();
|
|
|
|
// Standalone (no group) — appears on the front directory.
|
|
$standalone = publicListingFile(['name' => 'Standalone']);
|
|
|
|
// Assigned to the public group — appears on its page instead.
|
|
$inGroup = publicListingFile(['name' => 'In Group']);
|
|
$this->actingAs($staff)->post("/files/{$inGroup->id}/assignments", ['type' => 'group', 'id' => $group->id]);
|
|
auth()->logout();
|
|
|
|
$indexProps = publicPageProps($this->get('/public'));
|
|
expect(collect($indexProps['files'])->firstWhere('name', 'Standalone')['url'] ?? null)
|
|
->toBe(route('public.file', ['public', $standalone->slug]));
|
|
|
|
$groupProps = publicPageProps($this->get("/public/{$group->slug}"));
|
|
expect(collect($groupProps['files'])->firstWhere('name', 'In Group')['url'] ?? null)
|
|
->toBe(route('public.file', ['public', $inGroup->slug]));
|
|
});
|
|
|
|
test('a public file\'s details page shows a thumbnail url only when the mime type supports it', function () {
|
|
$staff = User::factory()->create();
|
|
$image = publicListingImageFile($staff);
|
|
$pdf = publicListingFile();
|
|
auth()->logout();
|
|
|
|
$imageResponse = $this->get(route('public.file', ['public', $image->slug]));
|
|
$imageResponse->assertInertia(
|
|
fn ($page) => $page->component('public/themes/default/file')
|
|
->where('file.name', $image->name)
|
|
->where('thumbnail_url', route('public.thumbnail', ['public', $image->slug])),
|
|
);
|
|
|
|
$pdfResponse = $this->get(route('public.file', ['public', $pdf->slug]));
|
|
$pdfResponse->assertInertia(fn ($page) => $page->where('thumbnail_url', null));
|
|
});
|
|
|
|
test('the directory and group listings carry a thumbnail_url per file too, not just the details page', function () {
|
|
$staff = User::factory()->create();
|
|
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
|
|
|
|
$image = publicListingImageFile($staff);
|
|
$this->actingAs($staff)->post("/files/{$image->id}/assignments", ['type' => 'group', 'id' => $group->id]);
|
|
|
|
$pdf = publicListingFile();
|
|
auth()->logout();
|
|
|
|
$indexProps = publicPageProps($this->get('/public'));
|
|
$pdfEntry = collect($indexProps['files'])->firstWhere('name', $pdf->name);
|
|
expect($pdfEntry)->not->toBeNull()
|
|
->and($pdfEntry['thumbnail_url'])->toBeNull();
|
|
|
|
$groupProps = publicPageProps($this->get("/public/{$group->slug}"));
|
|
expect(collect($groupProps['files'])->firstWhere('name', $image->name)['thumbnail_url'] ?? null)
|
|
->toBe(route('public.thumbnail', ['public', $image->slug]));
|
|
});
|
|
|
|
test('a non-public file\'s details page 404s', function () {
|
|
$file = publicListingFile(['public' => false]);
|
|
|
|
$this->get(route('public.file', ['public', $file->slug]))->assertNotFound();
|
|
});
|
|
|
|
test('the public thumbnail route generates and serves a thumbnail for a public image, and 404s otherwise', function () {
|
|
$staff = User::factory()->create();
|
|
$image = publicListingImageFile($staff);
|
|
$pdf = publicListingFile();
|
|
$privateImage = publicListingImageFile($staff);
|
|
$privateImage->update(['public' => false]);
|
|
auth()->logout();
|
|
|
|
$this->get(route('public.thumbnail', ['public', $image->slug]))
|
|
->assertOk()
|
|
->assertHeader('Content-Type', 'image/jpeg');
|
|
// The external variant, and only that one: a public visitor is never
|
|
// staff, and caching their thumbnail where a staff request would look
|
|
// for it would hand the staff file manager whatever a public listener
|
|
// drew on it (the cloud-modules watermark, today).
|
|
expect(Storage::disk('files')->exists("thumbnails/external/{$image->id}.jpg"))->toBeTrue()
|
|
->and(Storage::disk('files')->exists("thumbnails/{$image->id}.jpg"))->toBeFalse();
|
|
|
|
$this->get(route('public.thumbnail', ['public', $pdf->slug]))->assertNotFound();
|
|
$this->get(route('public.thumbnail', ['public', $privateImage->slug]))->assertNotFound();
|
|
});
|
|
|
|
test('a public thumbnail renders from external storage rather than a local path that does not exist', function () {
|
|
// The bug this covers: this route read its *source* through
|
|
// Storage::disk('files')->path(), which for an externally stored file
|
|
// is a path nothing ever wrote. The rendition is still cached locally
|
|
// — only the source moves. FileThumbnailController already handled
|
|
// this; the public twin did not.
|
|
Storage::fake('files_external');
|
|
|
|
$staff = User::factory()->create();
|
|
$image = publicListingImageFile($staff);
|
|
|
|
// Restage the bytes where an install with external storage configured
|
|
// would have put them, and remove the local copy so a local path
|
|
// cannot accidentally satisfy the request.
|
|
Storage::disk('files_external')->put($image->path, Storage::disk('files')->get($image->path));
|
|
Storage::disk('files')->delete($image->path);
|
|
$image->update(['disk' => 'files_external']);
|
|
|
|
auth()->logout();
|
|
|
|
$this->get(route('public.thumbnail', ['public', $image->slug]))
|
|
->assertOk()
|
|
->assertHeader('Content-Type', 'image/jpeg');
|
|
|
|
expect(Storage::disk('files')->exists("thumbnails/external/{$image->id}.jpg"))->toBeTrue();
|
|
});
|
|
|
|
test('existing literal routes are unaffected by the new catch-all public routes', function () {
|
|
$this->actingAs(User::factory()->create())->get('/dashboard')->assertOk();
|
|
$this->actingAs(User::factory()->create())->get('/files')->assertOk();
|
|
});
|
|
|
|
test('a public file carries its categories on the directory, a group page, and its own details page', function () {
|
|
// The /categories screen tells admins that everyone who can reach a
|
|
// file sees the labels on it. That is only true if every guest-facing
|
|
// surface actually sends them — this is the test that keeps it true.
|
|
$category = Category::query()->create(['name' => 'Tenders', 'color' => 'blue']);
|
|
$group = Group::query()->create(['name' => 'Open Group', 'public' => true]);
|
|
$staff = User::factory()->create();
|
|
|
|
$standalone = publicListingFile(['name' => 'Standalone']);
|
|
$standalone->categories()->attach($category->id);
|
|
|
|
$inGroup = publicListingFile(['name' => 'In Group']);
|
|
$inGroup->categories()->attach($category->id);
|
|
$this->actingAs($staff)->post("/files/{$inGroup->id}/assignments", ['type' => 'group', 'id' => $group->id]);
|
|
auth()->logout();
|
|
|
|
$expected = [['id' => $category->id, 'name' => 'Tenders', 'color' => 'blue']];
|
|
|
|
$indexProps = publicPageProps($this->get('/public'));
|
|
expect(collect($indexProps['files'])->firstWhere('name', 'Standalone')['categories'] ?? null)->toBe($expected);
|
|
|
|
$groupProps = publicPageProps($this->get("/public/{$group->slug}"));
|
|
expect(collect($groupProps['files'])->firstWhere('name', 'In Group')['categories'] ?? null)->toBe($expected);
|
|
|
|
$this->get(route('public.file', ['public', $standalone->slug]))
|
|
->assertInertia(fn ($page) => $page->where('file.categories', $expected));
|
|
});
|
|
|
|
test('an uncategorised public file sends an empty list, not a missing key', function () {
|
|
$file = publicListingFile(['name' => 'Bare']);
|
|
|
|
$props = publicPageProps($this->get('/public'));
|
|
expect(collect($props['files'])->firstWhere('name', 'Bare')['categories'] ?? null)->toBe([]);
|
|
|
|
$this->get(route('public.file', ['public', $file->slug]))
|
|
->assertInertia(fn ($page) => $page->where('file.categories', []));
|
|
});
|
|
|
|
test('the public listings load categories in one query instead of one per row', function () {
|
|
$category = Category::query()->create(['name' => 'Tenders']);
|
|
foreach (range(1, 5) as $i) {
|
|
publicListingFile(['name' => "File {$i}"])->categories()->attach($category->id);
|
|
}
|
|
|
|
DB::enableQueryLog();
|
|
$this->get('/public')->assertOk();
|
|
$categoryQueries = collect(DB::getQueryLog())
|
|
->filter(fn (array $query): bool => str_contains($query['query'], 'category_file'))
|
|
->count();
|
|
DB::disableQueryLog();
|
|
|
|
expect($categoryQueries)->toBe(1);
|
|
});
|