Files
SealShare/app/Services/ShareService.php
T
Andreas Reinhold / reiniandClaude Opus 5.5 b594fb2300 Download large shares file by file, resumably
A 29 GB share with 254 files failed as one ZIP: the single request ran
into the 300-second request limit and, behind a proxy we cannot
configure, could be cut at any time with no way to resume.

Single-file downloads now answer HTTP Range requests (206, ETag and
If-Range, 416 past the end): a SEALCHK2 file is decrypted from the chunk
holding the first byte asked for. On that, the download page's new
"Download all to a folder" (File System Access API, Chrome and Edge on a
computer) saves every file into a folder the recipient picks, one
request per file, in its folder path; a broken connection is retried
from the byte on disk, complete files are skipped, and failed files get
a Retry button. Other browsers keep the ZIP and the per-file buttons.

The ZIP is sized up front with ZipStream's strict simulation and sent
with Content-Length, so the browser shows progress and notices a cut.
Both download routes lift the time limit for their streams.

The recipient's download window now restarts with each download, so a
long file-by-file download never outlasts it. The download page shows
the file count and total size and marks the files this browser has
downloaded, and the upload page gains "Browse Folder", which keeps each
file's path as a dropped folder does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:20:35 +02:00

522 lines
18 KiB
PHP

<?php
namespace App\Services;
use App\Models\Setting;
use App\Models\Share;
use App\Models\ShareFile;
use Carbon\CarbonInterface;
use Illuminate\Contracts\Session\Session;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
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;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* 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
{
/**
* How long a recipient may keep starting downloads of a share after their download was counted.
*/
public const DOWNLOAD_WINDOW_MINUTES = 60;
public function __construct(
private FileEncryptionService $encryptionService,
) {}
/**
* 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. The share's private text is registered the
* same way, as `text.txt` with `$isText`: it is capped at ShareFile::MAX_TEXT_BYTES and does not
* count as one of the share's files.
*
* @throws ValidationException when the file breaks an admin limit
*/
public function registerFile(?Share $pendingShare, string $name, int $size, ?string $relativePath, bool $isText = false): ShareFile
{
$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 ($name === '' || mb_strlen($name) > 255 || $size < 0) {
$this->rejectFile(__('The file could not be added.'));
}
if ($isText && ($size < 1 || $size > ShareFile::MAX_TEXT_BYTES)) {
$this->rejectFile(__('The text is too long (maximum 100 KB).'));
}
if (! $isText && $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 (! $isText && $pendingShare && $pendingShare->files()->where('is_text', false)->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,
]);
$storedName = Str::uuid().'.enc';
Storage::disk('shares')->makeDirectory($share->token);
Storage::disk('shares')->put($share->token.'/'.$storedName, $this->encryptionService->createHeader((int) config('uploads.chunk_size')));
$file = $share->files()->create([
'original_name' => $name,
'relative_path' => $this->sanitizeRelativePath($relativePath),
'stored_path' => 'shares/'.$share->token.'/'.$storedName,
'file_size' => $size,
'is_text' => $isText,
]);
$share->increment('total_size', $size);
return $file;
}
/**
* 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');
if ($handle === false) {
throw (new ModelNotFoundException)->setModel(ShareFile::class, [$file->id]);
}
try {
['chunkSize' => $chunkSize, 'noncePrefix' => $noncePrefix] = $this->encryptionService->parseHeader(
(string) fread($handle, FileEncryptionService::HEADER_LENGTH),
);
$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(__('Add files or a text to share.'));
}
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->where('is_text', false)->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, and optionally a private text, 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 = [], ?string $text = null): Share
{
$share = null;
foreach ($files as $fileData) {
$file = $fileData['file'];
$shareFile = $this->registerFile($share, $file->getClientOriginalName(), $file->getSize(), $fileData['relativePath'] ?? null);
$share = $shareFile->share;
$source = fopen($file->getRealPath(), 'rb');
$this->storeContent($shareFile, $source);
fclose($source);
}
if ($text !== null && $text !== '') {
$shareFile = $this->registerFile($share, 'text.txt', strlen($text), null, isText: true);
$share = $shareFile->share;
$source = fopen('php://memory', 'r+b');
fwrite($source, $text);
rewind($source);
$this->storeContent($shareFile, $source);
fclose($source);
}
if ($share === null) {
$this->rejectFile(__('Add files or a text to share.'));
}
return $this->completeShare($share, $options);
}
/**
* Encrypt a registered file's content chunk by chunk from a stream and store the chunks, as the
* uploader's browser would.
*
* @param resource $source
*/
private function storeContent(ShareFile $shareFile, $source): void
{
$share = $shareFile->share;
$header = $this->readHeader($shareFile);
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);
}
}
/**
* A share's private text, decrypted in memory with the share's key; null when it has none.
*/
public function readText(Share $share, string $key): ?string
{
$textFile = $share->textFile()->first();
if ($textFile === null) {
return null;
}
return implode('', iterator_to_array(
$this->encryptionService->decryptedChunks($this->storedFilePath($textFile), $key),
false,
));
}
/**
* The key this session decrypts a share with: the one unlocked with the password and kept in
* the session, or the stored one for a share without a password.
*
* @throws HttpException 403 while a password share is still locked for this session
*/
public function sessionDecryptionKey(Share $share, Session $session): string
{
if ($share->isPasswordProtected()) {
$key = $session->get('share_key_'.$share->token);
abort_if(! $key, 403, 'Password required');
return $key;
}
return $this->getDecryptionKey($share);
}
/**
* Generate a unique share token with retry on collision.
*/
private function generateUniqueToken(): string
{
for ($i = 0; $i < 5; $i++) {
$token = Str::random(16);
if (! Share::query()->where('token', $token)->exists()) {
return $token;
}
}
throw new RuntimeException('Unable to generate a unique share token');
}
/**
* Delete a share and its files from disk.
*/
public function deleteShare(Share $share): void
{
Storage::disk('shares')->deleteDirectory($share->token);
$share->delete();
}
/**
* 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
{
if ($share->isPasswordProtected()) {
if (! $password) {
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));
}
return $share->encryption_key;
}
/**
* Verify a password against a share's stored hash.
*/
public function verifyPassword(Share $share, string $password): bool
{
if (! $share->isPasswordProtected()) {
return true;
}
return Hash::check($password, $share->password);
}
/**
* When this session's download window for a share ends, or null while it has none open. The
* window opens with the session's counted download and each download started in it keeps it
* open, so downloading a large share file by file never outlasts it; until it ends, the session
* may start more downloads of the share without counting them, even once the share has
* reached its limit.
*/
public function downloadWindowEndsAt(Share $share, Session $session): ?CarbonInterface
{
$countedAt = $session->get($this->downloadSessionKey($share));
if (! is_int($countedAt)) {
return null;
}
$endsAt = Carbon::createFromTimestamp($countedAt)->addMinutes(self::DOWNLOAD_WINDOW_MINUTES);
return $endsAt->isFuture() ? $endsAt : null;
}
/**
* Let this session download from a share: one recipient's visit is one download, so a session
* without an open window counts one and opens its window. The limit is checked in the same
* update that counts, so two recipients who start at once cannot both take the last download.
* False when no download is left for this session.
*/
public function claimDownload(Share $share, Session $session): bool
{
if ($this->downloadWindowEndsAt($share, $session) !== null) {
$session->put($this->downloadSessionKey($share), now()->getTimestamp());
return true;
}
$counted = Share::query()
->whereKey($share->id)
->where(fn ($query) => $query->whereNull('max_downloads')->orWhereColumn('download_count', '<', 'max_downloads'))
->increment('download_count', 1, ['last_downloaded_at' => now()]);
if ($counted === 0) {
return false;
}
$session->put($this->downloadSessionKey($share), now()->getTimestamp());
return true;
}
/**
* The session key holding when this session last started a download of a share.
*/
private function downloadSessionKey(Share $share): string
{
return 'share_download_'.$share->token;
}
/**
* Get total used space in bytes, files still being uploaded included.
*/
public function getTotalUsedSpace(): int
{
return (int) Share::query()->sum('total_size');
}
/**
* Check if storage is full based on admin-configured max quota.
*/
public function isStorageFull(): bool
{
return $this->getTotalUsedSpace() >= $this->getMaxStorageQuota();
}
/**
* Get the maximum storage quota in bytes.
*/
public function getMaxStorageQuota(): int
{
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]);
}
}