4 Commits
Author SHA1 Message Date
Andreas Reinhold / reiniandClaude Opus 5.5 f9a7839ad3 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>
2026-09-26 07:00:17 +02:00
surtic86andClaude Opus 5 202813a1e6 Draw the hero phone's bezel at the phone's own scale
docker / test (8.5) (push) Successful in 3m12s
linter / quality (push) Successful in 1m2s
tests / ci (8.5) (push) Successful in 3m11s
docker / build-and-push (push) Successful in 7m6s
docker / release (push) Skipped
A percentage padding is of the containing block's width, and the hero
phone is positioned absolutely in the hero visual at a quarter of its
width, so its 3.4% bezel came out four times too thick. The bezel is now
the screen's margin, a percentage of the phone's own width wherever the
phone sits; the gallery phones look as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 07:45:40 +02:00
surtic86andClaude Opus 5 2c1ed8b720 Describe SealShare 2.2 on the website
docker / test (8.5) (push) Successful in 3m12s
linter / quality (push) Successful in 1m8s
tests / ci (8.5) (push) Successful in 3m21s
docker / build-and-push (push) Successful in 7m3s
docker / release (push) Skipped
The download page and the admin dashboard screenshots are retaken for
2.2: the demo delivery share now has a download limit, so the download
page shows how many downloads are left and the dashboard shows "3 of 5
downloads" beside the full-width Sort by field. The download limit and
admin dashboard features mention the remaining downloads, the hour a
recipient then has for the files, and the installed version, and the
alt texts match the new screenshots.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 07:42:49 +02:00
surtic86andClaude Opus 5 e051aa1aec Release 2.2.0
linter / quality (push) Successful in 58s
tests / ci (8.5) (push) Successful in 3m11s
docker / build-and-push (push) Successful in 7m10s
docker / test (8.5) (push) Successful in 3m10s
docker / release (push) Successful in 3s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 06:13:04 +02:00
75 changed files with 836 additions and 158 deletions
+25 -15
View File
@@ -5,33 +5,41 @@ 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).
## [Unreleased]
## [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
- The admin dashboard shows the installed SealShare version, with links to its release notes, the SealShare website and noNameWEB.
### Fixed
- Docker installs updated from 2.0 answered every upload with "409 Conflict". The example `docker-compose.yml` mounted the SQLite volume over all of `/app/database`, which hid the image's new migration, so it never ran. The container now adds the migrations the volume is missing before migrating, so existing compose files keep working.
- A share with several files and a download limit was deleted as soon as one file was downloaded: every single file counted as a whole download. Now one recipient's visit counts once, and they have 1 hour to download all the files and the ZIP. Two recipients who start at the same moment can no longer both get the last download.
- The scheduler container no longer shows as "unhealthy": it inherited the image's healthcheck, which asks the web server that only the app container runs. To fix an existing install, add `healthcheck: { disable: true }` to the scheduler service in your `docker-compose.yml`.
- The "30 Days" expiration lasted a calendar month; it now lasts 30 days.
- Admin settings only save a default expiration that is one of the offered options.
### Changed
- `docker-compose.example.yml` mounts `sealshare_database` at `/app/database/sqlite` and sets `DB_DATABASE` to the file in it. To switch an existing install, mount the same volume there and set `DB_DATABASE: /app/database/sqlite/database.sqlite` in both services; the database is kept.
- A share that reached its download limit is closed at once, but deleted by the hourly cleanup 24 hours after its last download instead of immediately, so downloads still running can finish. Until then its files still count towards the storage quota.
- The download page of a share with a download limit says how many downloads are left, or how long the recipient can still download. The admin dashboard shows downloads as "2 of 3 downloads", marks shares at their limit as "Download limit reached" and no longer counts them as active.
- `docker-compose.example.yml` mounts `sealshare_database` at `/app/database/sqlite` instead of `/app/database`, and sets `DB_DATABASE: /app/database/sqlite/database.sqlite`. Existing compose files keep working. To switch, mount the same volume at the new path and set `DB_DATABASE` in both services; the database is kept.
- A share that reaches its download limit is closed at once, but deleted by the hourly cleanup 24 hours after its last download instead of immediately, so downloads still running can finish. Until then its files count towards the storage quota.
- The download page of a share with a download limit says how many downloads are left, or how long the recipient can still download.
- The admin dashboard shows downloads as "2 of 3 downloads" and marks shares at their limit as "Download limit reached". These no longer count as active shares.
- The sort dropdown on the admin dashboard spans the full width of the shares card.
- Development: `docker-compose.dev.yml` now extends `docker-compose.yml`, so the dev stack runs the scheduler and the production image's PHP extensions, and takes its settings from `.env` (which selects the file through `COMPOSE_FILE`). A Vite dev server with hot reload runs beside the app. No ports are published unless `docker-compose.ports.yml` is added; with OrbStack the app is at `https://app.sealshare.orb.local`. `docker/dev.Dockerfile` became the `dev` stage of the `Dockerfile`.
- The Docker image's PHP limits (`PHP_UPLOAD_MAX_FILESIZE`, `PHP_POST_MAX_SIZE`, `PHP_MAX_EXECUTION_TIME`, `PHP_MAX_INPUT_TIME`, `PHP_MEMORY_LIMIT`) are read by PHP itself from the environment; the entrypoint no longer writes an ini file on start. The variables and their defaults are unchanged.
- PHP reads the Docker image's limits (`PHP_UPLOAD_MAX_FILESIZE`, `PHP_POST_MAX_SIZE`, `PHP_MAX_EXECUTION_TIME`, `PHP_MAX_INPUT_TIME`, `PHP_MEMORY_LIMIT`) from the environment itself; the entrypoint no longer writes an ini file on start. The variables and their defaults are unchanged.
- Updated to Livewire Material 2.2.0.
- Development: `docker-compose.dev.yml` extends `docker-compose.yml`, so the dev stack runs the scheduler and the production image's PHP extensions, plus a Vite dev server with hot reload. It takes its settings from `.env`, and publishes ports only with `docker-compose.ports.yml`. `docker/dev.Dockerfile` became the `dev` stage of the `Dockerfile`.
### Fixed
- Docker installs set up before 2.1.0 answered every upload with "409 Conflict" after the update. The example `docker-compose.yml` mounted the SQLite volume over all of `/app/database`, which hid the image's new migrations, so they never ran. The container now adds the migrations the volume is missing before migrating.
- A share with several files and a download limit was deleted as soon as one file was downloaded, because every file counted as a whole download. Now one recipient's visit counts once, and they have 1 hour to download all the files and the ZIP. Two recipients who start at the same moment can no longer both get the last download.
- The scheduler container no longer shows as "unhealthy". It inherited the image's healthcheck, which asks the web server that only the app container runs. For an existing install, add `healthcheck: { disable: true }` to the scheduler service in your `docker-compose.yml`.
- The "30 Days" expiration lasted a calendar month; it now lasts 30 days.
- Admin settings only save a default expiration that is one of the offered options.
### Removed
- Email verification (`/email/verify`), which was never enforced: SealShare has a single admin account and no registration.
- The `composer dev` script: development runs in Docker (`docker-compose.dev.yml`).
- The `composer dev` script and the packages only it used (`concurrently`, `laravel/pail`, `laravel/sail`, `autoprefixer`), with the `shell-quote` override that `concurrently` needed. Development runs in Docker (`docker-compose.dev.yml`).
## [2.1.0] - 2026-09-16
@@ -166,6 +174,8 @@ 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
[2.0.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/releases/tag/v2.0.0
+1 -1
View File
@@ -114,7 +114,7 @@ RUN rm -rf node_modules tests .gitea docker/dev-entrypoint.sh .env .env.example
storage/logs database/sqlite \
&& chmod -R 777 storage database bootstrap/cache
# A docker-compose.yml from before 2.1.1 mounts the SQLite volume over all of database/, which hides
# A docker-compose.yml from before 2.2.0 mounts the SQLite volume over all of database/, which hides
# the migrations of every later image; the entrypoint adds the ones the volume is missing from here.
RUN cp -R database/migrations docker/migrations
+2 -1
View File
@@ -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
@@ -123,7 +124,7 @@ Migrations run automatically on startup. Open your configured domain — the Set
| `caddy_data` | `/data` | TLS certificates |
| `caddy_config` | `/config` | Caddy configuration |
A `docker-compose.yml` from before 2.1.1 mounts `sealshare_database` at `/app/database`, which also hides the image's migrations; the container adds the ones the volume is missing on startup, so it keeps working. To move to the layout above, mount the same volume at `/app/database/sqlite` and set `DB_DATABASE: /app/database/sqlite/database.sqlite` in both services — the existing database is at that path then, and nothing is lost.
A `docker-compose.yml` from before 2.2.0 mounts `sealshare_database` at `/app/database`, which also hides the image's migrations; the container adds the ones the volume is missing on startup, so it keeps working. To move to the layout above, mount the same volume at `/app/database/sqlite` and set `DB_DATABASE: /app/database/sqlite/database.sqlite` in both services — the existing database is at that path then, and nothing is lost.
**Large files:**
+9 -22
View File
@@ -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);
}
}
+4 -2
View File
@@ -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'),
+73 -17
View File
@@ -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.
*/
+24
View File
@@ -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(),
+16
View File
@@ -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<ShareFile, $this>
*/
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.
+7
View File
@@ -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',
];
+81 -15
View File
@@ -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<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 = []): Share
public function createShare(array $files, array $options = [], ?string $text = null): Share
{
$share = null;
@@ -238,8 +246,39 @@ 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);
}
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']);
@@ -255,15 +294,42 @@ class ShareService
$this->storeChunk($shareFile->refresh(), $index, $chunk);
}
fclose($source);
}
if ($share === null) {
$this->rejectFile(__('Please select at least one file to upload.'));
/**
* 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 $this->completeShare($share, $options);
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);
}
/**
+1 -1
View File
@@ -25,6 +25,6 @@ return [
|
*/
'version' => '2.1.0',
'version' => '2.3.0',
];
+14
View File
@@ -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,
]);
}
}
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* A share's private text is stored as one of its files, encrypted like the others; this flag
* keeps it out of the file list, the ZIP and the file counts.
*/
public function up(): void
{
Schema::table('share_files', function (Blueprint $table) {
$table->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');
});
}
};
+13
View File
@@ -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
+50 -1
View File
@@ -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,13 +22,61 @@
</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. --}}
{{-- 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 }">
<x-stack gap="space100">
{{-- 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 ($share->files as $file)
@foreach ($files as $file)
<x-list-item
:title="$file->relative_path ?: $file->original_name"
icon="description"
@@ -42,12 +90,32 @@
@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 ($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-page>
+22
View File
@@ -175,6 +175,28 @@ test('the settings Save button is end-aligned at less than the form\'s width', f
['/admin/settings', '[data-test="save-settings"]'],
]);
test('a recipient reveals a share\'s private text on a phone, and the page has no popovers', function () {
$page = ready(visit('/upload'));
$page->type('[data-test="share-text"]', 'the wifi password is sunflower')
->click('[data-test="create-share"]');
$page->wait(1);
$page->assertSee('Share Created!');
$shareUrl = $page->script('document.querySelector(\'[data-test="share-link"]\').value');
$page = ready(visit($shareUrl)->resize(393, 852));
$page->click('[data-test="show-text"]');
$page->wait(1);
$page->assertSeeIn('[data-test="shared-text"]', 'the wifi password is sunflower');
$page
->assertScript("document.querySelectorAll('[popover]').length === 0")
->assertScript('document.documentElement.scrollWidth <= window.innerWidth')
->assertNoJavaScriptErrors();
});
test('the admin dashboard heads its shares list with an h2, both in one card', function () {
$admin = User::factory()->admin()->create();
Share::factory()->count(2)->create();
@@ -139,6 +139,18 @@ test('without shares the dashboard shows an empty state instead of the list', fu
->assertDontSee('data-test="share-row"', false);
});
test('the shares list excludes the private text from its file count and shows it separately', function () {
$admin = User::query()->where('is_admin', true)->first();
$share = Share::factory()->create(['token' => 'textshare00000001']);
ShareFile::factory()->count(2)->for($share)->create();
ShareFile::factory()->for($share)->text()->create();
Livewire::actingAs($admin)
->test(AdminDashboard::class)
->assertSeeInOrder(['textshare00000001', '2 files', 'Text'])
->assertDontSee('3 files');
});
test('shares whose files are still uploading are neither listed nor counted, but their bytes count as used space', function () {
$admin = User::query()->where('is_admin', true)->first();
$completed = Share::factory()->create(['token' => 'completedshare01', 'total_size' => 1000]);
+65 -1
View File
@@ -135,6 +135,70 @@ test('removing files takes them out of the pending share', function () {
expect(Share::query()->sole()->total_size)->toBe(4);
});
test('registering the private text hands the browser its chunk target, like a file', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class);
$component->call('registerText', 20)
->assertReturned(fn (?array $target): bool => $target !== null);
$file = ShareFile::query()->sole();
expect($file->is_text)->toBeTrue();
expect($file->file_size)->toBe(20);
expect(session('pending_shares'))->toBe([$file->share->token]);
});
test('registering the private text again replaces the earlier text row', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class)
->call('registerText', 20);
$firstId = ShareFile::query()->sole()->id;
$component->call('registerText', 30);
$file = ShareFile::query()->sole();
expect($file->id)->not->toBe($firstId);
expect($file->file_size)->toBe(30);
});
test('registering the private text with 0 bytes removes it', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class)
->call('registerText', 20);
$component->call('registerText', 0)
->assertReturned(fn (?array $target): bool => $target === null);
expect(ShareFile::query()->count())->toBe(0);
});
test('a text-only pending share completes', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class)
->call('registerText', 20);
$file = ShareFile::query()->sole();
app(ShareService::class)->storeChunk($file, 0, encryptedChunk($file, str_repeat('a', 20), 0, true));
$component->call('createShare')
->assertRedirectContains('/share/');
$share = Share::query()->sole();
expect($share->isCompleted())->toBeTrue();
expect($share->files->first()->is_text)->toBeTrue();
});
test('the selected files list does not show the private text', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class);
uploadThroughPage($component, ['document.pdf' => 'the document']);
$component->call('registerText', 20);
$component->assertSeeHtml('data-test="selected-file"')
->assertSee('document.pdf')
->assertDontSee('text.txt');
});
test('a share cannot be created while a file is still uploading', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class)
@@ -297,7 +361,7 @@ test('file upload requires at least one file', function () {
$component = Livewire::test(FileUploader::class)
->call('createShare');
expect($component->errors()->first('files'))->toBe('Please select at least one file to upload.');
expect($component->errors()->first('files'))->toBe('Add files or a text to share.');
expect(Share::query()->count())->toBe(0);
});
+20
View File
@@ -2,7 +2,10 @@
use App\Models\Share;
use App\Services\QrCodeService;
use App\Services\ShareService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Storage;
test('the share created page offers the link as a QR code and through the share sheet', function () {
$share = Share::factory()->create();
@@ -60,3 +63,20 @@ test('the password is not shown without a flash for this share', function (?stri
'no flash (a reload or another visitor)' => [null],
'a flash for another share' => ['another-share-token'],
]);
test('the Private text stat shows for a share with text, and the Files stat excludes it', function () {
Storage::fake('shares');
$share = app(ShareService::class)->createShare(
[
['file' => UploadedFile::fake()->createWithContent('one.txt', 'one'), 'relativePath' => null],
['file' => UploadedFile::fake()->createWithContent('two.txt', 'two'), 'relativePath' => null],
],
[],
'the secret note',
);
$response = $this->get(route('share.created', $share));
$response->assertOk()
->assertSeeInOrder(['Files', '2', 'Private text']);
});
+98
View File
@@ -290,6 +290,104 @@ test('a password share created before key wrapping still unlocks and downloads',
expect($this->get(route('share.download.file', [$share, $file]))->streamedContent())->toBe('old content');
});
test('a text-only share page shows Show text and keeps the plaintext out of the response', function () {
Storage::fake('shares');
$share = app(ShareService::class)->createShare([], [], 'the secret note');
$response = $this->get(route('share.download', $share));
$response->assertOk()
->assertSeeHtml('data-test="show-text"')
->assertDontSee('the secret note');
});
test('revealText returns the private text and counts one download', function () {
Storage::fake('shares');
$share = app(ShareService::class)->createShare([], [], 'the secret note');
Livewire::test(ShareDownload::class, ['share' => $share])
->call('revealText')
->assertReturned('the secret note');
expect($share->fresh()->download_count)->toBe(1);
});
test('a second reveal in the same window is not counted again', function () {
Storage::fake('shares');
$share = app(ShareService::class)->createShare([], [], 'the secret note');
$component = Livewire::test(ShareDownload::class, ['share' => $share]);
$component->call('revealText');
$component->call('revealText')
->assertReturned('the secret note');
expect($share->fresh()->download_count)->toBe(1);
});
test('another session cannot open a text-only share whose one download was taken', function () {
Storage::fake('shares');
$share = app(ShareService::class)->createShare([], ['max_downloads' => 1], 'the secret note');
Livewire::test(ShareDownload::class, ['share' => $share])->call('revealText');
$this->flushSession();
$response = $this->get(route('share.download', $share));
$response->assertNotFound();
});
test('a password share\'s revealText returns null before unlocking and counts nothing', function () {
Storage::fake('shares');
$share = app(ShareService::class)->createShare([], ['password' => 'let-me-in'], 'the secret note');
Livewire::test(ShareDownload::class, ['share' => $share])
->call('revealText')
->assertReturned(null);
expect($share->fresh()->download_count)->toBe(0);
});
test('share.download.file for the text row returns 404', function () {
Storage::fake('shares');
$share = app(ShareService::class)->createShare(
[['file' => UploadedFile::fake()->createWithContent('notes.txt', 'file content'), 'relativePath' => null]],
[],
'the secret note',
);
$response = $this->get(route('share.download.file', [$share, $share->textFile]));
$response->assertNotFound();
});
test('the zip of a mixed share has only the files, not the private text', function () {
Storage::fake('shares');
$share = app(ShareService::class)->createShare(
[['file' => UploadedFile::fake()->createWithContent('notes.txt', 'file content'), 'relativePath' => null]],
[],
'the secret note',
);
$zipPath = tempnam(sys_get_temp_dir(), 'zip');
file_put_contents($zipPath, $this->get(route('share.download.all', $share))->streamedContent());
$zip = new ZipArchive;
expect($zip->open($zipPath))->toBeTrue();
expect($zip->numFiles)->toBe(1);
expect($zip->getFromName('notes.txt'))->toBe('file content');
expect($zip->locateName('text.txt'))->toBeFalse();
$zip->close();
unlink($zipPath);
});
test('share.download.all on a text-only share returns 404', function () {
Storage::fake('shares');
$share = app(ShareService::class)->createShare([], [], 'the secret note');
$response = $this->get(route('share.download.all', $share));
$response->assertNotFound();
});
/**
* Helper to create a share with an actual encrypted file.
*/
+1 -1
View File
@@ -74,7 +74,7 @@ test('every file the pages, their stylesheets and the README refer to exists', f
test('the screenshots are all there, for both themes and both widths', function () {
$expected = collect([
'desktop' => ['01-upload', '02-share-created', '03-qr-code', '04-download', '05-admin-dashboard', '06-admin-settings'],
'phone' => ['01-upload', '02-password', '03-download', '04-qr-code'],
'phone' => ['01-upload', '02-password', '03-download', '04-qr-code', '05-private-text'],
])->flatMap(fn (array $names, string $device): array => collect(['light', 'dark'])
->crossJoin($names, $device === 'desktop' ? [1600, 800] : [1080, 540])
->map(fn (array $shot): string => "{$device}/{$shot[0]}/{$shot[1]}-{$shot[2]}.webp")
+10 -4
View File
@@ -27,6 +27,9 @@ final class DemoData
/** The share the desktop upload creates, so its link and QR code read the same on every run. */
public const CREATED_TOKEN = 'Tf8gH2jK4mN6pQ3r';
/** The share holding only a private text, shown on the phone once the recipient asks for it. */
public const TEXT_TOKEN = 'Vx9bN4mQ7kR2tW5s';
public static function admin(): User
{
return User::factory()->admin()->create([
@@ -36,13 +39,13 @@ final class DemoData
}
/**
* Eight shares of different ages, sizes and states: one expired, two password-protected, some
* with a download limit, some never expiring.
* Nine shares of different ages, sizes and states: one expired, two password-protected, some
* with a download limit, some never expiring, and one holding only a private text.
*/
public static function shares(): void
{
$shares = [
['token' => self::DELIVERY_TOKEN, 'daysAgo' => 0, 'expiresAfterDays' => 7, 'downloads' => 3, 'options' => [],
['token' => self::DELIVERY_TOKEN, 'daysAgo' => 0, 'expiresAfterDays' => 7, 'downloads' => 3, 'options' => ['max_downloads' => 5],
'files' => ['Q3 Report.pdf' => 2400, 'Contract 2026.pdf' => 380, 'Product photos/hero-shot.jpg' => 4800, 'Product photos/detail.jpg' => 3900]],
['token' => self::PROTECTED_TOKEN, 'daysAgo' => 1, 'expiresAfterDays' => 3, 'downloads' => 1, 'options' => ['password' => self::PROTECTED_PASSWORD, 'max_downloads' => 3],
'files' => ['Payroll September.xlsx' => 210, 'Tax statement 2025.pdf' => 640]],
@@ -58,6 +61,8 @@ final class DemoData
'files' => ['Onboarding/Welcome.pdf' => 520, 'Onboarding/Handbook.pdf' => 2900, 'Onboarding/IT checklist.docx' => 130]],
['token' => 'Ae6fG7hJ8kL9mN1p', 'daysAgo' => 40, 'expiresAfterDays' => 10, 'downloads' => 12, 'options' => [],
'files' => ['Event photos.zip' => 15700]],
['token' => self::TEXT_TOKEN, 'daysAgo' => 3, 'expiresAfterDays' => 7, 'downloads' => 0, 'options' => ['max_downloads' => 1],
'files' => [], 'text' => "Guest Wi-Fi for the workshop\n\nNetwork: Harbour-Guest\nPassword: maple-lantern-8127\n\nIt works in meeting rooms 2 and 3."],
];
foreach ($shares as $share) {
@@ -69,7 +74,7 @@ final class DemoData
* @param array{password?: string, max_downloads?: int} $options
* @param array<string, int> $files relative path => size in kilobytes
*/
private static function share(string $token, int $daysAgo, ?int $expiresAfterDays, int $downloads, array $options, array $files): void
private static function share(string $token, int $daysAgo, ?int $expiresAfterDays, int $downloads, array $options, array $files, ?string $text = null): void
{
$createdAt = Carbon::now()->subDays($daysAgo)->subHours(2);
@@ -82,6 +87,7 @@ final class DemoData
'relativePath' => str_contains($path, '/') ? $path : null,
])->values()->all(),
[...$options, 'expires_at' => $expiresAfterDays === null ? null : $createdAt->copy()->addDays($expiresAfterDays)],
$text,
);
} finally {
Str::createRandomStringsNormally();
+13 -3
View File
@@ -115,13 +115,15 @@ test('desktop', function (string $theme) use ($files) {
$upload = visit('/upload');
$page = shotPage($upload, 'desktop', $theme);
selectFiles($page, $files);
// Creating the share sends this text through the page's own upload before the share is created.
$page->type('[data-test="share-text"]', 'The signed contract is in the folder. Call me on +41 44 555 01 23 if anything is missing.');
$page->click('label:has-text("Password protect")')
->click('[data-test="generate-password"]')
->wait(1)
->assertScript("document.querySelector('input[autocomplete=\"new-password\"]').value.length > 0");
$page->type('input[wire\:model="maxDownloads"]', '5');
// The drop zone, the files and the options fill the window; typing left the page wherever it scrolled.
$page->script("document.activeElement?.blur(); window.scrollTo(0, document.querySelector('[data-test=drop-zone]').getBoundingClientRect().top + window.scrollY - 24)");
// The foot of the drop zone, the files, the text and the password fill the window; typing left the page wherever it scrolled.
$page->script("document.activeElement?.blur(); window.scrollTo(0, document.querySelector('[data-test=drop-zone]').getBoundingClientRect().bottom + window.scrollY - 80)");
shoot($page, 'desktop', $theme, '01-upload');
// Creating the share is what offers the password once more beside the link.
@@ -135,7 +137,7 @@ test('desktop', function (string $theme) use ($files) {
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open");
shoot($page, 'desktop', $theme, '03-qr-code', DemoData::CREATED_TOKEN);
// The dashboard shows the eight demo shares, as before.
// The dashboard shows the nine demo shares, as before.
app(ShareService::class)->deleteShare(Share::query()->where('token', DemoData::CREATED_TOKEN)->firstOrFail());
$download = visit(route('share.download', DemoData::DELIVERY_TOKEN, false));
@@ -172,4 +174,12 @@ test('phone', function (string $theme) use ($files) {
$page->click('[data-test="show-qr-code"]')
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open");
shoot($page, 'phone', $theme, '04-qr-code');
// The text is fetched only when the recipient asks for it.
$text = visit(route('share.download', DemoData::TEXT_TOKEN, false));
$page = shotPage($text, 'phone', $theme);
$page->click('[data-test="show-text"]')
->waitForText('maple-lantern-8127')
->assertVisible('[data-test="shared-text"]');
shoot($page, 'phone', $theme, '05-private-text', DemoData::TEXT_TOKEN);
})->with(['light', 'dark']);
+45 -1
View File
@@ -332,7 +332,7 @@ test('completing a share without files is rejected', function () {
$share = Share::factory()->pending()->create();
expect(fn () => $this->service->completeShare($share))
->toThrow(ValidationException::class, 'Please select at least one file to upload.');
->toThrow(ValidationException::class, 'Add files or a text to share.');
});
test('completing a share with a password wraps its data key instead of storing it', function () {
@@ -355,3 +355,47 @@ test('create share stores an empty file', function () {
expect($share->files->first()->completed_at)->not->toBeNull();
expect(filesize($this->service->storedFilePath($share->files->first())))->toBe(FileEncryptionService::HEADER_LENGTH + FileEncryptionService::TAG_LENGTH);
});
test('a private text over the maximum length is rejected', function () {
$text = str_repeat('a', ShareFile::MAX_TEXT_BYTES + 1);
expect(fn () => $this->service->registerFile(null, 'text.txt', strlen($text), null, isText: true))
->toThrow(ValidationException::class, 'The text is too long (maximum 100 KB).');
expect(Share::query()->count())->toBe(0);
});
test('a private text does not count against the admin limit of files per share', function () {
Setting::set('max_files_per_share', 1);
$file = $this->service->registerFile(null, 'one.txt', 10, null);
$text = $this->service->registerFile($file->share, 'text.txt', 20, null, isText: true);
expect($text->share_id)->toBe($file->share_id);
expect(ShareFile::query()->count())->toBe(2);
});
test('create share with no files and a text completes a text-only share', function () {
$share = $this->service->createShare([], [], 'hi');
expect($share->isCompleted())->toBeTrue();
expect($share->files)->toHaveCount(1);
expect($share->files->first()->is_text)->toBeTrue();
expect($share->total_size)->toBe(2);
});
test('readText decrypts a share\'s private text without a password', function () {
$share = $this->service->createShare([], [], 'the secret note');
$text = $this->service->readText($share, $share->encryption_key);
expect($text)->toBe('the secret note');
});
test('readText decrypts a share\'s private text with the unwrapped key of a password share', function () {
$share = $this->service->createShare([], ['password' => 'a-long-password'], 'the secret note');
$key = $this->service->getDecryptionKey($share, 'a-long-password');
$text = $this->service->readText($share, $key);
expect($text)->toBe('the secret note');
});
+4 -1
View File
@@ -113,14 +113,17 @@
/* ---------------------------------------------------------------- phone */
/* The bezel is the screen's margin, not the phone's padding: a percentage margin is of the phone's
own width, where a padding would be of whatever the phone is positioned in (the hero). */
.device--phone {
padding: 3.4%;
display: flow-root;
border-radius: 15% / 7%;
background: var(--bezel);
box-shadow: inset 0 0 0 2px var(--bezel-edge), 0 24px 48px -20px var(--device-shadow);
}
.device--phone .device__screen {
margin: 3.4%;
aspect-ratio: 393 / 852;
border-radius: 12.5% / 5.8%;
}
+14 -1
View File
@@ -1195,6 +1195,13 @@ a.skip-link:focus-visible {
margin-inline: auto;
}
/* Five phones in two columns: the last one sits centred on its own row. */
.gallery--phone > li:last-child:nth-child(odd) {
grid-column: 1 / -1;
justify-self: center;
width: calc((100% - var(--md-sys-measurement-space200)) / 2);
}
.gallery[hidden] {
display: none;
}
@@ -1229,9 +1236,15 @@ a.skip-link:focus-visible {
}
.gallery--phone {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
column-gap: var(--md-sys-measurement-space400);
}
.gallery--phone > li:last-child:nth-child(odd) {
grid-column: auto;
justify-self: stretch;
width: auto;
}
}
@media (width >= 1200px) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+43 -23
View File
@@ -106,6 +106,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="sprite" aria-hidden="true">
<symbol id="i-cloud-upload" viewBox="0 -960 960 960"><path d="m440-446-36 35q-11 11-27.5 11T348-412q-11-11-11-28t11-28l104-104q12-12 28-12t28 12l104 104q11 11 11.5 27.5T612-412q-11 11-27.5 11.5T556-411l-36-35v206h220q42 0 71-29t29-71q0-42-29-71t-71-29h-60v-80q0-83-58.5-141.5T480-720q-83 0-141.5 58.5T280-520h-20q-58 0-99 41t-41 99q0 58 41 99t99 41h60q17 0 28.5 11.5T360-200q0 17-11.5 28.5T320-160h-60q-91 0-155.5-63T40-377q0-78 47-139t123-78q25-92 100-149t170-57q117 0 198.5 81.5T760-520q69 8 114.5 59.5T920-340q0 75-52.5 127.5T740-160H520q-33 0-56.5-23.5T440-240v-206Zm40 6Z"/></symbol>
<symbol id="i-sticky-note-2" viewBox="0 -960 960 960"><path d="M200-200h360v-160q0-17 11.5-28.5T600-400h160v-360H200v560Zm0 80q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v367q0 16-6 30.5T817-337L623-143q-11 11-25.5 17t-30.5 6H200Zm240-280H320q-17 0-28.5-11.5T280-440q0-17 11.5-28.5T320-480h120q17 0 28.5 11.5T480-440q0 17-11.5 28.5T440-400Zm200-160H320q-17 0-28.5-11.5T280-600q0-17 11.5-28.5T320-640h320q17 0 28.5 11.5T680-600q0 17-11.5 28.5T640-560ZM200-200v-560 560Z"/></symbol>
<symbol id="i-link" viewBox="0 -960 960 960"><path d="M280-280q-83 0-141.5-58.5T80-480q0-83 58.5-141.5T280-680h120q17 0 28.5 11.5T440-640q0 17-11.5 28.5T400-600H280q-50 0-85 35t-35 85q0 50 35 85t85 35h120q17 0 28.5 11.5T440-320q0 17-11.5 28.5T400-280H280Zm80-160q-17 0-28.5-11.5T320-480q0-17 11.5-28.5T360-520h240q17 0 28.5 11.5T640-480q0 17-11.5 28.5T600-440H360Zm200 160q-17 0-28.5-11.5T520-320q0-17 11.5-28.5T560-360h120q50 0 85-35t35-85q0-50-35-85t-85-35H560q-17 0-28.5-11.5T520-640q0-17 11.5-28.5T560-680h120q83 0 141.5 58.5T880-480q0 83-58.5 141.5T680-280H560Z"/></symbol>
<symbol id="i-qr-code-2" viewBox="0 -960 960 960"><path d="M520-120v-80h80v80h-80Zm-80-80v-200h80v200h-80Zm320-120v-160h80v160h-80Zm-80-160v-80h80v80h-80Zm-480 80v-80h80v80h-80Zm-80-80v-80h80v80h-80Zm360-280v-80h80v80h-80ZM180-660h120v-120H180v120Zm-60 20v-160q0-17 11.5-28.5T160-840h160q17 0 28.5 11.5T360-800v160q0 17-11.5 28.5T320-600H160q-17 0-28.5-11.5T120-640Zm60 460h120v-120H180v120Zm-60 20v-160q0-17 11.5-28.5T160-360h160q17 0 28.5 11.5T360-320v160q0 17-11.5 28.5T320-120H160q-17 0-28.5-11.5T120-160Zm540-500h120v-120H660v120Zm-60 20v-160q0-17 11.5-28.5T640-840h160q17 0 28.5 11.5T840-800v160q0 17-11.5 28.5T800-600H640q-17 0-28.5-11.5T600-640Zm80 520v-120h-80v-80h160v120h80v80H680ZM520-400v-80h160v80H520Zm-160 0v-80h-80v-80h240v80h-80v80h-80Zm40-200v-160h80v80h80v80H400Zm-190-90v-60h60v60h-60Zm0 480v-60h60v60h-60Zm480-480v-60h60v60h-60Z"/></symbol>
<symbol id="i-lock" viewBox="0 -960 960 960"><path d="M240-80q-33 0-56.5-23.5T160-160v-400q0-33 23.5-56.5T240-640h40v-80q0-83 58.5-141.5T480-920q83 0 141.5 58.5T680-720v80h40q33 0 56.5 23.5T800-560v400q0 33-23.5 56.5T720-80H240Zm0-80h480v-400H240v400Zm240-120q33 0 56.5-23.5T560-360q0-33-23.5-56.5T480-440q-33 0-56.5 23.5T400-360q0 33 23.5 56.5T480-280ZM360-640h240v-80q0-50-35-85t-85-35q-50 0-85 35t-35 85v80ZM240-160v-400 400Z"/></symbol>
@@ -293,72 +294,77 @@
<p>Drag & drop or browse; folders keep their structure. Files are encrypted in the browser and sent in chunks, with progress per file and a failed chunk retried on its own.</p>
</article>
<article class="feature">
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-link"/></svg></span>
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-sticky-note-2"/></svg></span>
<h3>Private text</h3>
<p>Share a password, a key or a short note, with 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.</p>
</article>
<article class="feature">
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-link"/></svg></span>
<h3>A link per share</h3>
<p>Every upload gets its own random, hard-to-guess link.</p>
</article>
<article class="feature">
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-qr-code-2"/></svg></span>
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-qr-code-2"/></svg></span>
<h3>QR code and share sheet</h3>
<p>Show the link as a QR code, save it as a PNG, or pass it to the phone's share sheet.</p>
</article>
<article class="feature">
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-lock"/></svg></span>
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-lock"/></svg></span>
<h3>Password protection</h3>
<p>Type a password or generate one, as random characters or a passphrase. It is shown once more beside the new link, and it protects the share's own random key.</p>
</article>
<article class="feature">
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-encrypted"/></svg></span>
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-encrypted"/></svg></span>
<h3>Encrypted at rest</h3>
<p>Encrypted in the browser with AES-256-GCM before upload, and stored only encrypted. With a share password the key is never stored as it is.</p>
</article>
<article class="feature">
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-schedule"/></svg></span>
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-schedule"/></svg></span>
<h3>Expiry</h3>
<p>From 1 hour to 30 days, or never when the admin allows it.</p>
</article>
<article class="feature">
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-counter-5"/></svg></span>
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-counter-5"/></svg></span>
<h3>Download limits</h3>
<p>Close a share after a set number of downloads.</p>
<p>Close a share after a set number of downloads. The page tells recipients how many are left, and each one then has an hour for all the files and the ZIP.</p>
</article>
<article class="feature">
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-folder-zip"/></svg></span>
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-folder-zip"/></svg></span>
<h3>ZIP download</h3>
<p>Recipients download everything in one archive.</p>
</article>
<article class="feature">
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-delete-sweep"/></svg></span>
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-delete-sweep"/></svg></span>
<h3>Automatic clean-up</h3>
<p>Expired shares and their files are deleted every hour, and unfinished uploads after 4 hours.</p>
</article>
<article class="feature">
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-dashboard"/></svg></span>
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-dashboard"/></svg></span>
<h3>Admin dashboard</h3>
<p>Every share with its size, downloads and expiry; storage use at a glance.</p>
<p>Every share with its size, downloads against its limit and expiry; storage use and the installed version at a glance.</p>
</article>
<article class="feature">
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-admin-panel-settings"/></svg></span>
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-admin-panel-settings"/></svg></span>
<h3>Limits and defaults</h3>
<p>Maximum file and share size, files per share, storage quota, default expiry, and a password generator that is off, on request or prefilled.</p>
</article>
<article class="feature">
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-palette"/></svg></span>
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-palette"/></svg></span>
<h3>Your branding</h3>
<p>Your logo, site title and description on the upload page, in one of eight colour profiles.</p>
</article>
<article class="feature">
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-shield-lock"/></svg></span>
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-shield-lock"/></svg></span>
<h3>System password</h3>
<p>Optionally close the upload page to everyone who does not have the password.</p>
</article>
<article class="feature">
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-phonelink-lock"/></svg></span>
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-phonelink-lock"/></svg></span>
<h3>Two-factor sign-in</h3>
<p>Admins protect their accounts with authenticator app codes.</p>
</article>
<article class="feature">
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-deployed-code"/></svg></span>
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-deployed-code"/></svg></span>
<h3>One Docker image</h3>
<p>FrankenPHP with Laravel Octane and SQLite &mdash; no separate database. Uploads need HTTPS: turn on automatic TLS, or use your reverse proxy.</p>
</article>
@@ -440,7 +446,7 @@
data-light-src="img/screenshots/desktop/light/04-download-800.webp" data-light-srcset="img/screenshots/desktop/light/04-download-1600.webp 1600w, img/screenshots/desktop/light/04-download-800.webp 800w"
data-dark-src="img/screenshots/desktop/dark/04-download-800.webp" data-dark-srcset="img/screenshots/desktop/dark/04-download-1600.webp 1600w, img/screenshots/desktop/dark/04-download-800.webp 800w"
width="1512" height="982" loading="lazy" decoding="async"
alt="The download page a recipient sees, listing the shared files">
alt="The download page a recipient sees, listing the shared files and how many downloads are left">
</div>
</div>
</div>
@@ -456,7 +462,7 @@
data-light-src="img/screenshots/desktop/light/05-admin-dashboard-800.webp" data-light-srcset="img/screenshots/desktop/light/05-admin-dashboard-1600.webp 1600w, img/screenshots/desktop/light/05-admin-dashboard-800.webp 800w"
data-dark-src="img/screenshots/desktop/dark/05-admin-dashboard-800.webp" data-dark-srcset="img/screenshots/desktop/dark/05-admin-dashboard-1600.webp 1600w, img/screenshots/desktop/dark/05-admin-dashboard-800.webp 800w"
width="1512" height="982" loading="lazy" decoding="async"
alt="The admin dashboard with share and file counts, disk usage and the list of shares with Sort by">
alt="The admin dashboard with share and file counts, disk usage and the list of shares with Sort by, each with its downloads out of its limit">
</div>
</div>
</div>
@@ -485,7 +491,7 @@
<figure class="shot">
<div class="device device--phone">
<div class="device__screen">
<img src="img/screenshots/phone/light/01-upload-540.webp" srcset="img/screenshots/phone/light/01-upload-1080.webp 1080w, img/screenshots/phone/light/01-upload-540.webp 540w" sizes="(max-width: 839px) 44vw, 232px"
<img src="img/screenshots/phone/light/01-upload-540.webp" srcset="img/screenshots/phone/light/01-upload-1080.webp 1080w, img/screenshots/phone/light/01-upload-540.webp 540w" sizes="(max-width: 839px) 44vw, 184px"
data-light-src="img/screenshots/phone/light/01-upload-540.webp" data-light-srcset="img/screenshots/phone/light/01-upload-1080.webp 1080w, img/screenshots/phone/light/01-upload-540.webp 540w"
data-dark-src="img/screenshots/phone/dark/01-upload-540.webp" data-dark-srcset="img/screenshots/phone/dark/01-upload-1080.webp 1080w, img/screenshots/phone/dark/01-upload-540.webp 540w"
width="393" height="852" loading="lazy" decoding="async"
@@ -499,7 +505,7 @@
<figure class="shot">
<div class="device device--phone">
<div class="device__screen">
<img src="img/screenshots/phone/light/02-password-540.webp" srcset="img/screenshots/phone/light/02-password-1080.webp 1080w, img/screenshots/phone/light/02-password-540.webp 540w" sizes="(max-width: 839px) 44vw, 232px"
<img src="img/screenshots/phone/light/02-password-540.webp" srcset="img/screenshots/phone/light/02-password-1080.webp 1080w, img/screenshots/phone/light/02-password-540.webp 540w" sizes="(max-width: 839px) 44vw, 184px"
data-light-src="img/screenshots/phone/light/02-password-540.webp" data-light-srcset="img/screenshots/phone/light/02-password-1080.webp 1080w, img/screenshots/phone/light/02-password-540.webp 540w"
data-dark-src="img/screenshots/phone/dark/02-password-540.webp" data-dark-srcset="img/screenshots/phone/dark/02-password-1080.webp 1080w, img/screenshots/phone/dark/02-password-540.webp 540w"
width="393" height="852" loading="lazy" decoding="async"
@@ -513,11 +519,11 @@
<figure class="shot">
<div class="device device--phone">
<div class="device__screen">
<img src="img/screenshots/phone/light/03-download-540.webp" srcset="img/screenshots/phone/light/03-download-1080.webp 1080w, img/screenshots/phone/light/03-download-540.webp 540w" sizes="(max-width: 839px) 44vw, 232px"
<img src="img/screenshots/phone/light/03-download-540.webp" srcset="img/screenshots/phone/light/03-download-1080.webp 1080w, img/screenshots/phone/light/03-download-540.webp 540w" sizes="(max-width: 839px) 44vw, 184px"
data-light-src="img/screenshots/phone/light/03-download-540.webp" data-light-srcset="img/screenshots/phone/light/03-download-1080.webp 1080w, img/screenshots/phone/light/03-download-540.webp 540w"
data-dark-src="img/screenshots/phone/dark/03-download-540.webp" data-dark-srcset="img/screenshots/phone/dark/03-download-1080.webp 1080w, img/screenshots/phone/dark/03-download-540.webp 540w"
width="393" height="852" loading="lazy" decoding="async"
alt="The list of shared files on a phone">
alt="The list of shared files on a phone, with the downloads left">
</div>
</div>
<figcaption><strong>Download on the go</strong><span>Files and a ZIP of all of them.</span></figcaption>
@@ -527,7 +533,7 @@
<figure class="shot">
<div class="device device--phone">
<div class="device__screen">
<img src="img/screenshots/phone/light/04-qr-code-540.webp" srcset="img/screenshots/phone/light/04-qr-code-1080.webp 1080w, img/screenshots/phone/light/04-qr-code-540.webp 540w" sizes="(max-width: 839px) 44vw, 232px"
<img src="img/screenshots/phone/light/04-qr-code-540.webp" srcset="img/screenshots/phone/light/04-qr-code-1080.webp 1080w, img/screenshots/phone/light/04-qr-code-540.webp 540w" sizes="(max-width: 839px) 44vw, 184px"
data-light-src="img/screenshots/phone/light/04-qr-code-540.webp" data-light-srcset="img/screenshots/phone/light/04-qr-code-1080.webp 1080w, img/screenshots/phone/light/04-qr-code-540.webp 540w"
data-dark-src="img/screenshots/phone/dark/04-qr-code-540.webp" data-dark-srcset="img/screenshots/phone/dark/04-qr-code-1080.webp 1080w, img/screenshots/phone/dark/04-qr-code-540.webp 540w"
width="393" height="852" loading="lazy" decoding="async"
@@ -537,6 +543,20 @@
<figcaption><strong>Scan to open</strong><span>The QR code fills the screen.</span></figcaption>
</figure>
</li>
<li>
<figure class="shot">
<div class="device device--phone">
<div class="device__screen">
<img src="img/screenshots/phone/light/05-private-text-540.webp" srcset="img/screenshots/phone/light/05-private-text-1080.webp 1080w, img/screenshots/phone/light/05-private-text-540.webp 540w" sizes="(max-width: 839px) 44vw, 184px"
data-light-src="img/screenshots/phone/light/05-private-text-540.webp" data-light-srcset="img/screenshots/phone/light/05-private-text-1080.webp 1080w, img/screenshots/phone/light/05-private-text-540.webp 540w"
data-dark-src="img/screenshots/phone/dark/05-private-text-540.webp" data-dark-srcset="img/screenshots/phone/dark/05-private-text-1080.webp 1080w, img/screenshots/phone/dark/05-private-text-540.webp 540w"
width="393" height="852" loading="lazy" decoding="async"
alt="A shared Wi-Fi password on a phone, shown after the recipient pressed Show text">
</div>
</div>
<figcaption><strong>Private text</strong><span>A password or note, shown once the recipient asks for it.</span></figcaption>
</figure>
</li>
</ul>
</div>
</section>