Encrypt uploads in the browser and send them in chunks
A 6 GB upload kept a customer waiting long after its progress bar
reached 100%. The server wrote every upload three times: PHP's
temporary file, Livewire's copy of it ("Processing files...") and the
encrypted file ("Create Share Link"), each a full rewrite of a slow
disk. The unencrypted copy also stayed behind in livewire-tmp.
Now the uploader's browser encrypts each file in 16 MB chunks with
WebCrypto and PUTs them one at a time; the server checks each chunk in
memory and writes it once, already encrypted. Creating the share only
wraps its key and saves the options. A 200 MB upload through the
Docker image took 2.8 s, and its download matched byte for byte.
- SEALCHK2: a 19-byte header (chunk size, 7-byte nonce prefix), then
ciphertext and tag per chunk. Each nonce holds the chunk index and a
last-chunk flag (the STREAM construction), so cut or reordered files
fail to decrypt. SEALCHK1 and the single-block format still read.
- Envelope encryption: one random key per share. With a password it is
wrapped with Argon2id (sodium, libsodium's interactive limits) in
shares.wrapped_key, which names its parameters. Password shares from
before keep their PBKDF2-derived key.
- The upload page registers each selection with FileUploader into a
pending share of its own, lists the files with their progress, retries
a failed chunk after 1-16 s, then offers Retry; Remove and Cancel
abort. UploadChunkController only accepts chunks from the session that
started the share: a repeat is acknowledged, a skip gets 409 with the
count stored. Chunks go out as Blobs, which Chromium sends about eight
times faster than ArrayBuffers.
- Uploads need a secure context: over plain HTTP the page says HTTPS is
needed and takes no files. The Docker image gains AUTO_HTTPS, which
serves Let's Encrypt on 443 for SERVER_NAME and redirects 80; without
it the container stays on HTTP 80 behind a proxy. docker/Caddyfile was
never loaded and is gone; docker/healthcheck.sh covers both modes.
- "Download all" streams the ZIP with maennchen/zipstream-php (STORE,
ZIP64) instead of decrypting whole files into memory and writing the
archive unencrypted to /tmp.
- Pending shares count towards the quota, stay out of the admin
dashboard and 404 everywhere else. shares:cleanup deletes uploads idle
for 4 hours and Livewire temporary files older than that.
- PHP's upload limits no longer cap the admin's max file size and
default to 64M; LIVEWIRE_MAX_UPLOAD_TIME is gone and
UPLOAD_CHUNK_SIZE_MB is new.
- Tests cover the format, key wrapping, registration limits, the chunk
endpoint's answers, completing a share, the streamed ZIP, cleanup,
and in Chromium a real chunked upload and the HTTPS warning; the
selected-files overflow test runs again. README, website, CHANGELOG
and .ai/rules follow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
504971ad7f
commit
40e35bab0e
@@ -5,12 +5,18 @@ namespace App\Console\Commands;
|
||||
use App\Models\Share;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Console\Command;
|
||||
use Livewire\Features\SupportFileUploads\FileUploadConfiguration;
|
||||
|
||||
class CleanupExpiredShares extends Command
|
||||
{
|
||||
/**
|
||||
* How long an upload or a temporary upload file may sit untouched before it is deleted.
|
||||
*/
|
||||
private const ABANDONED_AFTER_HOURS = 4;
|
||||
|
||||
protected $signature = 'shares:cleanup';
|
||||
|
||||
protected $description = 'Delete expired shares and shares that have reached their download limit';
|
||||
protected $description = 'Delete expired shares, shares that have reached their download limit, abandoned uploads and old temporary uploads';
|
||||
|
||||
public function handle(ShareService $shareService): int
|
||||
{
|
||||
@@ -21,14 +27,49 @@ class CleanupExpiredShares extends Command
|
||||
})
|
||||
->get();
|
||||
|
||||
$count = $expiredShares->count();
|
||||
|
||||
foreach ($expiredShares as $share) {
|
||||
$shareService->deleteShare($share);
|
||||
}
|
||||
|
||||
$this->info("Cleaned up {$count} expired share(s).");
|
||||
$this->info("Cleaned up {$expiredShares->count()} expired share(s).");
|
||||
|
||||
// A page that stopped sending chunks: closed, crashed or left behind.
|
||||
$abandonedUploads = Share::query()
|
||||
->whereNull('completed_at')
|
||||
->where('updated_at', '<', now()->subHours(self::ABANDONED_AFTER_HOURS))
|
||||
->get();
|
||||
|
||||
foreach ($abandonedUploads as $share) {
|
||||
$shareService->deleteShare($share);
|
||||
}
|
||||
|
||||
$this->info("Cleaned up {$abandonedUploads->count()} abandoned upload(s).");
|
||||
$this->info('Cleaned up '.$this->deleteOldTemporaryUploads().' temporary upload file(s).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Livewire's temporary uploads past the same age: the admin logo's, and the unencrypted
|
||||
* copies uploads left there before files were encrypted in the browser.
|
||||
*/
|
||||
private function deleteOldTemporaryUploads(): int
|
||||
{
|
||||
if (FileUploadConfiguration::isUsingS3()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$storage = FileUploadConfiguration::storage();
|
||||
$cutoff = now()->subHours(self::ABANDONED_AFTER_HOURS)->getTimestamp();
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($storage->allFiles(FileUploadConfiguration::path()) as $path) {
|
||||
if ($storage->exists($path) && $storage->lastModified($path) < $cutoff) {
|
||||
$storage->delete($path);
|
||||
$deleted++;
|
||||
}
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,12 @@ use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use App\Services\FileEncryptionService;
|
||||
use App\Services\ShareService;
|
||||
use GuzzleHttp\Psr7\PumpStream;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use ZipArchive;
|
||||
use ZipStream\CompressionMethod;
|
||||
use ZipStream\ZipStream;
|
||||
|
||||
class DownloadController extends Controller
|
||||
{
|
||||
@@ -20,41 +21,53 @@ class DownloadController extends Controller
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Download all files as a ZIP archive.
|
||||
* Download all files as a ZIP archive, streamed file by file as it is decrypted: stored without
|
||||
* compression, with ZIP64 for files over 4 GB, and never held in memory or written to disk.
|
||||
*/
|
||||
public function download(Share $share): BinaryFileResponse
|
||||
public function download(Share $share): StreamedResponse
|
||||
{
|
||||
abort_if($share->isExpired() || $share->hasReachedDownloadLimit(), 404);
|
||||
abort_if(! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit(), 404);
|
||||
|
||||
$share->load('files');
|
||||
$key = $this->resolveDecryptionKey($share);
|
||||
|
||||
$tempPath = tempnam(sys_get_temp_dir(), 'sealshare_');
|
||||
return new StreamedResponse(function () use ($share, $key): void {
|
||||
$zip = new ZipStream(
|
||||
defaultCompressionMethod: CompressionMethod::STORE,
|
||||
defaultEnableZeroHeader: true,
|
||||
sendHttpHeaders: false,
|
||||
flushOutput: true,
|
||||
);
|
||||
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($tempPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
||||
foreach ($share->files as $file) {
|
||||
$chunks = $this->encryptionService->decryptedChunks(
|
||||
Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path)),
|
||||
$key,
|
||||
);
|
||||
|
||||
foreach ($share->files as $file) {
|
||||
$encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path));
|
||||
$content = $this->encryptionService->decryptFile($encryptedPath, $key);
|
||||
$zip->addFileFromPsr7Stream(fileName: $this->archiveName($file), stream: new PumpStream(function () use ($chunks): string|false {
|
||||
while ($chunks->valid() && $chunks->current() === '') {
|
||||
$chunks->next();
|
||||
}
|
||||
|
||||
$filename = $file->relative_path ?: $file->original_name;
|
||||
$filename = str_replace('\\', '/', $filename);
|
||||
if (! $chunks->valid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_starts_with($filename, '/') || str_contains($filename, '..')) {
|
||||
$filename = basename($filename);
|
||||
$chunk = $chunks->current();
|
||||
$chunks->next();
|
||||
|
||||
return $chunk;
|
||||
}));
|
||||
}
|
||||
|
||||
$zip->addFromString($filename, $content);
|
||||
}
|
||||
$zip->finish();
|
||||
|
||||
$zip->close();
|
||||
|
||||
$this->shareService->recordDownload($share);
|
||||
|
||||
return response()->download($tempPath, 'share-'.$share->token.'.zip', [
|
||||
$this->shareService->recordDownload($share);
|
||||
}, 200, [
|
||||
'Content-Type' => 'application/zip',
|
||||
])->deleteFileAfterSend(true);
|
||||
'Content-Disposition' => HeaderUtils::makeDisposition('attachment', 'share-'.$share->token.'.zip'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,7 +75,7 @@ class DownloadController extends Controller
|
||||
*/
|
||||
public function downloadFile(Share $share, ShareFile $shareFile): StreamedResponse
|
||||
{
|
||||
abort_if($share->isExpired() || $share->hasReachedDownloadLimit(), 404);
|
||||
abort_if(! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit(), 404);
|
||||
abort_if($shareFile->share_id !== $share->id, 404);
|
||||
|
||||
$key = $this->resolveDecryptionKey($share);
|
||||
@@ -90,6 +103,21 @@ class DownloadController extends Controller
|
||||
}, 200, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* A file's path inside the archive: its folder path when it came from a dropped folder, never
|
||||
* one that could reach outside the archive.
|
||||
*/
|
||||
private function archiveName(ShareFile $file): string
|
||||
{
|
||||
$filename = str_replace('\\', '/', $file->relative_path ?: $file->original_name);
|
||||
|
||||
if (str_starts_with($filename, '/') || str_contains($filename, '..')) {
|
||||
return basename($filename);
|
||||
}
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the decryption key from session or share.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ShareFile;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class UploadChunkController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ShareService $shareService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Store one encrypted chunk of a file the uploader's page registered.
|
||||
*
|
||||
* Only the session that started the pending share may add to it. A chunk the server already
|
||||
* has is acknowledged without being written again; one that skips ahead gets a 409 with the
|
||||
* number of chunks stored, so the browser can continue from there.
|
||||
*/
|
||||
public function store(Request $request, ShareFile $shareFile, int $index): JsonResponse
|
||||
{
|
||||
$share = $shareFile->share;
|
||||
|
||||
abort_if($share->isCompleted() || ! in_array($share->token, $request->session()->get('pending_shares', []), true), 404);
|
||||
|
||||
if ($index !== $shareFile->uploaded_chunks) {
|
||||
return response()->json(
|
||||
['uploaded_chunks' => $shareFile->uploaded_chunks],
|
||||
$index < $shareFile->uploaded_chunks ? 200 : 409,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$uploadedChunks = $this->shareService->storeChunk($shareFile, $index, $request->getContent());
|
||||
} catch (InvalidArgumentException) {
|
||||
abort(422, 'The chunk is invalid.');
|
||||
}
|
||||
|
||||
return response()->json(['uploaded_chunks' => $uploadedChunks]);
|
||||
}
|
||||
}
|
||||
@@ -43,18 +43,20 @@ class AdminDashboard extends Component
|
||||
$column = in_array($this->sortBy['column'] ?? null, self::SORTABLE, true) ? $this->sortBy['column'] : 'created_at';
|
||||
$direction = ($this->sortBy['direction'] ?? null) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
// Shares whose files are still being uploaded are not shares yet; their bytes do count as used space.
|
||||
$shares = Share::query()
|
||||
->whereNotNull('completed_at')
|
||||
->withCount('files')
|
||||
->orderBy($column, $direction)
|
||||
->paginate(15);
|
||||
|
||||
return view('livewire.admin.admin-dashboard', [
|
||||
'shares' => $shares,
|
||||
'totalShares' => Share::query()->count(),
|
||||
'activeShares' => Share::query()->where(function ($q) {
|
||||
'totalShares' => Share::query()->whereNotNull('completed_at')->count(),
|
||||
'activeShares' => Share::query()->whereNotNull('completed_at')->where(function ($q) {
|
||||
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})->count(),
|
||||
'totalFiles' => ShareFile::query()->count(),
|
||||
'totalFiles' => ShareFile::query()->whereHas('share', fn ($query) => $query->whereNotNull('completed_at'))->count(),
|
||||
'usedSpace' => $shareService->getTotalUsedSpace(),
|
||||
'maxQuota' => $shareService->getMaxStorageQuota(),
|
||||
]);
|
||||
|
||||
@@ -70,10 +70,7 @@ class AdminSettings extends Component
|
||||
{
|
||||
$this->colorProfile = Scheme::profile() ?? '';
|
||||
$this->defaultExpiration = Setting::get('default_expiration', '') ?? '';
|
||||
$this->maxFileSize = min(
|
||||
(int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024),
|
||||
self::phpMaxUploadMb(),
|
||||
);
|
||||
$this->maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024);
|
||||
$this->maxStorageQuota = (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024) / (1024 * 1024 * 1024);
|
||||
$this->maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
||||
$this->maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024) / (1024 * 1024 * 1024);
|
||||
@@ -91,34 +88,11 @@ class AdminSettings extends Component
|
||||
$this->passphraseSeparator = $passwordOptions['separator'];
|
||||
}
|
||||
|
||||
public static function phpMaxUploadMb(): int
|
||||
{
|
||||
$parse = function (string $value): int {
|
||||
$value = trim($value);
|
||||
$last = strtolower($value[strlen($value) - 1]);
|
||||
$num = (int) $value;
|
||||
|
||||
return match ($last) {
|
||||
'g' => $num * 1024,
|
||||
'm' => $num,
|
||||
'k' => max(1, (int) ($num / 1024)),
|
||||
default => max(1, (int) ($num / (1024 * 1024))),
|
||||
};
|
||||
};
|
||||
|
||||
$upload = $parse(ini_get('upload_max_filesize') ?: '2M');
|
||||
$post = $parse(ini_get('post_max_size') ?: '8M');
|
||||
|
||||
return min($upload, $post);
|
||||
}
|
||||
|
||||
public function saveSettings(): void
|
||||
{
|
||||
$phpMaxMb = self::phpMaxUploadMb();
|
||||
|
||||
$validated = $this->validate([
|
||||
'colorProfile' => ['required', 'string', Rule::in(array_keys(Scheme::profiles()))],
|
||||
'maxFileSize' => ['required', 'integer', 'min:1', 'max:'.$phpMaxMb],
|
||||
'maxFileSize' => ['required', 'integer', 'min:1'],
|
||||
'maxStorageQuota' => ['required', 'integer', 'min:1'],
|
||||
'maxFilesPerShare' => ['required', 'integer', 'min:1'],
|
||||
'maxSizePerShare' => ['required', 'integer', 'min:1'],
|
||||
@@ -127,7 +101,6 @@ class AdminSettings extends Component
|
||||
'siteLogo' => ['nullable', 'file', 'mimes:png,jpg,jpeg,gif,webp', 'max:2048'],
|
||||
...$this->passwordGeneratorRules(),
|
||||
], [
|
||||
'maxFileSize.max' => __('Cannot exceed the PHP limit of :max MB. Increase upload_max_filesize and post_max_size in your PHP configuration.', ['max' => $phpMaxMb]),
|
||||
...$this->passwordGeneratorMessages(),
|
||||
]);
|
||||
|
||||
@@ -276,7 +249,6 @@ class AdminSettings extends Component
|
||||
return view('livewire.admin.admin-settings', [
|
||||
'hasSystemPassword' => (bool) Setting::get('system_password'),
|
||||
'currentLogo' => Setting::get('site_logo'),
|
||||
'phpMaxUploadMb' => self::phpMaxUploadMb(),
|
||||
'passwordExample' => $passwordPreviewOptions ? $passwordGenerator->generate($passwordPreviewOptions) : null,
|
||||
'passwordEntropy' => $passwordPreviewOptions ? $passwordGenerator->entropyBits($passwordPreviewOptions) : null,
|
||||
]);
|
||||
|
||||
+96
-118
@@ -3,26 +3,27 @@
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Services\PasswordGeneratorService;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
/**
|
||||
* The upload page. The browser encrypts each file chunk by chunk and sends the chunks to
|
||||
* UploadChunkController (resources/js/share-uploader.js); this component registers the files
|
||||
* into a pending share, lists them and completes the share with its options.
|
||||
*/
|
||||
#[Layout('layouts.app')]
|
||||
class FileUploader extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
/** @var array<int, TemporaryUploadedFile> */
|
||||
public array $files = [];
|
||||
|
||||
/** @var array<int, string|null> */
|
||||
public array $relativePaths = [];
|
||||
/** The pending share this page uploads into: created with the first file, one per page load. */
|
||||
#[Locked]
|
||||
public ?string $pendingToken = null;
|
||||
|
||||
public bool $usePassword = false;
|
||||
|
||||
@@ -40,67 +41,70 @@ class FileUploader extends Component
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an upload the temporary upload endpoint did not accept.
|
||||
* Register the files a visitor chose and hand the browser what it encrypts and sends them
|
||||
* with. A file an admin limit refuses gets `null` in its place and the reason under `files`.
|
||||
*
|
||||
* Validation errors (a 422) mean the whole file reached the server and was
|
||||
* rejected there, so the real reason is logged for the administrator rather
|
||||
* than guessed at in front of the user. Anything else is a transport failure.
|
||||
* @param array<int, array{name?: mixed, size?: mixed, path?: mixed}> $files
|
||||
* @return array<int, array{id: int, url: string, key: string, noncePrefix: string, chunkSize: int, chunkCount: int}|null>
|
||||
*/
|
||||
public function _uploadErrored($name, $errorsInJson, $isMultiple): void
|
||||
public function registerFiles(array $files, ShareService $shareService): array
|
||||
{
|
||||
$this->dispatch('upload:errored', name: $name)->self();
|
||||
$this->resetErrorBag('files');
|
||||
|
||||
$errors = is_null($errorsInJson) ? null : (json_decode($errorsInJson, true)['errors'] ?? null);
|
||||
$targets = [];
|
||||
|
||||
if ($errors) {
|
||||
Log::warning('File upload rejected by the temporary upload endpoint.', ['errors' => $errors]);
|
||||
foreach ($files as $file) {
|
||||
try {
|
||||
$shareFile = $shareService->registerFile(
|
||||
$this->pendingShare(),
|
||||
(string) ($file['name'] ?? ''),
|
||||
(int) ($file['size'] ?? -1),
|
||||
isset($file['path']) ? (string) $file['path'] : null,
|
||||
);
|
||||
} catch (ValidationException $e) {
|
||||
if (! $this->getErrorBag()->has('files')) {
|
||||
$this->addError('files', $e->errors()['files'][0]);
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'files' => __('Upload failed: the server could not accept the file. Please try again or contact the administrator.'),
|
||||
]);
|
||||
$targets[] = null;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->pendingToken !== $shareFile->share->token) {
|
||||
$this->pendingToken = $shareFile->share->token;
|
||||
session()->push('pending_shares', $this->pendingToken);
|
||||
}
|
||||
|
||||
$header = $shareService->readHeader($shareFile);
|
||||
|
||||
$targets[] = [
|
||||
'id' => $shareFile->id,
|
||||
'url' => Str::beforeLast(route('upload.chunk', ['shareFile' => $shareFile, 'index' => 0]), '/'),
|
||||
'key' => $shareFile->share->encryption_key,
|
||||
'noncePrefix' => bin2hex($header['noncePrefix']),
|
||||
'chunkSize' => $header['chunkSize'],
|
||||
'chunkCount' => $header['chunkCount'],
|
||||
];
|
||||
}
|
||||
|
||||
$maxFileSizeMb = (int) ((int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024));
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'files' => __('Upload failed: file may be too large (max :max MB) or the connection was interrupted.', ['max' => $maxFileSizeMb]),
|
||||
]);
|
||||
return $targets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a freshly uploaded batch of files.
|
||||
* Take files out of the pending share, whether or not their upload finished.
|
||||
*
|
||||
* Dispatches `files-processed` so the front end can drop its "uploading" state.
|
||||
* This runs for every batch, including additional files added to an existing
|
||||
* selection, which a one-off `x-init` on the file list cannot cover.
|
||||
* @param array<int, mixed> $fileIds
|
||||
*/
|
||||
public function updatedFiles(): void
|
||||
public function removeFiles(array $fileIds, ShareService $shareService): void
|
||||
{
|
||||
$this->dispatch('files-processed')->self();
|
||||
$files = $this->pendingShare()?->files()->whereIn('id', array_map('intval', $fileIds))->get() ?? [];
|
||||
|
||||
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
|
||||
$maxFileSizeMb = $maxFileSize / (1024 * 1024);
|
||||
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
||||
foreach ($files as $file) {
|
||||
$shareService->removeFile($file);
|
||||
}
|
||||
|
||||
$this->resetErrorBag('files');
|
||||
|
||||
if (count($this->files) > $maxFilesPerShare) {
|
||||
$this->addError('files', __('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->files as $file) {
|
||||
if ($file->getSize() > $maxFileSize) {
|
||||
$this->addError('files', __('":name" is too large (:size MB). Maximum file size is :max MB.', [
|
||||
'name' => $file->getClientOriginalName(),
|
||||
'size' => round($file->getSize() / (1024 * 1024), 1),
|
||||
'max' => (int) $maxFileSizeMb,
|
||||
]));
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,27 +130,11 @@ class FileUploader extends Component
|
||||
$this->resetErrorBag('password');
|
||||
}
|
||||
|
||||
public function removeFile(int $index): void
|
||||
{
|
||||
unset($this->files[$index], $this->relativePaths[$index]);
|
||||
$this->files = array_values($this->files);
|
||||
$this->relativePaths = array_values($this->relativePaths);
|
||||
}
|
||||
|
||||
public function createShare(ShareService $shareService): void
|
||||
{
|
||||
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
||||
$maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024);
|
||||
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
|
||||
$rules = [];
|
||||
|
||||
$rules = [
|
||||
'files' => ['required', 'array', 'min:1', 'max:'.$maxFilesPerShare],
|
||||
'files.*' => ['required', 'file', 'max:'.($maxFileSize / 1024)],
|
||||
];
|
||||
|
||||
$allowNeverExpire = (bool) Setting::get('allow_never_expire', false);
|
||||
|
||||
if (! $allowNeverExpire) {
|
||||
if (! Setting::get('allow_never_expire', false)) {
|
||||
$rules['expiration'] = ['required', 'string', 'in:1h,24h,48h,7d,14d,30d'];
|
||||
}
|
||||
|
||||
@@ -154,61 +142,36 @@ class FileUploader extends Component
|
||||
$rules['password'] = ['required', 'string', 'min:8'];
|
||||
}
|
||||
|
||||
$this->validate($rules, [
|
||||
'expiration.required' => __('An expiration time is required.'),
|
||||
'files.required' => __('Please select at least one file to upload.'),
|
||||
'files.max' => __('Too many files. Maximum :max files allowed per share.'),
|
||||
'files.*.max' => __('A file exceeds the maximum size of :max KB.'),
|
||||
]);
|
||||
if ($rules !== []) {
|
||||
$this->validate($rules, [
|
||||
'expiration.required' => __('An expiration time is required.'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($shareService->isStorageFull()) {
|
||||
$this->addError('files', __('Storage is full. Please contact the administrator.'));
|
||||
$pendingShare = $this->pendingShare();
|
||||
|
||||
if ($pendingShare === null) {
|
||||
$this->addError('files', __('Please select at least one file to upload.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$totalSize = collect($this->files)->sum(fn ($file) => $file->getSize());
|
||||
|
||||
if ($totalSize > $maxSizePerShare) {
|
||||
$this->addError('files', __('Total file size exceeds the maximum allowed per share.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$fileData = [];
|
||||
foreach ($this->files as $index => $file) {
|
||||
$relativePath = $this->relativePaths[$index] ?? null;
|
||||
|
||||
if ($relativePath !== null) {
|
||||
$relativePath = str_replace('\\', '/', $relativePath);
|
||||
|
||||
if (str_starts_with($relativePath, '/') || str_contains($relativePath, '..')) {
|
||||
$relativePath = null;
|
||||
}
|
||||
}
|
||||
|
||||
$fileData[] = [
|
||||
'file' => $file,
|
||||
'relativePath' => $relativePath,
|
||||
];
|
||||
}
|
||||
|
||||
$expiresAt = match ($this->expiration) {
|
||||
'1h' => now()->addHour(),
|
||||
'24h' => now()->addDay(),
|
||||
'48h' => now()->addDays(2),
|
||||
'7d' => now()->addWeek(),
|
||||
'14d' => now()->addDays(14),
|
||||
'30d' => now()->addMonth(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
$share = $shareService->createShare($fileData, [
|
||||
$share = $shareService->completeShare($pendingShare, [
|
||||
'password' => $this->usePassword ? $this->password : null,
|
||||
'expires_at' => $expiresAt,
|
||||
'expires_at' => match ($this->expiration) {
|
||||
'1h' => now()->addHour(),
|
||||
'24h' => now()->addDay(),
|
||||
'48h' => now()->addDays(2),
|
||||
'7d' => now()->addWeek(),
|
||||
'14d' => now()->addDays(14),
|
||||
'30d' => now()->addMonth(),
|
||||
default => null,
|
||||
},
|
||||
'max_downloads' => $this->maxDownloads ?: null,
|
||||
]);
|
||||
|
||||
session()->put('pending_shares', array_values(array_diff(session('pending_shares', []), [$share->token])));
|
||||
|
||||
// The page the upload leads to offers the password once more, next to the link; it is
|
||||
// never stored in the clear, so this flash is the only way it gets there.
|
||||
if ($this->usePassword) {
|
||||
@@ -224,8 +187,11 @@ class FileUploader extends Component
|
||||
public function render(): mixed
|
||||
{
|
||||
$shareService = app(ShareService::class);
|
||||
$pendingFiles = $this->pendingShare()?->files()->orderBy('id')->get() ?? collect();
|
||||
|
||||
return view('livewire.file-uploader', [
|
||||
'pendingFiles' => $pendingFiles,
|
||||
'allFilesUploaded' => $pendingFiles->isNotEmpty() && $pendingFiles->every(fn ($file): bool => $file->completed_at !== null),
|
||||
'isStorageFull' => $shareService->isStorageFull(),
|
||||
'siteTitle' => Setting::get('site_title'),
|
||||
'siteDescription' => Setting::get('site_description'),
|
||||
@@ -234,4 +200,16 @@ class FileUploader extends Component
|
||||
'passwordGeneratorMode' => app(PasswordGeneratorService::class)->mode(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* This page's pending share, while it is still pending and this session started it.
|
||||
*/
|
||||
private function pendingShare(): ?Share
|
||||
{
|
||||
if ($this->pendingToken === null || ! in_array($this->pendingToken, session('pending_shares', []), true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Share::query()->where('token', $this->pendingToken)->whereNull('completed_at')->first();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ class ShareCreated extends Component
|
||||
|
||||
public function mount(Share $share): void
|
||||
{
|
||||
abort_unless($share->isCompleted(), 404);
|
||||
|
||||
$this->share = $share;
|
||||
|
||||
$flashedPassword = session('share_password');
|
||||
|
||||
@@ -24,7 +24,7 @@ class ShareDownload extends Component
|
||||
{
|
||||
$this->share = $share->load('files');
|
||||
|
||||
if ($share->isExpired() || $share->hasReachedDownloadLimit()) {
|
||||
if (! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,12 @@ class Share extends Model
|
||||
'password',
|
||||
'encryption_key',
|
||||
'encryption_salt',
|
||||
'wrapped_key',
|
||||
'expires_at',
|
||||
'max_downloads',
|
||||
'download_count',
|
||||
'total_size',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -32,6 +34,7 @@ class Share extends Model
|
||||
'download_count' => 'integer',
|
||||
'total_size' => 'integer',
|
||||
'encryption_key' => 'encrypted',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -43,6 +46,15 @@ class Share extends Model
|
||||
return $this->hasMany(ShareFile::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the share was created: until then its files are still being uploaded and nobody
|
||||
* but the uploader's page may reach it.
|
||||
*/
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->completed_at !== null;
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at && $this->expires_at->isPast();
|
||||
|
||||
@@ -17,6 +17,8 @@ class ShareFile extends Model
|
||||
'stored_path',
|
||||
'file_size',
|
||||
'mime_type',
|
||||
'uploaded_chunks',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -26,6 +28,8 @@ class ShareFile extends Model
|
||||
{
|
||||
return [
|
||||
'file_size' => 'integer',
|
||||
'uploaded_chunks' => 'integer',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,30 @@ namespace App\Services;
|
||||
|
||||
use Generator;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* The encrypted file formats and the keys behind them.
|
||||
*
|
||||
* New files are `SEALCHK2`, written chunk by chunk as the uploader's browser sends them:
|
||||
*
|
||||
* [8 bytes: "SEALCHK2" magic]
|
||||
* [4 bytes: chunk size S, uint32 big-endian]
|
||||
* [7 bytes: random nonce prefix]
|
||||
* Per chunk i: [ciphertext (S bytes, fewer on the last chunk)][16 bytes: GCM tag]
|
||||
*
|
||||
* Chunk i's nonce is the prefix, i as uint32 big-endian and a byte that is 1 on the last chunk
|
||||
* and 0 on every other (the STREAM construction), so dropping, reordering or appending chunks
|
||||
* fails authentication. The browser encrypts with the same layout (resources/js/share-uploader.js).
|
||||
*
|
||||
* `SEALCHK1` (a tag before each chunk, the index XORed into a 12-byte nonce, no last-chunk flag)
|
||||
* and the single-block legacy format are still read for shares created before.
|
||||
*/
|
||||
class FileEncryptionService
|
||||
{
|
||||
public const HEADER_LENGTH = 19;
|
||||
|
||||
public const TAG_LENGTH = 16;
|
||||
|
||||
private const CIPHER = 'aes-256-gcm';
|
||||
|
||||
private const PBKDF2_ITERATIONS = 100000;
|
||||
@@ -17,14 +36,17 @@ class FileEncryptionService
|
||||
|
||||
private const NONCE_LENGTH = 12;
|
||||
|
||||
private const TAG_LENGTH = 16;
|
||||
private const NONCE_PREFIX_LENGTH = 7;
|
||||
|
||||
private const MAGIC_HEADER = 'SEALCHK1';
|
||||
private const MAGIC = 'SEALCHK2';
|
||||
|
||||
private const DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB
|
||||
private const LEGACY_CHUNKED_MAGIC = 'SEALCHK1';
|
||||
|
||||
private const WRAPPED_KEY_ALGORITHM = 'argon2id';
|
||||
|
||||
/**
|
||||
* Derive an encryption key from a password and salt using PBKDF2-SHA256.
|
||||
* Derive a key from a password and salt using PBKDF2-SHA256, as shares created before
|
||||
* envelope encryption were keyed.
|
||||
*/
|
||||
public function deriveKey(string $password, string $salt): string
|
||||
{
|
||||
@@ -48,17 +70,147 @@ class FileEncryptionService
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a file using chunked AES-256-GCM.
|
||||
* Wrap a share's data key with a key derived from its password (Argon2id).
|
||||
*
|
||||
* Output format:
|
||||
* [8 bytes: "SEALCHK1" magic]
|
||||
* [4 bytes: chunk size, uint32 big-endian]
|
||||
* [12 bytes: base nonce]
|
||||
* Per chunk:
|
||||
* [16 bytes: GCM auth tag]
|
||||
* [N bytes: ciphertext (up to chunk_size)]
|
||||
* The result names its algorithm and parameters, so they can be raised later without breaking
|
||||
* shares wrapped before: `argon2id$<opslimit>$<memlimit>$<salt>$<nonce>$<box>`, in hex.
|
||||
*/
|
||||
public function encryptFile(string $sourcePath, string $destPath, string $key): void
|
||||
public function wrapKey(string $dataKeyHex, string $password): string
|
||||
{
|
||||
$salt = random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES);
|
||||
$opslimit = SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE;
|
||||
$memlimit = SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE;
|
||||
|
||||
$wrappingKey = $this->deriveWrappingKey($password, $salt, $opslimit, $memlimit);
|
||||
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
|
||||
$box = sodium_crypto_secretbox(hex2bin($dataKeyHex), $nonce, $wrappingKey);
|
||||
|
||||
sodium_memzero($wrappingKey);
|
||||
|
||||
return implode('$', [self::WRAPPED_KEY_ALGORITHM, $opslimit, $memlimit, bin2hex($salt), bin2hex($nonce), bin2hex($box)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a share's data key with its password; returns the key as hex.
|
||||
*/
|
||||
public function unwrapKey(string $wrappedKey, string $password): string
|
||||
{
|
||||
$parts = explode('$', $wrappedKey);
|
||||
|
||||
if (count($parts) !== 6 || $parts[0] !== self::WRAPPED_KEY_ALGORITHM) {
|
||||
throw new RuntimeException('Unsupported wrapped key');
|
||||
}
|
||||
|
||||
[, $opslimit, $memlimit, $salt, $nonce, $box] = $parts;
|
||||
|
||||
$wrappingKey = $this->deriveWrappingKey($password, hex2bin($salt), (int) $opslimit, (int) $memlimit);
|
||||
$dataKey = sodium_crypto_secretbox_open(hex2bin($box), hex2bin($nonce), $wrappingKey);
|
||||
|
||||
sodium_memzero($wrappingKey);
|
||||
|
||||
if ($dataKey === false) {
|
||||
throw new RuntimeException('Unwrapping failed - wrong password or corrupted key');
|
||||
}
|
||||
|
||||
return bin2hex($dataKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* The header a new encrypted file starts with, with a fresh random nonce prefix.
|
||||
*/
|
||||
public function createHeader(int $chunkSize): string
|
||||
{
|
||||
return self::MAGIC.pack('N', $chunkSize).random_bytes(self::NONCE_PREFIX_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a `SEALCHK2` header.
|
||||
*
|
||||
* @return array{chunkSize: int, noncePrefix: string}
|
||||
*/
|
||||
public function parseHeader(string $header): array
|
||||
{
|
||||
if (strlen($header) !== self::HEADER_LENGTH || ! str_starts_with($header, self::MAGIC)) {
|
||||
throw new RuntimeException('Invalid encrypted file header');
|
||||
}
|
||||
|
||||
return [
|
||||
'chunkSize' => unpack('N', substr($header, 8, 4))[1],
|
||||
'noncePrefix' => substr($header, 12, self::NONCE_PREFIX_LENGTH),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* How many chunks a file of this size is sent in; an empty file is one empty chunk.
|
||||
*/
|
||||
public function chunkCount(int $size, int $chunkSize): int
|
||||
{
|
||||
return max(1, intdiv($size + $chunkSize - 1, $chunkSize));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where chunk `$index` starts in the encrypted file.
|
||||
*/
|
||||
public function chunkOffset(int $index, int $chunkSize): int
|
||||
{
|
||||
return self::HEADER_LENGTH + $index * ($chunkSize + self::TAG_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt one chunk: its ciphertext followed by its tag, as WebCrypto returns it.
|
||||
*/
|
||||
public function encryptChunk(string $plaintext, string $key, string $noncePrefix, int $index, bool $isLast): string
|
||||
{
|
||||
$tag = '';
|
||||
|
||||
$ciphertext = openssl_encrypt(
|
||||
$plaintext,
|
||||
self::CIPHER,
|
||||
$this->normalizeToBinaryKey($key),
|
||||
OPENSSL_RAW_DATA,
|
||||
$this->chunkNonce($noncePrefix, $index, $isLast),
|
||||
$tag,
|
||||
'',
|
||||
self::TAG_LENGTH,
|
||||
);
|
||||
|
||||
if ($ciphertext === false) {
|
||||
throw new RuntimeException('Encryption failed at chunk '.$index);
|
||||
}
|
||||
|
||||
return $ciphertext.$tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt one chunk, which fails unless its index and last-chunk flag are the ones it was
|
||||
* encrypted with.
|
||||
*/
|
||||
public function decryptChunk(string $chunk, string $key, string $noncePrefix, int $index, bool $isLast): string
|
||||
{
|
||||
if (strlen($chunk) < self::TAG_LENGTH) {
|
||||
throw new RuntimeException('Invalid encrypted file: truncated chunk '.$index);
|
||||
}
|
||||
|
||||
$plaintext = openssl_decrypt(
|
||||
substr($chunk, 0, -self::TAG_LENGTH),
|
||||
self::CIPHER,
|
||||
$this->normalizeToBinaryKey($key),
|
||||
OPENSSL_RAW_DATA,
|
||||
$this->chunkNonce($noncePrefix, $index, $isLast),
|
||||
substr($chunk, -self::TAG_LENGTH),
|
||||
);
|
||||
|
||||
if ($plaintext === false) {
|
||||
throw new RuntimeException('Decryption failed - wrong key or corrupted data');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a file on the server in the `SEALCHK2` format.
|
||||
*/
|
||||
public function encryptFile(string $sourcePath, string $destPath, string $key, int $chunkSize): void
|
||||
{
|
||||
$source = fopen($sourcePath, 'rb');
|
||||
|
||||
@@ -75,45 +227,16 @@ class FileEncryptionService
|
||||
}
|
||||
|
||||
try {
|
||||
$binaryKey = $this->normalizeToBinaryKey($key);
|
||||
$baseNonce = random_bytes(self::NONCE_LENGTH);
|
||||
$chunkSize = self::DEFAULT_CHUNK_SIZE;
|
||||
$header = $this->createHeader($chunkSize);
|
||||
$noncePrefix = $this->parseHeader($header)['noncePrefix'];
|
||||
$chunkCount = $this->chunkCount((int) filesize($sourcePath), $chunkSize);
|
||||
|
||||
// Write header
|
||||
fwrite($dest, self::MAGIC_HEADER);
|
||||
fwrite($dest, pack('N', $chunkSize));
|
||||
fwrite($dest, $baseNonce);
|
||||
fwrite($dest, $header);
|
||||
|
||||
$chunkIndex = 0;
|
||||
for ($index = 0; $index < $chunkCount; $index++) {
|
||||
$plaintext = (string) fread($source, $chunkSize);
|
||||
|
||||
while (! feof($source)) {
|
||||
$plaintext = fread($source, $chunkSize);
|
||||
|
||||
if ($plaintext === false || $plaintext === '') {
|
||||
break;
|
||||
}
|
||||
|
||||
$nonce = $this->deriveChunkNonce($baseNonce, $chunkIndex);
|
||||
$tag = '';
|
||||
|
||||
$ciphertext = openssl_encrypt(
|
||||
$plaintext,
|
||||
self::CIPHER,
|
||||
$binaryKey,
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$tag,
|
||||
'',
|
||||
self::TAG_LENGTH,
|
||||
);
|
||||
|
||||
if ($ciphertext === false) {
|
||||
throw new RuntimeException('Encryption failed at chunk '.$chunkIndex);
|
||||
}
|
||||
|
||||
fwrite($dest, $tag);
|
||||
fwrite($dest, $ciphertext);
|
||||
$chunkIndex++;
|
||||
fwrite($dest, $this->encryptChunk($plaintext, $key, $noncePrefix, $index, $index === $chunkCount - 1));
|
||||
}
|
||||
} catch (RuntimeException $e) {
|
||||
fclose($source);
|
||||
@@ -127,74 +250,33 @@ class FileEncryptionService
|
||||
fclose($dest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a file and return the plaintext content.
|
||||
*/
|
||||
public function decryptFile(string $encryptedPath, string $key): string
|
||||
{
|
||||
if ($this->isChunkedFormat($encryptedPath)) {
|
||||
$parts = [];
|
||||
|
||||
foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) {
|
||||
$parts[] = $chunk;
|
||||
}
|
||||
|
||||
return implode('', $parts);
|
||||
}
|
||||
|
||||
return $this->decryptLegacy($encryptedPath, $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a file and stream the response.
|
||||
*/
|
||||
public function decryptFileStream(string $encryptedPath, string $key, string $filename, string $mimeType, ?int $fileSize = null): StreamedResponse
|
||||
{
|
||||
$headers = [
|
||||
'Content-Type' => $mimeType ?: 'application/octet-stream',
|
||||
'Content-Disposition' => HeaderUtils::makeDisposition('attachment', $filename, 'download'),
|
||||
];
|
||||
|
||||
if ($fileSize !== null) {
|
||||
$headers['Content-Length'] = $fileSize;
|
||||
}
|
||||
|
||||
if ($this->isChunkedFormat($encryptedPath)) {
|
||||
return new StreamedResponse(function () use ($encryptedPath, $key): void {
|
||||
foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) {
|
||||
echo $chunk;
|
||||
flush();
|
||||
}
|
||||
}, 200, $headers);
|
||||
}
|
||||
|
||||
$content = $this->decryptLegacy($encryptedPath, $key);
|
||||
|
||||
if (! isset($headers['Content-Length'])) {
|
||||
$headers['Content-Length'] = strlen($content);
|
||||
}
|
||||
|
||||
return new StreamedResponse(function () use ($content): void {
|
||||
echo $content;
|
||||
}, 200, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream decrypted file content directly to output (echo).
|
||||
* Use this when you need to add post-streaming logic inside a StreamedResponse callback.
|
||||
*/
|
||||
public function streamDecryptedFile(string $encryptedPath, string $key): void
|
||||
{
|
||||
if ($this->isChunkedFormat($encryptedPath)) {
|
||||
foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) {
|
||||
echo $chunk;
|
||||
flush();
|
||||
}
|
||||
|
||||
return;
|
||||
foreach ($this->decryptedChunks($encryptedPath, $key) as $chunk) {
|
||||
echo $chunk;
|
||||
flush();
|
||||
}
|
||||
}
|
||||
|
||||
echo $this->decryptLegacy($encryptedPath, $key);
|
||||
/**
|
||||
* The decrypted content of a file in any of the three formats, chunk by chunk.
|
||||
*
|
||||
* @return Generator<int, string>
|
||||
*/
|
||||
public function decryptedChunks(string $encryptedPath, string $key): Generator
|
||||
{
|
||||
$magic = (string) file_get_contents($encryptedPath, false, null, 0, 8);
|
||||
|
||||
if ($magic === self::MAGIC) {
|
||||
yield from $this->decryptChunks($encryptedPath, $key);
|
||||
} elseif ($magic === self::LEGACY_CHUNKED_MAGIC) {
|
||||
yield from $this->decryptLegacyChunks($encryptedPath, $key);
|
||||
} else {
|
||||
yield $this->decryptLegacy($encryptedPath, $key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,73 +287,29 @@ class FileEncryptionService
|
||||
return strlen($key) === 64 ? hex2bin($key) : $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a unique nonce for a chunk by XORing the chunk index into the last 4 bytes.
|
||||
*/
|
||||
private function deriveChunkNonce(string $baseNonce, int $chunkIndex): string
|
||||
private function deriveWrappingKey(string $password, string $salt, int $opslimit, int $memlimit): string
|
||||
{
|
||||
$nonce = $baseNonce;
|
||||
$indexBytes = pack('N', $chunkIndex);
|
||||
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$nonce[self::NONCE_LENGTH - 4 + $i] = $nonce[self::NONCE_LENGTH - 4 + $i] ^ $indexBytes[$i];
|
||||
}
|
||||
|
||||
return $nonce;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file uses the chunked encryption format.
|
||||
*/
|
||||
private function isChunkedFormat(string $path): bool
|
||||
{
|
||||
$handle = fopen($path, 'rb');
|
||||
|
||||
if ($handle === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$magic = fread($handle, 8);
|
||||
fclose($handle);
|
||||
|
||||
return $magic === self::MAGIC_HEADER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a legacy single-block encrypted file.
|
||||
* Format: [12-byte nonce][16-byte auth tag][ciphertext]
|
||||
*/
|
||||
private function decryptLegacy(string $encryptedPath, string $key): string
|
||||
{
|
||||
$data = file_get_contents($encryptedPath);
|
||||
|
||||
if ($data === false) {
|
||||
throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}");
|
||||
}
|
||||
|
||||
$binaryKey = $this->normalizeToBinaryKey($key);
|
||||
$nonce = substr($data, 0, self::NONCE_LENGTH);
|
||||
$tag = substr($data, self::NONCE_LENGTH, self::TAG_LENGTH);
|
||||
$ciphertext = substr($data, self::NONCE_LENGTH + self::TAG_LENGTH);
|
||||
|
||||
$plaintext = openssl_decrypt(
|
||||
$ciphertext,
|
||||
self::CIPHER,
|
||||
$binaryKey,
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$tag,
|
||||
return sodium_crypto_pwhash(
|
||||
SODIUM_CRYPTO_SECRETBOX_KEYBYTES,
|
||||
$password,
|
||||
$salt,
|
||||
$opslimit,
|
||||
$memlimit,
|
||||
SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13,
|
||||
);
|
||||
|
||||
if ($plaintext === false) {
|
||||
throw new RuntimeException('Decryption failed - wrong key or corrupted data');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generator that yields decrypted plaintext chunks from a chunked encrypted file.
|
||||
* A `SEALCHK2` chunk's nonce: the file's prefix, the chunk index and the last-chunk flag.
|
||||
*/
|
||||
private function chunkNonce(string $noncePrefix, int $index, bool $isLast): string
|
||||
{
|
||||
return $noncePrefix.pack('N', $index).($isLast ? "\x01" : "\x00");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a `SEALCHK2` file; the chunk count comes from the file's length, so a file cut short
|
||||
* at a chunk boundary fails on its new last chunk.
|
||||
*
|
||||
* @return Generator<int, string>
|
||||
*/
|
||||
@@ -284,17 +322,44 @@ class FileEncryptionService
|
||||
}
|
||||
|
||||
try {
|
||||
// Read header
|
||||
$magic = fread($handle, 8);
|
||||
['chunkSize' => $chunkSize, 'noncePrefix' => $noncePrefix] = $this->parseHeader((string) fread($handle, self::HEADER_LENGTH));
|
||||
|
||||
if ($magic !== self::MAGIC_HEADER) {
|
||||
throw new RuntimeException('Invalid chunked file format');
|
||||
$storedChunkSize = $chunkSize + self::TAG_LENGTH;
|
||||
$payloadLength = (int) filesize($encryptedPath) - self::HEADER_LENGTH;
|
||||
$chunkCount = intdiv($payloadLength + $storedChunkSize - 1, $storedChunkSize);
|
||||
|
||||
if ($chunkCount === 0) {
|
||||
throw new RuntimeException('Invalid encrypted file: no chunks');
|
||||
}
|
||||
|
||||
$chunkSizeData = fread($handle, 4);
|
||||
$chunkSize = unpack('N', $chunkSizeData)[1];
|
||||
for ($index = 0; $index < $chunkCount; $index++) {
|
||||
$chunk = (string) fread($handle, $storedChunkSize);
|
||||
|
||||
$baseNonce = fread($handle, self::NONCE_LENGTH);
|
||||
yield $this->decryptChunk($chunk, $key, $noncePrefix, $index, $index === $chunkCount - 1);
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a `SEALCHK1` file.
|
||||
*
|
||||
* @return Generator<int, string>
|
||||
*/
|
||||
private function decryptLegacyChunks(string $encryptedPath, string $key): Generator
|
||||
{
|
||||
$handle = fopen($encryptedPath, 'rb');
|
||||
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}");
|
||||
}
|
||||
|
||||
try {
|
||||
fread($handle, 8);
|
||||
|
||||
$chunkSize = unpack('N', (string) fread($handle, 4))[1];
|
||||
$baseNonce = (string) fread($handle, self::NONCE_LENGTH);
|
||||
|
||||
if (strlen($baseNonce) !== self::NONCE_LENGTH) {
|
||||
throw new RuntimeException('Invalid chunked file: truncated header');
|
||||
@@ -320,14 +385,12 @@ class FileEncryptionService
|
||||
throw new RuntimeException('Invalid chunked file: missing ciphertext at chunk '.$chunkIndex);
|
||||
}
|
||||
|
||||
$nonce = $this->deriveChunkNonce($baseNonce, $chunkIndex);
|
||||
|
||||
$plaintext = openssl_decrypt(
|
||||
$ciphertext,
|
||||
self::CIPHER,
|
||||
$binaryKey,
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$this->legacyChunkNonce($baseNonce, $chunkIndex),
|
||||
$tag,
|
||||
);
|
||||
|
||||
@@ -342,4 +405,47 @@ class FileEncryptionService
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `SEALCHK1` chunk's nonce: the chunk index XORed into the last 4 bytes of the base nonce.
|
||||
*/
|
||||
private function legacyChunkNonce(string $baseNonce, int $chunkIndex): string
|
||||
{
|
||||
$nonce = $baseNonce;
|
||||
$indexBytes = pack('N', $chunkIndex);
|
||||
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$nonce[self::NONCE_LENGTH - 4 + $i] = $nonce[self::NONCE_LENGTH - 4 + $i] ^ $indexBytes[$i];
|
||||
}
|
||||
|
||||
return $nonce;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a legacy single-block encrypted file.
|
||||
* Format: [12-byte nonce][16-byte auth tag][ciphertext]
|
||||
*/
|
||||
private function decryptLegacy(string $encryptedPath, string $key): string
|
||||
{
|
||||
$data = file_get_contents($encryptedPath);
|
||||
|
||||
if ($data === false) {
|
||||
throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}");
|
||||
}
|
||||
|
||||
$plaintext = openssl_decrypt(
|
||||
substr($data, self::NONCE_LENGTH + self::TAG_LENGTH),
|
||||
self::CIPHER,
|
||||
$this->normalizeToBinaryKey($key),
|
||||
OPENSSL_RAW_DATA,
|
||||
substr($data, 0, self::NONCE_LENGTH),
|
||||
substr($data, self::NONCE_LENGTH, self::TAG_LENGTH),
|
||||
);
|
||||
|
||||
if ($plaintext === false) {
|
||||
throw new RuntimeException('Decryption failed - wrong key or corrupted data');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
}
|
||||
|
||||
+276
-48
@@ -5,12 +5,20 @@ namespace App\Services;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use InvalidArgumentException;
|
||||
use League\MimeTypeDetection\FinfoMimeTypeDetector;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* A share's life: files registered into a pending share, their encrypted chunks stored as the
|
||||
* uploader's browser sends them, and the share completed with its options.
|
||||
*/
|
||||
class ShareService
|
||||
{
|
||||
public function __construct(
|
||||
@@ -18,67 +26,236 @@ class ShareService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new share with encrypted files.
|
||||
* Register a file the uploader's browser is about to send, in the given pending share or in a
|
||||
* new one, and write its encrypted file's header.
|
||||
*
|
||||
* @param array<int, array{file: UploadedFile, relativePath: string|null}> $files
|
||||
* @param array{password?: string|null, expires_at?: string|null, max_downloads?: int|null} $options
|
||||
* @throws ValidationException when the file breaks an admin limit
|
||||
*/
|
||||
public function createShare(array $files, array $options = []): Share
|
||||
public function registerFile(?Share $pendingShare, string $name, int $size, ?string $relativePath): ShareFile
|
||||
{
|
||||
$token = $this->generateUniqueToken();
|
||||
$salt = $this->encryptionService->generateSalt();
|
||||
$password = $options['password'] ?? null;
|
||||
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
|
||||
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
||||
$maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024);
|
||||
|
||||
if ($password) {
|
||||
$encryptionKey = $this->encryptionService->deriveKey($password, $salt);
|
||||
$encryptionKeyHex = bin2hex($encryptionKey);
|
||||
$storedEncryptionKey = null;
|
||||
} else {
|
||||
$encryptionKeyHex = $this->encryptionService->generateRandomKey();
|
||||
$storedEncryptionKey = $encryptionKeyHex;
|
||||
if ($name === '' || mb_strlen($name) > 255 || $size < 0) {
|
||||
$this->rejectFile(__('The file could not be added.'));
|
||||
}
|
||||
|
||||
$share = Share::query()->create([
|
||||
'token' => $token,
|
||||
'password' => $password ? Hash::make($password) : null,
|
||||
'encryption_key' => $storedEncryptionKey,
|
||||
'encryption_salt' => $salt,
|
||||
'expires_at' => $options['expires_at'] ?? null,
|
||||
'max_downloads' => $options['max_downloads'] ?? null,
|
||||
if ($size > $maxFileSize) {
|
||||
$this->rejectFile(__('":name" is too large (:size MB). Maximum file size is :max MB.', [
|
||||
'name' => $name,
|
||||
'size' => round($size / (1024 * 1024), 1),
|
||||
'max' => intdiv($maxFileSize, 1024 * 1024),
|
||||
]));
|
||||
}
|
||||
|
||||
if ($pendingShare && $pendingShare->files()->count() >= $maxFilesPerShare) {
|
||||
$this->rejectFile(__('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare]));
|
||||
}
|
||||
|
||||
if (($pendingShare?->total_size ?? 0) + $size > $maxSizePerShare) {
|
||||
$this->rejectFile(__('Total file size exceeds the maximum allowed per share.'));
|
||||
}
|
||||
|
||||
if ($this->getTotalUsedSpace() + $size > $this->getMaxStorageQuota()) {
|
||||
$this->rejectFile(__('Storage is full. Please contact the administrator.'));
|
||||
}
|
||||
|
||||
$share = $pendingShare ?? Share::query()->create([
|
||||
'token' => $this->generateUniqueToken(),
|
||||
'encryption_key' => $this->encryptionService->generateRandomKey(),
|
||||
'total_size' => 0,
|
||||
]);
|
||||
|
||||
$totalSize = 0;
|
||||
$storedName = Str::uuid().'.enc';
|
||||
|
||||
foreach ($files as $fileData) {
|
||||
/** @var UploadedFile $file */
|
||||
$file = $fileData['file'];
|
||||
$relativePath = $fileData['relativePath'] ?? null;
|
||||
$storedName = Str::uuid().'.enc';
|
||||
$storedPath = 'shares/'.$share->token.'/'.$storedName;
|
||||
Storage::disk('shares')->makeDirectory($share->token);
|
||||
Storage::disk('shares')->put($share->token.'/'.$storedName, $this->encryptionService->createHeader((int) config('uploads.chunk_size')));
|
||||
|
||||
$tempPath = $file->getRealPath();
|
||||
$destPath = Storage::disk('shares')->path($share->token.'/'.$storedName);
|
||||
$file = $share->files()->create([
|
||||
'original_name' => $name,
|
||||
'relative_path' => $this->sanitizeRelativePath($relativePath),
|
||||
'stored_path' => 'shares/'.$share->token.'/'.$storedName,
|
||||
'file_size' => $size,
|
||||
]);
|
||||
|
||||
Storage::disk('shares')->makeDirectory($share->token);
|
||||
$share->increment('total_size', $size);
|
||||
|
||||
$this->encryptionService->encryptFile($tempPath, $destPath, $encryptionKeyHex);
|
||||
return $file;
|
||||
}
|
||||
|
||||
ShareFile::query()->create([
|
||||
'share_id' => $share->id,
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'relative_path' => $relativePath,
|
||||
'stored_path' => $storedPath,
|
||||
'file_size' => $file->getSize(),
|
||||
'mime_type' => $file->getMimeType(),
|
||||
]);
|
||||
/**
|
||||
* Verify the encrypted chunk that comes next for a file and write it into place; returns how
|
||||
* many of the file's chunks are stored. The plaintext only exists in memory, to be checked.
|
||||
*
|
||||
* @throws InvalidArgumentException when the chunk has the wrong length or fails authentication
|
||||
* @throws ModelNotFoundException when the file was removed meanwhile
|
||||
*/
|
||||
public function storeChunk(ShareFile $file, int $index, string $chunk): int
|
||||
{
|
||||
$share = $file->share;
|
||||
$handle = @fopen($this->storedFilePath($file), 'r+b');
|
||||
|
||||
$totalSize += $file->getSize();
|
||||
if ($handle === false) {
|
||||
throw (new ModelNotFoundException)->setModel(ShareFile::class, [$file->id]);
|
||||
}
|
||||
|
||||
$share->update(['total_size' => $totalSize]);
|
||||
try {
|
||||
['chunkSize' => $chunkSize, 'noncePrefix' => $noncePrefix] = $this->encryptionService->parseHeader(
|
||||
(string) fread($handle, FileEncryptionService::HEADER_LENGTH),
|
||||
);
|
||||
|
||||
return $share->fresh();
|
||||
$chunkCount = $this->encryptionService->chunkCount($file->file_size, $chunkSize);
|
||||
$isLast = $index === $chunkCount - 1;
|
||||
|
||||
if ($index >= $chunkCount) {
|
||||
throw new InvalidArgumentException('Chunk '.$index.' is beyond the end of the file');
|
||||
}
|
||||
|
||||
$plaintextLength = $isLast ? $file->file_size - $index * $chunkSize : $chunkSize;
|
||||
|
||||
if (strlen($chunk) !== $plaintextLength + FileEncryptionService::TAG_LENGTH) {
|
||||
throw new InvalidArgumentException('Chunk '.$index.' has the wrong length');
|
||||
}
|
||||
|
||||
try {
|
||||
$plaintext = $this->encryptionService->decryptChunk($chunk, $share->encryption_key, $noncePrefix, $index, $isLast);
|
||||
} catch (RuntimeException) {
|
||||
throw new InvalidArgumentException('Chunk '.$index.' failed authentication');
|
||||
}
|
||||
|
||||
$mimeType = $index === 0
|
||||
? ((new FinfoMimeTypeDetector)->detectMimeType($file->original_name, $plaintext) ?? 'application/octet-stream')
|
||||
: $file->mime_type;
|
||||
|
||||
unset($plaintext);
|
||||
|
||||
if (fseek($handle, $this->encryptionService->chunkOffset($index, $chunkSize)) !== 0
|
||||
|| fwrite($handle, $chunk) !== strlen($chunk)
|
||||
|| ! fflush($handle)) {
|
||||
throw new RuntimeException('Cannot write chunk '.$index.' of file '.$file->id);
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
// Counted only once, even when a retry of the same chunk raced this request.
|
||||
$stored = ShareFile::query()
|
||||
->whereKey($file->id)
|
||||
->where('uploaded_chunks', $index)
|
||||
->update([
|
||||
'uploaded_chunks' => $index + 1,
|
||||
'mime_type' => $mimeType,
|
||||
'completed_at' => $isLast ? now() : null,
|
||||
]);
|
||||
|
||||
if ($stored === 0) {
|
||||
return ShareFile::query()->findOrFail($file->id)->uploaded_chunks;
|
||||
}
|
||||
|
||||
$share->touch();
|
||||
|
||||
return $index + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a file from a pending share, whether or not its upload finished.
|
||||
*/
|
||||
public function removeFile(ShareFile $file): void
|
||||
{
|
||||
Storage::disk('shares')->delete($file->share->token.'/'.basename($file->stored_path));
|
||||
|
||||
$file->share->decrement('total_size', $file->file_size);
|
||||
$file->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a pending share once every file has arrived: with a password the data key is
|
||||
* wrapped and no longer stored as it is.
|
||||
*
|
||||
* @param array{password?: string|null, expires_at?: mixed, max_downloads?: int|null} $options
|
||||
*
|
||||
* @throws ValidationException when files are missing, unfinished or break an admin limit
|
||||
*/
|
||||
public function completeShare(Share $share, array $options = []): Share
|
||||
{
|
||||
$files = $share->files()->get();
|
||||
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
||||
$maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024);
|
||||
|
||||
if ($files->isEmpty()) {
|
||||
$this->rejectFile(__('Please select at least one file to upload.'));
|
||||
}
|
||||
|
||||
if ($files->contains(fn (ShareFile $file): bool => $file->completed_at === null)) {
|
||||
$this->rejectFile(__('Wait until every file has finished uploading, or remove the ones that failed.'));
|
||||
}
|
||||
|
||||
if ($files->count() > $maxFilesPerShare) {
|
||||
$this->rejectFile(__('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare]));
|
||||
}
|
||||
|
||||
if ($files->sum('file_size') > $maxSizePerShare) {
|
||||
$this->rejectFile(__('Total file size exceeds the maximum allowed per share.'));
|
||||
}
|
||||
|
||||
$password = $options['password'] ?? null;
|
||||
|
||||
$share->update([
|
||||
'password' => $password ? Hash::make($password) : null,
|
||||
'wrapped_key' => $password ? $this->encryptionService->wrapKey($share->encryption_key, $password) : null,
|
||||
'encryption_key' => $password ? null : $share->encryption_key,
|
||||
'expires_at' => $options['expires_at'] ?? null,
|
||||
'max_downloads' => $options['max_downloads'] ?? null,
|
||||
'total_size' => $files->sum('file_size'),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
return $share;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a share from files already on the server, through the same steps an upload from the
|
||||
* browser takes. Used by tests and demo data.
|
||||
*
|
||||
* @param array<int, array{file: UploadedFile, relativePath: string|null}> $files
|
||||
* @param array{password?: string|null, expires_at?: mixed, max_downloads?: int|null} $options
|
||||
*/
|
||||
public function createShare(array $files, array $options = []): Share
|
||||
{
|
||||
$share = null;
|
||||
|
||||
foreach ($files as $fileData) {
|
||||
$file = $fileData['file'];
|
||||
$shareFile = $this->registerFile($share, $file->getClientOriginalName(), $file->getSize(), $fileData['relativePath'] ?? null);
|
||||
$share = $shareFile->share;
|
||||
|
||||
$header = $this->readHeader($shareFile);
|
||||
$source = fopen($file->getRealPath(), 'rb');
|
||||
|
||||
for ($index = 0; $index < $header['chunkCount']; $index++) {
|
||||
$plaintextLength = min($header['chunkSize'], $shareFile->file_size - $index * $header['chunkSize']);
|
||||
|
||||
// A fake upload reports a size its content does not have: zeros make up the rest.
|
||||
$chunk = $this->encryptionService->encryptChunk(
|
||||
str_pad($plaintextLength > 0 ? (string) fread($source, $plaintextLength) : '', $plaintextLength, "\0"),
|
||||
$share->encryption_key,
|
||||
$header['noncePrefix'],
|
||||
$index,
|
||||
$index === $header['chunkCount'] - 1,
|
||||
);
|
||||
|
||||
$this->storeChunk($shareFile->refresh(), $index, $chunk);
|
||||
}
|
||||
|
||||
fclose($source);
|
||||
}
|
||||
|
||||
if ($share === null) {
|
||||
$this->rejectFile(__('Please select at least one file to upload.'));
|
||||
}
|
||||
|
||||
return $this->completeShare($share, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,7 +285,8 @@ class ShareService
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the decryption key for a share.
|
||||
* Get the decryption key for a share: unwrapped with the password, derived from it for shares
|
||||
* created before key wrapping, or stored for shares without a password.
|
||||
*/
|
||||
public function getDecryptionKey(Share $share, ?string $password = null): string
|
||||
{
|
||||
@@ -117,6 +295,10 @@ class ShareService
|
||||
throw new RuntimeException('Password required for this share');
|
||||
}
|
||||
|
||||
if ($share->wrapped_key !== null) {
|
||||
return $this->encryptionService->unwrapKey($share->wrapped_key, $password);
|
||||
}
|
||||
|
||||
return bin2hex($this->encryptionService->deriveKey($password, $share->encryption_salt));
|
||||
}
|
||||
|
||||
@@ -148,7 +330,7 @@ class ShareService
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total used space in bytes.
|
||||
* Get total used space in bytes, files still being uploaded included.
|
||||
*/
|
||||
public function getTotalUsedSpace(): int
|
||||
{
|
||||
@@ -160,9 +342,7 @@ class ShareService
|
||||
*/
|
||||
public function isStorageFull(): bool
|
||||
{
|
||||
$maxQuota = (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024);
|
||||
|
||||
return $this->getTotalUsedSpace() >= $maxQuota;
|
||||
return $this->getTotalUsedSpace() >= $this->getMaxStorageQuota();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,4 +352,52 @@ class ShareService
|
||||
{
|
||||
return (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024);
|
||||
}
|
||||
|
||||
/**
|
||||
* A registered file's chunk size, nonce prefix and chunk count, from its encrypted file's header.
|
||||
*
|
||||
* @return array{chunkSize: int, noncePrefix: string, chunkCount: int}
|
||||
*/
|
||||
public function readHeader(ShareFile $file): array
|
||||
{
|
||||
$header = $this->encryptionService->parseHeader(
|
||||
(string) file_get_contents($this->storedFilePath($file), false, null, 0, FileEncryptionService::HEADER_LENGTH),
|
||||
);
|
||||
|
||||
return [...$header, 'chunkCount' => $this->encryptionService->chunkCount($file->file_size, $header['chunkSize'])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a file's encrypted content is stored on disk.
|
||||
*/
|
||||
public function storedFilePath(ShareFile $file): string
|
||||
{
|
||||
return Storage::disk('shares')->path($file->share->token.'/'.basename($file->stored_path));
|
||||
}
|
||||
|
||||
/**
|
||||
* A relative path from a dropped folder, or null when it could reach outside the share.
|
||||
*/
|
||||
private function sanitizeRelativePath(?string $relativePath): ?string
|
||||
{
|
||||
if ($relativePath === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$relativePath = str_replace('\\', '/', $relativePath);
|
||||
|
||||
if (str_starts_with($relativePath, '/') || str_contains($relativePath, '..')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function rejectFile(string $message): never
|
||||
{
|
||||
throw ValidationException::withMessages(['files' => $message]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user