mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 00:55:07 +00:00
6e47d76ba6
Client file sharing, rebuilt from the ground up: a private area per client, resumable uploads, folders, groups and categories, sharing with expiry dates and download limits, comments, file versions, an activity log, a REST API, and sixteen languages. This repository begins here. ProjectSend 2 was developed privately, and that development history is not published — the previous generation remains available, with its own history, at projectsend/legacy. Free software under the GNU General Public License v2, or (at your option) any later version.
266 lines
11 KiB
PHP
266 lines
11 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\Folder;
|
|
use App\Modules\Files\Models\ZipDownload;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
// QUEUE_CONNECTION=sync in phpunit.xml — BuildZipDownloadJob runs
|
|
// synchronously within the same request, so the zip is already built by
|
|
// the time a `postJson('/zip-downloads', ...)` call returns.
|
|
|
|
beforeEach(function () {
|
|
Storage::fake('files');
|
|
$this->admin = User::factory()->create();
|
|
});
|
|
|
|
function zipUploadFile(User $as, string $name, ?int $folderId = null): File
|
|
{
|
|
test()->actingAs($as)->post('/files', [
|
|
'file' => UploadedFile::fake()->create($name, 4, 'application/pdf'),
|
|
'name' => '',
|
|
'description' => '',
|
|
'folder_id' => $folderId,
|
|
]);
|
|
|
|
return File::query()->latest('id')->firstOrFail();
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
function zipEntryNames(ZipDownload $zipDownload): array
|
|
{
|
|
$zip = new ZipArchive;
|
|
$zip->open(Storage::disk('files')->path($zipDownload->refresh()->path));
|
|
|
|
$names = [];
|
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
|
$names[] = $zip->getNameIndex($i);
|
|
}
|
|
$zip->close();
|
|
|
|
return $names;
|
|
}
|
|
|
|
test('zipping a folder builds an archive with the hierarchy preserved', function () {
|
|
$parent = Folder::query()->create(['name' => 'Reports']);
|
|
$child = Folder::query()->create(['name' => 'Q1', 'parent_id' => $parent->id, 'path' => "/{$parent->id}/"]);
|
|
|
|
zipUploadFile($this->admin, 'summary.pdf', $parent->id);
|
|
zipUploadFile($this->admin, 'detail.pdf', $child->id);
|
|
|
|
$response = $this->actingAs($this->admin)->postJson('/zip-downloads', ['folder_ids' => [$parent->id]])->assertOk();
|
|
|
|
$zipDownload = ZipDownload::query()->findOrFail($response->json('id'));
|
|
expect($zipDownload->status)->toBe(ZipDownload::STATUS_READY)
|
|
->and($zipDownload->file_count)->toBe(2);
|
|
|
|
expect(zipEntryNames($zipDownload))->toContain('Reports/summary.pdf', 'Reports/Q1/detail.pdf');
|
|
});
|
|
|
|
test('filename collisions get a numbered suffix', function () {
|
|
$fileA = zipUploadFile($this->admin, 'notes.pdf');
|
|
$fileB = zipUploadFile($this->admin, 'notes.pdf');
|
|
|
|
$response = $this->actingAs($this->admin)->postJson('/zip-downloads', [
|
|
'file_ids' => [$fileA->id, $fileB->id],
|
|
])->assertOk();
|
|
|
|
$zipDownload = ZipDownload::query()->findOrFail($response->json('id'));
|
|
|
|
expect(zipEntryNames($zipDownload))->toContain('notes.pdf', 'notes (2).pdf');
|
|
});
|
|
|
|
test('a mixed loose-file and folder selection places files at the root and folders as subfolders', function () {
|
|
$folder = Folder::query()->create(['name' => 'Reports']);
|
|
$loose = zipUploadFile($this->admin, 'loose.pdf');
|
|
zipUploadFile($this->admin, 'inside.pdf', $folder->id);
|
|
|
|
$response = $this->actingAs($this->admin)->postJson('/zip-downloads', [
|
|
'file_ids' => [$loose->id],
|
|
'folder_ids' => [$folder->id],
|
|
])->assertOk();
|
|
|
|
$zipDownload = ZipDownload::query()->findOrFail($response->json('id'));
|
|
|
|
expect(zipEntryNames($zipDownload))->toContain('loose.pdf', 'Reports/inside.pdf');
|
|
});
|
|
|
|
test('inaccessible files are silently dropped, rejecting the request if nothing remains', function () {
|
|
$client = User::factory()->client()->create();
|
|
$secretFile = zipUploadFile($this->admin, 'secret.pdf');
|
|
|
|
$this->actingAs($client)->postJson('/zip-downloads', ['file_ids' => [$secretFile->id]])->assertStatus(422);
|
|
});
|
|
|
|
test('a client can zip a file assigned to them', function () {
|
|
$client = User::factory()->client()->create();
|
|
$file = zipUploadFile($this->admin, 'shared.pdf');
|
|
$this->actingAs($this->admin)->post("/files/{$file->id}/assignments", ['type' => 'client', 'id' => $client->id]);
|
|
|
|
$response = $this->actingAs($client)->postJson('/zip-downloads', ['file_ids' => [$file->id]])->assertOk();
|
|
|
|
$zipDownload = ZipDownload::query()->findOrFail($response->json('id'));
|
|
expect($zipDownload->file_ids)->toBe([$file->id]);
|
|
});
|
|
|
|
test('a client cannot zip a folder that is not shared with them', function () {
|
|
$client = User::factory()->client()->create();
|
|
$folder = Folder::query()->create(['name' => 'Private']);
|
|
zipUploadFile($this->admin, 'a.pdf', $folder->id);
|
|
|
|
$this->actingAs($client)->postJson('/zip-downloads', ['folder_ids' => [$folder->id]])->assertStatus(422);
|
|
});
|
|
|
|
// Holding a folder never implied being able to read everything inside it —
|
|
// expiry hides a file from a client while leaving it in place — so the job
|
|
// re-derives visibility per file instead of inheriting it from the folder.
|
|
test('zipping a shared folder excludes files the client may no longer see', function () {
|
|
$client = User::factory()->client()->create();
|
|
$folder = Folder::query()->create(['name' => 'Shared']);
|
|
$this->actingAs($this->admin)->post("/folders/{$folder->id}/assignments", ['type' => 'client', 'id' => $client->id]);
|
|
|
|
$live = zipUploadFile($this->admin, 'live.pdf', $folder->id);
|
|
$expired = zipUploadFile($this->admin, 'expired.pdf', $folder->id);
|
|
$expired->update(['expires_at' => now()->subDay()]);
|
|
|
|
$response = $this->actingAs($client)->postJson('/zip-downloads', ['folder_ids' => [$folder->id]])->assertOk();
|
|
$zipDownload = ZipDownload::query()->findOrFail($response->json('id'));
|
|
|
|
expect(zipEntryNames($zipDownload))->toBe(['Shared/live.pdf'])
|
|
->and($zipDownload->file_count)->toBe(1);
|
|
|
|
// Staff keep access to an expired file, so the same folder still zips
|
|
// both for them — the rule is per-viewer, not a property of the file.
|
|
$staffResponse = $this->actingAs($this->admin)->postJson('/zip-downloads', ['folder_ids' => [$folder->id]])->assertOk();
|
|
expect(zipEntryNames(ZipDownload::query()->findOrFail($staffResponse->json('id'))))
|
|
->toEqualCanonicalizing(['Shared/live.pdf', 'Shared/expired.pdf']);
|
|
|
|
expect($live->fresh())->not->toBeNull();
|
|
});
|
|
|
|
test('a client cannot zip a loose file that expired after it was shared', function () {
|
|
$client = User::factory()->client()->create();
|
|
$file = zipUploadFile($this->admin, 'a.pdf');
|
|
$this->actingAs($this->admin)->post("/files/{$file->id}/assignments", ['type' => 'client', 'id' => $client->id]);
|
|
$file->update(['expires_at' => now()->subDay()]);
|
|
|
|
$this->actingAs($client)->postJson('/zip-downloads', ['file_ids' => [$file->id]])->assertStatus(422);
|
|
});
|
|
|
|
test('the download endpoint logs a FileDownloaded entry for every bundled file', function () {
|
|
$folder = Folder::query()->create(['name' => 'Reports']);
|
|
$file = zipUploadFile($this->admin, 'a.pdf', $folder->id);
|
|
|
|
$response = $this->actingAs($this->admin)->postJson('/zip-downloads', ['folder_ids' => [$folder->id]])->assertOk();
|
|
$zipDownload = ZipDownload::query()->findOrFail($response->json('id'));
|
|
|
|
$this->actingAs($this->admin)->get("/zip-downloads/{$zipDownload->id}/download")
|
|
->assertOk()
|
|
->assertHeader('X-Accel-Redirect', '/protected-files/'.$zipDownload->path)
|
|
->assertHeader('Content-Disposition', 'attachment; filename="Reports.zip"');
|
|
|
|
expect(ActivityLog::query()->where('action', Action::FileDownloaded)->where('subject_id', $file->id)->exists())->toBeTrue();
|
|
});
|
|
|
|
test('a pending zip download 404s until ready', function () {
|
|
$zipDownload = ZipDownload::query()->create(['requested_by' => $this->admin->id, 'status' => ZipDownload::STATUS_PENDING]);
|
|
|
|
$this->actingAs($this->admin)->get("/zip-downloads/{$zipDownload->id}/download")->assertNotFound();
|
|
});
|
|
|
|
test('only the requester can poll or download their own zip', function () {
|
|
$other = User::factory()->create();
|
|
$zipDownload = ZipDownload::query()->create([
|
|
'requested_by' => $this->admin->id,
|
|
'status' => ZipDownload::STATUS_READY,
|
|
'path' => 'zips/whatever.zip',
|
|
]);
|
|
|
|
$this->actingAs($other)->getJson("/zip-downloads/{$zipDownload->id}")->assertNotFound();
|
|
$this->actingAs($other)->get("/zip-downloads/{$zipDownload->id}/download")->assertNotFound();
|
|
});
|
|
|
|
test('an oversized selection is rejected', function () {
|
|
$folder = Folder::query()->create(['name' => 'Huge']);
|
|
|
|
$now = now();
|
|
$rows = [];
|
|
for ($i = 0; $i < 10001; $i++) {
|
|
$rows[] = [
|
|
'folder_id' => $folder->id,
|
|
'name' => "file{$i}",
|
|
// Bulk insert() bypasses Eloquent's creating() event (the
|
|
// auto-slug fallback), so a unique one is supplied directly.
|
|
'slug' => "huge-file-{$i}",
|
|
'original_name' => "file{$i}.txt",
|
|
'path' => "huge/file{$i}.txt",
|
|
'mime_type' => 'text/plain',
|
|
'size' => 1,
|
|
'checksum' => str_repeat('a', 64),
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
];
|
|
}
|
|
File::query()->insert($rows);
|
|
|
|
$this->actingAs($this->admin)->postJson('/zip-downloads', ['folder_ids' => [$folder->id]])->assertStatus(422);
|
|
});
|
|
|
|
test('the purge command removes zip downloads and files older than 24 hours', function () {
|
|
Storage::disk('files')->put('zips/old.zip', 'x');
|
|
Storage::disk('files')->put('zips/new.zip', 'x');
|
|
|
|
$old = ZipDownload::query()->create([
|
|
'requested_by' => $this->admin->id,
|
|
'status' => ZipDownload::STATUS_READY,
|
|
'path' => 'zips/old.zip',
|
|
]);
|
|
$old->forceFill(['created_at' => now()->subDays(2)])->save();
|
|
|
|
$new = ZipDownload::query()->create([
|
|
'requested_by' => $this->admin->id,
|
|
'status' => ZipDownload::STATUS_READY,
|
|
'path' => 'zips/new.zip',
|
|
]);
|
|
|
|
$this->artisan('projectsend:purge-zip-downloads')->assertSuccessful();
|
|
|
|
expect(ZipDownload::query()->find($old->id))->toBeNull()
|
|
->and(ZipDownload::query()->find($new->id))->not->toBeNull()
|
|
->and(Storage::disk('files')->exists('zips/old.zip'))->toBeFalse()
|
|
->and(Storage::disk('files')->exists('zips/new.zip'))->toBeTrue();
|
|
});
|
|
|
|
// original_name is uploader-chosen and validated only for length, so it
|
|
// must not be able to steer where an entry lands inside the archive.
|
|
test('a traversing filename cannot escape the archive as a zip entry', function () {
|
|
$folder = Folder::query()->create(['name' => 'Reports']);
|
|
$file = zipUploadFile($this->admin, 'ok.pdf', $folder->id);
|
|
// Set it directly: the sanitizer on the intake path would already have
|
|
// stripped this, and the point here is that the zip builder does not
|
|
// depend on that having happened.
|
|
$file->forceFill(['original_name' => '../../../.bashrc'])->save();
|
|
|
|
$response = $this->actingAs($this->admin)->postJson('/zip-downloads', ['folder_ids' => [$folder->id]])->assertOk();
|
|
|
|
$names = zipEntryNames(ZipDownload::query()->findOrFail($response->json('id')));
|
|
|
|
expect($names)->toHaveCount(1);
|
|
|
|
// Dots in a filename are fine — what must not exist is a path *segment*
|
|
// that walks up, which is what an extractor acts on.
|
|
$segments = explode('/', $names[0]);
|
|
|
|
expect($segments)->toHaveCount(2)
|
|
->and($segments)->not->toContain('..')
|
|
->and($names[0])->toStartWith('Reports/');
|
|
});
|