- Config: auth, services, logging, queue and database only repeated the
framework's own files and are gone; the others keep only the keys that
differ (app version, cache serializable_classes, session cookie name,
Markdown mail theme, the shares disk, three Octane values, Livewire's
pagination theme and payload guards).
- Email verification is removed: User never implemented MustVerifyEmail,
so it was never enforced, and SealShare has a single admin and no
registration. CreateNewUser goes with it.
- FileEncryptionService::encryptFile() and generateSalt() were only used
by tests; tests build files with encryptTestFile() in tests/Pest.php.
- The expiration options are defined once, as Share::EXPIRATIONS. "30 Days"
now lasts 30 days instead of a calendar month, and Admin settings only
save a default expiration that is one of the options.
- One-caller helpers are inlined, the uploader reads chunk responses with
XHR's responseType, and starter-kit leftovers are removed.
- Docker: PHP reads the PHP_* limits from the environment itself
(${VAR:-default} in uploads.ini); both entrypoints stop writing the ini.
docker-compose.yml shares the app and scheduler variables through one
anchor. The dev image installs gd for the screenshot publisher and fake
test images.
- Development runs in Docker only: the composer dev script, concurrently,
laravel/pail, laravel/sail, autoprefixer and the shell-quote override
are gone.
- phpunit.xml forces the test environment with <server> entries, so tests
run in the dev container no longer use its real database.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
158 lines
8.5 KiB
PHP
158 lines
8.5 KiB
PHP
<x-page brand>
|
|
{{-- Files this page already uploaded count towards the quota: they can still become a share. --}}
|
|
@if ($isStorageFull && $pendingFiles->isEmpty())
|
|
<x-alert color="warning" :title="__('Storage is full. Uploads are temporarily disabled.')" />
|
|
@else
|
|
<x-form
|
|
wire:submit="createShare"
|
|
x-data="shareUploader({
|
|
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
|
|
messages: {{ \Illuminate\Support\Js::from([
|
|
'queued' => __('Waiting'),
|
|
'uploaded' => __('Uploaded'),
|
|
'failed' => __('Upload failed'),
|
|
'sessionExpired' => __('Your session expired. Reload the page to upload again.'),
|
|
]) }},
|
|
})"
|
|
x-on:beforeunload.window="warnBeforeLeaving($event)"
|
|
>
|
|
{{-- WebCrypto, which encrypts the files in the browser, only exists on HTTPS (or localhost). --}}
|
|
<div x-show="! secure" x-cloak data-test="insecure-context">
|
|
<x-alert color="warning" :title="__('Uploads need a secure connection (HTTPS).')" :description="__('Ask the administrator to serve this site over HTTPS.')" />
|
|
</div>
|
|
|
|
{{-- Drop zone: the shape behind the icon turns into a burst while files are over it. --}}
|
|
<div
|
|
class="upload-drop-zone"
|
|
x-bind:data-dragging="dragging ? 'true' : 'false'"
|
|
x-bind:aria-disabled="secure ? 'false' : 'true'"
|
|
x-on:dragover.prevent="dragging = true"
|
|
x-on:dragleave.prevent="dragging = false"
|
|
x-on:drop.prevent="handleDrop($event)"
|
|
data-test="drop-zone"
|
|
>
|
|
<x-stack align="center" gap="space200">
|
|
<div class="upload-drop-shapes">
|
|
<x-shape name="cookie-9" class="upload-drop-shape upload-drop-shape--idle" />
|
|
<x-shape name="soft-burst" class="upload-drop-shape upload-drop-shape--burst" data-test="drop-zone-burst" />
|
|
<x-icon name="upload" size="48" class="upload-drop-icon" />
|
|
</div>
|
|
|
|
<x-stack align="center" gap="space50">
|
|
<p class="md-type-title-md md-text-center">{{ __('Drag & drop files or folders here') }}</p>
|
|
<p class="md-type-body-md md-ink-variant md-text-center">{{ __('or click to browse') }}</p>
|
|
</x-stack>
|
|
|
|
{{-- The button is the tab stop and opens the browser's own picker; the input only carries the selection. --}}
|
|
<x-button :label="__('Browse Files')" icon="folder_open" variant="outlined" x-on:click="$refs.picker.click()" x-bind:disabled="! secure" />
|
|
<input type="file" multiple hidden x-ref="picker" x-on:change="choose($event)" x-bind:disabled="! secure" data-test="file-input" />
|
|
</x-stack>
|
|
</div>
|
|
|
|
{{-- Upload progress, over every file still to send --}}
|
|
<div x-show="busy" x-cloak data-test="upload-progress">
|
|
<x-stack gap="space100">
|
|
<x-row justify="between">
|
|
<span class="md-type-label-lg">{{ __('Uploading...') }} <span x-text="Math.round(progress)"></span>%</span>
|
|
<x-button :label="__('Cancel')" size="xs" x-on:click="cancel()" />
|
|
</x-row>
|
|
<x-progress bind="progress" wavy :label="__('Uploading')" />
|
|
</x-stack>
|
|
</div>
|
|
|
|
@error('files')
|
|
<x-alert color="error">{{ $message }}</x-alert>
|
|
@enderror
|
|
|
|
{{-- Selected files --}}
|
|
@if ($pendingFiles->isNotEmpty())
|
|
<x-stack gap="space100">
|
|
<h2 class="md-type-title-lg">{{ __('Selected Files') }} ({{ $pendingFiles->count() }})</h2>
|
|
|
|
<div class="upload-file-list">
|
|
<x-list segmented :label="__('Selected Files')">
|
|
@foreach ($pendingFiles as $file)
|
|
<x-list-item
|
|
:title="$file->relative_path ?? $file->original_name"
|
|
icon="description"
|
|
wire:key="selected-file-{{ $file->id }}"
|
|
data-test="selected-file"
|
|
>
|
|
<x-slot:description>
|
|
<span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span>
|
|
· <span class="md-tabular" x-text="statusOf({{ $file->id }}, {{ $file->completed_at ? 'true' : 'false' }})" data-test="file-status"></span>
|
|
</x-slot:description>
|
|
<x-slot:end>
|
|
<span x-show="uploads[{{ $file->id }}]?.state === 'failed'" x-cloak>
|
|
<x-button icon="refresh" :aria-label="__('Retry')" x-on:click="retry({{ $file->id }})" />
|
|
</span>
|
|
<x-button icon="close" :aria-label="__('Remove')" x-on:click="remove({{ $file->id }})" />
|
|
</x-slot:end>
|
|
</x-list-item>
|
|
@endforeach
|
|
</x-list>
|
|
</div>
|
|
</x-stack>
|
|
@endif
|
|
|
|
{{-- Options --}}
|
|
<x-card :title="__('Share Options')" heading="h2" variant="outlined">
|
|
<x-stack gap="space200">
|
|
<x-toggle wire:model.live="usePassword" :label="__('Password protect')" right />
|
|
|
|
@if ($usePassword)
|
|
<x-stack gap="space100">
|
|
<x-password full wire:model="password" :label="__('Password')" autocomplete="new-password" />
|
|
|
|
{{-- Generate draws one as Admin settings say (App\Services\PasswordGeneratorService); Copy takes
|
|
whatever is in the field, typed or generated, with the snackbar a copyable field shows. --}}
|
|
<x-row gap="space100" wrap>
|
|
@if ($passwordGeneratorMode !== 'off')
|
|
<x-button :label="__('Generate')" icon="password" variant="tonal" wire:click="generatePassword" spinner="generatePassword" data-test="generate-password" />
|
|
@endif
|
|
|
|
<x-button
|
|
:label="__('Copy')"
|
|
icon="content_copy"
|
|
variant="tonal"
|
|
x-on:click="navigator.clipboard.writeText($wire.password).then(() => window.materialToast({{ \Illuminate\Support\Js::from(__('Copied to the clipboard')) }}, { type: 'success' }))"
|
|
x-bind:disabled="! $wire.password"
|
|
data-test="copy-password"
|
|
/>
|
|
</x-row>
|
|
</x-stack>
|
|
@endif
|
|
|
|
<x-select full
|
|
wire:model="expiration"
|
|
:label="__('Expiration')"
|
|
:placeholder="$allowNeverExpire ? __('Never') : null"
|
|
:options="collect(\App\Models\Share::EXPIRATIONS)->map(fn (array $option, string $id): array => ['id' => $id, 'name' => __($option['label'])])->values()->all()"
|
|
/>
|
|
|
|
<x-input full
|
|
wire:model="maxDownloads"
|
|
:label="__('Max downloads')"
|
|
type="number"
|
|
min="1"
|
|
:placeholder="__('Unlimited')"
|
|
/>
|
|
</x-stack>
|
|
</x-card>
|
|
|
|
<x-slot:actions>
|
|
<x-button
|
|
type="submit"
|
|
:label="__('Create Share Link')"
|
|
variant="filled"
|
|
size="md"
|
|
icon="link"
|
|
spinner="createShare"
|
|
x-bind:disabled="busy || {{ $allFilesUploaded ? 'false' : 'true' }}"
|
|
data-test="create-share"
|
|
/>
|
|
</x-slot:actions>
|
|
</x-form>
|
|
@endif
|
|
</x-page>
|