Release 2.3.0
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:
co-authored by
Claude Opus 5.5
parent
202813a1e6
commit
f9a7839ad3
@@ -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
|
||||
|
||||
@@ -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 = ''
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<a href="{{ route('share.download', $share) }}" target="_blank" rel="noopener" class="md-link"><code>{{ $share->token }}</code></a>
|
||||
|
||||
<x-slot:description>
|
||||
<span class="admin-share-detail md-tabular">{{ 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) }}</span>
|
||||
<span class="admin-share-detail md-tabular">{{ 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) }}</span>
|
||||
|
||||
{{-- A share at its limit is closed; the cleanup deletes it a day after its last download. --}}
|
||||
@if ($share->hasReachedDownloadLimit())
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<x-page :title="__('Share Created!')" :description="__('Your files are ready to share')">
|
||||
<x-page :title="__('Share Created!')" :description="__('Your share is ready')">
|
||||
{{-- The link is ready: a check on an Expressive shape that settles in (share-ready, app.css). --}}
|
||||
<x-slot:mark>
|
||||
<div class="share-check">
|
||||
@@ -73,10 +73,14 @@
|
||||
</x-stack>
|
||||
|
||||
<x-grid :columns="2" gap="space200">
|
||||
<x-stat :title="__('Files')" :value="$share->files->count()" icon="description" />
|
||||
<x-stat :title="__('Files')" :value="$share->files->where('is_text', false)->count()" icon="description" />
|
||||
<x-stat :title="__('Total Size')" :value="Number::fileSize($share->total_size)" icon="hard_drive" />
|
||||
<x-stat :title="__('Expires')" :value="$share->expires_at ? $share->expires_at->diffForHumans() : __('Never')" icon="schedule" />
|
||||
<x-stat :title="__('Max Downloads')" :value="$share->max_downloads ?? __('Unlimited')" icon="download" />
|
||||
|
||||
@if ($share->hasText())
|
||||
<x-stat :title="__('Private text')" :value="Number::fileSize($share->textFile->file_size)" icon="notes" />
|
||||
@endif
|
||||
</x-grid>
|
||||
|
||||
@if ($share->isPasswordProtected())
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
iOS before Safari 18.4, which cannot position them. --}}
|
||||
|
||||
<x-page brand>
|
||||
{{-- Each state is one card under the page's h1: the card holds everything the recipient acts
|
||||
on, and it is the shape SealShare has always shown them. --}}
|
||||
{{-- Under the page's h1, what the recipient acts on is in cards: the password, or the share's
|
||||
private text and its files, each in its own card, with the share's expiry and limit under them. --}}
|
||||
@if (! $authenticated)
|
||||
<x-card :title="__('Password Required')" :subtitle="__('Enter the password to access these files')" heading="h2" variant="outlined">
|
||||
<x-card :title="__('Password Required')" :subtitle="__('Enter the password to open this share')" heading="h2" variant="outlined">
|
||||
<x-form wire:submit="verifyPassword">
|
||||
<x-password
|
||||
full
|
||||
@@ -22,32 +22,100 @@
|
||||
</x-form>
|
||||
</x-card>
|
||||
@else
|
||||
<x-card :title="__('Shared Files')" heading="h2" variant="outlined">
|
||||
{{-- A download link does not render the page again: the download limit's note switches
|
||||
on the first press here, and the server draws the open window on the next visit. --}}
|
||||
<x-stack gap="space200" x-data="{ downloaded: false }">
|
||||
<x-stack gap="space100">
|
||||
<x-list :label="__('Shared Files')">
|
||||
@foreach ($share->files as $file)
|
||||
<x-list-item
|
||||
:title="$file->relative_path ?: $file->original_name"
|
||||
icon="description"
|
||||
wire:key="file-{{ $file->id }}"
|
||||
>
|
||||
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span></x-slot:description>
|
||||
<x-slot:end>
|
||||
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" x-on:click="downloaded = true" />
|
||||
</x-slot:end>
|
||||
</x-list-item>
|
||||
@endforeach
|
||||
</x-list>
|
||||
{{-- A download link does not render the page again: the download limit's note switches on
|
||||
the first press of any download or "Show text", and the server draws the open window on
|
||||
the next visit. --}}
|
||||
<x-stack gap="space200" x-data="{ downloaded: false }">
|
||||
{{-- The text is fetched only when the recipient asks for it, never with the page: a link
|
||||
preview must not use up a share limited to one download. It stays in Alpine and is
|
||||
drawn as plain text. --}}
|
||||
@if ($hasText)
|
||||
<x-card :title="__('Private text')" heading="h2" variant="outlined">
|
||||
<x-stack
|
||||
gap="space200"
|
||||
x-data="{
|
||||
text: null,
|
||||
async reveal() {
|
||||
const text = await $wire.revealText();
|
||||
|
||||
if (text === null) {
|
||||
window.materialToast({{ \Illuminate\Support\Js::from(__('This text is no longer available.')) }}, { type: 'error' });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.text = text;
|
||||
downloaded = true;
|
||||
},
|
||||
}"
|
||||
>
|
||||
<template x-if="text !== null">
|
||||
<x-stack gap="space200">
|
||||
<div class="share-text md-type-body-lg" x-text="text" data-test="shared-text"></div>
|
||||
|
||||
<x-row justify="end">
|
||||
<x-button
|
||||
:label="__('Copy')"
|
||||
icon="content_copy"
|
||||
variant="tonal"
|
||||
x-on:click="navigator.clipboard.writeText(text).then(() => window.materialToast({{ \Illuminate\Support\Js::from(__('Copied to the clipboard')) }}, { type: 'success' }))"
|
||||
data-test="copy-text"
|
||||
/>
|
||||
</x-row>
|
||||
</x-stack>
|
||||
</template>
|
||||
|
||||
<x-row justify="end" x-show="text === null">
|
||||
<x-button :label="__('Show text')" icon="visibility" variant="filled" x-on:click="reveal()" spinner="revealText" data-test="show-text" />
|
||||
</x-row>
|
||||
</x-stack>
|
||||
</x-card>
|
||||
@endif
|
||||
|
||||
@if ($files->isNotEmpty())
|
||||
<x-card :title="__('Shared Files')" heading="h2" variant="outlined">
|
||||
<x-stack gap="space200">
|
||||
<x-list :label="__('Shared Files')">
|
||||
@foreach ($files as $file)
|
||||
<x-list-item
|
||||
:title="$file->relative_path ?: $file->original_name"
|
||||
icon="description"
|
||||
wire:key="file-{{ $file->id }}"
|
||||
>
|
||||
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span></x-slot:description>
|
||||
<x-slot:end>
|
||||
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" x-on:click="downloaded = true" />
|
||||
</x-slot:end>
|
||||
</x-list-item>
|
||||
@endforeach
|
||||
</x-list>
|
||||
|
||||
<x-row justify="end">
|
||||
@if ($files->count() > 1)
|
||||
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate x-on:click="downloaded = true" />
|
||||
@else
|
||||
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $files->first()])" no-wire-navigate x-on:click="downloaded = true" />
|
||||
@endif
|
||||
</x-row>
|
||||
</x-stack>
|
||||
</x-card>
|
||||
@endif
|
||||
|
||||
@if ($share->expires_at || $share->max_downloads)
|
||||
<x-stack gap="space100">
|
||||
@if ($share->expires_at)
|
||||
<p class="md-type-body-sm md-ink-variant">{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}</p>
|
||||
@endif
|
||||
|
||||
@if ($share->max_downloads)
|
||||
@if ($downloadWindowEndsAt)
|
||||
@if ($hasText)
|
||||
@if ($downloadWindowEndsAt)
|
||||
<p class="md-type-body-sm md-ink-variant">{{ __('You can open this share for another :time.', ['time' => $downloadWindowEndsAt->diffForHumans(syntax: \Carbon\CarbonInterface::DIFF_ABSOLUTE)]) }}</p>
|
||||
@elseif ($remainingDownloads > 0)
|
||||
<p class="md-type-body-sm md-ink-variant" x-show="! downloaded">{{ trans_choice('{1} Showing the text or downloading uses the last remaining download. You then have :window to open this share.|[2,*] Showing the text or downloading uses 1 of :count remaining downloads. You then have :window to open this share.', $remainingDownloads, ['window' => $downloadWindow]) }}</p>
|
||||
<p class="md-type-body-sm md-ink-variant" x-show="downloaded" x-cloak>{{ __('You have :window to open this share.', ['window' => $downloadWindow]) }}</p>
|
||||
@endif
|
||||
@elseif ($downloadWindowEndsAt)
|
||||
<p class="md-type-body-sm md-ink-variant">{{ __('You can download these files for another :time.', ['time' => $downloadWindowEndsAt->diffForHumans(syntax: \Carbon\CarbonInterface::DIFF_ABSOLUTE)]) }}</p>
|
||||
@elseif ($remainingDownloads > 0)
|
||||
<p class="md-type-body-sm md-ink-variant" x-show="! downloaded">{{ trans_choice('{1} Downloading uses the last remaining download. You then have :window to download the files.|[2,*] Downloading uses 1 of :count remaining downloads. You then have :window to download the files.', $remainingDownloads, ['window' => $downloadWindow]) }}</p>
|
||||
@@ -55,15 +123,7 @@
|
||||
@endif
|
||||
@endif
|
||||
</x-stack>
|
||||
|
||||
<x-row justify="end">
|
||||
@if ($share->files->count() > 1)
|
||||
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate x-on:click="downloaded = true" />
|
||||
@else
|
||||
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $share->files->first()])" no-wire-navigate x-on:click="downloaded = true" />
|
||||
@endif
|
||||
</x-row>
|
||||
</x-stack>
|
||||
</x-card>
|
||||
@endif
|
||||
</x-stack>
|
||||
@endif
|
||||
</x-page>
|
||||
|
||||
Reference in New Issue
Block a user