Files
ignacionelson 57540164fa Read a file from the disk it is actually on, everywhere
Two routes still assumed every file sits on local disk, which stopped
being true the moment external storage was switched on. A share link
answered with X-Accel-Redirect whatever the file's disk said, pointing
nginx at a path it has nothing behind; a public listing built a
thumbnail from Storage::disk('files')->path(), which for an externally
stored file is a path nobody ever wrote. Both fail only for installs
using S3, and only on those two routes, so the same file downloading
correctly from the file manager made the share link look like the
broken thing rather than where the file lives.

Neither is a new rule. FileDownloadController and
FileThumbnailController already did it right, which is the actual
finding: 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.
Both are now objects with one job.

StoredFileResponse replaces InlineFileResponse and grows an
attachment() alongside inline(), since the two differ only by
disposition. LocalSourceFile takes a closure rather than returning a
path: the version that returned one also left the caller to unlink it,
and both of those are exactly the mistakes made here.

The regression tests fail against the previous controllers — checked in
both directions rather than assumed.
2026-08-24 16:24:39 -03:00

294 lines
12 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\ShareLink;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Models\RolePermission;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
$this->admin = User::factory()->create();
});
function shareTestFile(User $uploader): File
{
return File::factory()->create([
'uploaded_by' => $uploader->id,
'name' => 'Report',
'original_name' => 'report.pdf',
'path' => '2026/07/'.Str::uuid()->toString().'.pdf',
'mime_type' => 'application/pdf',
'size' => 1024,
]);
}
/** A staff user whose role has exactly the given permission keys. */
function shareStaffWith(array $permissions): User
{
$role = Role::query()->create(['name' => 'Role '.Str::random(6)]);
foreach ($permissions as $permission) {
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission]);
}
return User::factory()->create(['role_id' => $role->id]);
}
test('creating a share link requires update permission on the file', function () {
$file = shareTestFile($this->admin);
$noAccess = shareStaffWith(['upload']);
$this->actingAs($noAccess)->post("/files/{$file->id}/share-links")->assertForbidden();
$this->actingAs($this->admin)->post("/files/{$file->id}/share-links")->assertRedirect();
$link = ShareLink::query()->sole();
expect($link->shareable_id)->toBe($file->id)
->and(strlen($link->token))->toBe(32)
->and(ActivityLog::query()->where('action', Action::ShareLinkCreated)->exists())->toBeTrue();
});
test('a custom token is used verbatim instead of a random one', function () {
$file = shareTestFile($this->admin);
$this->actingAs($this->admin)->post("/files/{$file->id}/share-links", ['token' => 'my-custom-link'])->assertRedirect();
$link = ShareLink::query()->sole();
expect($link->token)->toBe('my-custom-link');
});
test('a custom token must be unique', function () {
$file = shareTestFile($this->admin);
ShareLink::query()->create(['shareable_type' => $file->getMorphClass(), 'shareable_id' => $file->id, 'token' => 'taken-token']);
$this->actingAs($this->admin)->post("/files/{$file->id}/share-links", ['token' => 'taken-token'])
->assertSessionHasErrors('token');
});
test('a custom token cannot match the file\'s own public slug', function () {
$file = shareTestFile($this->admin);
$file->update(['slug' => 'report-slug']);
$this->actingAs($this->admin)->post("/files/{$file->id}/share-links", ['token' => 'report-slug'])
->assertSessionHasErrors('token');
expect(ShareLink::query()->count())->toBe(0);
});
test('a custom token must match the expected format', function () {
$file = shareTestFile($this->admin);
$this->actingAs($this->admin)->post("/files/{$file->id}/share-links", ['token' => 'has spaces!'])
->assertSessionHasErrors('token');
$this->actingAs($this->admin)->post("/files/{$file->id}/share-links", ['token' => 'ab'])
->assertSessionHasErrors('token');
});
test('expiry and download-limit fields are dropped without their permission, honored with it', function () {
$file = shareTestFile($this->admin);
$futureDate = now()->addDays(3)->toDateString();
// edit_others_files alone (the file belongs to $this->admin): neither
// field is honored, even if submitted.
$editorOnly = shareStaffWith(['upload', 'edit_others_files']);
$this->actingAs($editorOnly)->post("/files/{$file->id}/share-links", [
'expires_at' => $futureDate,
'max_downloads' => 5,
])->assertRedirect();
$plain = ShareLink::query()->sole();
expect($plain->expires_at)->toBeNull()->and($plain->max_downloads)->toBeNull();
$plain->delete();
// With both permissions, both fields are honored.
$privileged = shareStaffWith(['upload', 'edit_others_files', 'set_file_expiration_date', 'limit_downloads']);
$this->actingAs($privileged)->post("/files/{$file->id}/share-links", [
'expires_at' => $futureDate,
'max_downloads' => 5,
])->assertRedirect();
$full = ShareLink::query()->sole();
expect($full->expires_at?->toDateString())->toBe($futureDate)
->and($full->max_downloads)->toBe(5);
});
test('the read-only details panel lists share links but no way to create or revoke one', function () {
$file = shareTestFile($this->admin);
ShareLink::query()->create(['shareable_type' => $file->getMorphClass(), 'shareable_id' => $file->id, 'token' => Str::random(32)]);
$this->actingAs($this->admin)->getJson("/files/{$file->id}/details")->assertOk()->assertJson(fn ($json) => $json
->has('share_links', 1)
->missing('share_link_store_url')
->missing('can_set_expiration')
->missing('can_limit_downloads')
->missing('assign_url')
->missing('unassign_url')
->etc());
});
test('the edit page carries the share-link create form and capability flags', function () {
$file = shareTestFile($this->admin);
ShareLink::query()->create(['shareable_type' => $file->getMorphClass(), 'shareable_id' => $file->id, 'token' => Str::random(32)]);
$this->actingAs($this->admin)->get("/files/{$file->id}")->assertInertia(fn (AssertableInertia $page) => $page
->component('files/edit')
->has('share_links', 1)
->where('can_set_expiration', true)
->where('can_limit_downloads', true));
});
test('the public show and download routes work with no authenticated user at all', function () {
$file = shareTestFile($this->admin);
$link = ShareLink::query()->create([
'shareable_type' => $file->getMorphClass(),
'shareable_id' => $file->id,
'token' => Str::random(32),
]);
// Critical: a guest must never be redirected to login.
$this->get("/s/{$link->token}")->assertOk()->assertInertia(fn (AssertableInertia $page) => $page
->component('share/show')
->where('status', 'active')
->where('file.original_name', 'report.pdf'));
$response = $this->get("/s/{$link->token}/download");
$response->assertOk()->assertHeader('X-Accel-Redirect', '/protected-files/'.$file->path);
$entry = ActivityLog::query()->where('action', Action::ShareLinkDownloaded)->sole();
expect($link->refresh()->downloads_count)->toBe(1)
->and($entry->actor_id)->toBeNull()
->and($entry->actor_name)->toBeNull();
});
test('a share link to an externally stored file hands out a presigned url, not an nginx path', function () {
// The bug this covers: this route used to answer every download with
// X-Accel-Redirect regardless of the file's disk, so a share link to a
// file on external storage pointed nginx at a path that does not exist
// on its filesystem. Every other download path already got this right.
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 = shareTestFile($this->admin);
$file->update(['disk' => 'files_external']);
$link = ShareLink::query()->create([
'shareable_type' => $file->getMorphClass(),
'shareable_id' => $file->id,
'token' => Str::random(32),
]);
$response = $this->get("/s/{$link->token}/download");
$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"');
// The link's counter still moves for an external file.
expect($link->refresh()->downloads_count)->toBe(1);
});
test('an unknown token shows a not-found state instead of a 404', function () {
$this->get('/s/does-not-exist')->assertOk()->assertInertia(fn (AssertableInertia $page) => $page->where('status', 'not_found'));
$this->get('/s/does-not-exist/download')->assertRedirect(route('share.show', 'does-not-exist'));
});
test('an expired link refuses the download and shows an expired state', function () {
$file = shareTestFile($this->admin);
$link = ShareLink::query()->create([
'shareable_type' => $file->getMorphClass(),
'shareable_id' => $file->id,
'token' => Str::random(32),
'expires_at' => now()->subDay(),
]);
$this->get("/s/{$link->token}")->assertInertia(fn (AssertableInertia $page) => $page->where('status', 'expired'));
$this->get("/s/{$link->token}/download")->assertRedirect();
expect($link->refresh()->downloads_count)->toBe(0);
});
test('a link stops working the instant it hits its download limit', function () {
$file = shareTestFile($this->admin);
$link = ShareLink::query()->create([
'shareable_type' => $file->getMorphClass(),
'shareable_id' => $file->id,
'token' => Str::random(32),
'max_downloads' => 2,
]);
$this->get("/s/{$link->token}/download")->assertOk();
$this->get("/s/{$link->token}/download")->assertOk();
expect($link->refresh()->downloads_count)->toBe(2);
$this->get("/s/{$link->token}")->assertInertia(fn (AssertableInertia $page) => $page->where('status', 'limit_reached'));
$this->get("/s/{$link->token}/download")->assertRedirect();
expect($link->refresh()->downloads_count)->toBe(2);
});
test('revoking a share link deletes it and the token stops working', function () {
$file = shareTestFile($this->admin);
$link = ShareLink::query()->create([
'shareable_type' => $file->getMorphClass(),
'shareable_id' => $file->id,
'token' => Str::random(32),
]);
$noAccess = shareStaffWith(['upload']);
$this->actingAs($noAccess)->delete("/share-links/{$link->id}")->assertForbidden();
$this->actingAs($this->admin)->delete("/share-links/{$link->id}")->assertRedirect();
expect(ShareLink::query()->count())->toBe(0)
->and(ActivityLog::query()->where('action', Action::ShareLinkRevoked)->exists())->toBeTrue();
$this->get("/s/{$link->token}")->assertInertia(fn (AssertableInertia $page) => $page->where('status', 'not_found'));
});
// expires_at is how a file's access gets revoked everywhere else (clients,
// public listing), so a link that outlived it would be a way around that.
test('a share link stops working once the file itself expires, even if the link has not', function () {
$file = shareTestFile($this->admin);
$link = ShareLink::query()->create([
'shareable_type' => $file->getMorphClass(),
'shareable_id' => $file->id,
'token' => Str::random(32),
'expires_at' => now()->addYear(),
]);
$this->get("/s/{$link->token}")->assertInertia(fn (AssertableInertia $page) => $page->where('status', 'active'));
$file->update(['expires_at' => now()->subDay()]);
$this->get("/s/{$link->token}")->assertInertia(fn (AssertableInertia $page) => $page->where('status', 'expired'));
$this->get("/s/{$link->token}/download")->assertRedirect(route('share.show', $link->token));
// A refused download must not burn a slot off the limit either.
expect($link->refresh()->downloads_count)->toBe(0);
});
test('the share page shows the file\'s categories, the same as every other surface a visitor can reach', function () {
$category = Category::query()->create(['name' => 'Tenders', 'color' => 'blue']);
$file = shareTestFile($this->admin);
$file->categories()->attach($category->id);
$link = ShareLink::query()->create([
'shareable_type' => $file->getMorphClass(),
'shareable_id' => $file->id,
'token' => Str::random(32),
]);
$this->get("/s/{$link->token}")->assertInertia(fn (AssertableInertia $page) => $page
->where('file.categories', [['id' => $category->id, 'name' => 'Tenders', 'color' => 'blue']]));
});