diff --git a/CHANGELOG.md b/CHANGELOG.md index f97f956..cc572a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.3.0] - 2026-09-26 + +### Added + +- Private text: a share can hold a text, such as a password, a key or a short note, with its files or on its own. It is typed into the new "Private text" card on the upload page (up to 100 KB) and encrypted in the browser like a file, so it gets the share's password, expiry and download limit. The recipient sees it only after pressing "Show text", which counts as their download, so a link preview cannot use up a share limited to one download. The text is left out of the file list, the ZIP and the file counts; the admin dashboard marks shares that hold one with "Text". + ## [2.2.0] - 2026-09-19 ### Added @@ -168,6 +174,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Dark themed UI built with Livewire, Alpine.js, Tailwind CSS and DaisyUI. - Docker images published to `ghcr.io/surtic86/sealshare`, served by FrankenPHP via Laravel Octane. +[2.3.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.2.0...v2.3.0 [2.2.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.1.0...v2.2.0 [2.1.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.1...v2.1.0 [2.0.1]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.0...v2.0.1 diff --git a/README.md b/README.md index 8e0438f..89eefbd 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A simple, self-hosted file sharing solution built with Laravel. Upload files, ge ## Features - **File Uploading** — Drag & drop or browse to upload single/multiple files and folders with real-time progress; large files go up in chunks, each retried on its own if the connection drops +- **Private Text** — Share a password, a key or a short note (up to 100 KB) with the files or on its own; it is encrypted in the browser like a file and stays hidden until the recipient presses "Show text", which counts as a download - **Shareable Links** — Each upload generates a unique link for recipients, also as a QR code (saved as a PNG) or through the device's share sheet - **Encryption at Rest** — Files are encrypted in the uploader's browser, chunk by chunk with AES-256-GCM, before they are sent, and are stored only in encrypted form; with a share password the share's key is wrapped with a key derived from it (Argon2id) and never stored as it is. It is not end-to-end encryption: the server issues the key, checks each chunk, and decrypts the files for downloads - **Password Protection** — Optionally protect shares with a password, typed or generated (random characters or a passphrase, as the admin configures) and copied on the upload page or next to the new link diff --git a/app/Http/Controllers/DownloadController.php b/app/Http/Controllers/DownloadController.php index 0c6cf39..e29593e 100644 --- a/app/Http/Controllers/DownloadController.php +++ b/app/Http/Controllers/DownloadController.php @@ -23,14 +23,17 @@ class DownloadController extends Controller /** * 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. + * compression, with ZIP64 for files over 4 GB, and never held in memory or written to disk. The + * private text is left out: it is only ever shown on the page. */ public function download(Request $request, Share $share): StreamedResponse { abort_if(! $share->isCompleted() || $share->isExpired(), 404); - $share->load('files'); - $key = $this->resolveDecryptionKey($share); + $share->load(['files' => fn ($query) => $query->where('is_text', false)]); + abort_if($share->files->isEmpty(), 404); + + $key = $this->shareService->sessionDecryptionKey($share, $request->session()); // Counted before the body streams: the session is saved by then. abort_unless($this->shareService->claimDownload($share, $request->session()), 404); @@ -73,14 +76,14 @@ class DownloadController extends Controller } /** - * Download a single file. + * Download a single file; never the private text, which is only ever shown on the page. */ public function downloadFile(Request $request, Share $share, ShareFile $shareFile): StreamedResponse { abort_if(! $share->isCompleted() || $share->isExpired(), 404); - abort_if($shareFile->share_id !== $share->id, 404); + abort_if($shareFile->share_id !== $share->id || $shareFile->is_text, 404); - $key = $this->resolveDecryptionKey($share); + $key = $this->shareService->sessionDecryptionKey($share, $request->session()); abort_unless($this->shareService->claimDownload($share, $request->session()), 404); @@ -122,20 +125,4 @@ class DownloadController extends Controller return $filename; } - - /** - * Resolve the decryption key from session or share. - */ - private function resolveDecryptionKey(Share $share): string - { - if ($share->isPasswordProtected()) { - $key = session('share_key_'.$share->token); - - abort_if(! $key, 403, 'Password required'); - - return $key; - } - - return $this->shareService->getDecryptionKey($share); - } } diff --git a/app/Livewire/Admin/AdminDashboard.php b/app/Livewire/Admin/AdminDashboard.php index aa5d15d..e236bcc 100644 --- a/app/Livewire/Admin/AdminDashboard.php +++ b/app/Livewire/Admin/AdminDashboard.php @@ -59,7 +59,9 @@ class AdminDashboard extends Component // 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') + // A share's private text is not one of its files. + ->withCount(['files' => fn ($query) => $query->where('is_text', false)]) + ->withExists('textFile') // Shares that never expire come after every share that does, whichever way expiry is sorted. ->when($column === 'expires_at', fn ($query) => $query->orderByRaw('expires_at is null')) ->orderBy($column, $direction) @@ -74,7 +76,7 @@ class AdminDashboard extends Component })->where(function ($q) { $q->whereNull('max_downloads')->orWhereColumn('download_count', '<', 'max_downloads'); })->count(), - 'totalFiles' => ShareFile::query()->whereHas('share', fn ($query) => $query->whereNotNull('completed_at'))->count(), + 'totalFiles' => ShareFile::query()->where('is_text', false)->whereHas('share', fn ($query) => $query->whereNotNull('completed_at'))->count(), 'usedSpace' => $shareService->getTotalUsedSpace(), 'maxQuota' => $shareService->getMaxStorageQuota(), 'version' => config('app.version'), diff --git a/app/Livewire/FileUploader.php b/app/Livewire/FileUploader.php index 6536789..1612e58 100644 --- a/app/Livewire/FileUploader.php +++ b/app/Livewire/FileUploader.php @@ -4,6 +4,7 @@ namespace App\Livewire; use App\Models\Setting; use App\Models\Share; +use App\Models\ShareFile; use App\Services\PasswordGeneratorService; use App\Services\ShareService; use Carbon\CarbonInterval; @@ -73,26 +74,50 @@ class FileUploader extends Component continue; } - if ($this->pendingToken !== $shareFile->share->token) { - $this->pendingToken = $shareFile->share->token; - session()->push('pending_shares', $this->pendingToken); - } + $this->rememberPendingShare($shareFile->share); - $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'], - ]; + $targets[] = $this->uploadTarget($shareFile, $shareService); } return $targets; } + /** + * Register the private text as the share is created, in place of any text an earlier attempt + * registered, and hand the browser what it encrypts and sends it with. Only its size in UTF-8 + * bytes reaches the server here: the text itself arrives encrypted, like a file. Nothing is + * registered for an empty text; a text an admin limit refuses gets `null` and the reason under + * `text`. + * + * @return array{id: int, url: string, key: string, noncePrefix: string, chunkSize: int, chunkCount: int}|null + */ + public function registerText(int $size, ShareService $shareService): ?array + { + $this->resetErrorBag('text'); + + $existingText = $this->pendingShare()?->textFile()->first(); + + if ($existingText !== null) { + $shareService->removeFile($existingText); + } + + if ($size === 0) { + return null; + } + + try { + $shareFile = $shareService->registerFile($this->pendingShare(), 'text.txt', $size, null, isText: true); + } catch (ValidationException $e) { + $this->addError('text', $e->errors()['files'][0]); + + return null; + } + + $this->rememberPendingShare($shareFile->share); + + return $this->uploadTarget($shareFile, $shareService); + } + /** * Take files out of the pending share, whether or not their upload finished. * @@ -153,7 +178,7 @@ class FileUploader extends Component $pendingShare = $this->pendingShare(); if ($pendingShare === null) { - $this->addError('files', __('Please select at least one file to upload.')); + $this->addError('files', __('Add files or a text to share.')); return; } @@ -183,17 +208,48 @@ class FileUploader extends Component public function render(): mixed { $shareService = app(ShareService::class); - $pendingFiles = $this->pendingShare()?->files()->orderBy('id')->get() ?? collect(); + $pendingFiles = $this->pendingShare()?->files()->where('is_text', false)->orderBy('id')->get() ?? collect(); return view('livewire.file-uploader', [ 'pendingFiles' => $pendingFiles, - 'allFilesUploaded' => $pendingFiles->isNotEmpty() && $pendingFiles->every(fn ($file): bool => $file->completed_at !== null), + 'allFilesUploaded' => $pendingFiles->every(fn ($file): bool => $file->completed_at !== null), + 'maxTextBytes' => ShareFile::MAX_TEXT_BYTES, 'isStorageFull' => $shareService->isStorageFull(), 'allowNeverExpire' => (bool) Setting::get('allow_never_expire', false), 'passwordGeneratorMode' => app(PasswordGeneratorService::class)->mode(), ]); } + /** + * Make a share the one this page uploads into, and let this session reach it. + */ + private function rememberPendingShare(Share $share): void + { + if ($this->pendingToken !== $share->token) { + $this->pendingToken = $share->token; + session()->push('pending_shares', $this->pendingToken); + } + } + + /** + * What the browser encrypts and sends a registered file with. + * + * @return array{id: int, url: string, key: string, noncePrefix: string, chunkSize: int, chunkCount: int} + */ + private function uploadTarget(ShareFile $shareFile, ShareService $shareService): array + { + $header = $shareService->readHeader($shareFile); + + return [ + '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'], + ]; + } + /** * This page's pending share, while it is still pending and this session started it. */ diff --git a/app/Livewire/ShareDownload.php b/app/Livewire/ShareDownload.php index f14442f..fe35c06 100644 --- a/app/Livewire/ShareDownload.php +++ b/app/Livewire/ShareDownload.php @@ -7,6 +7,7 @@ use App\Services\ShareService; use Carbon\CarbonInterval; use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Layout; +use Livewire\Attributes\Renderless; use Livewire\Attributes\Validate; use Livewire\Component; @@ -60,11 +61,34 @@ class ShareDownload extends Component $this->authenticated = true; } + /** + * The share's private text, for the recipient who pressed "Show text": counted as their + * download, as a file would be. Returned to the browser only, never kept in a property, so it + * is not in the component's snapshot. Null when the text is no longer available; no abort(), + * which would open Livewire's error modal. Renderless: a re-render would morph the download + * note Alpine just switched back into the one it hid. + */ + #[Renderless] + public function revealText(ShareService $shareService): ?string + { + $share = $this->share; + + if (! $share->isCompleted() || $share->isExpired() || ! $share->hasText() + || ($share->isPasswordProtected() && ! session('share_key_'.$share->token)) + || ! $shareService->claimDownload($share, session()->driver())) { + return null; + } + + return $shareService->readText($share, $shareService->sessionDecryptionKey($share, session()->driver())); + } + public function render(): mixed { $shareService = app(ShareService::class); return view('livewire.share-download', [ + 'files' => $this->share->files->where('is_text', false)->values(), + 'hasText' => $this->share->files->contains('is_text', true), 'downloadWindowEndsAt' => $shareService->downloadWindowEndsAt($this->share, session()->driver()), 'remainingDownloads' => $this->share->max_downloads ? max($this->share->max_downloads - $this->share->download_count, 0) : null, 'downloadWindow' => CarbonInterval::minutes(ShareService::DOWNLOAD_WINDOW_MINUTES)->cascade()->forHumans(), diff --git a/app/Models/Share.php b/app/Models/Share.php index 45972ca..3b68fb0 100644 --- a/app/Models/Share.php +++ b/app/Models/Share.php @@ -5,6 +5,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; class Share extends Model { @@ -62,6 +63,21 @@ class Share extends Model return $this->hasMany(ShareFile::class); } + /** + * The share's private text, stored as one of its files. + * + * @return HasOne + */ + public function textFile(): HasOne + { + return $this->hasOne(ShareFile::class)->where('is_text', true); + } + + public function hasText(): bool + { + return $this->textFile()->exists(); + } + /** * Whether the share was created: until then its files are still being uploaded and nobody * but the uploader's page may reach it. diff --git a/app/Models/ShareFile.php b/app/Models/ShareFile.php index 2f79c67..90bdc90 100644 --- a/app/Models/ShareFile.php +++ b/app/Models/ShareFile.php @@ -10,6 +10,11 @@ class ShareFile extends Model { use HasFactory; + /** + * The most a share's private text may hold, in UTF-8 bytes: one chunk of the upload pipeline. + */ + public const MAX_TEXT_BYTES = 100 * 1024; + protected $fillable = [ 'share_id', 'original_name', @@ -17,6 +22,7 @@ class ShareFile extends Model 'stored_path', 'file_size', 'mime_type', + 'is_text', 'uploaded_chunks', 'completed_at', ]; @@ -28,6 +34,7 @@ class ShareFile extends Model { return [ 'file_size' => 'integer', + 'is_text' => 'boolean', 'uploaded_chunks' => 'integer', 'completed_at' => 'datetime', ]; diff --git a/app/Services/ShareService.php b/app/Services/ShareService.php index 1c3e790..8dbb503 100644 --- a/app/Services/ShareService.php +++ b/app/Services/ShareService.php @@ -17,6 +17,7 @@ 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 @@ -35,11 +36,13 @@ class ShareService /** * 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. + * 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): ShareFile + 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); @@ -49,7 +52,11 @@ class ShareService $this->rejectFile(__('The file could not be added.')); } - if ($size > $maxFileSize) { + 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), @@ -57,7 +64,7 @@ class ShareService ])); } - if ($pendingShare && $pendingShare->files()->count() >= $maxFilesPerShare) { + if (! $isText && $pendingShare && $pendingShare->files()->where('is_text', false)->count() >= $maxFilesPerShare) { $this->rejectFile(__('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare])); } @@ -85,6 +92,7 @@ class ShareService 'relative_path' => $this->sanitizeRelativePath($relativePath), 'stored_path' => 'shares/'.$share->token.'/'.$storedName, 'file_size' => $size, + 'is_text' => $isText, ]); $share->increment('total_size', $size); @@ -192,14 +200,14 @@ class ShareService $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.')); + $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->count() > $maxFilesPerShare) { + if ($files->where('is_text', false)->count() > $maxFilesPerShare) { $this->rejectFile(__('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare])); } @@ -223,13 +231,13 @@ class ShareService } /** - * 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. + * 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 $files * @param array{password?: string|null, expires_at?: mixed, max_downloads?: int|null} $options */ - public function createShare(array $files, array $options = []): Share + public function createShare(array $files, array $options = [], ?string $text = null): Share { $share = null; @@ -238,34 +246,92 @@ class ShareService $shareFile = $this->registerFile($share, $file->getClientOriginalName(), $file->getSize(), $fileData['relativePath'] ?? null); $share = $shareFile->share; - $header = $this->readHeader($shareFile); $source = fopen($file->getRealPath(), 'rb'); + $this->storeContent($shareFile, $source); + fclose($source); + } - 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); - } + 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(__('Please select at least one file to upload.')); + $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. */ diff --git a/config/app.php b/config/app.php index 7cb74a2..f3d84f4 100644 --- a/config/app.php +++ b/config/app.php @@ -25,6 +25,6 @@ return [ | */ - 'version' => '2.2.0', + 'version' => '2.3.0', ]; diff --git a/database/factories/ShareFileFactory.php b/database/factories/ShareFileFactory.php index f6b5e71..661b117 100644 --- a/database/factories/ShareFileFactory.php +++ b/database/factories/ShareFileFactory.php @@ -25,6 +25,7 @@ class ShareFileFactory extends Factory 'stored_path' => 'shares/'.fake()->uuid().'.enc', 'file_size' => fake()->numberBetween(1024, 10485760), 'mime_type' => 'text/plain', + 'is_text' => false, 'uploaded_chunks' => 1, 'completed_at' => now(), ]; @@ -41,4 +42,17 @@ class ShareFileFactory extends Factory 'completed_at' => null, ]); } + + /** + * A share's private text. + */ + public function text(): static + { + return $this->state(fn (array $attributes) => [ + 'original_name' => 'text.txt', + 'relative_path' => null, + 'file_size' => fake()->numberBetween(1, 1024), + 'is_text' => true, + ]); + } } diff --git a/database/migrations/2026_09_26_043433_add_is_text_to_share_files_table.php b/database/migrations/2026_09_26_043433_add_is_text_to_share_files_table.php new file mode 100644 index 0000000..2f9821c --- /dev/null +++ b/database/migrations/2026_09_26_043433_add_is_text_to_share_files_table.php @@ -0,0 +1,31 @@ +boolean('is_text')->default(false)->after('mime_type'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('share_files', function (Blueprint $table) { + $table->dropColumn('is_text'); + }); + } +}; diff --git a/resources/css/app.css b/resources/css/app.css index 8bcdfe0..9115e00 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -243,6 +243,19 @@ block-size: 100%; } +/* + * resources/views/livewire/share-download.blade.php: the private text once the recipient shows it, + * as it was typed — its line breaks kept and a long word or key wrapped rather than overflowing. + */ +.share-text { + white-space: pre-wrap; + overflow-wrap: anywhere; + padding: var(--md-sys-measurement-space200); + border-radius: var(--md-sys-shape-corner-md); + background-color: var(--md-sys-color-surface-container-highest); + color: var(--md-sys-color-on-surface); +} + /* * resources/views/pages/settings/two-factor.blade.php: the setup QR code. Fortify's own * twoFactorQrCodeSvg() draws no quiet zone, so the SVG comes from App\Services\QrCodeService diff --git a/resources/js/share-uploader.js b/resources/js/share-uploader.js index 1780698..c186037 100644 --- a/resources/js/share-uploader.js +++ b/resources/js/share-uploader.js @@ -10,6 +10,10 @@ * A failed request is retried after 1, 2, 4, 8 and 16 seconds; after that the file waits for its * Retry button, which picks up from the chunk the server last confirmed. The server answers 409 * with its own count when a chunk skips ahead, and acknowledges a chunk it already has. + * + * The private text stays in this component (Alpine only, never a Livewire property) until the share + * is created: `submit()` registers its size, sends it through the same queue as a file named + * text.txt, and only then asks the component to create the share. */ const RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000] @@ -33,6 +37,14 @@ document.addEventListener('alpine:init', () => { /** The request on its way, so Cancel and Remove can abort it. */ request: null, + /** The private text, as typed. */ + text: '', + + /** The private text's size as the server counts it, in UTF-8 bytes. */ + get textBytes() { + return new TextEncoder().encode(this.text).length + }, + get progress() { const unfinished = Object.values(this.uploads).filter((upload) => upload.state !== 'failed') const size = unfinished.reduce((total, upload) => total + upload.size, 0) @@ -223,6 +235,43 @@ document.addEventListener('alpine:init', () => { }) }, + /** + * Send the private text, if there is one, then create the share. The text replaces any the + * server kept from an earlier attempt, so what is shared is what the field holds now. + */ + async submit() { + if (this.busy) { + return + } + + const text = this.text.trim() === '' ? '' : this.text + const target = await this.$wire.registerText(text === '' ? 0 : this.textBytes) + + if (text !== '') { + if (! target) { + return + } + + const item = { id: target.id, file: new File([text], 'text.txt', { type: 'text/plain' }), target, nextIndex: 0 } + + this.uploads[target.id] = { state: 'queued', sent: 0, size: item.file.size } + this.queue.push(item) + await this.run() + + const sent = this.uploads[target.id]?.state === 'uploaded' + + this.forget([target.id]) + + if (! sent) { + window.materialToast(messages.textFailed, { type: 'error' }) + + return + } + } + + await this.$wire.createShare() + }, + retry(id) { const item = this.failed[id] @@ -283,7 +332,7 @@ document.addEventListener('alpine:init', () => { }, warnBeforeLeaving(event) { - if (this.busy) { + if (this.busy || this.text !== '') { event.preventDefault() event.returnValue = '' } diff --git a/resources/views/livewire/admin/admin-dashboard.blade.php b/resources/views/livewire/admin/admin-dashboard.blade.php index 2f780e6..e3e6587 100644 --- a/resources/views/livewire/admin/admin-dashboard.blade.php +++ b/resources/views/livewire/admin/admin-dashboard.blade.php @@ -37,7 +37,7 @@ {{ $share->token }} - {{ trans_choice(':count file|:count files', $share->files_count) }} · {{ Number::fileSize($share->total_size) }} · {{ $share->max_downloads ? trans_choice(':count of :max download|:count of :max downloads', $share->max_downloads, ['count' => $share->download_count, 'max' => $share->max_downloads]) : trans_choice(':count download|:count downloads', $share->download_count) }} + {{ collect([$share->files_count || ! $share->text_file_exists ? trans_choice(':count file|:count files', $share->files_count) : null, $share->text_file_exists ? __('Text') : null])->filter()->implode(' · ') }} · {{ Number::fileSize($share->total_size) }} · {{ $share->max_downloads ? trans_choice(':count of :max download|:count of :max downloads', $share->max_downloads, ['count' => $share->download_count, 'max' => $share->max_downloads]) : trans_choice(':count download|:count downloads', $share->download_count) }} {{-- A share at its limit is closed; the cleanup deletes it a day after its last download. --}} @if ($share->hasReachedDownloadLimit()) diff --git a/resources/views/livewire/file-uploader.blade.php b/resources/views/livewire/file-uploader.blade.php index ec23540..a352cff 100644 --- a/resources/views/livewire/file-uploader.blade.php +++ b/resources/views/livewire/file-uploader.blade.php @@ -3,8 +3,9 @@ @if ($isStorageFull && $pendingFiles->isEmpty()) @else + {{-- Submitting sends the private text first (submit(), resources/js/share-uploader.js), then creates the share. --}} @endif + {{-- Private text: bound in Alpine only, never to the component, so it never reaches the server + unencrypted; it is sent with the share (submit()). The limit is in UTF-8 bytes, as the server counts. --}} + + + + +

+ {{ __('bytes') }} / {{ Number::fileSize($maxTextBytes) }} +

+ + @error('text') + {{ $message }} + @enderror +
+
+ {{-- Options --}} @@ -148,7 +174,7 @@ size="md" icon="link" spinner="createShare" - x-bind:disabled="busy || {{ $allFilesUploaded ? 'false' : 'true' }}" + x-bind:disabled="busy || {{ $allFilesUploaded ? 'false' : 'true' }} || ({{ $pendingFiles->isEmpty() ? 'true' : 'false' }} && text.trim() === '') || textBytes > {{ $maxTextBytes }}" data-test="create-share" /> diff --git a/resources/views/livewire/share-created.blade.php b/resources/views/livewire/share-created.blade.php index 813bb97..9b45ba6 100644 --- a/resources/views/livewire/share-created.blade.php +++ b/resources/views/livewire/share-created.blade.php @@ -1,4 +1,4 @@ - + {{-- The link is ready: a check on an Expressive shape that settles in (share-ready, app.css). --}}