diff --git a/CHANGELOG.md b/CHANGELOG.md index 27d0cb1..c779cbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.0.0] - 2026-09-13 +### Added + +- The share created page offers the link as a QR code: "Show QR code" opens it in a dialog (full screen on a phone) and "Download" saves it as a PNG. For a password-protected share the dialog reminds that recipients also need the password; the code holds only the link. +- A "Share…" button on the same page opens the device's share sheet with the link, where the browser has one (mostly phones and Safari). + ### Changed - The interface is rebuilt on [Livewire Material](https://gitea.nonameweb.ch/noNameWEB/livewire-material), a Material 3 Expressive component library, replacing Mary UI and DaisyUI. Every page — upload, share created, download, sign-in, settings, admin and the setup wizard — uses its components, in a colour scheme generated from SealShare's indigo. diff --git a/README.md b/README.md index 1d464b1..ea022c0 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A simple, self-hosted file sharing solution built with Laravel. Upload files, ge ## Features - **File Uploading** — Drag & drop or browse to upload single/multiple files and folders with real-time progress -- **Shareable Links** — Each upload generates a unique link for recipients +- **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 - **End-to-End Encryption** — All files encrypted at rest using AES-256-GCM (chunked, streaming) - **Password Protection** — Optionally protect shares with a password - **Expiration** — Shares auto-expire after a configurable duration (1 hour to 30 days) diff --git a/app/Livewire/ShareCreated.php b/app/Livewire/ShareCreated.php index 36cc561..d31f761 100644 --- a/app/Livewire/ShareCreated.php +++ b/app/Livewire/ShareCreated.php @@ -2,7 +2,9 @@ namespace App\Livewire; +use App\Models\Setting; use App\Models\Share; +use App\Services\QrCodeService; use Livewire\Attributes\Layout; use Livewire\Component; @@ -18,6 +20,12 @@ class ShareCreated extends Component public function render(): mixed { - return view('livewire.share-created'); + $shareUrl = route('share.download', $this->share); + + return view('livewire.share-created', [ + 'shareUrl' => $shareUrl, + 'qrCodeSvg' => app(QrCodeService::class)->svg($shareUrl), + 'siteTitle' => Setting::get('site_title') ?: config('app.name'), + ]); } } diff --git a/app/Services/QrCodeService.php b/app/Services/QrCodeService.php new file mode 100644 index 0000000..f48b485 --- /dev/null +++ b/app/Services/QrCodeService.php @@ -0,0 +1,37 @@ +writeString($contents, Encoder::DEFAULT_BYTE_MODE_ENCODING, ErrorCorrectionLevel::M()); + + return trim(substr($svg, strpos($svg, "\n") + 1)); + } +} diff --git a/docs/plans/share-qr-code.md b/docs/plans/share-qr-code.md new file mode 100644 index 0000000..89fae4d --- /dev/null +++ b/docs/plans/share-qr-code.md @@ -0,0 +1,149 @@ +# Share by QR code and share sheet + +## Goal + +After an upload, the share created page offers two more ways to hand a share over besides +copying the link: a QR code, in a dialog, that another device scans (and that downloads as a +PNG for chats and mails), and, where the browser has one, the device's native share sheet. Both +carry only the share's link — never a password. + +## Context + +- Laravel 13.31, Livewire 4.4, Livewire Material 1.0.x, Pest 5 with browser tests, Octane + (FrankenPHP). Production image `dunglas/frankenphp:php8.5-alpine` with `intl`, `pcntl`, `zip` + added; it has `xmlwriter` and `iconv`, and neither `gd` nor `imagick`. +- `bacon/bacon-qr-code` v3.1.1 is installed through `laravel/fortify` (`^3.0`), which draws the + two-factor setup QR with `Writer` + `ImageRenderer` + `SvgImageBackEnd` and strips the XML + declaration (`TwoFactorAuthenticatable::twoFactorQrCodeSvg()`). SVG needs no image extension; + a server-side PNG would. +- `app/Livewire/ShareCreated.php` (`#[Layout('layouts.app')]`, `public Share $share`) renders + `resources/views/livewire/share-created.blade.php`: the link as + ``, + four ``, an info alert for password-protected shares, and "Upload More". The route + `share/{share:token}/created` sits behind `system.password`, like the upload page. +- The two-factor dialog (`pages/settings/⚡two-factor`) is the in-app pattern: the SVG inline + on a `bg-white` panel inside ``, so it stays scannable in dark mode. +- `` without `wire:model` opens from `open` in the surrounding Alpine scope and gives + `close()`; `materialToast()` is global; `resources/js/app.js` imports only the package JS. +- Services live in `app/Services` (`ShareService`, `FileEncryptionService`). Share passwords + are hashed and turned into a key; the plain password is never stored. +- No feature test covers `ShareCreated` yet; `tests/Browser/SealShareTest.php` copies the link + on that page. + +## Decisions + +- **Only on the share created page** — that is where a share is handed over; the admin + dashboard and the download page stay as they are. +- **A "Show QR code" button opens a dialog** — the page stays as calm as now; the dialog is + `` (the whole screen on a phone, to hold up to another camera) with the + QR on a white panel. +- **Download is a PNG made in the browser** — the dialog's SVG is drawn onto a canvas and saved + as `share-.png`; no server route and no `gd`/`imagick` in the Docker images. +- **Require `bacon/bacon-qr-code:^3.0` directly** — the version already installed through + Fortify, declared so SealShare does not depend on Fortify keeping it. +- **Password-protected shares get a note in the dialog** — "Recipients also need the password." + The QR holds the link only. +- **A "Share…" button opens the native share sheet** — shown only where `navigator.share` exists + (mostly phones and Safari), sharing `{ title: , url: }`. + Cancelling the sheet (`AbortError`) does nothing; any other failure shows an error snackbar. +- **In 2.0.0, on the `material` branch** — 2.0.0 is not released and the page was just rebuilt + there; one PR, one changelog entry. +- **Black modules on white, a four-module quiet zone, error correction M** — the most reliable + to scan from a screen or a print; the site's theme does not tint it. +- **The SVG is drawn at 1024 × 1024** — CSS scales it down in the dialog, and the canvas draws it + at its own size, so the PNG is sharp in every browser (Safari rasterises an SVG at its + intrinsic size). +- **Generated server-side with the page, opened client-side** — the SVG is a few kilobytes and + the dialog needs no round trip; the dialog's `open` is Alpine state, not a Livewire property. +- **`App\Services\QrCodeService::svg(string $contents): string`** — one place that knows Bacon's + API; `ShareCreated::render()` passes `shareUrl`, `qrCodeSvg` and `siteTitle` to the view (as + `FileUploader::render()` passes `siteTitle`), so the URL is built once. +- **The share and download behaviour lives in `resources/js/share-created.js`** — an + `Alpine.data('shareActions', …)` with `canShare`, `share()` and `downloadQrCode()`, imported + by `app.js`, instead of long inline Alpine in the view. + +## Out of scope + +- QR codes on the admin dashboard or the download page. +- Sharing the QR image itself through the share sheet (`navigator.share({ files })`). +- An SVG download, a server-rendered PNG, or a print layout. +- A logo in the middle of the QR, or colours from the theme. +- Putting the password (or any secret beyond the link's token) into the QR or the share sheet. + +## Implementation steps + +1. **Dependency.** `composer require bacon/bacon-qr-code:^3.0` (stays at v3.1.1). +2. **Service.** `php artisan make:class Services/QrCodeService`: `svg(string $contents): string` + renders with `new Writer(new ImageRenderer(new RendererStyle(1024, 4, null, null, + Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(0, 0, 0))), new SvgImageBackEnd))`, + `writeString($contents, Encoder::DEFAULT_BYTE_MODE_ENCODING, ErrorCorrectionLevel::M())`, and + drops the XML declaration as Fortify does. +3. **Component.** `ShareCreated::render()` builds `$shareUrl = route('share.download', + $this->share)` and passes `shareUrl`, `qrCodeSvg` (from the service) and `siteTitle` + (`Setting::get('site_title') ?: config('app.name')`) to the view; the link field uses + `$shareUrl`. +4. **JavaScript.** `resources/js/share-created.js` registers on `alpine:init` + `Alpine.data('shareActions', ({ url, title, filename, messages }) => …)`, `messages` holding + the translated `shareFailed` and `downloadFailed`: + - `open: false` for the dialog; + - `canShare`: `typeof navigator.share === 'function'`, read once at init; + - `share()`: `navigator.share({ title, url })`, ignoring `AbortError`, otherwise + `materialToast(messages.shareFailed, { type: 'error' })`; + - `downloadQrCode(svg)`: takes the `` element (the button passes + `$el.closest('dialog').querySelector('[data-qr-code] svg')` — the dialog has its own Alpine + scope, so `$refs` from the outer one would not reach it), serialises it, loads it into an + `Image` from a Blob URL, draws it on a 1024 × 1024 canvas with a white fill and + `imageSmoothingEnabled = false`, `toBlob('image/png')`, clicks a temporary `` + named `filename`, and revokes both object URLs; a failed load or an empty blob shows + `materialToast(messages.downloadFailed, { type: 'error' })`. + `resources/js/app.js` imports it after the package. +5. **View.** In `share-created.blade.php`, wrap the link and actions in + `
` + (a plain element, so `@js` compiles there): + - under the link field, a row with `` and, in a + `` wrapper, ``; + - `` holding + `
{!! $qrCodeSvg !!}
` + (the SVG is generated from the app's own URL — no user input), then, for a + password-protected share, ``, + and actions `` + and ``. + "Upload More" and the stats stay where they are. +6. **Docs.** README: the "Shareable Links" feature line mentions the QR code and share sheet. + CHANGELOG `2.0.0`: an "Added" section (before "Changed", as Keep a Changelog orders them) + with an entry for both. + +## Testing + +- `tests/Unit/QrCodeServiceTest.php` (the service needs no application): + `svg()` returns markup starting with `.png`; the + password note appears for a protected share and not for an open one. +- `tests/Browser/SealShareTest.php`: + - "Show QR code" opens the dialog with the QR on a white panel; Download produces an + `image/png` blob named `share-.png` (recorded by stubbing + `HTMLAnchorElement.prototype.click` through `window.eval`), with no JavaScript errors; + - the Share button is hidden where `navigator.share` is missing, and `share()` passes the + link to a stubbed `navigator.share`, stays quiet on `AbortError` and shows the error snackbar + on any other rejection. +- `DesignLanguageTest` keeps passing (`qr_code_2`, `share`, `download` are Material Symbols; + `bg-white` is a token). +- Narrow runs per step, then the full suite. + +## Risks and open questions + +- **Scanning reliability** is not proven by the tests (no decoder in the stack): the feature + test pins the SVG to Bacon's output for the exact URL, and a manual scan with a phone during + review is the check. +- **The QR is only as right as the link.** Behind a reverse proxy with a wrong `APP_URL` or + trusted-proxy setting, both point at the wrong host — unchanged from today. +- **Safari and canvas.** Drawing an SVG from a Blob URL onto a canvas works in current + Chrome, Firefox and Safari without tainting the canvas; the browser test runs in Chromium + locally and in CI, and the three-engine check is manual. +- **Share sheet on desktop** exists in Safari and Chromium on some platforms and not in + Firefox; the button's absence there is by design. diff --git a/resources/js/app.js b/resources/js/app.js index 6dfabc7..ed1081e 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,2 +1,3 @@ // Livewire Material. Alpine is bundled and started by Livewire 4: never import it here as well. import '../../vendor/nonameweb/livewire-material/resources/js/material.js' +import './share-created.js' diff --git a/resources/js/share-created.js b/resources/js/share-created.js new file mode 100644 index 0000000..1b62995 --- /dev/null +++ b/resources/js/share-created.js @@ -0,0 +1,76 @@ +/** + * `shareActions`: the ways the share created page hands a share over besides copying its link — + * the device's share sheet, where the browser has one, and the QR code in its dialog saved as a + * PNG. Both carry only the link. + * + * The PNG is drawn in the browser from the dialog's SVG, which is 1024 pixels square, so the + * server needs no image extension and every browser rasterises it at full size. + */ +document.addEventListener('alpine:init', () => { + window.Alpine.data('shareActions', ({ url, title, filename, messages }) => ({ + open: false, + + canShare: typeof navigator.share === 'function', + + async share() { + try { + await navigator.share({ title, url }) + } catch (error) { + if (error?.name !== 'AbortError') { + window.materialToast(messages.shareFailed, { type: 'error' }) + } + } + }, + + async downloadQrCode(svg) { + try { + const png = await rasterise(svg) + const link = document.createElement('a') + const href = URL.createObjectURL(png) + + link.href = href + link.download = filename + link.click() + + setTimeout(() => URL.revokeObjectURL(href), 0) + } catch { + window.materialToast(messages.downloadFailed, { type: 'error' }) + } + }, + })) +}) + +/** + * The SVG element as a PNG blob, at the SVG's own width and height, on white. + */ +async function rasterise(svg) { + const width = Number(svg.getAttribute('width')) + const height = Number(svg.getAttribute('height')) + const source = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(svg)], { type: 'image/svg+xml' })) + + try { + const image = new Image() + image.src = source + await image.decode() + + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + + const context = canvas.getContext('2d') + context.imageSmoothingEnabled = false + context.fillStyle = '#ffffff' + context.fillRect(0, 0, width, height) + context.drawImage(image, 0, 0, width, height) + + const png = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')) + + if (!png) { + throw new Error('The canvas gave no image.') + } + + return png + } finally { + URL.revokeObjectURL(source) + } +} diff --git a/resources/views/livewire/share-created.blade.php b/resources/views/livewire/share-created.blade.php index 019bfd0..4a7535e 100644 --- a/resources/views/livewire/share-created.blade.php +++ b/resources/views/livewire/share-created.blade.php @@ -11,14 +11,51 @@
- + {{-- Besides the link: a QR code in a dialog, saved as a PNG in the browser, and the device's + share sheet where there is one (resources/js/share-created.js). Both carry the link only. --}} +
+ + +
+ + + + + +
+ + + {{-- White in either theme: a scanner needs the contrast. The SVG is drawn from the app's own URL. --}} +
{!! $qrCodeSvg !!}
+ + @if ($share->isPasswordProtected()) +
+ +
+ @endif + + + + + +
+
diff --git a/tests/Browser/SealShareTest.php b/tests/Browser/SealShareTest.php index d8c7801..46ece81 100644 --- a/tests/Browser/SealShareTest.php +++ b/tests/Browser/SealShareTest.php @@ -54,6 +54,51 @@ test('a new share\'s link can be copied from the page the upload leads to', func ->assertSee('Copied to the clipboard'); }); +test('a new share\'s QR code opens in a dialog and saves as a PNG', function () { + $share = Share::factory()->withPassword()->create(); + + $page = ready(visit(route('share.created', $share, false))); + + $page->click('[data-test="show-qr-code"]') + ->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open") + ->assertScript("getComputedStyle(document.querySelector('[data-qr-code]')).backgroundColor === 'rgb(255, 255, 255)'") + ->assertScript("document.querySelector('[data-qr-code] svg').getBoundingClientRect().width > 200") + ->assertSee('Recipients also need the password.'); + + // Record what would be saved instead of saving it. + $page->script('window.eval("URL.revokeObjectURL = () => {}; HTMLAnchorElement.prototype.click = function () { window.saved = { name: this.download, href: this.href } }")'); + + $page->click('[data-test="download-qr-code"]') + ->assertScript("window.eval('window.saved?.name') === 'share-{$share->token}.png'"); + + // A QR code is roughly a third to a half dark; a blank or failed drawing is not. + $page->script("window.eval(\"(async () => { const blob = await (await fetch(window.saved.href)).blob(); const bitmap = await createImageBitmap(blob); const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); const context = canvas.getContext('2d'); context.drawImage(bitmap, 0, 0); const pixels = context.getImageData(0, 0, bitmap.width, bitmap.height).data; let dark = 0; for (let i = 0; i < pixels.length; i += 4) { if (pixels[i] < 128) { dark++ } } window.png = { type: blob.type, width: bitmap.width, dark: dark / (pixels.length / 4) } })()\")"); + + $page->assertScript("window.eval('window.png?.type') === 'image/png'") + ->assertScript("window.eval('window.png.width') === 1024") + ->assertScript("window.eval('window.png.dark') > 0.2 && window.eval('window.png.dark') < 0.6") + ->assertNoJavaScriptErrors(); +}); + +test('the share sheet gets the link, and says so only when it fails for another reason than a cancel', function () { + $share = Share::factory()->create(); + $actions = "Alpine.\$data(document.querySelector('[data-test=share-actions]'))"; + + $page = ready(visit(route('share.created', $share, false))); + + // Shown only where the browser has a share sheet. + $page->assertScript("window.eval(\"getComputedStyle(document.querySelector('[data-test=share-sheet]').parentElement).display === 'none'\") === (typeof navigator.share !== 'function')"); + + $page->script("window.eval(\"navigator.share = async (data) => { window.shared = data }; {$actions}.share()\")"); + $page->assertScript("window.eval('window.shared?.url') === '".route('share.download', $share)."'"); + + $page->script("window.eval(\"navigator.share = async () => { throw new DOMException('Cancelled', 'AbortError') }; {$actions}.share()\")"); + $page->wait(0.3)->assertDontSee('The share sheet could not open.'); + + $page->script("window.eval(\"navigator.share = async () => { throw new DOMException('Not allowed', 'NotAllowedError') }; {$actions}.share()\")"); + $page->assertSee('The share sheet could not open.'); +}); + test('a recipient on a phone unlocks a password-protected share and sees its files', function () { $share = app(ShareService::class)->createShare( [['file' => UploadedFile::fake()->create('holiday-photos.zip', 120), 'relativePath' => null]], diff --git a/tests/Feature/ShareCreatedTest.php b/tests/Feature/ShareCreatedTest.php new file mode 100644 index 0000000..fc510fb --- /dev/null +++ b/tests/Feature/ShareCreatedTest.php @@ -0,0 +1,27 @@ +create(); + $url = route('share.download', $share); + + $response = $this->get(route('share.created', $share)); + + $response->assertOk() + ->assertSee($url, false) + ->assertSee('data-test="show-qr-code"', false) + ->assertSee('data-test="share-sheet"', false) + ->assertSee('share-'.$share->token.'.png') + ->assertSee('
'.app(QrCodeService::class)->svg($url).'
', false) + ->assertDontSee('Recipients also need the password.'); +}); + +test('the QR code dialog reminds that a protected share also needs its password', function () { + $share = Share::factory()->withPassword()->create(); + + $this->get(route('share.created', $share)) + ->assertOk() + ->assertSee('Recipients also need the password.'); +}); diff --git a/tests/Unit/QrCodeServiceTest.php b/tests/Unit/QrCodeServiceTest.php new file mode 100644 index 0000000..224072f --- /dev/null +++ b/tests/Unit/QrCodeServiceTest.php @@ -0,0 +1,21 @@ +svg('https://share.example.com/s/aBcDeFgHiJkLmNoP'); + + expect($svg)->toStartWith('not->toContain('toContain('width="1024" height="1024"') + ->toContain('fill="#ffffff"') + ->toContain('fill="#000000"'); +}); + +test('the same contents give the same code, other contents another', function () { + $service = new QrCodeService; + + expect($service->svg('https://share.example.com/s/aBcDeFgHiJkLmNoP')) + ->toBe($service->svg('https://share.example.com/s/aBcDeFgHiJkLmNoP')) + ->not->toBe($service->svg('https://share.example.com/s/zYxWvUtSrQpOnMlK')); +});