mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
acda732ff4
A download limit is checked when an archive is ordered and again while it is built, but it is only spent when the archive is collected. Nothing about ordering or building moves the count, so every check along the way sees an allowance that is still untouched. That turns a prepared archive into a voucher. Order the same limited file into ten archives and all ten pass, because at the point each one is checked nothing has been taken yet. Collect them all and the file has been downloaded ten times against a limit of one. The three endpoints are independent of the interface that normally drives them, so this needs nothing more than calling store() in a loop — and no timing luck at all, since the archives can be collected minutes apart. DownloadAllowance says of itself that six routes put a file's bytes on the wire and that every one of them asks, precisely because there is no choke point to put the rule in. The zip pair asked in the two places that do not count and not in the one that does. So the delivery re-checks what the archive holds, where the count actually moves. Refusing is 403, matching the single-file download route for the same situation. It is also the only one of the two candidates that reaches the person: an archive is fetched by navigating to it, and there is no error view for 422, so the message would be replaced by the framework's generic "something is broken" page. One refused file refuses the whole delivery, because nothing can be taken out of a finished archive without building it again. Ordering the same selection afresh is the way through — the build leaves the spent file out and names it in skipped_files, which the poll already reports. This is stricter than store(), which drops spent files from a selection and refuses only when nothing survives: there, a selection can still be narrowed, and here it cannot. Checking costs nothing where nothing is limited. An unlimited file is answered from its own column and never reaches a count. Claiming the delivery is a conditional update now rather than a read followed by a write. Two fetches of one archive arriving together both saw delivered_at unset and both wrote a full set of downloads, counting a single delivery twice — the same shape as the conditional increment that guards a share link's max_downloads. Only the fetch that moves the column logs anything; the other still receives the archive, which is the existing rule that re-fetching one prepared zip is one delivery. Two things this deliberately leaves alone. Simultaneous downloads of one file can still both pass before either is logged: that race is documented in DownloadAllowance, and closing it needs the counter column it explains why it does not have. And an archive already delivered stays fetchable for its 24 hours even once the limit is spent — one delivery, re-fetched, which is what that rule is for. An archive built before the job recorded its contents is handed over the way it always was, without this check. What it holds can only be guessed at by resolving the selection a second time, and guessing is exactly what must not decide a refusal: the same reconstruction refuses over files the archive does not hold and misses files it does. Those rows stop existing within a day or two of an upgrade, and until then they behave as they did before this change rather than worse.
382 lines
16 KiB
PHP
382 lines
16 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 App\Modules\Files\Models\ShareLink;
|
|
use App\Modules\Files\Models\ZipDownload;
|
|
use App\Modules\Identity\UserType;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* Six routes put a file's bytes on the wire and they authorize four
|
|
* different ways — the whole reason DownloadAllowance is an object every
|
|
* one of them consults rather than a rule in the policy. There is a test
|
|
* here per route, because the failure mode this guards against is one of
|
|
* them quietly not asking.
|
|
*/
|
|
beforeEach(function () {
|
|
Storage::fake('files');
|
|
|
|
$this->uploader = User::factory()->create();
|
|
$this->client = User::factory()->create(['type' => UserType::Client]);
|
|
});
|
|
|
|
function limitedFile(array $overrides = []): File
|
|
{
|
|
$file = File::factory()->create(array_merge([
|
|
'uploaded_by' => test()->uploader->id,
|
|
'name' => 'Report',
|
|
'original_name' => 'report.pdf',
|
|
'path' => '2026/08/'.Str::uuid()->toString().'.pdf',
|
|
'mime_type' => 'application/pdf',
|
|
'size' => 12,
|
|
'download_limit' => 1,
|
|
], $overrides));
|
|
|
|
Storage::disk('files')->put($file->path, 'pdf bytes...');
|
|
|
|
return $file;
|
|
}
|
|
|
|
/** Spend one download of $file as $actor, without going through a route. */
|
|
function spend(File $file, ?User $actor, Action $action = Action::FileDownloaded): void
|
|
{
|
|
ActivityLog::query()->create([
|
|
'actor_id' => $actor?->id,
|
|
'actor_name' => $actor?->name,
|
|
'actor_type' => $actor?->type->value,
|
|
'action' => $action,
|
|
'subject_type' => $file->getMorphClass(),
|
|
'subject_id' => $file->id,
|
|
'created_at' => now(),
|
|
]);
|
|
}
|
|
|
|
test('a direct download is refused once the limit is spent', function () {
|
|
$file = limitedFile();
|
|
shareFileWith($file, $this->client);
|
|
|
|
$this->actingAs($this->client)->get("/files/{$file->id}/download")->assertOk();
|
|
$this->actingAs($this->client)->get("/files/{$file->id}/download")->assertForbidden();
|
|
});
|
|
|
|
test('the file stays visible to the client whose limit is spent', function () {
|
|
// The difference from expiry, and the reason the rule is not in a
|
|
// visibility scope: they can still see it, they just cannot take it.
|
|
$file = limitedFile();
|
|
shareFileWith($file, $this->client);
|
|
spend($file, $this->client);
|
|
|
|
$this->actingAs($this->client)->get("/files/{$file->id}/download")->assertForbidden();
|
|
|
|
expect(File::query()->whereKey($file->id)->visibleToClient($this->client)->exists())->toBeTrue();
|
|
});
|
|
|
|
test('the uploader is never refused their own file', function () {
|
|
$file = limitedFile();
|
|
spend($file, $this->client);
|
|
|
|
$this->actingAs($this->uploader)->get("/files/{$file->id}/download")->assertOk();
|
|
});
|
|
|
|
test('the API download honours the limit, because it is the same controller', function () {
|
|
// One token per test on purpose: within a single test the auth guard
|
|
// resolves its user once and keeps it, so swapping the Authorization
|
|
// header mid-test does not swap who the request is from — every
|
|
// later assertion would silently be about the first token's user.
|
|
$other = User::factory()->create();
|
|
$token = $other->createToken('t', ['upload', 'edit_files', 'edit_others_files'])->plainTextToken;
|
|
|
|
$file = limitedFile(['uploaded_by' => $this->uploader->id]);
|
|
|
|
// The one download this file allows…
|
|
$this->withToken($token)->get("/api/v1/files/{$file->id}/download")->assertOk();
|
|
|
|
// …and it is spent, over the API exactly as over the web route.
|
|
$this->withToken($token)->getJson("/api/v1/files/{$file->id}/download")->assertForbidden();
|
|
});
|
|
|
|
test('the API keeps the uploader exempt from their own file\'s limit', function () {
|
|
$uploader = User::factory()->create();
|
|
$token = $uploader->createToken('t', ['upload', 'edit_files', 'edit_others_files'])->plainTextToken;
|
|
|
|
$file = limitedFile(['uploaded_by' => $uploader->id]);
|
|
|
|
// Three times over a limit of one, and every one of them logged.
|
|
$this->withToken($token)->get("/api/v1/files/{$file->id}/download")->assertOk();
|
|
$this->withToken($token)->get("/api/v1/files/{$file->id}/download")->assertOk();
|
|
$this->withToken($token)->get("/api/v1/files/{$file->id}/download")->assertOk();
|
|
|
|
expect($file->downloads()->count())->toBe(3);
|
|
});
|
|
|
|
test('preview is refused once the limit is spent, but never spends it', function () {
|
|
// preview() serves the original bytes for images unless something
|
|
// asks for a rendering, so an unguarded preview would be a way
|
|
// around every limit on the install.
|
|
$file = limitedFile(['mime_type' => 'image/jpeg', 'original_name' => 'photo.jpg', 'download_limit' => 2]);
|
|
shareFileWith($file, $this->client);
|
|
|
|
$this->actingAs($this->client)->get("/files/{$file->id}/preview")->assertOk();
|
|
$this->actingAs($this->client)->get("/files/{$file->id}/preview")->assertOk();
|
|
|
|
// Two previews, and the allowance is untouched.
|
|
$this->actingAs($this->client)->get("/files/{$file->id}/download")->assertOk();
|
|
$this->actingAs($this->client)->get("/files/{$file->id}/download")->assertOk();
|
|
|
|
// Now it is spent — and the preview closes with it.
|
|
$this->actingAs($this->client)->get("/files/{$file->id}/preview")->assertForbidden();
|
|
});
|
|
|
|
test('a share link refuses a file whose own limit is spent, without spending the link', function () {
|
|
$file = limitedFile();
|
|
$link = ShareLink::query()->create([
|
|
'shareable_type' => $file->getMorphClass(),
|
|
'shareable_id' => $file->id,
|
|
'token' => Str::random(40),
|
|
'created_by' => $this->uploader->id,
|
|
]);
|
|
|
|
spend($file, null, Action::PublicFileDownloaded);
|
|
|
|
$this->get("/s/{$link->token}/download")->assertRedirect(route('share.show', $link->token));
|
|
|
|
// The link's own counter must not move for a download it never made.
|
|
expect($link->fresh()->downloads_count)->toBe(0);
|
|
});
|
|
|
|
test('the share page says the limit was reached when it is the file that is spent', function () {
|
|
$file = limitedFile();
|
|
$link = ShareLink::query()->create([
|
|
'shareable_type' => $file->getMorphClass(),
|
|
'shareable_id' => $file->id,
|
|
'token' => Str::random(40),
|
|
'created_by' => $this->uploader->id,
|
|
]);
|
|
|
|
spend($file, null, Action::ShareLinkDownloaded);
|
|
|
|
$this->get("/s/{$link->token}")
|
|
->assertOk()
|
|
->assertInertia(fn ($page) => $page->component('share/show')->where('status', 'limit_reached'));
|
|
});
|
|
|
|
test('an anonymous public download is refused once the file is spent', function () {
|
|
app(Settings::class)->set(Setting::PublicListingEnabled, true);
|
|
app(Settings::class)->set(Setting::PublicListingSlug, 'public');
|
|
|
|
$file = limitedFile(['public' => true]);
|
|
spend($file, null, Action::PublicFileDownloaded);
|
|
|
|
// 403, not 404: the file is genuinely there and genuinely public.
|
|
$this->get("/public/files/{$file->slug}/download")->assertForbidden();
|
|
});
|
|
|
|
test('a zip leaves out files whose limit is spent and says which', function () {
|
|
$folder = makeFolder('Shared');
|
|
|
|
$available = limitedFile(['name' => 'Available', 'original_name' => 'available.pdf', 'download_limit' => null]);
|
|
$spent = limitedFile(['name' => 'Spent', 'original_name' => 'spent.pdf']);
|
|
|
|
$available->update(['folder_id' => $folder->id]);
|
|
$spent->update(['folder_id' => $folder->id]);
|
|
spend($spent, $this->uploader);
|
|
|
|
// A different staff member, so the uploader exemption is not what is
|
|
// being measured.
|
|
$staff = staffWithPermissions(['upload', 'edit_files', 'edit_others_files']);
|
|
|
|
$response = $this->actingAs($staff)
|
|
->postJson('/zip-downloads', ['folder_ids' => [$folder->id]])
|
|
->assertOk();
|
|
|
|
$zip = ZipDownload::query()->findOrFail($response->json('id'));
|
|
|
|
expect($zip->file_count)->toBe(1)
|
|
->and($zip->skipped_files)->toHaveCount(1)
|
|
->and($zip->skipped_files[0]['name'])->toBe('Spent');
|
|
|
|
$this->actingAs($staff)->getJson("/zip-downloads/{$zip->id}")
|
|
->assertOk()
|
|
->assertJsonPath('skipped_files.0.name', 'Spent');
|
|
});
|
|
|
|
test('refusing every selected file says so rather than claiming they are missing', function () {
|
|
$file = limitedFile();
|
|
shareFileWith($file, $this->client);
|
|
spend($file, $this->client);
|
|
|
|
$this->actingAs($this->client)
|
|
->postJson('/zip-downloads', ['file_ids' => [$file->id]])
|
|
->assertStatus(422)
|
|
->assertSee('reached their download limit', false);
|
|
});
|
|
|
|
test('re-fetching one prepared zip does not count as downloading everything again', function () {
|
|
$file = limitedFile(['download_limit' => null]);
|
|
shareFileWith($file, $this->client);
|
|
|
|
$response = $this->actingAs($this->client)
|
|
->postJson('/zip-downloads', ['file_ids' => [$file->id]])
|
|
->assertOk();
|
|
|
|
$zip = ZipDownload::query()->findOrFail($response->json('id'));
|
|
|
|
$this->actingAs($this->client)->get("/zip-downloads/{$zip->id}/download")->assertOk();
|
|
$this->actingAs($this->client)->get("/zip-downloads/{$zip->id}/download")->assertOk();
|
|
|
|
// One delivery, one download — not two.
|
|
expect($file->downloads()->count())->toBe(1)
|
|
->and($zip->fresh()->delivered_at)->not->toBeNull();
|
|
});
|
|
|
|
test('a prepared zip cannot be collected once its files have been spent elsewhere', function () {
|
|
$file = limitedFile();
|
|
shareFileWith($file, $this->client);
|
|
|
|
// Two archives of the same file, both ordered and both built before
|
|
// either is collected. Nothing is spent yet, so every check made
|
|
// while ordering and building passes for both of them.
|
|
$first = $this->actingAs($this->client)
|
|
->postJson('/zip-downloads', ['file_ids' => [$file->id]])->assertOk();
|
|
$second = $this->actingAs($this->client)
|
|
->postJson('/zip-downloads', ['file_ids' => [$file->id]])->assertOk();
|
|
|
|
$firstZip = ZipDownload::query()->findOrFail($first->json('id'));
|
|
$secondZip = ZipDownload::query()->findOrFail($second->json('id'));
|
|
|
|
expect($firstZip->file_count)->toBe(1)
|
|
->and($secondZip->file_count)->toBe(1);
|
|
|
|
$this->actingAs($this->client)->get("/zip-downloads/{$firstZip->id}/download")->assertOk();
|
|
|
|
// The single allowed download is now spent. The second archive was
|
|
// built while it still was not, and holding it must not be a way to
|
|
// take a copy that is no longer allowed.
|
|
$this->actingAs($this->client)->get("/zip-downloads/{$secondZip->id}/download")->assertForbidden();
|
|
|
|
expect($file->downloads()->count())->toBe(1)
|
|
->and($secondZip->fresh()->delivered_at)->toBeNull();
|
|
});
|
|
|
|
test('one spent file refuses the whole archive rather than part of it', function () {
|
|
$folder = makeFolder('Shared');
|
|
|
|
$spendable = limitedFile(['name' => 'Spendable', 'original_name' => 'spendable.pdf']);
|
|
$uncapped = limitedFile(['name' => 'Uncapped', 'original_name' => 'uncapped.pdf', 'download_limit' => null]);
|
|
|
|
$spendable->update(['folder_id' => $folder->id]);
|
|
$uncapped->update(['folder_id' => $folder->id]);
|
|
|
|
$staff = staffWithPermissions(['upload', 'edit_files', 'edit_others_files']);
|
|
|
|
$response = $this->actingAs($staff)
|
|
->postJson('/zip-downloads', ['folder_ids' => [$folder->id]])->assertOk();
|
|
|
|
$zip = ZipDownload::query()->findOrFail($response->json('id'));
|
|
expect($zip->file_count)->toBe(2);
|
|
|
|
// Spent after the archive was built, so the copy inside it is one the
|
|
// limit no longer covers.
|
|
spend($spendable, $staff);
|
|
|
|
$this->actingAs($staff)->get("/zip-downloads/{$zip->id}/download")->assertForbidden();
|
|
|
|
// Refused as a whole: the file that was still free is not counted as
|
|
// downloaded either, since nothing was handed over.
|
|
expect($uncapped->downloads()->count())->toBe(0);
|
|
});
|
|
|
|
test('a spent file that is not in the archive does not refuse it', function () {
|
|
$folder = makeFolder('Shared');
|
|
|
|
$bundled = limitedFile(['name' => 'Bundled', 'original_name' => 'bundled.pdf', 'download_limit' => null]);
|
|
$bundled->update(['folder_id' => $folder->id]);
|
|
|
|
$staff = staffWithPermissions(['upload', 'edit_files', 'edit_others_files']);
|
|
|
|
$response = $this->actingAs($staff)
|
|
->postJson('/zip-downloads', ['folder_ids' => [$folder->id]])->assertOk();
|
|
|
|
$zip = ZipDownload::query()->findOrFail($response->json('id'));
|
|
|
|
// Lands in the folder after the archive was written, and is already
|
|
// spent. The selection would resolve to it now; the archive does not
|
|
// hold it, so it has no say over handing that archive over.
|
|
$late = limitedFile(['name' => 'Late', 'original_name' => 'late.pdf']);
|
|
$late->update(['folder_id' => $folder->id]);
|
|
spend($late, $staff);
|
|
|
|
$this->actingAs($staff)->get("/zip-downloads/{$zip->id}/download")->assertOk();
|
|
|
|
// Still only the download that spent it — the delivery neither
|
|
// refused over it nor counted it.
|
|
expect($bundled->downloads()->count())->toBe(1)
|
|
->and($late->downloads()->count())->toBe(1);
|
|
});
|
|
|
|
test('an archive from before its contents were recorded is handed over as it always was', function () {
|
|
$folder = makeFolder('Shared');
|
|
|
|
$bundled = limitedFile(['name' => 'Bundled', 'original_name' => 'bundled.pdf', 'download_limit' => null]);
|
|
$bundled->update(['folder_id' => $folder->id]);
|
|
|
|
$staff = staffWithPermissions(['upload', 'edit_files', 'edit_others_files']);
|
|
|
|
$response = $this->actingAs($staff)
|
|
->postJson('/zip-downloads', ['folder_ids' => [$folder->id]])->assertOk();
|
|
|
|
$zip = ZipDownload::query()->findOrFail($response->json('id'));
|
|
|
|
// Stands in for a row written by an older release, which has no
|
|
// record of what went into the archive.
|
|
ZipDownload::query()->whereKey($zip->id)->update(['contained_file_ids' => null]);
|
|
|
|
// A spent file joins the folder afterwards. Resolving the selection
|
|
// again — all such a row can do — would sweep it up and refuse over a
|
|
// file the archive does not hold, so this path refuses nothing.
|
|
$late = limitedFile(['name' => 'Late', 'original_name' => 'late.pdf']);
|
|
$late->update(['folder_id' => $folder->id]);
|
|
spend($late, $staff);
|
|
|
|
$this->actingAs($staff)->get("/zip-downloads/{$zip->id}/download")->assertOk();
|
|
|
|
expect($bundled->downloads()->count())->toBe(1)
|
|
->and($zip->fresh()->delivered_at)->not->toBeNull();
|
|
});
|
|
|
|
test('two fetches of one archive arriving together still count as one delivery', function () {
|
|
$file = limitedFile(['download_limit' => null]);
|
|
shareFileWith($file, $this->client);
|
|
|
|
$response = $this->actingAs($this->client)
|
|
->postJson('/zip-downloads', ['file_ids' => [$file->id]])->assertOk();
|
|
|
|
$zip = ZipDownload::query()->findOrFail($response->json('id'));
|
|
|
|
// Stands in for a second fetch of the same archive that arrives at
|
|
// the same moment and claims the delivery first. The request below
|
|
// has already read the row by then, so its own copy still says the
|
|
// archive has never been handed over — the check-then-set this
|
|
// replaced would believe it and count everything a second time.
|
|
ZipDownload::retrieved(function (ZipDownload $retrieved): void {
|
|
ZipDownload::query()
|
|
->whereKey($retrieved->id)
|
|
->whereNull('delivered_at')
|
|
->update(['delivered_at' => now()]);
|
|
});
|
|
|
|
$this->actingAs($this->client)->get("/zip-downloads/{$zip->id}/download")->assertOk();
|
|
|
|
// Losing the claim means not logging: the fetch that won it is the
|
|
// one that counts, and here that is the stand-in, which logs nothing.
|
|
expect($file->downloads()->count())->toBe(0);
|
|
});
|