mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
41b4e477b5
A full parallel run failed once and passed on retry while I was doing the #1703 follow-up. A flake is worse than a steady failure: it trains you to re-run rather than look, and it quietly weakens every green run reported beside it. Upload parts are real files under storage_path('app/uploads-tmp/{session_id}'), not a faked disk. Every parallel worker gets its own database, so session ids restart at 1 in each of them, and two workers writing parts land in the same directory. On top of that ChunkedUploadsTest's afterEach deleted the whole tree rather than its own share, for everybody. Six test files write parts, so this was reachable without anything I added. The same collision exists inside one worker: RefreshDatabase rolls back, so ids restart at 1 for every test, and a run that died before its cleanup leaves parts sitting under the id the next test is about to claim. LocalPartStore now reads its root from config, defaulting to exactly where it always was -- an installation with UPLOAD_PARTS_PATH unset behaves identically. Tests\TestCase points it at a per-worker directory and empties that directory per test, which closes the cross-worker, the cross-run and the intra-worker versions together. ChunkedUploadsTest's cleanup and its two directory assertions read the configured root rather than the hardcoded path, so they can no longer reach into a neighbour. Verified with eight consecutive parallel runs, green, and by watching the per-worker directories appear separately (w1, w2, w4 … w14) rather than one shared tree. The isolation itself cannot be asserted from inside a single test; what a test can pin is the mechanism it rests on, so one does: parts go where the configured root says.
285 lines
9.6 KiB
PHP
285 lines
9.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Files\Uploads;
|
|
|
|
use App\Modules\Files\Storage\ResolvingUploadDisk;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\File as FileSystem;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Facades\URL;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Part storage for installs without object storage (any VPS / mounted
|
|
* volume): parts arrive as signed PUTs to the app, land in a temp
|
|
* session directory, and are stream-assembled onto whichever disk the
|
|
* ResolvingUploadDisk event resolves (local 'files' by default; the
|
|
* external storage settings, when active, redirect this to
|
|
* 'files_external') with the sha256 computed during the single
|
|
* concatenation pass.
|
|
*
|
|
* The cloud edition replaces this with an S3 implementation handing
|
|
* out real presigned part URLs behind the same contract.
|
|
*/
|
|
class LocalPartStore
|
|
{
|
|
/**
|
|
* The route name is a parameter because the same flow is mounted twice:
|
|
* once on the session-authenticated web routes for the browser, once on
|
|
* the token-authenticated API routes. The signature is over the URL, so
|
|
* it has to be minted against the route the caller will actually PUT to.
|
|
*/
|
|
public function signPartUrl(UploadSession $session, int $partNumber, string $routeName = 'uploads.parts.put'): string
|
|
{
|
|
return URL::temporarySignedRoute(
|
|
$routeName,
|
|
now()->addMinutes(30),
|
|
['session' => $session->id, 'part' => $partNumber],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* $maxBytes is enforced while copying, not just from Content-Length:
|
|
* that header can be absent or untrue under chunked transfer encoding,
|
|
* so the byte count during the copy is the only figure worth trusting.
|
|
* An over-long part is discarded rather than truncated — a truncated
|
|
* part would assemble into a silently corrupt file.
|
|
*
|
|
* @param resource $stream
|
|
*/
|
|
public function storePart(UploadSession $session, int $partNumber, $stream, ?int $maxBytes = null): string
|
|
{
|
|
$directory = $this->directory($session);
|
|
|
|
try {
|
|
FileSystem::ensureDirectoryExists($directory);
|
|
} catch (Throwable $e) {
|
|
// mkdir() failures surface as promoted warnings. The raw message
|
|
// — "mkdir(): Permission denied" with a framework stack trace —
|
|
// was the single most opaque failure QA hit: name the directory
|
|
// and the likely fix instead, so the log line is actionable, and
|
|
// keep the original as the previous exception.
|
|
throw new RuntimeException(sprintf(
|
|
'%s could not be created — uploads cannot accept bytes.'
|
|
.' Almost always ownership: make storage/ writable by the user'
|
|
.' the app runs as (in the Docker image: chown -R www-data:www-data storage).',
|
|
$directory,
|
|
), previous: $e);
|
|
}
|
|
|
|
$path = $this->partPath($session, $partNumber);
|
|
|
|
$out = fopen($path, 'wb');
|
|
|
|
if ($out === false) {
|
|
throw new RuntimeException(sprintf('Could not open part file %s for writing.', $path));
|
|
}
|
|
|
|
if ($maxBytes === null) {
|
|
stream_copy_to_stream($stream, $out);
|
|
fclose($out);
|
|
|
|
return md5_file($path) ?: '';
|
|
}
|
|
|
|
$written = 0;
|
|
|
|
while (! feof($stream)) {
|
|
$buffer = fread($stream, 1024 * 1024);
|
|
|
|
if ($buffer === false || $buffer === '') {
|
|
break;
|
|
}
|
|
|
|
$written += strlen($buffer);
|
|
|
|
if ($written > $maxBytes) {
|
|
fclose($out);
|
|
@unlink($path);
|
|
|
|
throw new PartTooLargeException('Upload part exceeds the maximum part size.');
|
|
}
|
|
|
|
fwrite($out, $buffer);
|
|
}
|
|
|
|
fclose($out);
|
|
|
|
return md5_file($path) ?: '';
|
|
}
|
|
|
|
/**
|
|
* @return list<array{PartNumber: int, Size: int, ETag: string}>
|
|
*/
|
|
public function listParts(UploadSession $session): array
|
|
{
|
|
$directory = $this->directory($session);
|
|
|
|
if (! is_dir($directory)) {
|
|
return [];
|
|
}
|
|
|
|
$parts = [];
|
|
|
|
foreach (glob($directory.'/*.part') ?: [] as $path) {
|
|
$number = (int) basename($path, '.part');
|
|
$parts[$number] = [
|
|
'PartNumber' => $number,
|
|
'Size' => (int) filesize($path),
|
|
'ETag' => md5_file($path) ?: '',
|
|
];
|
|
}
|
|
|
|
ksort($parts);
|
|
|
|
return array_values($parts);
|
|
}
|
|
|
|
/**
|
|
* Stream-append parts in order onto the files disk, hashing as we
|
|
* go. Peak temp usage ≈ file size + one part (parts are unlinked
|
|
* as they are consumed).
|
|
*
|
|
* @return array{path: string, disk: string, size: int, checksum: string}
|
|
*/
|
|
public function assemble(UploadSession $session, string $targetPath): array
|
|
{
|
|
$parts = $this->listParts($session);
|
|
|
|
$expected = range(1, count($parts));
|
|
$actual = array_column($parts, 'PartNumber');
|
|
|
|
if ($parts === [] || $actual !== $expected) {
|
|
throw new RuntimeException('Upload is incomplete: missing parts.');
|
|
}
|
|
|
|
$assembledPath = $this->directory($session).'/assembled';
|
|
$out = fopen($assembledPath, 'wb');
|
|
|
|
if ($out === false) {
|
|
throw new RuntimeException('Could not open assembly target.');
|
|
}
|
|
|
|
$hash = hash_init('sha256');
|
|
$size = 0;
|
|
|
|
foreach ($parts as $part) {
|
|
$partPath = $this->partPath($session, $part['PartNumber']);
|
|
$in = fopen($partPath, 'rb');
|
|
|
|
if ($in === false) {
|
|
fclose($out);
|
|
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
|
|
}
|
|
|
|
while (! feof($in)) {
|
|
$buffer = fread($in, 1024 * 1024);
|
|
|
|
if ($buffer === false) {
|
|
break;
|
|
}
|
|
|
|
fwrite($out, $buffer);
|
|
hash_update($hash, $buffer);
|
|
$size += strlen($buffer);
|
|
}
|
|
|
|
fclose($in);
|
|
unlink($partPath);
|
|
}
|
|
|
|
fclose($out);
|
|
|
|
$readStream = fopen($assembledPath, 'rb');
|
|
|
|
if ($readStream === false) {
|
|
throw new RuntimeException('Could not reopen assembled file.');
|
|
}
|
|
|
|
$diskEvent = new ResolvingUploadDisk($session->user);
|
|
Event::dispatch($diskEvent);
|
|
$disk = $diskEvent->disk;
|
|
|
|
$written = Storage::disk($disk)->writeStream($targetPath, $readStream);
|
|
|
|
if (is_resource($readStream)) {
|
|
fclose($readStream);
|
|
}
|
|
|
|
// The disks are configured with 'throw' => false, so a refused
|
|
// write is a `false` return rather than an exception — and the
|
|
// caller goes on to record a File row for bytes that were never
|
|
// stored. Losing an upload silently is worse than failing it, and
|
|
// this is the only place that can tell the difference: a real
|
|
// instance of it was a GCS bucket rejecting the adapter's ACL,
|
|
// which looked exactly like a successful upload.
|
|
if ($written === false) {
|
|
// The reason is lost by the time it gets here — 'throw' => false
|
|
// means Flysystem swallowed the exception rather than passing it
|
|
// on — so log what was attempted. Which bucket it was is the
|
|
// difference between reading this as "my credentials expired"
|
|
// and "I typed the wrong bucket name", and only the log can say
|
|
// it: the message below is shown to whoever was uploading, which
|
|
// includes clients, and a bucket name is not theirs to see.
|
|
Log::error('Upload could not be written to storage.', [
|
|
'disk' => $disk,
|
|
'bucket' => config('filesystems.disks.'.$disk.'.bucket'),
|
|
'driver' => config('filesystems.disks.'.$disk.'.driver'),
|
|
'path' => $targetPath,
|
|
]);
|
|
|
|
throw new RuntimeException(
|
|
'Could not write the assembled upload to the "'.$disk.'" disk. '
|
|
.'Check the storage backend is reachable and its credentials are still valid.'
|
|
);
|
|
}
|
|
|
|
$this->abort($session);
|
|
|
|
return [
|
|
'path' => $targetPath,
|
|
'disk' => $disk,
|
|
'size' => $size,
|
|
'checksum' => hash_final($hash),
|
|
];
|
|
}
|
|
|
|
public function abort(UploadSession $session): void
|
|
{
|
|
FileSystem::deleteDirectory($this->directory($session));
|
|
}
|
|
|
|
private function directory(UploadSession $session): string
|
|
{
|
|
return $this->root().'/'.$session->id;
|
|
}
|
|
|
|
/**
|
|
* Where part files live while a transfer is in progress.
|
|
*
|
|
* Configurable only so the test suite can hold it apart per parallel
|
|
* worker. This is a real directory rather than a faked disk, and each
|
|
* worker's database restarts session ids at 1, so two workers writing
|
|
* parts land in the same place — and ChunkedUploadsTest's afterEach
|
|
* deletes the whole tree, for everybody. Unset, which is every
|
|
* installation, the path is what it has always been.
|
|
*/
|
|
private function root(): string
|
|
{
|
|
$configured = config('projectsend.uploads.parts_path');
|
|
|
|
return is_string($configured) && $configured !== ''
|
|
? rtrim($configured, '/')
|
|
: storage_path('app/uploads-tmp');
|
|
}
|
|
|
|
private function partPath(UploadSession $session, int $partNumber): string
|
|
{
|
|
return $this->directory($session).'/'.$partNumber.'.part';
|
|
}
|
|
}
|