Release 2.3.0
linter / quality (push) Successful in 1m5s
tests / ci (8.5) (push) Successful in 3m24s
docker / build-and-push (push) Successful in 7m10s
docker / test (8.5) (push) Successful in 3m21s
docker / release (push) Successful in 4s

A share can now hold a private text (a password, a key, a short note)
with its files or on its own. The upload page's new "Private text" card
takes up to 100 KB; the browser encrypts the text and sends it through
the same chunk pipeline as a file, flagged is_text on share_files, so it
gets the share's password, expiry, download limit and cleanup.

The recipient sees the text only after pressing "Show text", which
counts as their download, so a messenger 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".

The website gains a Private text feature card and a fifth phone
screenshot; every screenshot is retaken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-26 07:00:17 +02:00
co-authored by Claude Opus 5.5
parent 202813a1e6
commit f9a7839ad3
73 changed files with 806 additions and 134 deletions
@@ -3,8 +3,9 @@
@if ($isStorageFull && $pendingFiles->isEmpty())
<x-alert color="warning" :title="__('Storage is full. Uploads are temporarily disabled.')" />
@else
{{-- Submitting sends the private text first (submit(), resources/js/share-uploader.js), then creates the share. --}}
<x-form
wire:submit="createShare"
x-on:submit.prevent="submit()"
x-data="shareUploader({
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
messages: {{ \Illuminate\Support\Js::from([
@@ -12,6 +13,7 @@
'uploaded' => __('Uploaded'),
'failed' => __('Upload failed'),
'sessionExpired' => __('Your session expired. Reload the page to upload again.'),
'textFailed' => __('The text could not be sent. Try again.'),
]) }},
})"
x-on:beforeunload.window="warnBeforeLeaving($event)"
@@ -95,6 +97,30 @@
</x-stack>
@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. --}}
<x-card :title="__('Private text')" heading="h2" variant="outlined">
<x-stack gap="space100">
<x-textarea
full
x-model="text"
:label="__('Text')"
:maxlength="$maxTextBytes"
autocomplete="off"
spellcheck="false"
data-test="share-text"
/>
<p class="md-type-body-sm md-tabular" x-bind:class="textBytes > {{ $maxTextBytes }} ? 'md-ink-error' : 'md-ink-variant'" data-test="share-text-size">
<span x-text="textBytes"></span> {{ __('bytes') }} / {{ Number::fileSize($maxTextBytes) }}
</p>
@error('text')
<x-alert color="error">{{ $message }}</x-alert>
@enderror
</x-stack>
</x-card>
{{-- Options --}}
<x-card :title="__('Share Options')" heading="h2" variant="outlined">
<x-stack gap="space200">
@@ -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"
/>
</x-slot:actions>