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

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

The recipient sees the text only after pressing "Show text", which
counts as their download, so a messenger link preview cannot use up a
share limited to one download. The text is left out of the file list,
the ZIP and the file counts; the admin dashboard marks shares that hold
one with "Text".

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-26 07:00:17 +02:00
co-authored by Claude Opus 5.5
parent 202813a1e6
commit f9a7839ad3
73 changed files with 806 additions and 134 deletions
+7
View File
@@ -5,6 +5,12 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.3.0] - 2026-09-26
### Added
- Private text: a share can hold a text, such as a password, a key or a short note, with its files or on its own. It is typed into the new "Private text" card on the upload page (up to 100 KB) and encrypted in the browser like a file, so it gets the share's password, expiry and download limit. The recipient sees it only after pressing "Show text", which counts as their download, so a link preview cannot use up a share limited to one download. The text is left out of the file list, the ZIP and the file counts; the admin dashboard marks shares that hold one with "Text".
## [2.2.0] - 2026-09-19 ## [2.2.0] - 2026-09-19
### Added ### Added
@@ -168,6 +174,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Dark themed UI built with Livewire, Alpine.js, Tailwind CSS and DaisyUI. - 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. - 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.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.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.1]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.0...v2.0.1
+1
View File
@@ -17,6 +17,7 @@ A simple, self-hosted file sharing solution built with Laravel. Upload files, ge
## Features ## 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 - **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 - **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 - **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 - **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
+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 * 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 public function download(Request $request, Share $share): StreamedResponse
{ {
abort_if(! $share->isCompleted() || $share->isExpired(), 404); abort_if(! $share->isCompleted() || $share->isExpired(), 404);
$share->load('files'); $share->load(['files' => fn ($query) => $query->where('is_text', false)]);
$key = $this->resolveDecryptionKey($share); abort_if($share->files->isEmpty(), 404);
$key = $this->shareService->sessionDecryptionKey($share, $request->session());
// Counted before the body streams: the session is saved by then. // Counted before the body streams: the session is saved by then.
abort_unless($this->shareService->claimDownload($share, $request->session()), 404); 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 public function downloadFile(Request $request, Share $share, ShareFile $shareFile): StreamedResponse
{ {
abort_if(! $share->isCompleted() || $share->isExpired(), 404); 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); abort_unless($this->shareService->claimDownload($share, $request->session()), 404);
@@ -122,20 +125,4 @@ class DownloadController extends Controller
return $filename; 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 whose files are still being uploaded are not shares yet; their bytes do count as used space.
$shares = Share::query() $shares = Share::query()
->whereNotNull('completed_at') ->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. // 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')) ->when($column === 'expires_at', fn ($query) => $query->orderByRaw('expires_at is null'))
->orderBy($column, $direction) ->orderBy($column, $direction)
@@ -74,7 +76,7 @@ class AdminDashboard extends Component
})->where(function ($q) { })->where(function ($q) {
$q->whereNull('max_downloads')->orWhereColumn('download_count', '<', 'max_downloads'); $q->whereNull('max_downloads')->orWhereColumn('download_count', '<', 'max_downloads');
})->count(), })->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(), 'usedSpace' => $shareService->getTotalUsedSpace(),
'maxQuota' => $shareService->getMaxStorageQuota(), 'maxQuota' => $shareService->getMaxStorageQuota(),
'version' => config('app.version'), 'version' => config('app.version'),
+73 -17
View File
@@ -4,6 +4,7 @@ namespace App\Livewire;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Share; use App\Models\Share;
use App\Models\ShareFile;
use App\Services\PasswordGeneratorService; use App\Services\PasswordGeneratorService;
use App\Services\ShareService; use App\Services\ShareService;
use Carbon\CarbonInterval; use Carbon\CarbonInterval;
@@ -73,26 +74,50 @@ class FileUploader extends Component
continue; continue;
} }
if ($this->pendingToken !== $shareFile->share->token) { $this->rememberPendingShare($shareFile->share);
$this->pendingToken = $shareFile->share->token;
session()->push('pending_shares', $this->pendingToken);
}
$header = $shareService->readHeader($shareFile); $targets[] = $this->uploadTarget($shareFile, $shareService);
$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'],
];
} }
return $targets; 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. * Take files out of the pending share, whether or not their upload finished.
* *
@@ -153,7 +178,7 @@ class FileUploader extends Component
$pendingShare = $this->pendingShare(); $pendingShare = $this->pendingShare();
if ($pendingShare === null) { 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; return;
} }
@@ -183,17 +208,48 @@ class FileUploader extends Component
public function render(): mixed public function render(): mixed
{ {
$shareService = app(ShareService::class); $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', [ return view('livewire.file-uploader', [
'pendingFiles' => $pendingFiles, '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(), 'isStorageFull' => $shareService->isStorageFull(),
'allowNeverExpire' => (bool) Setting::get('allow_never_expire', false), 'allowNeverExpire' => (bool) Setting::get('allow_never_expire', false),
'passwordGeneratorMode' => app(PasswordGeneratorService::class)->mode(), '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. * 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 Carbon\CarbonInterval;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Renderless;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
@@ -60,11 +61,34 @@ class ShareDownload extends Component
$this->authenticated = true; $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 public function render(): mixed
{ {
$shareService = app(ShareService::class); $shareService = app(ShareService::class);
return view('livewire.share-download', [ 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()), 'downloadWindowEndsAt' => $shareService->downloadWindowEndsAt($this->share, session()->driver()),
'remainingDownloads' => $this->share->max_downloads ? max($this->share->max_downloads - $this->share->download_count, 0) : null, '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(), '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\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Share extends Model class Share extends Model
{ {
@@ -62,6 +63,21 @@ class Share extends Model
return $this->hasMany(ShareFile::class); 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 * Whether the share was created: until then its files are still being uploaded and nobody
* but the uploader's page may reach it. * but the uploader's page may reach it.
+7
View File
@@ -10,6 +10,11 @@ class ShareFile extends Model
{ {
use HasFactory; 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 = [ protected $fillable = [
'share_id', 'share_id',
'original_name', 'original_name',
@@ -17,6 +22,7 @@ class ShareFile extends Model
'stored_path', 'stored_path',
'file_size', 'file_size',
'mime_type', 'mime_type',
'is_text',
'uploaded_chunks', 'uploaded_chunks',
'completed_at', 'completed_at',
]; ];
@@ -28,6 +34,7 @@ class ShareFile extends Model
{ {
return [ return [
'file_size' => 'integer', 'file_size' => 'integer',
'is_text' => 'boolean',
'uploaded_chunks' => 'integer', 'uploaded_chunks' => 'integer',
'completed_at' => 'datetime', 'completed_at' => 'datetime',
]; ];
+91 -25
View File
@@ -17,6 +17,7 @@ use Illuminate\Validation\ValidationException;
use InvalidArgumentException; use InvalidArgumentException;
use League\MimeTypeDetection\FinfoMimeTypeDetector; use League\MimeTypeDetection\FinfoMimeTypeDetector;
use RuntimeException; use RuntimeException;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** /**
* A share's life: files registered into a pending share, their encrypted chunks stored as the * 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 * 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 * @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); $maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50); $maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
@@ -49,7 +52,11 @@ class ShareService
$this->rejectFile(__('The file could not be added.')); $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.', [ $this->rejectFile(__('":name" is too large (:size MB). Maximum file size is :max MB.', [
'name' => $name, 'name' => $name,
'size' => round($size / (1024 * 1024), 1), '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])); $this->rejectFile(__('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare]));
} }
@@ -85,6 +92,7 @@ class ShareService
'relative_path' => $this->sanitizeRelativePath($relativePath), 'relative_path' => $this->sanitizeRelativePath($relativePath),
'stored_path' => 'shares/'.$share->token.'/'.$storedName, 'stored_path' => 'shares/'.$share->token.'/'.$storedName,
'file_size' => $size, 'file_size' => $size,
'is_text' => $isText,
]); ]);
$share->increment('total_size', $size); $share->increment('total_size', $size);
@@ -192,14 +200,14 @@ class ShareService
$maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024); $maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024);
if ($files->isEmpty()) { 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)) { 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.')); $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])); $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 * Create a share from files already on the server, and optionally a private text, through the
* browser takes. Used by tests and demo data. * 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<int, array{file: UploadedFile, relativePath: string|null}> $files
* @param array{password?: string|null, expires_at?: mixed, max_downloads?: int|null} $options * @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; $share = null;
@@ -238,34 +246,92 @@ class ShareService
$shareFile = $this->registerFile($share, $file->getClientOriginalName(), $file->getSize(), $fileData['relativePath'] ?? null); $shareFile = $this->registerFile($share, $file->getClientOriginalName(), $file->getSize(), $fileData['relativePath'] ?? null);
$share = $shareFile->share; $share = $shareFile->share;
$header = $this->readHeader($shareFile);
$source = fopen($file->getRealPath(), 'rb'); $source = fopen($file->getRealPath(), 'rb');
$this->storeContent($shareFile, $source);
fclose($source);
}
for ($index = 0; $index < $header['chunkCount']; $index++) { if ($text !== null && $text !== '') {
$plaintextLength = min($header['chunkSize'], $shareFile->file_size - $index * $header['chunkSize']); $shareFile = $this->registerFile($share, 'text.txt', strlen($text), null, isText: true);
$share = $shareFile->share;
// A fake upload reports a size its content does not have: zeros make up the rest.
$chunk = $this->encryptionService->encryptChunk(
str_pad($plaintextLength > 0 ? (string) fread($source, $plaintextLength) : '', $plaintextLength, "\0"),
$share->encryption_key,
$header['noncePrefix'],
$index,
$index === $header['chunkCount'] - 1,
);
$this->storeChunk($shareFile->refresh(), $index, $chunk);
}
$source = fopen('php://memory', 'r+b');
fwrite($source, $text);
rewind($source);
$this->storeContent($shareFile, $source);
fclose($source); fclose($source);
} }
if ($share === null) { if ($share === null) {
$this->rejectFile(__('Please select at least one file to upload.')); $this->rejectFile(__('Add files or a text to share.'));
} }
return $this->completeShare($share, $options); return $this->completeShare($share, $options);
} }
/**
* Encrypt a registered file's content chunk by chunk from a stream and store the chunks, as the
* uploader's browser would.
*
* @param resource $source
*/
private function storeContent(ShareFile $shareFile, $source): void
{
$share = $shareFile->share;
$header = $this->readHeader($shareFile);
for ($index = 0; $index < $header['chunkCount']; $index++) {
$plaintextLength = min($header['chunkSize'], $shareFile->file_size - $index * $header['chunkSize']);
// A fake upload reports a size its content does not have: zeros make up the rest.
$chunk = $this->encryptionService->encryptChunk(
str_pad($plaintextLength > 0 ? (string) fread($source, $plaintextLength) : '', $plaintextLength, "\0"),
$share->encryption_key,
$header['noncePrefix'],
$index,
$index === $header['chunkCount'] - 1,
);
$this->storeChunk($shareFile->refresh(), $index, $chunk);
}
}
/**
* A share's private text, decrypted in memory with the share's key; null when it has none.
*/
public function readText(Share $share, string $key): ?string
{
$textFile = $share->textFile()->first();
if ($textFile === null) {
return null;
}
return implode('', iterator_to_array(
$this->encryptionService->decryptedChunks($this->storedFilePath($textFile), $key),
false,
));
}
/**
* The key this session decrypts a share with: the one unlocked with the password and kept in
* the session, or the stored one for a share without a password.
*
* @throws HttpException 403 while a password share is still locked for this session
*/
public function sessionDecryptionKey(Share $share, Session $session): string
{
if ($share->isPasswordProtected()) {
$key = $session->get('share_key_'.$share->token);
abort_if(! $key, 403, 'Password required');
return $key;
}
return $this->getDecryptionKey($share);
}
/** /**
* Generate a unique share token with retry on collision. * Generate a unique share token with retry on collision.
*/ */
+1 -1
View File
@@ -25,6 +25,6 @@ return [
| |
*/ */
'version' => '2.2.0', 'version' => '2.3.0',
]; ];
+14
View File
@@ -25,6 +25,7 @@ class ShareFileFactory extends Factory
'stored_path' => 'shares/'.fake()->uuid().'.enc', 'stored_path' => 'shares/'.fake()->uuid().'.enc',
'file_size' => fake()->numberBetween(1024, 10485760), 'file_size' => fake()->numberBetween(1024, 10485760),
'mime_type' => 'text/plain', 'mime_type' => 'text/plain',
'is_text' => false,
'uploaded_chunks' => 1, 'uploaded_chunks' => 1,
'completed_at' => now(), 'completed_at' => now(),
]; ];
@@ -41,4 +42,17 @@ class ShareFileFactory extends Factory
'completed_at' => null, '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%; 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 * 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 * 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 * 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 * 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. * 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] 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. */ /** The request on its way, so Cancel and Remove can abort it. */
request: null, 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() { get progress() {
const unfinished = Object.values(this.uploads).filter((upload) => upload.state !== 'failed') const unfinished = Object.values(this.uploads).filter((upload) => upload.state !== 'failed')
const size = unfinished.reduce((total, upload) => total + upload.size, 0) 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) { retry(id) {
const item = this.failed[id] const item = this.failed[id]
@@ -283,7 +332,7 @@ document.addEventListener('alpine:init', () => {
}, },
warnBeforeLeaving(event) { warnBeforeLeaving(event) {
if (this.busy) { if (this.busy || this.text !== '') {
event.preventDefault() event.preventDefault()
event.returnValue = '' event.returnValue = ''
} }
@@ -37,7 +37,7 @@
<a href="{{ route('share.download', $share) }}" target="_blank" rel="noopener" class="md-link"><code>{{ $share->token }}</code></a> <a href="{{ route('share.download', $share) }}" target="_blank" rel="noopener" class="md-link"><code>{{ $share->token }}</code></a>
<x-slot:description> <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. --}} {{-- A share at its limit is closed; the cleanup deletes it a day after its last download. --}}
@if ($share->hasReachedDownloadLimit()) @if ($share->hasReachedDownloadLimit())
@@ -3,8 +3,9 @@
@if ($isStorageFull && $pendingFiles->isEmpty()) @if ($isStorageFull && $pendingFiles->isEmpty())
<x-alert color="warning" :title="__('Storage is full. Uploads are temporarily disabled.')" /> <x-alert color="warning" :title="__('Storage is full. Uploads are temporarily disabled.')" />
@else @else
{{-- Submitting sends the private text first (submit(), resources/js/share-uploader.js), then creates the share. --}}
<x-form <x-form
wire:submit="createShare" x-on:submit.prevent="submit()"
x-data="shareUploader({ x-data="shareUploader({
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }}, csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
messages: {{ \Illuminate\Support\Js::from([ messages: {{ \Illuminate\Support\Js::from([
@@ -12,6 +13,7 @@
'uploaded' => __('Uploaded'), 'uploaded' => __('Uploaded'),
'failed' => __('Upload failed'), 'failed' => __('Upload failed'),
'sessionExpired' => __('Your session expired. Reload the page to upload again.'), '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)" x-on:beforeunload.window="warnBeforeLeaving($event)"
@@ -95,6 +97,30 @@
</x-stack> </x-stack>
@endif @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 --}} {{-- Options --}}
<x-card :title="__('Share Options')" heading="h2" variant="outlined"> <x-card :title="__('Share Options')" heading="h2" variant="outlined">
<x-stack gap="space200"> <x-stack gap="space200">
@@ -148,7 +174,7 @@
size="md" size="md"
icon="link" icon="link"
spinner="createShare" 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" data-test="create-share"
/> />
</x-slot:actions> </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). --}} {{-- The link is ready: a check on an Expressive shape that settles in (share-ready, app.css). --}}
<x-slot:mark> <x-slot:mark>
<div class="share-check"> <div class="share-check">
@@ -73,10 +73,14 @@
</x-stack> </x-stack>
<x-grid :columns="2" gap="space200"> <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="__('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="__('Expires')" :value="$share->expires_at ? $share->expires_at->diffForHumans() : __('Never')" icon="schedule" />
<x-stat :title="__('Max Downloads')" :value="$share->max_downloads ?? __('Unlimited')" icon="download" /> <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> </x-grid>
@if ($share->isPasswordProtected()) @if ($share->isPasswordProtected())
@@ -2,10 +2,10 @@
iOS before Safari 18.4, which cannot position them. --}} iOS before Safari 18.4, which cannot position them. --}}
<x-page brand> <x-page brand>
{{-- Each state is one card under the page's h1: the card holds everything the recipient acts {{-- Under the page's h1, what the recipient acts on is in cards: the password, or the share's
on, and it is the shape SealShare has always shown them. --}} private text and its files, each in its own card, with the share's expiry and limit under them. --}}
@if (! $authenticated) @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-form wire:submit="verifyPassword">
<x-password <x-password
full full
@@ -22,32 +22,100 @@
</x-form> </x-form>
</x-card> </x-card>
@else @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
{{-- A download link does not render the page again: the download limit's note switches the first press of any download or "Show text", and the server draws the open window on
on the first press here, and the server draws the open window on the next visit. --}} the next visit. --}}
<x-stack gap="space200" x-data="{ downloaded: false }"> <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
<x-list :label="__('Shared Files')"> preview must not use up a share limited to one download. It stays in Alpine and is
@foreach ($share->files as $file) drawn as plain text. --}}
<x-list-item @if ($hasText)
:title="$file->relative_path ?: $file->original_name" <x-card :title="__('Private text')" heading="h2" variant="outlined">
icon="description" <x-stack
wire:key="file-{{ $file->id }}" gap="space200"
> x-data="{
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span></x-slot:description> text: null,
<x-slot:end> async reveal() {
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" x-on:click="downloaded = true" /> const text = await $wire.revealText();
</x-slot:end>
</x-list-item>
@endforeach
</x-list>
if (text === null) {
window.materialToast({{ \Illuminate\Support\Js::from(__('This text is no longer available.')) }}, { type: 'error' });
return;
}
this.text = text;
downloaded = true;
},
}"
>
<template x-if="text !== null">
<x-stack gap="space200">
<div class="share-text md-type-body-lg" x-text="text" data-test="shared-text"></div>
<x-row justify="end">
<x-button
:label="__('Copy')"
icon="content_copy"
variant="tonal"
x-on:click="navigator.clipboard.writeText(text).then(() => window.materialToast({{ \Illuminate\Support\Js::from(__('Copied to the clipboard')) }}, { type: 'success' }))"
data-test="copy-text"
/>
</x-row>
</x-stack>
</template>
<x-row justify="end" x-show="text === null">
<x-button :label="__('Show text')" icon="visibility" variant="filled" x-on:click="reveal()" spinner="revealText" data-test="show-text" />
</x-row>
</x-stack>
</x-card>
@endif
@if ($files->isNotEmpty())
<x-card :title="__('Shared Files')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-list :label="__('Shared Files')">
@foreach ($files as $file)
<x-list-item
:title="$file->relative_path ?: $file->original_name"
icon="description"
wire:key="file-{{ $file->id }}"
>
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span></x-slot:description>
<x-slot:end>
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" x-on:click="downloaded = true" />
</x-slot:end>
</x-list-item>
@endforeach
</x-list>
<x-row justify="end">
@if ($files->count() > 1)
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate x-on:click="downloaded = true" />
@else
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $files->first()])" no-wire-navigate x-on:click="downloaded = true" />
@endif
</x-row>
</x-stack>
</x-card>
@endif
@if ($share->expires_at || $share->max_downloads)
<x-stack gap="space100">
@if ($share->expires_at) @if ($share->expires_at)
<p class="md-type-body-sm md-ink-variant">{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}</p> <p class="md-type-body-sm md-ink-variant">{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}</p>
@endif @endif
@if ($share->max_downloads) @if ($share->max_downloads)
@if ($downloadWindowEndsAt) @if ($hasText)
@if ($downloadWindowEndsAt)
<p class="md-type-body-sm md-ink-variant">{{ __('You can open this share for another :time.', ['time' => $downloadWindowEndsAt->diffForHumans(syntax: \Carbon\CarbonInterface::DIFF_ABSOLUTE)]) }}</p>
@elseif ($remainingDownloads > 0)
<p class="md-type-body-sm md-ink-variant" x-show="! downloaded">{{ trans_choice('{1} Showing the text or downloading uses the last remaining download. You then have :window to open this share.|[2,*] Showing the text or downloading uses 1 of :count remaining downloads. You then have :window to open this share.', $remainingDownloads, ['window' => $downloadWindow]) }}</p>
<p class="md-type-body-sm md-ink-variant" x-show="downloaded" x-cloak>{{ __('You have :window to open this share.', ['window' => $downloadWindow]) }}</p>
@endif
@elseif ($downloadWindowEndsAt)
<p class="md-type-body-sm md-ink-variant">{{ __('You can download these files for another :time.', ['time' => $downloadWindowEndsAt->diffForHumans(syntax: \Carbon\CarbonInterface::DIFF_ABSOLUTE)]) }}</p> <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) @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> <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
@endif @endif
</x-stack> </x-stack>
@endif
<x-row justify="end"> </x-stack>
@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 @endif
</x-page> </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"]'], ['/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 () { test('the admin dashboard heads its shares list with an h2, both in one card', function () {
$admin = User::factory()->admin()->create(); $admin = User::factory()->admin()->create();
Share::factory()->count(2)->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); ->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 () { 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(); $admin = User::query()->where('is_admin', true)->first();
$completed = Share::factory()->create(['token' => 'completedshare01', 'total_size' => 1000]); $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); 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 () { test('a share cannot be created while a file is still uploading', function () {
Storage::fake('shares'); Storage::fake('shares');
$component = Livewire::test(FileUploader::class) $component = Livewire::test(FileUploader::class)
@@ -297,7 +361,7 @@ test('file upload requires at least one file', function () {
$component = Livewire::test(FileUploader::class) $component = Livewire::test(FileUploader::class)
->call('createShare'); ->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); expect(Share::query()->count())->toBe(0);
}); });
+20
View File
@@ -2,7 +2,10 @@
use App\Models\Share; use App\Models\Share;
use App\Services\QrCodeService; use App\Services\QrCodeService;
use App\Services\ShareService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Crypt; 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 () { test('the share created page offers the link as a QR code and through the share sheet', function () {
$share = Share::factory()->create(); $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], 'no flash (a reload or another visitor)' => [null],
'a flash for another share' => ['another-share-token'], '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'); 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. * 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 () { test('the screenshots are all there, for both themes and both widths', function () {
$expected = collect([ $expected = collect([
'desktop' => ['01-upload', '02-share-created', '03-qr-code', '04-download', '05-admin-dashboard', '06-admin-settings'], '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']) ])->flatMap(fn (array $names, string $device): array => collect(['light', 'dark'])
->crossJoin($names, $device === 'desktop' ? [1600, 800] : [1080, 540]) ->crossJoin($names, $device === 'desktop' ? [1600, 800] : [1080, 540])
->map(fn (array $shot): string => "{$device}/{$shot[0]}/{$shot[1]}-{$shot[2]}.webp") ->map(fn (array $shot): string => "{$device}/{$shot[0]}/{$shot[1]}-{$shot[2]}.webp")
+9 -3
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. */ /** The share the desktop upload creates, so its link and QR code read the same on every run. */
public const CREATED_TOKEN = 'Tf8gH2jK4mN6pQ3r'; 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 public static function admin(): User
{ {
return User::factory()->admin()->create([ return User::factory()->admin()->create([
@@ -36,8 +39,8 @@ final class DemoData
} }
/** /**
* Eight shares of different ages, sizes and states: one expired, two password-protected, some * Nine shares of different ages, sizes and states: one expired, two password-protected, some
* with a download limit, some never expiring. * with a download limit, some never expiring, and one holding only a private text.
*/ */
public static function shares(): void public static function shares(): void
{ {
@@ -58,6 +61,8 @@ final class DemoData
'files' => ['Onboarding/Welcome.pdf' => 520, 'Onboarding/Handbook.pdf' => 2900, 'Onboarding/IT checklist.docx' => 130]], 'files' => ['Onboarding/Welcome.pdf' => 520, 'Onboarding/Handbook.pdf' => 2900, 'Onboarding/IT checklist.docx' => 130]],
['token' => 'Ae6fG7hJ8kL9mN1p', 'daysAgo' => 40, 'expiresAfterDays' => 10, 'downloads' => 12, 'options' => [], ['token' => 'Ae6fG7hJ8kL9mN1p', 'daysAgo' => 40, 'expiresAfterDays' => 10, 'downloads' => 12, 'options' => [],
'files' => ['Event photos.zip' => 15700]], '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) { foreach ($shares as $share) {
@@ -69,7 +74,7 @@ final class DemoData
* @param array{password?: string, max_downloads?: int} $options * @param array{password?: string, max_downloads?: int} $options
* @param array<string, int> $files relative path => size in kilobytes * @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); $createdAt = Carbon::now()->subDays($daysAgo)->subHours(2);
@@ -82,6 +87,7 @@ final class DemoData
'relativePath' => str_contains($path, '/') ? $path : null, 'relativePath' => str_contains($path, '/') ? $path : null,
])->values()->all(), ])->values()->all(),
[...$options, 'expires_at' => $expiresAfterDays === null ? null : $createdAt->copy()->addDays($expiresAfterDays)], [...$options, 'expires_at' => $expiresAfterDays === null ? null : $createdAt->copy()->addDays($expiresAfterDays)],
$text,
); );
} finally { } finally {
Str::createRandomStringsNormally(); Str::createRandomStringsNormally();
+13 -3
View File
@@ -115,13 +115,15 @@ test('desktop', function (string $theme) use ($files) {
$upload = visit('/upload'); $upload = visit('/upload');
$page = shotPage($upload, 'desktop', $theme); $page = shotPage($upload, 'desktop', $theme);
selectFiles($page, $files); 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")') $page->click('label:has-text("Password protect")')
->click('[data-test="generate-password"]') ->click('[data-test="generate-password"]')
->wait(1) ->wait(1)
->assertScript("document.querySelector('input[autocomplete=\"new-password\"]').value.length > 0"); ->assertScript("document.querySelector('input[autocomplete=\"new-password\"]').value.length > 0");
$page->type('input[wire\:model="maxDownloads"]', '5'); $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. // 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().top + window.scrollY - 24)"); $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'); shoot($page, 'desktop', $theme, '01-upload');
// Creating the share is what offers the password once more beside the link. // 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"); ->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open");
shoot($page, 'desktop', $theme, '03-qr-code', DemoData::CREATED_TOKEN); 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()); app(ShareService::class)->deleteShare(Share::query()->where('token', DemoData::CREATED_TOKEN)->firstOrFail());
$download = visit(route('share.download', DemoData::DELIVERY_TOKEN, false)); $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"]') $page->click('[data-test="show-qr-code"]')
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open"); ->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open");
shoot($page, 'phone', $theme, '04-qr-code'); 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']); })->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(); $share = Share::factory()->pending()->create();
expect(fn () => $this->service->completeShare($share)) 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 () { 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($share->files->first()->completed_at)->not->toBeNull();
expect(filesize($this->service->storedFilePath($share->files->first())))->toBe(FileEncryptionService::HEADER_LENGTH + FileEncryptionService::TAG_LENGTH); 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');
});
+14 -1
View File
@@ -1195,6 +1195,13 @@ a.skip-link:focus-visible {
margin-inline: auto; 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] { .gallery[hidden] {
display: none; display: none;
} }
@@ -1229,9 +1236,15 @@ a.skip-link:focus-visible {
} }
.gallery--phone { .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); 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) { @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: 17 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 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: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 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: 12 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: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 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: 52 KiB

After

Width:  |  Height:  |  Size: 52 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: 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

+38 -18
View File
@@ -106,6 +106,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="sprite" aria-hidden="true"> <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-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-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-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> <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> <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>
<article class="feature"> <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> <h3>A link per share</h3>
<p>Every upload gets its own random, hard-to-guess link.</p> <p>Every upload gets its own random, hard-to-guess link.</p>
</article> </article>
<article class="feature"> <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> <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> <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>
<article class="feature"> <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> <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> <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>
<article class="feature"> <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> <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> <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>
<article class="feature"> <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> <h3>Expiry</h3>
<p>From 1 hour to 30 days, or never when the admin allows it.</p> <p>From 1 hour to 30 days, or never when the admin allows it.</p>
</article> </article>
<article class="feature"> <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> <h3>Download limits</h3>
<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> <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>
<article class="feature"> <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> <h3>ZIP download</h3>
<p>Recipients download everything in one archive.</p> <p>Recipients download everything in one archive.</p>
</article> </article>
<article class="feature"> <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> <h3>Automatic clean-up</h3>
<p>Expired shares and their files are deleted every hour, and unfinished uploads after 4 hours.</p> <p>Expired shares and their files are deleted every hour, and unfinished uploads after 4 hours.</p>
</article> </article>
<article class="feature"> <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> <h3>Admin dashboard</h3>
<p>Every share with its size, downloads against its limit and expiry; storage use and the installed version 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>
<article class="feature"> <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> <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> <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>
<article class="feature"> <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> <h3>Your branding</h3>
<p>Your logo, site title and description on the upload page, in one of eight colour profiles.</p> <p>Your logo, site title and description on the upload page, in one of eight colour profiles.</p>
</article> </article>
<article class="feature"> <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> <h3>System password</h3>
<p>Optionally close the upload page to everyone who does not have the password.</p> <p>Optionally close the upload page to everyone who does not have the password.</p>
</article> </article>
<article class="feature"> <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> <h3>Two-factor sign-in</h3>
<p>Admins protect their accounts with authenticator app codes.</p> <p>Admins protect their accounts with authenticator app codes.</p>
</article> </article>
<article class="feature"> <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> <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> <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> </article>
@@ -485,7 +491,7 @@
<figure class="shot"> <figure class="shot">
<div class="device device--phone"> <div class="device device--phone">
<div class="device__screen"> <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-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" 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" width="393" height="852" loading="lazy" decoding="async"
@@ -499,7 +505,7 @@
<figure class="shot"> <figure class="shot">
<div class="device device--phone"> <div class="device device--phone">
<div class="device__screen"> <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-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" 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" width="393" height="852" loading="lazy" decoding="async"
@@ -513,7 +519,7 @@
<figure class="shot"> <figure class="shot">
<div class="device device--phone"> <div class="device device--phone">
<div class="device__screen"> <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-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" 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" width="393" height="852" loading="lazy" decoding="async"
@@ -527,7 +533,7 @@
<figure class="shot"> <figure class="shot">
<div class="device device--phone"> <div class="device device--phone">
<div class="device__screen"> <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-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" 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" 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> <figcaption><strong>Scan to open</strong><span>The QR code fills the screen.</span></figcaption>
</figure> </figure>
</li> </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> </ul>
</div> </div>
</section> </section>