27 Commits
Author SHA1 Message Date
Andreas Reinhold / reiniandClaude Opus 5 eb25631799 Release 2.1.0
linter / quality (push) Successful in 1m4s
tests / ci (8.5) (push) Successful in 3m24s
docker / build-and-push (push) Successful in 7m6s
docker / test (8.5) (push) Successful in 3m30s
docker / release (push) Successful in 4s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:52:04 +02:00
Andreas Reinhold / reiniandClaude Opus 5 c552ee9f9d Update PHP and npm dependencies
Everything stays within its existing constraint: Laravel 13.32, Livewire
4.4.5, Livewire Material 2.1.0, Pest 5.2, Boost 2.9, Pint 1.32.1, Vite
8.3 and autoprefixer 10.6. Boost's copy of the guidelines and skills
follows Livewire Material 2.1, which is plain CSS without utilities.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:52:04 +02:00
Andreas Reinhold / reiniandClaude Opus 5 5853f1f7d6 Give every page the share pages' layout, at one width
The pages were built five ways: two layouts, seven widths from 28 to
64rem and four heading styles. Every page now looks like the share
pages: a centred heading over one 40rem column of outlined cards, with
the floating toolbar below.

- <x-page> (components/page.blade.php) is the root of every page. It
  draws the h1 and its line (`brand` takes the site's logo, title and
  description from Admin settings), an optional `mark` and `navigation`
  slot, then the content. It has no width prop: every page is the same
  <x-pane width="narrow">.
- The sign-in, password reset, confirm, verify email, two-factor
  challenge, setup and system password pages move onto layouts/app with
  the brand heading and their form in a card titled with the task.
  layouts/auth, auth-header and the settings heading partial are gone,
  and so is the per-page width CSS.
- Settings put their section nav under the heading; the admin pages get
  a description line each. FileUploader and ShareDownload no longer pass
  the branding to their views.
- The admin dashboard's table needed about 49rem, so its shares are a
  list: created above the token, which opens the share, then files,
  size, downloads and expiry on two lines that wrap instead of clipping,
  and one delete button. A "Sort by" select replaces the column headers
  (newest, oldest, expiring soonest with never-expiring last, largest,
  most downloads, most files) and resets the page. The stats stay two
  by two. table.css and sort-header.css are no longer imported.
- Branding hints in Admin settings name every page the title shows on.
- Tests: PageTemplateTest renders every page once and checks one page
  template, one h1 and the width, and the brand heading with its
  fallbacks. FrameTest measures the page column instead of the auth
  card and the 64rem main; dashboard tests follow the list and the sort
  select, including expiry order. .ai/rules/views.md records <x-page>,
  the CHANGELOG notes the change and the website screenshots are
  regenerated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:42:04 +02:00
Andreas Reinhold / reiniandClaude Opus 5 40e35bab0e Encrypt uploads in the browser and send them in chunks
A 6 GB upload kept a customer waiting long after its progress bar
reached 100%. The server wrote every upload three times: PHP's
temporary file, Livewire's copy of it ("Processing files...") and the
encrypted file ("Create Share Link"), each a full rewrite of a slow
disk. The unencrypted copy also stayed behind in livewire-tmp.

Now the uploader's browser encrypts each file in 16 MB chunks with
WebCrypto and PUTs them one at a time; the server checks each chunk in
memory and writes it once, already encrypted. Creating the share only
wraps its key and saves the options. A 200 MB upload through the
Docker image took 2.8 s, and its download matched byte for byte.

- SEALCHK2: a 19-byte header (chunk size, 7-byte nonce prefix), then
  ciphertext and tag per chunk. Each nonce holds the chunk index and a
  last-chunk flag (the STREAM construction), so cut or reordered files
  fail to decrypt. SEALCHK1 and the single-block format still read.
- Envelope encryption: one random key per share. With a password it is
  wrapped with Argon2id (sodium, libsodium's interactive limits) in
  shares.wrapped_key, which names its parameters. Password shares from
  before keep their PBKDF2-derived key.
- The upload page registers each selection with FileUploader into a
  pending share of its own, lists the files with their progress, retries
  a failed chunk after 1-16 s, then offers Retry; Remove and Cancel
  abort. UploadChunkController only accepts chunks from the session that
  started the share: a repeat is acknowledged, a skip gets 409 with the
  count stored. Chunks go out as Blobs, which Chromium sends about eight
  times faster than ArrayBuffers.
- Uploads need a secure context: over plain HTTP the page says HTTPS is
  needed and takes no files. The Docker image gains AUTO_HTTPS, which
  serves Let's Encrypt on 443 for SERVER_NAME and redirects 80; without
  it the container stays on HTTP 80 behind a proxy. docker/Caddyfile was
  never loaded and is gone; docker/healthcheck.sh covers both modes.
- "Download all" streams the ZIP with maennchen/zipstream-php (STORE,
  ZIP64) instead of decrypting whole files into memory and writing the
  archive unencrypted to /tmp.
- Pending shares count towards the quota, stay out of the admin
  dashboard and 404 everywhere else. shares:cleanup deletes uploads idle
  for 4 hours and Livewire temporary files older than that.
- PHP's upload limits no longer cap the admin's max file size and
  default to 64M; LIVEWIRE_MAX_UPLOAD_TIME is gone and
  UPLOAD_CHUNK_SIZE_MB is new.
- Tests cover the format, key wrapping, registration limits, the chunk
  endpoint's answers, completing a share, the streamed ZIP, cleanup,
  and in Chromium a real chunked upload and the HTTPS warning; the
  selected-files overflow test runs again. README, website, CHANGELOG
  and .ai/rules follow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:49:17 +02:00
Andreas Reinhold / reiniandClaude Opus 5 504971ad7f Generate share passwords and offer them again beside the new link
Uploaders no longer have to make up a share password. With "Password
protect" on, the upload page has Generate and Copy under the field, and
the page the upload leads to offers the password once more beside the
link: masked, with the same copy button at the end of the field as the
link's. The password also derives the share's encryption key and only
its hash is stored, so a lost one means files nobody can open.

- PasswordGeneratorService draws from Random\Randomizer's secure engine.
  Characters are drawn uniformly and redrawn until every chosen set
  appears; passphrases come from EFF's large word list (CC BY 3.0 US,
  credited in the README), without its four hyphenated words.
- Admin settings gain a "Share Passwords" card: mode (off, on request,
  prefilled as protection is switched on), kind (characters: length
  12–64, the sets, look-alikes left out; passphrase: 4–10 words and a
  separator), and an example with its estimated entropy that follows the
  form before saving. Fields the chosen mode or kind hides are excluded
  from validation and keep their saved value. The default is on
  request, 20 letters and numbers without look-alikes.
- FileUploader flashes the password encrypted with the share's token;
  ShareCreated shows it only when the token matches, so a reload or any
  other visitor sees nothing. Crypt covers installs without
  SESSION_ENCRYPT, which the Docker setup does not set.
- The symbol set leaves out what chat apps turn into formatting and
  what breaks inside quotes, so a pasted password arrives unchanged.
- app.css imports group.css for <x-group>; .ai/rules/views.md records
  that <x-group> drops data-test and other attributes.
- Tests cover the generator, the admin card's saving, validation and
  example, prefill and generate on the upload page, the flash, and in
  Chromium Generate and Copy on the upload page and the masked copy on
  the share page. The admin settings page now has six headed sections.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 11:00:11 +02:00
Andreas Reinhold / reiniandClaude Opus 5 a88a052d9a Move onto Livewire Material 2.0.0 and leave Tailwind behind
Livewire Material 2.0.0 aligns every component with Material 3
Expressive and carries no Tailwind anywhere, so SealShare drops
tailwindcss and its Vite plugin and writes its views in the package's
vocabulary: layout components (<x-pane>, <x-stack>, <x-row>, <x-grid>,
<x-form>) with M3's spacing tokens, the md-type-*/md-ink-* text classes,
and --md-sys-* tokens in its own small stylesheet.

- resources/css/app.css opens with the package's layer order, imports
  foundation.css and the stylesheet of each component the views render,
  then the scheme, regenerated with the 2025 colour rules at M3's three
  contrast levels. The app's own rules follow, one section per view,
  on tokens and on M3's breakpoints (600/840/1200/1600px) only.
- Every view is rewritten in that vocabulary while SealShare keeps the
  shape it had: the admin table, the admin settings, the recovery codes,
  the share options and the download page are cards, and their fields
  fill them rather than stopping at the 40rem bound a card already
  bounds. The user settings pages became cards too, to match the
  admin's, each with the sections that stand apart from its one subject
  — deleting the account, the recovery codes — in a card beside it.
  Material 3 decides how a component behaves, not whether a container
  survives: buttons keep their label's width, a form's actions end it,
  and each heading level keeps one type role.
- The layouts clear the floating toolbar by the --material-bottom-toolbar
  the package publishes, and the snackbar clears it by itself.
- The two-factor setup QR code comes from QrCodeService, so it keeps a
  white field and quiet zone in the dark theme and still scans.
- Browse Files is a real button that opens the file input, reachable
  and visibly focused from the keyboard.
- Tests: the design test scans views, JS, CSS and app/ and checks the
  CSS entry both ways (no missing, no unused import). New Chromium
  suites cover the frame, settings and admin, the share flow, and every
  page at M3's breakpoint edges (599/600, 839/840, 1199/1200, 1600px).
  The package's renamed data-md-* hooks replace the 1.x ones.
- Boost's update brings the material-3 guideline and skill and drops
  the Tailwind skill.

composer.json requires nonameweb/livewire-material ^2.0 from the Gitea
repository, resolved at the 2.0.0 tag. The CHANGELOG records the move as
2.1.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 22:30:51 +02:00
Andreas Reinhold / reiniandClaude Opus 5 461bc23f0a Fix the Docker image not starting: update Livewire Material to 1.1.1
tests / ci (8.5) (push) Successful in 2m16s
linter / quality (push) Successful in 1m1s
docker / build-and-push (push) Successful in 7m5s
docker / test (8.5) (push) Successful in 2m16s
docker / release (push) Successful in 4s
The entrypoint's php artisan view:cache failed on the package's showcase
views, whose components were only registered with the showcase enabled.
1.1.1 registers them always. ProductionBootTest caches every view with
the showcase off, as the entrypoint does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 17:59:34 +02:00
Andreas Reinhold / reiniandClaude Opus 5 cd97612b4d Keep planning notes out of the repository
docker / test (8.5) (push) Successful in 2m22s
linter / quality (push) Successful in 1m7s
tests / ci (8.5) (push) Successful in 2m24s
docker / build-and-push (push) Successful in 7m26s
docker / release (push) Skipped
docs/plans is ignored from now on; the notes stay on the machine that
wrote them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 16:24:30 +02:00
Andreas Reinhold / reiniandClaude Opus 5 c95d0c43c2 Load the website's Plausible site script
linter / quality (push) Successful in 1m7s
tests / ci (8.5) (push) Successful in 2m16s
docker / build-and-push (push) Successful in 7m5s
docker / test (8.5) (push) Successful in 2m25s
docker / release (push) Successful in 5s
Plausible gives each new site its own script and an init call instead
of the generic script with data-domain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 16:21:51 +02:00
Andreas Reinhold / reiniandClaude Opus 5 09e24ade14 Build the amd64 image with QEMU 9.2.2 and only emulate the final stage
docker / test (8.5) (push) Successful in 2m20s
linter / quality (push) Successful in 1m5s
tests / ci (8.5) (push) Successful in 2m16s
docker / build-and-push (push) Successful in 20m13s
docker / release (push) Skipped
QEMU 8.x crashes running x86_64 programs on an arm64 host (QEMU issue
2168), which the 8.1.5 pin walked into, and 10.2 segfaults on the runner
as well; 9.2.2 runs node, composer and install-php-extensions on the
runner's host. The Composer and npm stages now build on the build
machine's platform: their output is the same for every target, so a
multi-arch build runs them once and natively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 15:52:58 +02:00
Andreas Reinhold / reiniandClaude Opus 5 21bea9646d Let the admin choose one of eight colour profiles
linter / quality (push) Successful in 1m3s
tests / ci (8.5) (push) Successful in 2m8s
docker / test (8.5) (push) Successful in 2m15s
docker / build-and-push (push) Failing after 7m22s
docker / release (push) Skipped
Indigo (the default), Blue, Teal, Green, Amber, Rose and Violet in the
Vibrant style and Graphite in the Neutral style are generated from
config into the stylesheet. Admin settings opens with a colour profile
card: a swatch previews the profile on the page, and Save Settings
stores it as color_profile, which AppServiceProvider hands to the
package's resolver, so every page, mail and error page wears it. An
unknown profile is refused, and a saved one that disappears falls back
to indigo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 15:06:47 +02:00
Andreas Reinhold / reiniandClaude Opus 5 27e322c352 Update Livewire Material to 1.1.0
It brings colour profiles: several generated schemes under
<html data-scheme>, a resolver for the active one, and a picker. Boost's
copy of the package guideline and skill follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 15:06:47 +02:00
Andreas Reinhold / reiniandClaude Opus 5 ca0dfa9396 Emulate the amd64 image with QEMU 8 on the arm64 runner
docker / test (8.5) (push) Successful in 2m9s
linter / quality (push) Successful in 1m4s
tests / ci (8.5) (push) Successful in 2m10s
docker / build-and-push (push) Failing after 9m53s
docker / release (push) Has been skipped
Recent QEMU segfaults compiling PHP extensions for amd64 on the runner's
6.8 kernel (docker/buildx#3170); QEMU 8.1.5 is the version reported to
work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 14:31:45 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d860a16551 Move SealShare from GitHub to Gitea only
docker / test (8.5) (push) Successful in 2m13s
linter / quality (push) Successful in 1m8s
tests / ci (8.5) (push) Successful in 2m7s
docker / build-and-push (push) Failing after 9m54s
docker / release (push) Has been skipped
The workflows live in .gitea/workflows. The Docker workflow logs in to
the Gitea container registry with REGISTRY_TOKEN (Gitea's job token cannot
publish packages yet), publishes gitea.nonameweb.ch/nonameweb/sealshare
with a registry build cache, and makes the release on Gitea with the
version's section of the changelog as its notes.

The README, the website's quick start, both compose files and the image
label point to Gitea; the changelog announces the new image name and
links Gitea. GiteaOnlyTest keeps GitHub and ghcr.io out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 13:59:07 +02:00
Andreas Reinhold / reiniandClaude Opus 5 606cc766f2 Add the SealShare website for sealshare.nonameweb.ch
docker / test (8.5) (push) Successful in 2m5s
docker / build-and-push (push) Failing after 9m38s
docker / release (push) Has been skipped
linter / quality (push) Successful in 1m8s
tests / ci (8.5) (push) Successful in 2m4s
website/ is hand-written HTML and CSS, uploaded as it is, like the
MailifySMS site: SealShare as software a company installs for its own
upload platform, how it works, features, a desktop/phone and light/dark
gallery of the generated screenshots, a dated and sourced comparison with
hosted transfer services and self-hosted tools, the Docker quick start,
FAQ and a privacy page for Plausible. Colours come from the app's scheme,
Google Sans Flex is served locally, nothing else loads from other hosts.

The README shows three screenshots and no longer calls the encryption
end-to-end. WebsiteTest guards missing files, other hosts, the screenshot
set and the encryption wording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 12:38:49 +02:00
Andreas Reinhold / reiniandClaude Opus 5 e49bdf3eb3 Take the website's screenshots with one command
composer screenshots runs Pest browser tests in tests/Screenshots, outside
every test suite: fixed demo data under a frozen clock, desktop (MacBook
14, 2x) and phone (iPhone 15 Pro, 3x) in light and dark, each capture
published at once as WebP at two widths into website/img/screenshots.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 12:30:53 +02:00
Andreas Reinhold / reiniandClaude Opus 5 4470c2ed87 Give the dev container its own node_modules
npm installs the build tools' native binaries for the platform it runs
on, so a folder shared with the host only ever held Linux's or macOS's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 12:30:53 +02:00
Andreas Reinhold / reiniandClaude Opus 5 1352533667 Offer a new share as a QR code and through the share sheet
The share created page gets "Show QR code", a dialog with the link as a
QR code (black on white, full screen on a phone) that downloads as a PNG
drawn in the browser, with a password reminder for protected shares; and
"Share…", which opens the device's share sheet where there is one. Both
carry only the link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 11:46:07 +02:00
Andreas Reinhold / reiniandClaude Opus 5 fbe358b2e5 Require Bacon QR Code directly and update Livewire Material to 1.0.1
Bacon QR Code stays at the v3.1.1 Fortify already installed; SealShare
now draws QR codes with it too. Livewire Material 1.0.1 compiles its
components to the same view on every machine and keeps a full-screen
dialog's subtitle on a phone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 11:46:07 +02:00
Andreas Reinhold / reiniandClaude Opus 5 9fd600571d Keep the dev container's compiled views out of the shared storage
The checkout is mounted at /app, so views compiled in the container and
on the host landed in one storage/framework/views, and each side read the
other's absolute paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 11:17:38 +02:00
Andreas Reinhold / reiniandClaude Opus 5 accbc17c1c Move the navigation into a floating toolbar at the bottom
The top app bar repeated the site's name, which already heads the upload
and download pages, and spread its actions to the window's far edge. A
floating toolbar centred above the bottom edge, as wide as its buttons,
now holds them: Upload and the admin pages with the current one filled,
and the account menu; guests get Upload, the theme toggle and Log in,
without tooltips. On a phone the sign-in card no longer stretches to the
full height, and the admin dashboard's empty state sits outside the
scrolling table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 11:12:12 +02:00
Andreas Reinhold / reiniandClaude Opus 5 2a363defd1 Install Playwright before the Docker workflow's tests
Its test job runs the whole suite, browser tests included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 11:02:28 +02:00
Andreas Reinhold / reiniandClaude Opus 5 3604005cc7 Describe 2.0.0 in the README and changelog
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 11:02:03 +02:00
Andreas Reinhold / reiniandClaude Opus 5 a86f035add Install Livewire Material's Boost guideline and skill, and record rules
The scheme is regenerated, never hand-edited; the download page stays
free of anchored components; the theme key stays sealshare-theme.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 11:02:03 +02:00
Andreas Reinhold / reiniandClaude Opus 5 eb33bf77cc Send Markdown mail in the Material theme
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 11:02:03 +02:00
Andreas Reinhold / reiniandClaude Opus 5 bc69066ead Add browser tests for the Material interface
Upload drop zone, copying a share link, unlocking a download on a phone,
sorting and deleting on the admin dashboard, and the system theme with
the Appearance picker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 11:02:03 +02:00
Andreas Reinhold / reiniandClaude Opus 5 c4a17b65c8 Move SealShare onto Livewire Material
Replaces maryUI and daisyUI with nonameweb/livewire-material: the Vibrant
indigo scheme, a system/light/dark theme under sealshare-theme, one top
app bar with the account menu, the upload drop zone and link-ready
moments, M3 fields, dialogs instead of wire:confirm, snackbars instead of
flashed messages, a sortable admin table, and the starter-kit cleanup.
Docker builds assets after Composer; CI drops the Flux step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 10:49:38 +02:00
199 changed files with 28372 additions and 4062 deletions
+9
View File
@@ -0,0 +1,9 @@
---
paths:
- config/livewire-material.php
---
# Config
## Theme storage key is sealshare-theme
The theme is stored in localStorage under `sealshare-theme` (default `system`). `mary-theme` stays in `theme.legacy_keys` so visitors from 1.x keep their choice once; don't rename the key or drop the legacy entry, or every returning visitor's theme resets.
+9
View File
@@ -0,0 +1,9 @@
---
paths:
- 'resources/css/material-scheme.*'
---
# Css
## Regenerate the colour profiles, never hand-edit the scheme
material-scheme.css and material-scheme.json are generated together by `php artisan material:scheme` (no seed) from the eight `profiles` in config/livewire-material.php — all Vibrant (chosen over Tonal Spot, which read washed out on indigo) except Graphite (Neutral); `profile` is the default, indigo. The JSON colours the Markdown mail theme and the fallback error pages and lists the profiles Admin settings offers and validates against, so a hand edit to the CSS alone leaves them out of step. Change the config and rerun the command; a profile only exists once generated. The admin's choice is the `color_profile` setting, read through `Scheme::resolveProfileUsing()` in AppServiceProvider.
+13
View File
@@ -0,0 +1,13 @@
# Project Rules Index
Before planning or editing, find the row whose globs match the file's path and read that rule file.
| Applies to | Rule file |
| --- | --- |
| config/livewire-material.php | .ai/rules/config.md |
| resources/css/material-scheme.* | .ai/rules/css.md |
| resources/views/livewire/share-download.blade.php | .ai/rules/livewire.md |
| tests/Screenshots/** | .ai/rules/screenshots.md |
| app/Services/** | .ai/rules/services.md |
| resources/views/** | .ai/rules/views.md |
| website/** | .ai/rules/website.md |
+9
View File
@@ -0,0 +1,9 @@
---
paths:
- resources/views/livewire/share-download.blade.php
---
# Livewire
## No anchored components on the download page
Recipients open share links on any phone, including iOS Safari below 18.4, where CSS anchor positioning is missing. Keep menus, tooltips, selects-as-menus, datepickers and other anchored/popover components off this page; use plain buttons with aria-label and native controls. tests/Browser/SealShareTest.php checks the page at 393px has no popovers.
+9
View File
@@ -0,0 +1,9 @@
---
paths:
- 'tests/Screenshots/**'
---
# Screenshots
## Screenshots come from composer screenshots, before a release
Run `composer screenshots` whenever the interface changes and before a release; it builds assets and runs tests/Screenshots (not part of any test suite or CI), publishing WebP files to website/img/screenshots. Demo data (DemoData) and the clock are fixed so runs are reproducible. Traps: Pest only starts its browser for a test whose body calls `visit(` after whitespace; the upload shot's files are registered through the page's `registerFiles` and their encrypted chunks stored server-side (the in-process server takes request bodies up to 128 KB only), then the list refreshed; the in-process server's random port is shown as https://files.example.com and the QR redrawn for it.
+9
View File
@@ -0,0 +1,9 @@
---
paths:
- 'app/Services/**'
---
# Services
## Uploads are encrypted in the browser, never on the server
Share files are encrypted chunk by chunk in the uploader's browser (resources/js/share-uploader.js, WebCrypto) in the SEALCHK2 format and PUT to UploadChunkController, which verifies each chunk in memory and writes it once. Never add a server-side upload path that puts plaintext on disk (Livewire temp uploads, multipart spooling): PHP spools every request body to upload_tmp_dir. ShareService::createShare() exists only for tests and demo data. Send chunk bodies as a Blob, not an ArrayBuffer: Chromium uploads an ArrayBuffer about 8x slower.
+12
View File
@@ -0,0 +1,12 @@
---
paths:
- 'resources/views/**'
---
# Views
## `<x-group>` drops data-test and other attributes
`<x-group>` (Livewire Material) keeps only class, style and wire:key on its fieldset and wire:model/x-model on its inputs; data-test, id and every other attribute are silently dropped. Tests reach a group through its binding instead, e.g. assertSeeHtml('wire:model.live="passwordGeneratorType"') or input[value="…"]. Rendering `<x-group>` also needs components/group.css imported in resources/css/app.css (DesignLanguageTest's missingStylesheets guards it).
## Every page renders <x-page>
Every page (Livewire page, settings SFC via pages/settings/layout, Fortify auth view) has <x-page> (resources/views/components/page.blade.php) at its root, inside layouts/app — the only layout. It draws the centred h1 header (`brand` for the site's logo/title/description on public and sign-in pages, or title/description, optional `mark` and `navigation` slots) over one centred column. Every page is the same 40rem column and <x-page> has no width prop: content that needs more room is rearranged to fit (the admin dashboard's shares are a list with a sort select, not a table). Content goes in outlined cards (`<x-card variant="outlined" heading="h2">`). Never give a page its own width class, h1 or header stack. tests/Feature/PageTemplateTest.php lists every page.
+9
View File
@@ -0,0 +1,9 @@
---
paths:
- 'website/**'
---
# Website
## website/ is the live site, uploaded by hand
website/ is a faithful copy of sealshare.nonameweb.ch (METANET hosting), hand-written HTML/CSS with no build step, uploaded wholesale when it changes. Colours in css/theme.css are copied from the indigo profile (the JSON's top-level light/dark) in resources/css/material-scheme.json — copy them again if indigo is regenerated differently; the site does not follow the admin's colour profile. The comparison tables are dated and every competitor value has a source from the product's own site, docs or repo; an unsourced value is "—", never a guess. Never call SealShare's encryption end-to-end (it encrypts at rest on the server). Nothing may load from another host except plausible.io. tests/Feature/WebsiteTest.php guards all of this.
+2 -1
View File
@@ -1,6 +1,7 @@
---
name: infer-conventions
description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand."
description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Only run this skill when the user explicitly asks for it; never start a sweep as part of another task. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand."
disable-model-invocation: true
license: MIT
metadata:
author: laravel
File diff suppressed because it is too large Load Diff
+303
View File
@@ -0,0 +1,303 @@
---
name: material-3-design
description: Material 3 Expressive's design system as Livewire Material implements it — colour roles and surface containers, elevation, shape, type, motion, states and targets, window size classes, spacing, icons, accessibility — each M3 name beside the class, prop or token that draws it and Google's source page, for deciding how a screen should look and behave before writing it.
---
# Material 3 design
## When to use this skill
Use this skill when deciding how a screen, panel or control should look or behave — which colour, container, corner, type style, motion, breakpoint or spacing — in an application that requires `nonameweb/livewire-material`, and when reviewing a view against Material 3. The props and slots of each component are in the `livewire-material-development` skill; this one is the design language they implement. The rules an agent must always follow are in the `material-3` guideline; the tables here are what those rules compress.
Every table pairs the M3 name with what the library gives for it. The library is plain CSS with no utility classes, so that is one of three things: a component or layout component prop (`color="error"`, `<x-surface level="surface-container">`, `gap="space200"`), one of the fixed text and interaction classes (`md-type-*`, `md-ink-*`, `md-state-layer`, `md-focus-ring`, `md-touch-target`, `md-link`), or a token the application's own CSS reads with `var()`. The tokens are CSS custom properties (`--md-sys-color-*`, `--md-sys-typescale-*`, `--md-sys-shape-*`, `--md-sys-elevation-*`, `--md-sys-motion-*`, `--md-sys-state-*`, `--md-sys-measurement-*`, `--md-ref-typeface-*`), so a stylesheet names a token and never a value.
## Colour
A colour scheme is generated from one seed by Google's colour science (`php artisan material:scheme`); every role below is a slot in that scheme, light and dark, at three contrast levels. A view names a role and nothing else — never a hex, a palette tone or an opacity — because only a role follows the theme, the contrast level and a colour profile.
### Roles
| Role | Purpose | Its `on-` pair | In this library |
| --- | --- | --- | --- |
| primary | High-emphasis fills, text and icons: the key action on a screen | on-primary | `var(--md-sys-color-primary)`, `md-ink-primary`, `<x-button variant="filled">` |
| primary-container | A standout fill for key components (FAB, an emphasised panel) | on-primary-container | `var(--md-sys-color-primary-container)` |
| primary-dim | A darker primary for a pressed or contrasting fill (2025 spec) | on-primary | `var(--md-sys-color-primary-dim)` |
| secondary | Less prominent fills, text and icons | on-secondary | `var(--md-sys-color-secondary)` |
| secondary-container | The recessive fill: tonal buttons, selected navigation, selected chips | on-secondary-container | `var(--md-sys-color-secondary-container)`, `<x-button variant="tonal">` |
| tertiary | A complementary accent, used sparingly for contrast | on-tertiary | `var(--md-sys-color-tertiary)`, `color="tertiary"` |
| tertiary-container | The complementary fill | on-tertiary-container | `var(--md-sys-color-tertiary-container)` |
| error | Urgency and errors; static, does not follow dynamic colour | on-error | `md-ink-error`, `var(--md-sys-color-error)`, `color="error"` |
| error-container | An error panel | on-error-container | `var(--md-sys-color-error-container)`, `<x-alert color="error">` |
| success, warning, info | This library's custom state colours, built like error on the 2025 spec, with `-container` and `on-` pairs | on-success … | `md-ink-success`, `var(--md-sys-color-warning-container)`, `color="info"` |
| surface | The page background | on-surface | the page itself (the foundation paints it), `<x-surface level="surface">` |
| on-surface-variant | Lower-emphasis text and icons on any surface | — | `md-ink-variant` |
| outline | A boundary that must be read: a text field, a target's edge (3:1 against surface) | — | `md-ink-quiet`, `var(--md-sys-color-outline)` |
| outline-variant | Decorative lines: dividers, card edges | — | `<x-divider>`, `<x-surface outlined>`, `var(--md-sys-color-outline-variant)` |
| inverse-surface | A surface that contrasts with its surroundings (the snackbar) | inverse-on-surface | `var(--md-sys-color-inverse-surface)` with `md-ink-inverse` |
| inverse-primary | An action on an inverse surface (the snackbar's action) | — | `var(--md-sys-color-inverse-primary)` |
| scrim | Behind a modal, at 32% | — | `color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent)` |
| shadow | The shadow colour, inside every `--md-sys-elevation-*` | — | — |
| surface-dim, surface-bright | Add-on surfaces that keep their relative brightness in both themes | on-surface | `<x-surface level="surface-dim">`, `<x-surface level="surface-bright">` |
| primary-fixed, primary-fixed-dim, on-primary-fixed, on-primary-fixed-variant (and secondary, tertiary) | Add-on roles with the same tone in light and dark; for a colour that must not change with the theme; never where contrast matters | — | `var(--md-sys-color-primary-fixed)` with `var(--md-sys-color-on-primary-fixed)` |
Pairing: a role's `on-` pair is the only combination whose contrast is guaranteed at every contrast level. A `primary` fill under `on-primary` text and a `secondary-container` fill under `on-secondary-container` are right; `primary-container` under `on-surface`, or `secondary-container` under `primary`, are not, and break as the contrast level rises. A component sets its own pair; the application's CSS writes both halves (`background-color: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary)`). Google: "Pair and layer color roles only as intended … Don't mix roles improperly."
### Surface containers
A hierarchy of emphasis, not of height: the tone separates panels before any shadow does, and a region keeps its role at every breakpoint (body always `surface`, navigation always `surface-container`).
| Role | Use | In this library |
| --- | --- | --- |
| surface | The page | the page itself, `<x-surface level="surface">` |
| surface-container-lowest | The most recessed panel; an elevated card's body in dark themes | `<x-surface level="surface-container-lowest">` |
| surface-container-low | An elevated card, a modal bottom or side sheet, the full-screen search view | `<x-surface level="surface-container-low">` |
| surface-container | Navigation bar and rail, docked and floating toolbars, menus, the segmented list | `<x-surface>` (the default level) |
| surface-container-high | Dialogs, the search bar, date and time pickers, a rich tooltip | `<x-surface level="surface-container-high">` |
| surface-container-highest | A filled card, a filled text field, a filled chip's selected state | `<x-surface level="surface-container-highest">` |
In the application's CSS each is `var(--md-sys-color-surface-container-low)` and so on; the ink on every one of them is `on-surface`.
### Emphasis and lines
- Default ink is `on-surface` (`md-ink`); lower emphasis is `on-surface-variant` (`md-ink-variant`); decoration is `outline` (`md-ink-quiet`). Emphasis is never an opacity: M3 reserves 38% (`--md-sys-state-disabled-content-opacity`) for disabled content and 12% (`--md-sys-state-disabled-container-opacity`) for a disabled container.
- `outline` for a boundary that has to be perceived (a text field's edge, a target's edge — 3:1 against the surface); `outline-variant` for dividers and the edge of a card or any component holding several elements. Google: "Don't use the outline color for dividers … use outline variant instead." `outline-variant` may edge a chip or a button only because the content inside already carries the contrast.
- A hyperlink in running text is `primary` (or `tertiary` for a quieter link) **and** underlined: `md-link` with `md-ink-primary`.
### Contrast
| Level | Target | How |
| --- | --- | --- |
| Standard | Hierarchy from high- and low-contrast elements together; text 4.5:1, large text and icons 3:1, grouped non-text controls 3:1 | the default scheme |
| Medium | 3:1 minimum everywhere, without halation | `<html data-contrast="medium">` |
| High | 7:1 | `<html data-contrast="high">`, or the visitor's OS setting (`theme.contrast.default` = `system`) |
Every role changes with the level automatically; a component built from roles needs nothing else. Disabled states are exempt from contrast. A colour outside the roles (a hex, white, black) does not change and is the one thing that breaks a contrast level.
Sources: https://m3.material.io/styles/color/roles · https://m3.material.io/styles/color/system/how-the-system-works · https://m3.material.io/styles/color/advanced/apply-colors · https://m3.material.io/foundations/designing/color-contrast
## Surfaces and elevation
M3 separates surfaces by tone first; a shadow says that something floats over the content or is being interacted with. "When it comes to applying shadows, less is more."
| Level | Shadow | Rests here | In this library |
| --- | --- | --- | --- |
| 0 | none | The page, cards (filled, outlined), buttons (filled, tonal, outlined), button groups, icon buttons, lists, chips, tabs, sliders, the rail, a docked side sheet, a carousel, a full-screen dialog, a FAB inside the rail, an app bar at rest | — |
| 1 | 1dp | Elevated cards, elevated buttons and chips, modal bottom and side sheets, a banner | `box-shadow: var(--md-sys-elevation-1)` |
| 2 | 3dp | Menus, the navigation bar, a scrolled app bar, toolbars, rich tooltips | `var(--md-sys-elevation-2)` |
| 3 | 6dp | FAB and extended FAB, the FAB menu's close button, dialogs, date and time pickers, the search bar | `var(--md-sys-elevation-3)` |
| 4 | 8dp | Interaction only: a level-3 element on hover or while dragged | `var(--md-sys-elevation-4)` |
| 5 | 12dp | Interaction only | `var(--md-sys-elevation-5)` |
- Hover lifts an element one level (a FAB 3 → 4, an elevated card 1 → 2); focus and selection may too; a raised element lowers when something higher appears.
- Overlapping panels take different surface-container roles to show separation; the roles are not tied to the levels.
- A scrim (`scrim` at 32%) brings focus to a modal over a large surface; it is never a substitute for a shadow on a small floating element.
- On a dark surface a shadow is nearly invisible, so the tone does the work there.
Sources: https://m3.material.io/styles/elevation/overview · https://m3.material.io/styles/elevation/applying-elevation · https://m3.material.io/styles/elevation/tokens
## Shape
### The corner scale
| Style | Value | In this library |
| --- | --- | --- |
| None | 0 | `var(--md-sys-shape-corner-none)`, `corner="none"` |
| Extra small | 4px | `var(--md-sys-shape-corner-xs)`, `corner="xs"` |
| Small | 8px | `var(--md-sys-shape-corner-sm)`, `corner="sm"` |
| Medium | 12px | `var(--md-sys-shape-corner-md)`, `corner="md"` |
| Large | 16px | `var(--md-sys-shape-corner-lg)`, `corner="lg"` |
| Large increased | 20px | `var(--md-sys-shape-corner-lg-increased)`, `corner="lg-increased"` |
| Extra large | 28px | `var(--md-sys-shape-corner-xl)`, `corner="xl"` |
| Extra large increased | 32px | `var(--md-sys-shape-corner-xl-increased)`, `corner="xl-increased"` |
| Extra extra large | 48px | `var(--md-sys-shape-corner-xxl)`, `corner="xxl"` |
| Full | a stadium or circle | `var(--md-sys-shape-corner-full)`, `corner="full"` |
`corner` is `<x-surface>`'s prop. In the application's CSS a corner is `border-radius` on a token, and one side at a time a logical longhand (`border-start-start-radius` and `border-start-end-radius` for a bottom sheet's top); a length of your own is off the scale.
### Corner by component
| Component | Corner | Note |
| --- | --- | --- |
| Buttons, icon buttons, split button (outer), FAB menu items | full | a press morphs to `md` (xs/sm sizes), `lg` (md), `xl` (lg/xl); a selected toggle swaps round ↔ square |
| Connected button group | full outside, `sm` between segments | segments press to `xs` |
| FAB | `lg` 16 (baseline 56px), `lg-increased` 20 (medium 80px), `xl` 28 (large 96px) | extended FAB `lg` |
| Chips | `sm` 8 | an avatar in a chip `md` 12 |
| Cards | `md` 12 | no change on hover |
| Text fields | `xs` 4 (outlined: all corners; filled: top corners only) | |
| Menus, snackbar, plain tooltip | `xs` 4 | the Expressive vertical menu rounds the focused item |
| Rich tooltip | `md` 12 | |
| Dialogs | `xl` 28 | full-screen dialog `none` |
| Bottom sheet | `xl` 28 on top | |
| Side sheet | `lg` 16 on the inner side | |
| Search bar | full | search view `xl` 28 when docked, `none` full-screen |
| Date and time pickers | `xl` 28 | date cells full |
| Carousel items | `xl` 28 | |
| Navigation indicator, badges, switch, slider handle, checkbox state layer | full | checkbox box 2px, tab indicator 3px on top |
| Navigation bar, app bar, docked toolbar, tabs | none | floating toolbar full |
| Segmented list rows | `xs` inner, `lg` outer; a selected row `lg` | |
### Rules
- Optical roundness: a shape nested in a rounded container takes inner radius = outer radius padding (48 14 = 34), never the container's own radius.
- Large and full corners do not belong on information-dense containers (cards, tables, text fields).
- A press squares a round shape and rounds a square one (the components carry the morph on the fast spatial spring); nothing morphs on hover.
- The 35 Expressive shapes (`<x-shape name="cookie-9">`, also the loading indicator and the standard button group's press shape) are decoration for emphasis and delight — never a carrier of meaning, never behind text-heavy content, and used sparingly.
Sources: https://m3.material.io/styles/shape/corner-radius-scale · https://m3.material.io/styles/shape/shape-morph · https://m3.material.io/styles/shape/overview-principles
## Type
The typeface is Google Sans Flex for brand and plain styles (`--md-ref-typeface-brand`, `--md-ref-typeface-plain`); an application may replace it after importing the stylesheet. Each style is one class that sets size, line height, weight, family and tracking together — or, in the application's CSS, `font: var(--md-sys-typescale-body-md)` with `letter-spacing: var(--md-sys-typescale-body-md-tracking)`. A size, weight, line height or letter spacing of your own is off the scale.
| Role | Style | Size / line | Weight | In this library | Use for |
| --- | --- | --- | --- | --- | --- |
| Display | large / medium / small | 57/64 · 45/52 · 36/44 | 400 | `md-type-display-lg` … | hero figures, one short marketing line; never running text |
| Headline | large / medium / small | 32/40 · 28/36 · 24/32 | 400 | `md-type-headline-lg` … | page titles, section titles, a dialog's headline (`headline-sm`) |
| Title | large / medium / small | 22/28 · 16/24 · 14/20 | 400 / 500 / 500 | `md-type-title-lg` … | app bar title (`lg`), card and list-section titles (`md`), dense headers (`sm`) |
| Body | large / medium / small | 16/24 · 14/20 · 12/16 | 400 | `md-type-body-lg` … | paragraphs (`lg` for reading, `md` in components), supporting text (`sm`) |
| Label | large / medium / small | 14/20 · 12/16 · 11/16 | 500 | `md-type-label-lg` … | buttons and tabs (`lg`), chips and navigation (`md`), captions and badges (`sm`) |
- `md-type-emphasized-*` (`--md-sys-typescale-emphasized-*`) is the same size and line height one weight step heavier (400 → 500, 500 → 700), fully rounded in Google Sans Flex, with its own tracking. M3 uses it deliberately, never by default: a selected list or menu item, a button's label on a primary action, an extended FAB, a badge, a headline given editorial weight.
- Tracking follows Compose's `TypeScaleTokens`: display-large 0.2, title-medium 0.2, title-small 0.1, body-large 0.5, body-medium 0.2, body-small 0.4, label-large 0.1, label-medium and small 0.5 (sp; rem = sp/16); the emphasized set tightens a few (display-large 0, title-medium 0.15, body-large 0.15, body-medium 0.25).
- Line length 4060 characters (`max-inline-size: 60ch` in the application's CSS). Figures that change take `md-tabular`.
- Text must scale to 200%: containers grow, side-by-side controls stack, padding stays; components without text (progress, checkboxes) do not scale. Truncate to an ellipsis (`md-truncate`) only when the full text is one tooltip or link away.
- When customising, change the typeface or tracking, never the sizes: component layout depends on them.
Sources: https://m3.material.io/styles/typography/type-scale-tokens · https://m3.material.io/styles/typography/applying-type · https://m3.material.io/styles/typography/fonts · https://m3.material.io/foundations/writing/text-resizing · https://m3.material.io/foundations/writing/text-truncation
## Motion
M3 Expressive moves on physics: every transition is a spring, and the library samples each spring into a CSS `linear()` easing paired with a duration. Use the pair together, or the curve is stretched over the wrong time.
| Spring | Damping / stiffness | Duration | In this library | For |
| --- | --- | --- | --- | --- |
| Spatial fast | 0.6 / 800 | 350ms | `var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast)` | small elements: a button's press morph, a switch, a chip |
| Spatial default | 0.8 / 380 | 500ms | `var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default)` | most position, size and shape changes |
| Spatial slow | 0.8 / 200 | 650ms | `var(--md-sys-motion-spatial-slow-duration) var(--md-sys-motion-spatial-slow)` | large surfaces: a sheet, a pane, a full-screen transition |
| Effects fast | 1.0 / 3800 | 150ms | `var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast)` | state layers, small fades |
| Effects default | 1.0 / 1600 | 200ms | `var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default)` | most colour and opacity changes |
| Effects slow | 1.0 / 800 | 300ms | `var(--md-sys-motion-effects-slow-duration) var(--md-sys-motion-effects-slow)` | large fades, a scrim |
A transition names the property, then the pair: `transition: transform var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default), opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast)`.
- Spatial springs are underdamped and overshoot — that bounce is what reads as Expressive — so they carry only position, size and shape. Effects springs are critically damped and carry colour and opacity, which must never overshoot. A transition on `all` mixes the two and is wrong.
- The Standard motion scheme (`<html data-motion="standard">`, config `motion.scheme`) swaps the spatial springs for stiffer ones with almost no bounce (0.9 / 1400, 700, 300; 350, 500, 750ms) for utilitarian products; effects are shared.
- Direction: something entering decelerates (`--md-sys-motion-easing-emphasized-decelerate`, or a spatial spring from off-screen), a permanent exit accelerates (`--md-sys-motion-easing-emphasized-accelerate`), a temporary exit that can be recalled (a drawer, a sheet) takes `--md-sys-motion-easing-emphasized`; exits are shorter than entrances, and larger areas move longer.
- The cubic-bezier set (`--md-sys-motion-easing-standard`, `-emphasized`, `-emphasized-decelerate`, `-emphasized-accelerate`, with `--md-sys-motion-duration-short|medium|long`) is for the few transitions whose duration is fixed from outside: a view transition, an animated scroll.
- Reduced motion zeroes every duration token, so anything animated through them turns instant; a literal `300ms`, or a keyframe animation with its own timing, ignores the visitor's setting and is a bug. Container transforms, parallax and expansions are removed, not slowed.
Sources: https://m3.material.io/styles/motion/overview · https://m3.material.io/styles/motion/overview/specs · https://m3.material.io/styles/motion/easing-and-duration/tokens-specs · https://m3.material.io/styles/motion/transitions/transition-patterns
## States and targets
| State | Layer | Class or hook | Also |
| --- | --- | --- | --- |
| Enabled | none | — | |
| Hover | 8% of the content colour | `md-state-layer` (pointer devices only) | one level of elevation on floating elements |
| Focused | 10% | `md-state-layer md-focus-ring` (keyboard focus: a 3px `secondary` ring, 2px out) | only one focused element at a time |
| Pressed | 10% | `md-state-layer` (`:active`) | the shape morph on buttons |
| Dragged | 16% | `md-state-layer` with `data-md-dragged` | one level of elevation |
| Disabled | content 38%, container 12%, no state layer, not focusable | `color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent)`, and the container likewise with `--md-sys-state-disabled-container-opacity` | exempt from contrast; a FAB is hidden rather than disabled |
| Selected | the `secondary-container` pair, a filled icon, the emphasized style | component props (`selected`, `aria-selected`, `aria-pressed`) | combines with hover, focus and press |
- The state layer takes the content's `on-` colour (on `secondary-container` it is `on-secondary-container`), is 40px on a 48px target, and only one shows at a time. `md-state-layer` draws it in `currentColor` as a `::before`, so the element becomes `position: relative`.
- Every state shows two indicators, so a colour change alone is never a state: add a shape, an outline, an icon, a weight or a word (`aria-selected` plus the container, an error colour plus an icon and a message).
- Targets: 48×48px minimum, 8px between targets, on every device; `md-touch-target` extends a smaller drawing to 48px. Density is an opt-in prop (`dense`) that steps padding by 4px and never applies to menus, snackbars, dialogs or settings controls, and never takes a target below 48px.
- Keyboard: Tab and Shift+Tab between components in DOM order, arrows within a component (menu, tabs, grid, radio group), Enter and Space activate, Escape dismisses; a dialog moves focus in on open and back to its opener on close.
Sources: https://m3.material.io/foundations/interaction/states/state-layers · https://m3.material.io/foundations/interaction/states/applying-states · https://m3.material.io/foundations/designing/structure · https://m3.material.io/foundations/layout/grids-spacing/density
## Layout and breakpoints
Layout keys on the width of the window, in M3's five window size classes and only those. A layout component names the class in a prop (`hide-below`, `hide-from`, `stack-below`, `<x-grid>`'s `columns` map); the application's CSS writes the width as a range media query; a script asks `resources/js/breakpoints.js` (`from('expanded')`, `upTo('medium')`) for the same numbers.
| Class | Width | Prop value · CSS | Navigation | Panes | Dialogs and choices | Margins |
| --- | --- | --- | --- | --- | --- | --- |
| Compact | below 600px | the default; `hide-from="medium"` for "only here" · `@media (width < 600px)` | navigation bar; the rail opens as a modal | 1 | full-screen or basic dialog; a bottom sheet for choices | 16px |
| Medium | 600839px | `medium` · `@media (width >= 600px)` | collapsed rail (96px) | 1, or 2 for low-density content at 50% each | basic dialog; a menu for choices | 24px |
| Expanded | 8401199px | `expanded` · `@media (width >= 840px)` | rail, collapsed or expanded, collapsible | 2 recommended; a fixed pane 360px | basic dialog; menu | 24px |
| Large | 12001599px | `large` · `@media (width >= 1200px)` | rail expanded | 2; a fixed pane 412px | basic dialog; menu | 24px |
| Extra-large | 1600px and up | `extra-large` · `@media (width >= 1600px)` | rail expanded | 2, or 3 with a standard side sheet (at most 400px) | basic dialog; menu | 24px |
- `<x-scaffold>` implements the navigation column; `<x-pane>` is a content region with the margins above; `<x-list-detail>` is the second pane of a list-detail layout from expanded, `<x-supporting-pane>` puts a supporting pane (360px, beside the focus pane) from expanded and below it before that. Moving up a class, ask what to reveal, divide into panes, resize, reposition or swap — never swap a component for one that does not do the same job.
- Scaffold: bars (app bar at the top, navigation bar at the bottom: 35 destinations), rails (the navigation rail, toolbars, the FAB, on the leading edge), panes (all content), around a safety region that stays clear of the device's own chrome (`--material-safe-top|bottom|left|right`).
- Canonical layouts: feed (`<x-feed>`, a grid of cards that gains columns as the room grows), list-detail (one pane on compact, two from expanded; a back button only in single-pane mode, a selected row only in two-pane mode), supporting pane (two thirds focus, one third support).
- Bidirectionality: write logical properties (`padding-inline-start`, `margin-inline-end`, `inset-inline-start`, `border-inline-start`, `md-text-start`); `<x-row>` runs in the inline direction and mirrors by itself; leading and trailing icons swap, directional icons (back, send) mirror, the rail moves to the right; charts, media controls, clocks and Hebrew progress bars stay left-to-right.
Sources: https://m3.material.io/foundations/layout/breakpoints/overview · https://m3.material.io/foundations/layout/breakpoints/compact (medium, expanded, large-extra-large) · https://m3.material.io/foundations/layout/scaffold/overview · https://m3.material.io/foundations/layout/canonical-examples/overview · https://m3.material.io/foundations/layout/bidirectionality-rtl
## Spacing
M3's spacing tokens are multiples of an 8px base on a 4px grid. A layout component takes the token's name (`gap="space200"`, `<x-surface padding="space300">`); the application's CSS reads it (`var(--md-sys-measurement-space200)`).
| Token | Value | In this library |
| --- | --- | --- |
| space25 | 2px | `space25` |
| space50 | 4px | `space50` |
| space75 | 6px | `space75` |
| space100 | 8px (the base) | `space100` |
| space125 | 10px | `space125` |
| space200 | 16px | `space200` — a component's padding, compact margins |
| space300 | 24px | `space300` — a dialog's padding, margins from medium |
| space400 | 32px | `space400` |
| space500 | 40px | `space500` |
| space600 | 48px | `space600` — a target |
| space700 | 56px | `space700` |
| space800 | 64px | `space800` |
| space900 | 72px | `space900` |
- Padding and gaps live on the parent (`<x-surface padding="space200">` around `<x-stack gap="space100">`), never as margins on children; a margin is for space beyond a container's padding or between layout regions.
- Spacing does not scale with text: at 200% text size the same padding and gaps stay.
- Name a gap by what it separates when a component has several (iconlabel 8px, labelsupporting text 4px).
Sources: https://m3.material.io/styles/spacing/overview · https://m3.material.io/styles/spacing/tokens · https://m3.material.io/styles/spacing/applying-spacing
## Icons
`<x-icon name="lock">` draws a Material Symbol Rounded (weight 400, grade 0), outlined or `filled`, at optical size 24 or 20.
| Axis | Values | In this library |
| --- | --- | --- |
| Fill | 0 outlined, 1 filled | `filled` — active, selected or on state (a selected navigation item, a FAB's icon, a checked filter chip) |
| Weight | 100700; never below 200 at 24px | 400 for every icon; one weight per group |
| Grade | 25 on dark backgrounds, 0 otherwise, positive for emphasis | 0 |
| Optical size | 20 dense, 24 standard, 4048 with display type | `size="20"` and below pick the 20 cut (small buttons, chips, dense lists); `optical="20"` for an icon sized by the application's own CSS |
- An icon beside text takes the text's size and colour (`size="20"` beside `md-type-label-lg`, 24 beside body) and the same optical weight; its baseline sits about 11.5% of the text size below the text's.
- Icons stay flat and forward-facing, on the pixel grid, inside their 20px live area of the 24px canvas.
- An icon-only control has an accessible name (`aria-label`, or a tooltip that names it); a decorative icon is `aria-hidden`; a complex icon drawn below 20px needs a label beside it.
Sources: https://m3.material.io/styles/icons/overview · https://m3.material.io/styles/icons/designing-icons · https://m3.material.io/styles/icons/applying-icons
## Accessibility
- Native elements before ARIA: `<button>`, `<a href>`, `<dialog>`, `<input>`, `<select>`; a styled `div` that fakes one needs everything re-implemented and tested.
- Landmarks: one `main`, one `banner`, one `contentinfo` per page; `nav`, `search`, `complementary`, `form`, `region` labelled when they repeat, never with their own role in the label ("Primary", not "Primary navigation").
- Headings: one H1 for the page, then H2H6 in order without skipping; the level is the document's structure, the `md-type-*` class is the appearance, and they need not match.
- Names: an interactive icon, image or ambiguous button ("Save", "Learn more") has a name that says what it does, without the word "button"; decorative images are `alt=""` or `aria-hidden`; text only a screen reader needs is `md-visually-hidden`.
- Focus: DOM order is reading order; a dialog moves focus to its first meaningful control and returns it to the opener; grouped controls are one Tab stop with arrows inside; a keyboard shortcut is two keys, or a single key only while its component is focused.
- Announcements: an error is tied to its field (`aria-invalid`, `aria-describedby`) and announced; a snackbar is a polite live region that never steals focus and stays while it carries an action; a loading state has a name.
- Contrast and states: 4.5:1 text, 3:1 large text and icons and grouped controls, disabled exempt; every state has two indicators; targets 48px with 8px between; text scales to 200%; motion honours reduced motion.
Sources: https://m3.material.io/foundations/overview/principles · https://m3.material.io/foundations/designing/structure · https://m3.material.io/foundations/designing/flow · https://m3.material.io/foundations/designing/elements · https://m3.material.io/foundations/overview/assistive-technology
## Don'ts
What Google's pages say not to do, and this library follows:
| Don't | Because | Instead |
| --- | --- | --- |
| Put an icon in a snackbar | a snackbar is a short message with at most one action | `<x-toast>` types choose the announcement, not a picture |
| Disable a FAB | "if the action is unavailable, the FAB shouldn't appear" | hide it |
| Lay radio buttons in a row | a row reads as one control | a vertical group; chips or a connected button group for a horizontal choice |
| Morph a card's corners on hover | shape morphs mark a press or a selection | the state layer and one level of elevation |
| Use `outline` on a divider | dividers carry no contrast requirement and read too heavy | `<x-divider>` (`outline-variant`) |
| Use a hex, white, black or an opacity for ink | it ignores theme, contrast level and profile | a role |
| Write a utility class, or a breakpoint, radius, shadow, type size or easing of your own | nothing defines utility classes, and other values are not M3's | the layout components' props, `md-type-*` and `md-ink-*`, and `--md-sys-*` tokens at 600/840/1200/1600px in your own CSS |
| Use segmented buttons, a navigation drawer or a bottom app bar | deprecated in M3 Expressive | `<x-button-group connected>`, the expanded rail, `<x-toolbar>` |
| Truncate without a way to read the rest | an ellipsis alone is not accessible | wrap, grow the container, or a tooltip |
| Animate with a literal duration | it ignores reduced motion | the paired tokens |
## Attribution
The rules, tables and wording here are Google's, condensed from the Material Design 3 documentation at https://m3.material.io (Foundations, Styles and Components), which Google publishes under the Creative Commons Attribution 4.0 License except as otherwise noted; the numeric token values are from the Android Open Source Project's Material 3 token files in androidx Compose (Apache License 2.0). Copyright Google LLC; Copyright The Android Open Source Project. The library's `NOTICE` records the same. Dates and page names are those of the site as read on 2026-09-13; the full extracted references, with every source page, are kept in the package repository under `docs/reference/m3/`.
@@ -1,96 +0,0 @@
---
name: tailwindcss-development
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode
@@ -29,7 +29,7 @@ Read this section before you write a test.
- Leave framework behavior to framework tests. Testing project configuration is not testing the framework. A constrained relationship, cast, scope, or validation rule belongs to this project.
- Keep every test that can detect a distinct defect. When two tests detect the same defect, trim the higher-layer test to one case and report the duplication. Do not delete an existing test.
- Write a feature test first. Write a unit test only for logic that does not use the framework.
- Write a feature test for every behavior reachable through a request. Real-browser tests require `pestphp/pest-plugin-browser` and a browser download, neither of which this project installs. Mention the package only if the user asks for a real-browser test.
- Write a browser test only for behavior in JavaScript that a feature test cannot reach. Put a browser test in `tests/Browser`, and call `assertNoJavaScriptErrors()` in it.
- Judge an architecture test by the convention it protects, not by the rules above. An `arch()` test declares a rule for an entire directory, such as the parent class of every model, the classes that may use an enum, or the methods every factory declares. It intentionally checks declarations and fails when a new file breaks the convention.
- Use the test tools that the project installs. Add a new test dependency, plugin, or browser only after the user asks for it.
@@ -31,6 +31,32 @@ An HTTP test shows that the endpoint performs authorization. It cannot identify
- Write one HTTP test for one refused role, which shows that the endpoint calls the authorization.
- Use the helper of the project that asserts the ability and the arguments of the gate, if such a helper exists.
## Browser Tests
Write a browser test only for JavaScript behavior that an HTTP test cannot reach, such as modal interaction, drag-and-drop, live search, or client-side validation. Browser tests are slower than HTTP tests and can fail for reasons unrelated to the code under test.
- Assert the state that the user can see, and assert the state in the database that the interaction saves.
- Wait until the test reaches the required state. Do not wait for a fixed number of seconds, which can fail on a slower machine.
- Call `assertNoJavaScriptErrors()` in each browser test. An error in the console is a defect.
### Where a Browser Test Lives and How to Run It
The plugin runs browser tests as normal Pest tests, so they need no separate suite. Put them in `tests/Browser` to separate them from faster tests and run the directory with one command.
- Run a browser test with `vendor/bin/pest tests/Browser`, and add `--parallel` for the complete suite.
- Run `vendor/bin/pest --debug` to open the window of the browser and to pause at a failure. Use `--headed` to watch a run that passes.
- Add `--browser firefox` or `--browser safari` to run the test in a different browser. The default browser is Chrome.
- The run needs Playwright and a browser on the machine. Follow the plugin documentation for local and CI installation commands.
- Fetch `https://pestphp.com/docs/browser-testing` for the interactions, the assertions, and the devices that the plugin gives.
### Browser Test Pitfalls
- The plugin waits five seconds for an element. Raise the value with `pest()->browser()->timeout(10000)` in `Pest.php` for a page that is slower, and do not add a wait for a number of seconds to the test.
- Apply `RefreshDatabase` to the browser tests in `Pest.php`. A browser test hits the application through a real request, and the records that it leaves break the next test.
- Add `tests/Browser/Screenshots` to `.gitignore`. A failure writes a screenshot, and the file is not part of the repository.
- Give `withKeyDown()` a key code, such as `KeyA`. A letter such as `'a'` gives the lowercase character, whatever modifier the test holds.
- Interact inside the callback of `withinFrame()`. An interaction outside the callback does not reach the frame.
## Testing Validation
- Write one test for each validation rule when each failure represents a separate contract.
+4 -1
View File
@@ -21,10 +21,13 @@ docker-compose*.yml
Dockerfile
# CI/CD
.github
.gitea
# Testing
tests
# Website (published separately)
website
phpunit.xml
.phpunit.cache
+3
View File
@@ -69,5 +69,8 @@ VITE_APP_NAME="${APP_NAME}"
# OCTANE_HTTPS=false
# OCTANE_MAX_EXECUTION_TIME=300
# Uploads: each encrypted chunk the browser sends, in MB
# UPLOAD_CHUNK_SIZE_MB=16
# Docker (used only when deploying with docker-compose.yml)
# SERVER_NAME=share.example.com
+1 -1
View File
@@ -8,4 +8,4 @@
CHANGELOG.md export-ignore
README.md export-ignore
.github/workflows/browser-tests.yml export-ignore
.gitea export-ignore
@@ -42,9 +42,6 @@ jobs:
- name: Install Node Dependencies
run: npm ci
- name: Add Flux Credentials Loaded From ENV
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
@@ -57,6 +54,9 @@ jobs:
- name: Build Assets
run: npm run build
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
- name: Run Tests
run: ./vendor/bin/pest
@@ -67,25 +67,33 @@ jobs:
- name: Checkout code
uses: actions/checkout@v6
# The runner is an arm64 server, so the amd64 image's final stage is emulated. QEMU 8.x
# crashes running x86_64 programs on an arm64 host (QEMU issue 2168, "QEMU internal
# SIGSEGV {code=MAPERR, addr=0x20}") and 10.2 segfaults on this runner too; 9.2.2 was
# checked on the runner's host: node, composer and install-php-extensions all run.
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v9.2.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
# Gitea's job token cannot publish packages yet: REGISTRY_TOKEN is an access token with
# package write rights, owned by the account that pushes (gitea.actor).
- name: Log in to the Gitea container registry
if: gitea.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
registry: gitea.nonameweb.ch
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
images: gitea.nonameweb.ch/nonameweb/sealshare
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
@@ -98,21 +106,27 @@ jobs:
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
push: ${{ gitea.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=registry,ref=gitea.nonameweb.ch/nonameweb/sealshare:buildcache
cache-to: ${{ gitea.event_name != 'pull_request' && 'type=registry,ref=gitea.nonameweb.ch/nonameweb/sealshare:buildcache,mode=max' || '' }}
release:
runs-on: ubuntu-latest
needs: build-and-push
if: startsWith(github.ref, 'refs/tags/v')
if: startsWith(gitea.ref, 'refs/tags/v')
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
- name: Take the release notes from the changelog
run: |
version="${{ gitea.ref_name }}"
awk -v heading="## [${version#v}]" 'index($0, heading) == 1 { found = 1; next } found && /^## \[/ { exit } found { print }' CHANGELOG.md > release-notes.md
test -s release-notes.md
- name: Create the Gitea release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
generate_release_notes: true
body_path: release-notes.md
@@ -29,9 +29,6 @@ jobs:
with:
php-version: '8.5'
- name: Add Flux Credentials Loaded From ENV
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
- name: Install Dependencies
run: |
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
@@ -47,4 +44,4 @@ jobs:
# commit_options: '--no-verify'
# file_pattern: |
# **/*
# !.github/workflows/*
# !.gitea/workflows/*
@@ -42,9 +42,6 @@ jobs:
- name: Install Node Dependencies
run: npm i
- name: Add Flux Credentials Loaded From ENV
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
@@ -57,5 +54,8 @@ jobs:
- name: Build Assets
run: npm run build
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
- name: Run Tests
run: ./vendor/bin/pest
+5
View File
@@ -25,3 +25,8 @@ yarn-error.log
**/caddy
frankenphp
frankenphp-worker.php
/tests/Browser/Screenshots
# Planning notes stay local
/docs/plans
+61 -6
View File
@@ -5,7 +5,64 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [2.1.0] - 2026-09-16
### Added
- A password generator for share passwords, with a copy button. The password is shown once more beside the new link. Admins can turn it off, or switch between random characters and a passphrase, in Admin settings.
- `AUTO_HTTPS` for the Docker image: set it to `"true"` with `SERVER_NAME` to get a Let's Encrypt certificate and serve HTTPS. Without it the container serves plain HTTP on port 80, as before.
- `UPLOAD_CHUNK_SIZE_MB` sets the size of each upload chunk (default 16).
### Changed
- **Breaking: uploads need HTTPS.** Files are now encrypted in the browser and uploaded in chunks, which browsers only allow over HTTPS or on `localhost`. Over plain HTTP downloads still work, but uploads don't. Use `AUTO_HTTPS` or a reverse proxy that terminates TLS.
- Large uploads are much faster: each chunk is written to disk once, already encrypted, and a failed chunk is retried.
- Each share has its own random key; with a share password it is protected with Argon2id instead of PBKDF2. Existing shares keep working.
- PHP's upload limits no longer cap the share file size. `PHP_UPLOAD_MAX_FILESIZE` and `PHP_POST_MAX_SIZE` default to `64M`, and `LIVEWIRE_MAX_UPLOAD_TIME` is no longer needed.
- Unfinished uploads count towards the storage quota and are deleted after 4 hours.
- The interface moves to [Livewire Material](https://gitea.nonameweb.ch/noNameWEB/livewire-material) 2.1.0 and no longer ships Tailwind CSS. Every page uses the same single-column layout of cards, and the admin dashboard lists shares instead of a table. Colour profiles and light/dark choices carry over.
### Fixed
- "Download all" works for large shares: the ZIP is streamed instead of being built in memory and written unencrypted to a temporary file.
- Unencrypted copies of uploads no longer stay behind in Livewire's temporary folder; the hourly cleanup removes old ones.
- Removed the unused `docker/Caddyfile`.
### Security
- An encrypted file with missing or reordered chunks now fails to decrypt.
## [2.0.1] - 2026-09-13
### Fixed
- The 2.0.0 Docker image did not start: the entrypoint's `php artisan view:cache` failed with "Unable to locate a class or view for component [showcase::example]", because Livewire Material only registered its showcase components while the showcase was enabled, which it is not in production. Livewire Material 1.1.1 registers them always, and a test now caches every view as the entrypoint does.
## [2.0.0] - 2026-09-13
### Added
- Eight colour profiles — Indigo (the default), Blue, Teal, Green, Amber, Rose, Violet and Graphite. An admin picks one in Admin settings, previews it on the page, and after saving every page, mail and error page uses it; light and dark stay each visitor's own choice.
- 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
- SealShare moved from GitHub to Gitea: the source is at https://gitea.nonameweb.ch/noNameWEB/SealShare, and the Docker image is published as `gitea.nonameweb.ch/nonameweb/sealshare`. Images at `ghcr.io/surtic86/sealshare` are no longer updated — change `image:` in your `docker-compose.yml` to the new name to keep receiving releases.
- 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.
- The theme follows the system's light or dark setting until a user picks Light, Dark or System in Settings → Appearance, or from the account menu. A theme chosen in 1.x is kept.
- A floating toolbar centred at the bottom of every page replaces the sidebar and header layouts. Signed-in users reach Upload and the admin pages from it, and Settings, the theme and Log out from its account menu. The site's name and logo head the upload and download pages.
- Confirmations for deleting a share, removing the logo, clearing the system password and deleting the account are dialogs instead of browser prompts, and "Saved." messages are snackbars.
- The two-factor setup opens full screen on a phone.
- HTTP error pages and Markdown mail (password reset, email verification) use the same Material design and colours. Set `MAIL_MARKDOWN_THEME=default` to get Laravel's mail theme back.
- Signing in now lands on the admin dashboard. The starter kit's placeholder `/dashboard` page is gone.
- The Docker build installs Composer packages before building the frontend, because the stylesheet imports Livewire Material from `vendor/`.
- Removed the dependencies `robsontenorio/mary`, `daisyui` and `alpinejs` (Livewire bundles Alpine). The Bunny Fonts request is gone.
### Fixed
- The README called SealShare's encryption end-to-end. Files are encrypted at rest on the server; the README now says so, and that a share password's key is never stored.
- The admin dashboard passed its sort column and direction straight to the query, so a crafted Livewire request could order shares by any column or cause a server error. It now sorts only by the columns it shows and otherwise falls back to newest first.
## [1.2.0] - 2026-09-10
@@ -81,8 +138,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Dark themed UI built with Livewire, Alpine.js, Tailwind CSS and DaisyUI.
- Docker images published to `ghcr.io/surtic86/sealshare`, served by FrankenPHP via Laravel Octane.
[Unreleased]: https://github.com/surtic86/SealShare/compare/v1.2.0...HEAD
[1.2.0]: https://github.com/surtic86/SealShare/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/surtic86/SealShare/compare/v1.0.1...v1.1.0
[1.0.1]: https://github.com/surtic86/SealShare/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/surtic86/SealShare/releases/tag/v1.0.0
[2.1.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.1...v2.1.0
[2.0.1]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.0...v2.0.1
[2.0.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/releases/tag/v2.0.0
+91 -3
View File
@@ -73,7 +73,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
## Project Rules
- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it.
- Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo.
- Record a rule with `record-rule` only when the user explicitly asks for one. Instructions for the work at hand are not rules, no matter how emphatic: "remove this typo", "use X here" are work to do, not rules to record. Never record a rule on your own initiative, as a byproduct of a change, or to summarize what you just did. When the user does ask, pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Use `record-rule` rather than your native memory or notes tool, because native memory is personal and session-scoped, while only `.ai/rules` is shared with the team and persists in the repo.
## Artisan
@@ -109,8 +109,9 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
# Test Enforcement
- Test every code change by adding or updating a test.
- Run the affected tests and ensure they pass.
- Add or update tests for behavior and logic changes when a test provides meaningful regression coverage.
- Pure copy, styling, and layout-only changes do not require new or updated tests.
- When test coverage applies, run the affected tests and ensure they pass.
- Test the changed behavior and its important failure modes, but do not add tests beyond them.
- Read the `testing-best-practices` skill before writing tests.
@@ -187,4 +188,91 @@ When working on Octane-specific features (concurrency, shared tables, memory, dr
- Run `vendor/bin/pest` to call the test runner directly. It accepts the same file path and `--filter=testName` arguments.
- After the feature tests pass, ask the user to run the complete suite with `php artisan test --compact`.
=== nonameweb/livewire-material/core rules ===
## Livewire Material
This application uses `nonameweb/livewire-material`: Material 3 Expressive components for Laravel and Livewire, in plain CSS. It replaces UI kits such as maryUI, daisyUI and Flux, and Tailwind CSS, in this application.
- Components are anonymous Blade components, unprefixed unless `config/livewire-material.php` sets a `prefix`. Before writing or changing a view that uses them, activate the `livewire-material-development` skill for the props, slots and traps of each component.
- The CSS entry imports `foundation.css` first, then the stylesheet of each component the views render (or `all.css` for all of them). A component whose stylesheet is not imported renders unstyled; `DesignGuard::missingStylesheets()` names each missing `@import`.
- Never write a utility class — Tailwind's, the library's 1.x ones or daisyUI's — or a maryUI tag. Nothing defines them, so they compile to nothing and fail silently. Layout is the layout components (`<x-row>`, `<x-stack>`, `<x-grid>`, `<x-surface>`, `<x-pane>`), text is `md-type-*` and `md-ink-*`, and everything else is the application's own CSS on `--md-sys-*` custom properties.
- Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` — never edit `resources/css/material-scheme.css` by hand. With colour profiles (`livewire-material.profiles`), run it without a seed after changing them; the active profile comes from `Scheme::resolveProfileUsing()`.
- While the application runs locally, every token and component renders in the application's own scheme at `/material` (the showcase).
- HTTP error pages and the Markdown mail theme come from the package. Change error wording by publishing `--tag=livewire-material-errors`; select the mail theme with `MAIL_MARKDOWN_THEME=livewire-material::mail.theme`.
=== nonameweb/livewire-material/material-3 rules ===
## Material 3
Every view in this application is Material 3 Expressive (m3.material.io), through `nonameweb/livewire-material`. These rules decide what to write; the `material-3-design` skill carries the tables, the numbers and Google's source pages behind each one — activate it before designing a screen.
The library is plain CSS on M3's tokens, and there are no utility classes: a Tailwind class, or one of the library's 1.x utilities (bg-primary, type-body-md, medium:hidden), compiles to nothing. A view is written three ways:
- Components and their props: `<x-button variant="filled">`, and the layout components `<x-row>`, `<x-stack>`, `<x-grid>`, `<x-feed>`, `<x-surface>` and `<x-pane>`, whose `gap` and `padding` take a spacing token (`space200`) and whose `hide-below`, `hide-from` and `stack-below` take a window size class.
- A fixed set of classes for text and interaction on plain elements: `md-type-*`, `md-ink-*`, `md-text-*`, `md-truncate`, `md-tabular`, `md-visually-hidden`, `md-state-layer`, `md-focus-ring`, `md-touch-target` and `md-link`.
- The application's own CSS, named by the application, whose values are `--md-sys-*` custom properties.
### Colour
- A colour is always a role: `md-ink-variant` on text, `var(--md-sys-color-outline-variant)` in the application's CSS, `color="error"` on a component. Never a hex, a palette tone or an opacity.
- Pair a role only with its `on-` partner: a `primary` fill takes `on-primary` text, a `secondary-container` fill takes `on-secondary-container`. That pair is the one whose contrast is guaranteed at every contrast level; mixing pairs (`primary-container` under `on-surface`) is not.
- `primary` is the one key action on a screen (a filled button; the FAB in `primary-container`). `secondary-container` is the quiet fill (tonal buttons, selected navigation, selected chips). `tertiary` is a contrasting accent, used rarely. `error`, `success`, `warning`, `info` mean state and nothing else: the `-container` for a tinted panel, the role itself for its text and icon.
- Ink is `on-surface` (`md-ink`); lower emphasis is `on-surface-variant` (`md-ink-variant`); decoration is `outline` (`md-ink-quiet`). Never dim ink with an opacity: 38% means disabled.
- `outline` is a boundary that must be read (a text field, the edge of a target). `outline-variant` is a divider or a card edge (`<x-divider>`, `<x-surface outlined>`). Never `outline` on a divider.
- Fixed and dim roles (`primary-fixed`, `surface-dim`, …) are for a colour that must not change with the theme; if unsure, don't. Inverse roles only on an inverse surface (the snackbar).
- A link in running text is underlined (`md-link`, with `md-ink-primary`); colour alone signals nothing.
- Contrast: 4.5:1 for text, 3:1 for large text, icons and grouped controls; disabled is exempt. Three contrast levels exist (`<html data-contrast>`: standard, medium, high) and every role changes with them — which is why only roles are allowed.
### Surfaces and elevation
- The page is `surface`. Panels separate by tone first: `surface-container-lowest``surface-container-highest` is a hierarchy of emphasis, not of height (`<x-surface level="surface-container-high">`). Navigation chrome is `surface-container`; a dialog, a menu, the search bar are `surface-container-high`; a modal sheet is `surface-container-low`; a filled card is `surface-container-highest`. A region keeps its role at every width.
- Shadows (`var(--md-sys-elevation-1)``-5`) are for what floats or lifts: 1 for elevated cards, buttons and modal sheets; 2 for menus, the navigation bar, a scrolled app bar; 3 for the FAB, dialogs, pickers and search; one level more on hover; nothing rests above 3. Fewer shadows carry more meaning.
- A scrim is `scrim` at 32%: `color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent)`.
### Shape
- Corners come from the scale `var(--md-sys-shape-corner-{none|xs|sm|md|lg|lg-increased|xl|xl-increased|xxl|full})`, or `<x-surface corner="md">`; never a length of your own.
- By family: `full` buttons, icon buttons, chips' avatars, badges, switches, sliders, the search bar, navigation indicators; `xs` text fields, menus, snackbars, plain tooltips; `sm` chips; `md` cards, rich tooltips; `lg` the FAB and a side sheet's inner corners; `xl` dialogs, bottom sheets, the search view, pickers, carousel items; `xxl` large hero containers.
- Nested shapes: inner radius = outer radius padding; never the same radius inside and out.
- A press squares a round shape (the components do it; nothing morphs on hover). The 35 `<x-shape>`s are decoration, never meaning, used sparingly.
### Type
- Every text element carries one `md-type-*` class: `display` for hero figures and short marketing lines; `headline` for page and section titles; `title` for card, dialog and list-section titles; `body` for paragraphs (`md-type-body-lg` for reading); `label` inside components (buttons, chips, tabs, captions). In the application's CSS a style is `font: var(--md-sys-typescale-body-md)` with its `-tracking`; never a size, weight, line height or letter spacing of your own.
- `md-type-emphasized-*` is opt-in: a selected item, a primary action, a headline, a badge — not decoration.
- 4060 characters per line; `md-tabular` on figures that change; text must scale to 200% without loss (containers grow, rows wrap, no fixed heights on text, no ellipsis without a way to read the rest).
### Motion
- Position, size and shape move on the spatial springs (they overshoot): `transition: transform var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default)``fast` for small elements, `slow` for large ones. Colour and opacity move on the effects springs (`--md-sys-motion-effects-*`), which never overshoot. Always pair an easing with its duration.
- Entering decelerates, a permanent exit accelerates, a temporary exit (a sheet, a drawer) takes the emphasized curve; exits are shorter than entrances.
- Everything that moves goes through these tokens, so reduced motion makes it instant; a literal duration is a bug.
### States and targets
- Interactive elements carry `md-state-layer md-focus-ring`: hover 8%, focus 10%, pressed 10%, dragged 16% (`data-md-dragged`) of the content colour. Disabled is content at 38% and a container at 12% of `on-surface` (`--md-sys-state-disabled-content-opacity`, `--md-sys-state-disabled-container-opacity`, through `color-mix()`), with no state layer. Every state shows two indicators: colour plus a shape, an outline, an icon or a word.
- Every target is at least 48×48px with 8px between targets (`md-touch-target` on anything drawn smaller); a denser layout is an opt-in prop, never a default.
- Keyboard: Tab between components, arrows within one, Enter and Space activate, Escape dismisses; a dialog takes focus and gives it back to what opened it.
### Layout and breakpoints
- Widths are M3's window size classes and only those: compact below 600px (the default), medium 600, expanded 840, large 1200, extra-large 1600. A layout component takes them as props (`<x-row stack-below="medium">`, `<x-stack hide-from="expanded">`, `<x-grid :columns="['compact' => 1, 'expanded' => 2]">`); the application's CSS writes `@media (width >= 840px)`; a script asks `from()` and `upTo()` from `resources/js/breakpoints.js`.
- What changes per class: compact — navigation bar, one pane, full-screen dialogs, a bottom sheet for choices; medium — collapsed rail, one pane; expanded — rail (collapsible), two panes, menus and basic dialogs; large and extra-large — the rail expanded, two panes, a third only at extra-large as a side sheet. `<x-scaffold>` does this; content lives in panes (`<x-pane>`, `<x-list-detail>` for a list's second pane), never beside the rail by hand.
- Margins are 16px below medium and 24px from it (`<x-pane>` draws them); spacing sits on the 4px grid as `space25``space900`, as padding and gaps on the parent, with margins only between layout regions. A fixed pane is 360px (expanded) or 412px (large); a side sheet at most 400px.
- Write logical properties (`padding-inline-start`, `inset-inline-end`, `md-text-start`); directional icons mirror in RTL; charts and media controls stay LTR. Keep controls inside the safe area (`--material-safe-*`).
### Accessibility
- Native elements first (`<button>`, `<dialog>`, `<input>`), then ARIA. One `main`, one `banner`, one `contentinfo`; every repeated `nav` labelled, without the word "navigation".
- Headings in order from a single H1; the level is structure, the `md-type-*` class is appearance.
- An icon-only control has an accessible name that does not include its role; decorative icons are hidden; an error is announced and tied to its field (`aria-describedby`); a toast uses a polite live region and never takes focus. A single-key shortcut needs a modifier or a focused component.
### Icons
- `<x-icon name="home">` is a Material Symbol Rounded: `filled` means active or selected, `optical="20"` when drawn at 20px or less, one weight per group, the size and colour of the text beside it.
### Don'ts
- No icon in a snackbar; no disabled FAB (hide it); no horizontal radio rows; no hover morph on cards; no `outline` on dividers; no hex colours; no utility classes, and no breakpoint, radius, shadow, type size or duration off M3's scales; no segmented buttons, navigation drawer or bottom app bar — use `<x-button-group connected>`, the expanded rail and `<x-toolbar>`.
</laravel-boost-guidelines>
+27 -25
View File
@@ -1,22 +1,9 @@
# ============================================
# Stage 1: Build frontend assets
# Stage 1: Install PHP dependencies
# ============================================
FROM node:24-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --prefer-offline
COPY vite.config.js ./
COPY resources/ ./resources/
RUN npm run build
# ============================================
# Stage 2: Install PHP dependencies
# ============================================
FROM composer:2 AS vendor
# Built on the build machine's own platform: vendor/ is plain PHP, the same for every target, so a
# multi-arch build runs it once and never under emulation.
FROM --platform=$BUILDPLATFORM composer:2 AS vendor
WORKDIR /app
@@ -33,13 +20,31 @@ COPY . .
RUN composer dump-autoload --optimize --no-dev
# ============================================
# Stage 2: Build frontend assets
# ============================================
# After Composer: the stylesheet and script import Livewire Material from vendor/. On the build
# machine's platform too: the output is CSS and JavaScript, whatever the target.
FROM --platform=$BUILDPLATFORM node:24-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --prefer-offline
COPY vite.config.js ./
COPY resources/ ./resources/
COPY --from=vendor /app/vendor/nonameweb ./vendor/nonameweb
RUN npm run build
# ============================================
# Stage 3: Production image (FrankenPHP/Octane)
# ============================================
FROM dunglas/frankenphp:php8.5-alpine AS production
LABEL maintainer="surtic86"
LABEL org.opencontainers.image.source="https://github.com/surtic86/SealShare"
LABEL org.opencontainers.image.source="https://gitea.nonameweb.ch/noNameWEB/SealShare"
LABEL org.opencontainers.image.description="Self-hosted encrypted file sharing"
# Install required PHP extensions
@@ -66,9 +71,6 @@ ENV APP_NAME="SealShare" \
WORKDIR /app
# Copy Caddyfile
COPY docker/Caddyfile /etc/caddy/Caddyfile
# Copy PHP ini for upload limits
COPY docker/php/uploads.ini /usr/local/etc/php/conf.d/99-uploads.ini
@@ -82,7 +84,7 @@ COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
# Remove dev/build files and stale cache not needed in production
RUN rm -rf node_modules tests .github docker/dev.Dockerfile docker/dev-entrypoint.sh .env .env.example \
RUN rm -rf node_modules tests .gitea docker/dev.Dockerfile docker/dev-entrypoint.sh .env .env.example \
bootstrap/cache/*.php \
&& mkdir -p storage/app/shares storage/app/public storage/framework/cache \
storage/framework/sessions storage/framework/testing storage/framework/views \
@@ -93,12 +95,12 @@ RUN rm -rf node_modules tests .github docker/dev.Dockerfile docker/dev-entrypoin
RUN touch database/database.sqlite \
&& chmod 666 database/database.sqlite
# Make entrypoint executable
RUN chmod +x docker/entrypoint.sh
# Make entrypoint and healthcheck executable
RUN chmod +x docker/entrypoint.sh docker/healthcheck.sh
EXPOSE 80 443 443/udp
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl --silent --fail http://localhost/up || exit 1
CMD /app/docker/healthcheck.sh
ENTRYPOINT ["docker/entrypoint.sh"]
+39 -21
View File
@@ -2,23 +2,36 @@
A simple, self-hosted file sharing solution built with Laravel. Upload files, get a shareable link, done. All files are encrypted at rest with AES-256-GCM.
**Website:** [sealshare.nonameweb.ch](https://sealshare.nonameweb.ch)
## Screenshots
<p>
<img src="website/img/screenshots/desktop/light/01-upload-800.webp" alt="Uploading files and folders with share options" width="49%">
<img src="website/img/screenshots/desktop/light/02-share-created-800.webp" alt="A new share with its link and QR code button" width="49%">
</p>
<p>
<img src="website/img/screenshots/phone/light/03-download-540.webp" alt="The recipient's download page on a phone" width="30%">
</p>
## 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
- **End-to-End Encryption** — All files encrypted at rest using AES-256-GCM (chunked, streaming)
- **Password Protection** — Optionally protect shares with a password
- **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
- **Shareable Links** — Each upload generates a unique link for recipients, also as a QR code (saved as a PNG) or through the device's share sheet
- **Encryption at Rest** — Files are encrypted in the uploader's browser, chunk by chunk with AES-256-GCM, before they are sent, and are stored only in encrypted form; with a share password the share's key is wrapped with a key derived from it (Argon2id) and never stored as it is. It is not end-to-end encryption: the server issues the key, checks each chunk, and decrypts the files for downloads
- **Password Protection** — Optionally protect shares with a password, typed or generated (random characters or a passphrase, as the admin configures) and copied on the upload page or next to the new link
- **Expiration** — Shares auto-expire after a configurable duration (1 hour to 30 days)
- **Download Limits** — Set a maximum number of downloads per share
- **ZIP Downloads** — Download all files in a share as a single ZIP archive
- **ZIP Downloads** — Download all files in a share as a single ZIP archive, streamed as it is built, whatever the files' size
- **Auto-Cleanup** — Expired shares and files are automatically deleted (hourly)
- **Admin Dashboard** — View, manage, and delete all shares
- **Admin Settings** — Configure upload limits, storage quotas, branding, and more
- **Site Branding** — Custom logo, title, and description
- **Colour Profiles** — Eight Material 3 colour profiles (Indigo, Blue, Teal, Green, Amber, Rose, Violet, Graphite); the admin picks one for every page, mail and error page
- **System Password** — Optional global password gate to restrict upload access
- **User Authentication** — Login, registration, password reset, email verification
- **User Authentication** — Login, password reset, email verification
- **Two-Factor Authentication** — TOTP-based 2FA via Laravel Fortify
- **Dark Mode** — Dark themed UI with DaisyUI components
- **Light and Dark Themes** — Material 3 Expressive design that follows the system theme, or light or dark by choice
- **Setup Wizard** — First-run wizard to create the initial admin account
## Tech Stack
@@ -27,11 +40,11 @@ A simple, self-hosted file sharing solution built with Laravel. Upload files, ge
|-------|-----------|
| **Framework** | Laravel 13 |
| **Application Server** | FrankenPHP (via Laravel Octane) |
| **Frontend** | Livewire 4, Alpine.js, Tailwind CSS 4, DaisyUI 5, Mary UI |
| **Frontend** | Livewire 4, Tailwind CSS 4, [Livewire Material](https://gitea.nonameweb.ch/noNameWEB/livewire-material) (Material 3 Expressive) |
| **Authentication** | Laravel Fortify |
| **Encryption** | Chunked AES-256-GCM with PBKDF2-SHA256 key derivation |
| **ZIP Downloads** | Native PHP ZipArchive |
| **Testing** | Pest 4 |
| **Encryption** | Chunked AES-256-GCM (WebCrypto in the browser), keys wrapped with Argon2id |
| **ZIP Downloads** | [ZipStream-PHP](https://packagist.org/packages/maennchen/zipstream-php) |
| **Testing** | Pest 5 with browser tests (Playwright) |
| **Code Style** | Laravel Pint |
| **Build Tool** | Vite |
@@ -56,13 +69,13 @@ The app is available at `http://localhost:8000` with Vite HMR on port `5173`.
```bash
mkdir sealshare && cd sealshare
curl -O https://raw.githubusercontent.com/surtic86/SealShare/main/docker-compose.example.yml
curl -O https://gitea.nonameweb.ch/noNameWEB/SealShare/raw/branch/main/docker-compose.example.yml
cp docker-compose.example.yml docker-compose.yml
# Generate an app key and paste it into docker-compose.yml
docker run --rm ghcr.io/surtic86/sealshare:latest php artisan key:generate --show
docker run --rm gitea.nonameweb.ch/nonameweb/sealshare:latest php artisan key:generate --show
# Edit docker-compose.yml — set APP_KEY, APP_URL, and SERVER_NAME
# Edit docker-compose.yml — set APP_KEY and APP_URL, and choose how HTTPS is served (below)
# Then start:
docker compose up -d
```
@@ -75,7 +88,11 @@ Migrations run automatically on startup. Open your configured domain — the Set
|----------|----------|-------------|
| `APP_KEY` | Yes | Laravel encryption key |
| `APP_URL` | Yes | Full URL (e.g. `https://share.example.com`) |
| `SERVER_NAME` | Yes | Domain for auto-TLS (e.g. `share.example.com`) |
| `AUTO_HTTPS` | No | `true` to fetch a Let's Encrypt certificate for `SERVER_NAME` and serve HTTPS on port 443 (port 80 redirects); default `false`, plain HTTP on port 80 for a reverse proxy |
| `SERVER_NAME` | With `AUTO_HTTPS` | The domain to fetch the certificate for (e.g. `share.example.com`) |
| `UPLOAD_CHUNK_SIZE_MB` | No | Size of each encrypted chunk the browser sends; default `16` |
**HTTPS is required for uploads.** Files are encrypted in the uploader's browser with WebCrypto, which browsers only offer over HTTPS or on `localhost`; over plain HTTP the upload page says so and takes no files (downloads keep working). Either set `AUTO_HTTPS: "true"` with `SERVER_NAME` — ports 80 and 443 must be reachable from the internet — or put a reverse proxy that terminates TLS in front of port 80.
**Volumes:**
@@ -88,21 +105,20 @@ Migrations run automatically on startup. Open your configured domain — the Set
**Large files:**
Uploads beyond the defaults need these limits raised together:
Files go up in chunks of `UPLOAD_CHUNK_SIZE_MB`, one request each, so PHP's upload limits and a proxy's request timeout do not limit a file's size. What does:
| Limit | Where | Default |
|-------|-------|---------|
| `PHP_UPLOAD_MAX_FILESIZE` / `PHP_POST_MAX_SIZE` | Environment | `4G` — hard cap per file / per upload batch |
| Max file size / Max size per share | Admin → Settings | 100 MB / 2 GB |
| `LIVEWIRE_MAX_UPLOAD_TIME` | Environment | 30 minutes per upload |
| `OCTANE_MAX_EXECUTION_TIME` / `PHP_MAX_EXECUTION_TIME` | Environment | 300 seconds — encrypting a large file takes a while |
| Storage quota | Admin → Settings | 20 GB — files still uploading count towards it |
| `UPLOAD_CHUNK_SIZE_MB` | Environment | `16` |
Behind a reverse proxy, raise its request body limit and read timeout as well (nginx: `client_max_body_size`, `proxy_read_timeout`).
Behind a reverse proxy, its request body limit must be a little larger than a chunk (nginx: `client_max_body_size 32m;`), and `proxy_request_buffering off;` keeps nginx from writing each chunk to its own temporary files. `PHP_UPLOAD_MAX_FILESIZE` / `PHP_POST_MAX_SIZE` (default `64M`) only apply to the admin's logo upload. An upload no chunk reached for 4 hours is deleted by the hourly cleanup.
### Manual (without Docker)
```bash
git clone https://github.com/surtic86/SealShare.git
git clone https://gitea.nonameweb.ch/noNameWEB/SealShare.git
cd SealShare
composer install --no-dev --optimize-autoloader
@@ -139,3 +155,5 @@ Add the scheduler to your crontab:
## License
This project is open-source software licensed under the [MIT License](LICENSE).
Generated passphrases draw from the [EFF Large Wordlist](https://www.eff.org/deeplinks/2016/07/new-wordlists-random-passphrases) by the Electronic Frontier Foundation, licensed under [CC BY 3.0 US](https://creativecommons.org/licenses/by/3.0/us/) (`resources/wordlists/eff-large-wordlist.txt`, without its four hyphenated words).
+45 -4
View File
@@ -5,12 +5,18 @@ namespace App\Console\Commands;
use App\Models\Share;
use App\Services\ShareService;
use Illuminate\Console\Command;
use Livewire\Features\SupportFileUploads\FileUploadConfiguration;
class CleanupExpiredShares extends Command
{
/**
* How long an upload or a temporary upload file may sit untouched before it is deleted.
*/
private const ABANDONED_AFTER_HOURS = 4;
protected $signature = 'shares:cleanup';
protected $description = 'Delete expired shares and shares that have reached their download limit';
protected $description = 'Delete expired shares, shares that have reached their download limit, abandoned uploads and old temporary uploads';
public function handle(ShareService $shareService): int
{
@@ -21,14 +27,49 @@ class CleanupExpiredShares extends Command
})
->get();
$count = $expiredShares->count();
foreach ($expiredShares as $share) {
$shareService->deleteShare($share);
}
$this->info("Cleaned up {$count} expired share(s).");
$this->info("Cleaned up {$expiredShares->count()} expired share(s).");
// A page that stopped sending chunks: closed, crashed or left behind.
$abandonedUploads = Share::query()
->whereNull('completed_at')
->where('updated_at', '<', now()->subHours(self::ABANDONED_AFTER_HOURS))
->get();
foreach ($abandonedUploads as $share) {
$shareService->deleteShare($share);
}
$this->info("Cleaned up {$abandonedUploads->count()} abandoned upload(s).");
$this->info('Cleaned up '.$this->deleteOldTemporaryUploads().' temporary upload file(s).');
return self::SUCCESS;
}
/**
* Delete Livewire's temporary uploads past the same age: the admin logo's, and the unencrypted
* copies uploads left there before files were encrypted in the browser.
*/
private function deleteOldTemporaryUploads(): int
{
if (FileUploadConfiguration::isUsingS3()) {
return 0;
}
$storage = FileUploadConfiguration::storage();
$cutoff = now()->subHours(self::ABANDONED_AFTER_HOURS)->getTimestamp();
$deleted = 0;
foreach ($storage->allFiles(FileUploadConfiguration::path()) as $path) {
if ($storage->exists($path) && $storage->lastModified($path) < $cutoff) {
$storage->delete($path);
$deleted++;
}
}
return $deleted;
}
}
+52 -24
View File
@@ -6,11 +6,12 @@ use App\Models\Share;
use App\Models\ShareFile;
use App\Services\FileEncryptionService;
use App\Services\ShareService;
use GuzzleHttp\Psr7\PumpStream;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\StreamedResponse;
use ZipArchive;
use ZipStream\CompressionMethod;
use ZipStream\ZipStream;
class DownloadController extends Controller
{
@@ -20,41 +21,53 @@ class DownloadController extends Controller
) {}
/**
* Download all files as a ZIP archive.
* 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.
*/
public function download(Share $share): BinaryFileResponse
public function download(Share $share): StreamedResponse
{
abort_if($share->isExpired() || $share->hasReachedDownloadLimit(), 404);
abort_if(! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit(), 404);
$share->load('files');
$key = $this->resolveDecryptionKey($share);
$tempPath = tempnam(sys_get_temp_dir(), 'sealshare_');
return new StreamedResponse(function () use ($share, $key): void {
$zip = new ZipStream(
defaultCompressionMethod: CompressionMethod::STORE,
defaultEnableZeroHeader: true,
sendHttpHeaders: false,
flushOutput: true,
);
$zip = new ZipArchive;
$zip->open($tempPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
foreach ($share->files as $file) {
$chunks = $this->encryptionService->decryptedChunks(
Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path)),
$key,
);
foreach ($share->files as $file) {
$encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path));
$content = $this->encryptionService->decryptFile($encryptedPath, $key);
$zip->addFileFromPsr7Stream(fileName: $this->archiveName($file), stream: new PumpStream(function () use ($chunks): string|false {
while ($chunks->valid() && $chunks->current() === '') {
$chunks->next();
}
$filename = $file->relative_path ?: $file->original_name;
$filename = str_replace('\\', '/', $filename);
if (! $chunks->valid()) {
return false;
}
if (str_starts_with($filename, '/') || str_contains($filename, '..')) {
$filename = basename($filename);
$chunk = $chunks->current();
$chunks->next();
return $chunk;
}));
}
$zip->addFromString($filename, $content);
}
$zip->finish();
$zip->close();
$this->shareService->recordDownload($share);
return response()->download($tempPath, 'share-'.$share->token.'.zip', [
$this->shareService->recordDownload($share);
}, 200, [
'Content-Type' => 'application/zip',
])->deleteFileAfterSend(true);
'Content-Disposition' => HeaderUtils::makeDisposition('attachment', 'share-'.$share->token.'.zip'),
]);
}
/**
@@ -62,7 +75,7 @@ class DownloadController extends Controller
*/
public function downloadFile(Share $share, ShareFile $shareFile): StreamedResponse
{
abort_if($share->isExpired() || $share->hasReachedDownloadLimit(), 404);
abort_if(! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit(), 404);
abort_if($shareFile->share_id !== $share->id, 404);
$key = $this->resolveDecryptionKey($share);
@@ -90,6 +103,21 @@ class DownloadController extends Controller
}, 200, $headers);
}
/**
* A file's path inside the archive: its folder path when it came from a dropped folder, never
* one that could reach outside the archive.
*/
private function archiveName(ShareFile $file): string
{
$filename = str_replace('\\', '/', $file->relative_path ?: $file->original_name);
if (str_starts_with($filename, '/') || str_contains($filename, '..')) {
return basename($filename);
}
return $filename;
}
/**
* Resolve the decryption key from session or share.
*/
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers;
use App\Models\ShareFile;
use App\Services\ShareService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use InvalidArgumentException;
class UploadChunkController extends Controller
{
public function __construct(
private ShareService $shareService,
) {}
/**
* Store one encrypted chunk of a file the uploader's page registered.
*
* Only the session that started the pending share may add to it. A chunk the server already
* has is acknowledged without being written again; one that skips ahead gets a 409 with the
* number of chunks stored, so the browser can continue from there.
*/
public function store(Request $request, ShareFile $shareFile, int $index): JsonResponse
{
$share = $shareFile->share;
abort_if($share->isCompleted() || ! in_array($share->token, $request->session()->get('pending_shares', []), true), 404);
if ($index !== $shareFile->uploaded_chunks) {
return response()->json(
['uploaded_chunks' => $shareFile->uploaded_chunks],
$index < $shareFile->uploaded_chunks ? 200 : 409,
);
}
try {
$uploadedChunks = $this->shareService->storeChunk($shareFile, $index, $request->getContent());
} catch (InvalidArgumentException) {
abort(422, 'The chunk is invalid.');
}
return response()->json(['uploaded_chunks' => $uploadedChunks]);
}
}
+35 -17
View File
@@ -14,49 +14,67 @@ class AdminDashboard extends Component
{
use WithPagination;
/** @var array<string, string> */
public array $sortBy = ['column' => 'created_at', 'direction' => 'desc'];
/**
* The orders the shares list offers, each a column and a direction.
*
* @var array<string, array{0: string, 1: string}>
*/
public const SORTS = [
'newest' => ['created_at', 'desc'],
'oldest' => ['created_at', 'asc'],
'expiring' => ['expires_at', 'asc'],
'largest' => ['total_size', 'desc'],
'most-downloaded' => ['download_count', 'desc'],
'most-files' => ['files_count', 'desc'],
];
public string $sort = 'newest';
/** The share the delete dialog is asking about, while it is open. */
public ?int $deletingShareId = null;
public function deleteShare(int $shareId, ShareService $shareService): void
{
$share = Share::query()->findOrFail($shareId);
$shareService->deleteShare($share);
$this->deletingShareId = null;
}
/**
* @return array<string, array<string, string|bool>>
* A new order starts again from the first page.
*/
public function headers(): array
public function updatedSort(): void
{
return [
['key' => 'token', 'label' => __('Token')],
['key' => 'files_count', 'label' => __('Files')],
['key' => 'total_size', 'label' => __('Size')],
['key' => 'download_count', 'label' => __('Downloads')],
['key' => 'expires_at', 'label' => __('Expires')],
['key' => 'created_at', 'label' => __('Created')],
];
$this->resetPage();
}
public function render(): mixed
{
$shareService = app(ShareService::class);
// The sort comes from the browser: only a known order reaches the query.
[$column, $direction] = self::SORTS[$this->sort] ?? self::SORTS['newest'];
// Shares whose files are still being uploaded are not shares yet; their bytes do count as used space.
$shares = Share::query()
->whereNotNull('completed_at')
->withCount('files')
->orderBy($this->sortBy['column'], $this->sortBy['direction'])
// Shares that never expire come after every share that does, whichever way expiry is sorted.
->when($column === 'expires_at', fn ($query) => $query->orderByRaw('expires_at is null'))
->orderBy($column, $direction)
->orderByDesc('id')
->paginate(15);
return view('livewire.admin.admin-dashboard', [
'shares' => $shares,
'totalShares' => Share::query()->count(),
'activeShares' => Share::query()->where(function ($q) {
'totalShares' => Share::query()->whereNotNull('completed_at')->count(),
'activeShares' => Share::query()->whereNotNull('completed_at')->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})->count(),
'totalFiles' => ShareFile::query()->count(),
'totalFiles' => ShareFile::query()->whereHas('share', fn ($query) => $query->whereNotNull('completed_at'))->count(),
'usedSpace' => $shareService->getTotalUsedSpace(),
'maxQuota' => $shareService->getMaxStorageQuota(),
'headers' => $this->headers(),
]);
}
}
+143 -33
View File
@@ -3,17 +3,26 @@
namespace App\Livewire\Admin;
use App\Models\Setting;
use App\Services\PasswordGeneratorService;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithFileUploads;
use NoNameWeb\LivewireMaterial\Concerns\Toasts;
use NoNameWeb\LivewireMaterial\Support\Scheme;
#[Layout('layouts.app')]
class AdminSettings extends Component
{
use Toasts;
use WithFileUploads;
/** The colour profile every page, mail and error page wears (config/livewire-material.php). */
public string $colorProfile = '';
public string $systemPassword = '';
public string $defaultExpiration = '';
@@ -28,68 +37,78 @@ class AdminSettings extends Component
public bool $allowNeverExpire = false;
/** How the upload page offers generated share passwords: `off`, `button` or `prefill`. */
public string $passwordGeneratorMode = 'button';
/** `characters` or `passphrase`. */
public string $passwordGeneratorType = 'characters';
public int $passwordLength = 20;
/** @var list<string> */
public array $passwordCharacterSets = [];
public bool $passwordAvoidAmbiguous = true;
public int $passphraseWords = 6;
public string $passphraseSeparator = 'hyphen';
public string $siteTitle = '';
public string $siteDescription = '';
public $siteLogo;
/** Whether the "Remove the logo?" dialog is open. */
public bool $confirmingLogoRemoval = false;
/** Whether the "Remove the system password?" dialog is open. */
public bool $confirmingPasswordRemoval = false;
public function mount(): void
{
$this->colorProfile = Scheme::profile() ?? '';
$this->defaultExpiration = Setting::get('default_expiration', '') ?? '';
$this->maxFileSize = min(
(int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024),
self::phpMaxUploadMb(),
);
$this->maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024);
$this->maxStorageQuota = (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024) / (1024 * 1024 * 1024);
$this->maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
$this->maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024) / (1024 * 1024 * 1024);
$this->allowNeverExpire = (bool) Setting::get('allow_never_expire', false);
$this->siteTitle = Setting::get('site_title', '') ?? '';
$this->siteDescription = Setting::get('site_description', '') ?? '';
}
public static function phpMaxUploadMb(): int
{
$parse = function (string $value): int {
$value = trim($value);
$last = strtolower($value[strlen($value) - 1]);
$num = (int) $value;
return match ($last) {
'g' => $num * 1024,
'm' => $num,
'k' => max(1, (int) ($num / 1024)),
default => max(1, (int) ($num / (1024 * 1024))),
};
};
$upload = $parse(ini_get('upload_max_filesize') ?: '2M');
$post = $parse(ini_get('post_max_size') ?: '8M');
return min($upload, $post);
$passwordOptions = app(PasswordGeneratorService::class)->options();
$this->passwordGeneratorMode = $passwordOptions['mode'];
$this->passwordGeneratorType = $passwordOptions['type'];
$this->passwordLength = $passwordOptions['length'];
$this->passwordCharacterSets = $passwordOptions['characterSets'];
$this->passwordAvoidAmbiguous = $passwordOptions['avoidAmbiguous'];
$this->passphraseWords = $passwordOptions['words'];
$this->passphraseSeparator = $passwordOptions['separator'];
}
public function saveSettings(): void
{
$phpMaxMb = self::phpMaxUploadMb();
$this->validate([
'maxFileSize' => ['required', 'integer', 'min:1', 'max:'.$phpMaxMb],
$validated = $this->validate([
'colorProfile' => ['required', 'string', Rule::in(array_keys(Scheme::profiles()))],
'maxFileSize' => ['required', 'integer', 'min:1'],
'maxStorageQuota' => ['required', 'integer', 'min:1'],
'maxFilesPerShare' => ['required', 'integer', 'min:1'],
'maxSizePerShare' => ['required', 'integer', 'min:1'],
'siteTitle' => ['nullable', 'string', 'max:255'],
'siteDescription' => ['nullable', 'string', 'max:1000'],
'siteLogo' => ['nullable', 'file', 'mimes:png,jpg,jpeg,gif,webp', 'max:2048'],
...$this->passwordGeneratorRules(),
], [
'maxFileSize.max' => __('Cannot exceed the PHP limit of :max MB. Increase upload_max_filesize and post_max_size in your PHP configuration.', ['max' => $phpMaxMb]),
...$this->passwordGeneratorMessages(),
]);
if ($this->systemPassword) {
Setting::set('system_password', Hash::make($this->systemPassword));
}
Setting::set('color_profile', $this->colorProfile);
Setting::set('default_expiration', $this->defaultExpiration ?: null);
Setting::set('max_file_size', $this->maxFileSize * 1024 * 1024);
Setting::set('max_storage_quota', $this->maxStorageQuota * 1024 * 1024 * 1024);
@@ -100,6 +119,8 @@ class AdminSettings extends Component
Setting::set('site_title', $this->siteTitle ?: null);
Setting::set('site_description', $this->siteDescription ?: null);
$this->savePasswordGeneratorSettings($validated);
if ($this->siteLogo && is_object($this->siteLogo)) {
$existingLogo = Setting::get('site_logo');
if ($existingLogo) {
@@ -113,7 +134,88 @@ class AdminSettings extends Component
$this->systemPassword = '';
session()->flash('message', __('Settings saved successfully.'));
$this->success(__('Settings saved successfully.'));
}
/**
* The generator's rules. A field the chosen mode or type hides is excluded, so it never blocks
* saving and keeps the value saved before.
*
* @return array<string, array<int, mixed>>
*/
protected function passwordGeneratorRules(): array
{
$characters = ['exclude_if:passwordGeneratorMode,off', 'exclude_unless:passwordGeneratorType,characters'];
$passphrase = ['exclude_if:passwordGeneratorMode,off', 'exclude_unless:passwordGeneratorType,passphrase'];
return [
'passwordGeneratorMode' => ['required', 'string', Rule::in(PasswordGeneratorService::MODES)],
'passwordGeneratorType' => ['exclude_if:passwordGeneratorMode,off', 'required', 'string', Rule::in(PasswordGeneratorService::TYPES)],
'passwordLength' => [...$characters, 'required', 'integer', 'min:'.PasswordGeneratorService::MIN_LENGTH, 'max:'.PasswordGeneratorService::MAX_LENGTH],
'passwordCharacterSets' => [...$characters, 'required', 'array'],
'passwordCharacterSets.*' => [...$characters, 'string', Rule::in(array_keys(PasswordGeneratorService::CHARACTER_SETS))],
'passwordAvoidAmbiguous' => [...$characters, 'boolean'],
'passphraseWords' => [...$passphrase, 'required', 'integer', 'min:'.PasswordGeneratorService::MIN_WORDS, 'max:'.PasswordGeneratorService::MAX_WORDS],
'passphraseSeparator' => [...$passphrase, 'required', 'string', Rule::in(array_keys(PasswordGeneratorService::SEPARATORS))],
];
}
/**
* @return array<string, string>
*/
protected function passwordGeneratorMessages(): array
{
return [
'passwordCharacterSets.required' => __('Choose at least one kind of character.'),
];
}
/**
* Store the generator settings that passed validation; excluded ones keep their saved value.
*
* @param array<string, mixed> $validated
*/
protected function savePasswordGeneratorSettings(array $validated): void
{
Setting::set('password_generator_mode', $validated['passwordGeneratorMode']);
if (array_key_exists('passwordGeneratorType', $validated)) {
Setting::set('password_generator_type', $validated['passwordGeneratorType']);
}
if (array_key_exists('passwordLength', $validated)) {
Setting::set('password_generator_length', $validated['passwordLength']);
Setting::set('password_generator_character_sets', implode(',', $validated['passwordCharacterSets']));
Setting::set('password_generator_avoid_ambiguous', $validated['passwordAvoidAmbiguous'] ? '1' : '0');
}
if (array_key_exists('passphraseWords', $validated)) {
Setting::set('password_generator_words', $validated['passphraseWords']);
Setting::set('password_generator_separator', $validated['passphraseSeparator']);
}
}
/**
* The form's generator options while they are valid, for the example; `null` otherwise.
*
* @return array{type: string, length: int, characterSets: list<string>, avoidAmbiguous: bool, words: int, separator: string}|null
*/
protected function passwordPreviewOptions(): ?array
{
$values = $this->only(['passwordGeneratorMode', 'passwordGeneratorType', 'passwordLength', 'passwordCharacterSets', 'passwordAvoidAmbiguous', 'passphraseWords', 'passphraseSeparator']);
if ($this->passwordGeneratorMode === 'off' || Validator::make($values, $this->passwordGeneratorRules())->fails()) {
return null;
}
return [
'type' => $this->passwordGeneratorType,
'length' => $this->passwordLength,
'characterSets' => array_values($this->passwordCharacterSets),
'avoidAmbiguous' => $this->passwordAvoidAmbiguous,
'words' => $this->passphraseWords,
'separator' => $this->passphraseSeparator,
];
}
public function removeLogo(): void
@@ -125,22 +227,30 @@ class AdminSettings extends Component
Setting::set('site_logo', null);
}
session()->flash('message', __('Logo removed.'));
$this->confirmingLogoRemoval = false;
$this->success(__('Logo removed.'));
}
public function clearSystemPassword(): void
{
Setting::set('system_password', null);
session()->flash('message', __('System password cleared.'));
$this->confirmingPasswordRemoval = false;
$this->success(__('System password cleared.'));
}
public function render(): mixed
{
$passwordGenerator = app(PasswordGeneratorService::class);
$passwordPreviewOptions = $this->passwordPreviewOptions();
return view('livewire.admin.admin-settings', [
'hasSystemPassword' => (bool) Setting::get('system_password'),
'currentLogo' => Setting::get('site_logo'),
'phpMaxUploadMb' => self::phpMaxUploadMb(),
'passwordExample' => $passwordPreviewOptions ? $passwordGenerator->generate($passwordPreviewOptions) : null,
'passwordEntropy' => $passwordPreviewOptions ? $passwordGenerator->entropyBits($passwordPreviewOptions) : null,
]);
}
}
+126 -116
View File
@@ -3,24 +3,27 @@
namespace App\Livewire;
use App\Models\Setting;
use App\Models\Share;
use App\Services\PasswordGeneratorService;
use App\Services\ShareService;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
/**
* The upload page. The browser encrypts each file chunk by chunk and sends the chunks to
* UploadChunkController (resources/js/share-uploader.js); this component registers the files
* into a pending share, lists them and completes the share with its options.
*/
#[Layout('layouts.app')]
class FileUploader extends Component
{
use WithFileUploads;
/** @var array<int, TemporaryUploadedFile> */
public array $files = [];
/** @var array<int, string|null> */
public array $relativePaths = [];
/** The pending share this page uploads into: created with the first file, one per page load. */
#[Locked]
public ?string $pendingToken = null;
public bool $usePassword = false;
@@ -38,90 +41,100 @@ class FileUploader extends Component
}
/**
* Handle an upload the temporary upload endpoint did not accept.
* Register the files a visitor chose and hand the browser what it encrypts and sends them
* with. A file an admin limit refuses gets `null` in its place and the reason under `files`.
*
* Validation errors (a 422) mean the whole file reached the server and was
* rejected there, so the real reason is logged for the administrator rather
* than guessed at in front of the user. Anything else is a transport failure.
* @param array<int, array{name?: mixed, size?: mixed, path?: mixed}> $files
* @return array<int, array{id: int, url: string, key: string, noncePrefix: string, chunkSize: int, chunkCount: int}|null>
*/
public function _uploadErrored($name, $errorsInJson, $isMultiple): void
public function registerFiles(array $files, ShareService $shareService): array
{
$this->dispatch('upload:errored', name: $name)->self();
$this->resetErrorBag('files');
$errors = is_null($errorsInJson) ? null : (json_decode($errorsInJson, true)['errors'] ?? null);
$targets = [];
if ($errors) {
Log::warning('File upload rejected by the temporary upload endpoint.', ['errors' => $errors]);
foreach ($files as $file) {
try {
$shareFile = $shareService->registerFile(
$this->pendingShare(),
(string) ($file['name'] ?? ''),
(int) ($file['size'] ?? -1),
isset($file['path']) ? (string) $file['path'] : null,
);
} catch (ValidationException $e) {
if (! $this->getErrorBag()->has('files')) {
$this->addError('files', $e->errors()['files'][0]);
}
throw ValidationException::withMessages([
'files' => __('Upload failed: the server could not accept the file. Please try again or contact the administrator.'),
]);
$targets[] = null;
continue;
}
if ($this->pendingToken !== $shareFile->share->token) {
$this->pendingToken = $shareFile->share->token;
session()->push('pending_shares', $this->pendingToken);
}
$header = $shareService->readHeader($shareFile);
$targets[] = [
'id' => $shareFile->id,
'url' => Str::beforeLast(route('upload.chunk', ['shareFile' => $shareFile, 'index' => 0]), '/'),
'key' => $shareFile->share->encryption_key,
'noncePrefix' => bin2hex($header['noncePrefix']),
'chunkSize' => $header['chunkSize'],
'chunkCount' => $header['chunkCount'],
];
}
$maxFileSizeMb = (int) ((int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024));
throw ValidationException::withMessages([
'files' => __('Upload failed: file may be too large (max :max MB) or the connection was interrupted.', ['max' => $maxFileSizeMb]),
]);
return $targets;
}
/**
* Validate a freshly uploaded batch of files.
* Take files out of the pending share, whether or not their upload finished.
*
* Dispatches `files-processed` so the front end can drop its "uploading" state.
* This runs for every batch, including additional files added to an existing
* selection, which a one-off `x-init` on the file list cannot cover.
* @param array<int, mixed> $fileIds
*/
public function updatedFiles(): void
public function removeFiles(array $fileIds, ShareService $shareService): void
{
$this->dispatch('files-processed')->self();
$files = $this->pendingShare()?->files()->whereIn('id', array_map('intval', $fileIds))->get() ?? [];
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
$maxFileSizeMb = $maxFileSize / (1024 * 1024);
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
$this->resetErrorBag('files');
if (count($this->files) > $maxFilesPerShare) {
$this->addError('files', __('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare]));
return;
foreach ($files as $file) {
$shareService->removeFile($file);
}
foreach ($this->files as $file) {
if ($file->getSize() > $maxFileSize) {
$this->addError('files', __('":name" is too large (:size MB). Maximum file size is :max MB.', [
'name' => $file->getClientOriginalName(),
'size' => round($file->getSize() / (1024 * 1024), 1),
'max' => (int) $maxFileSizeMb,
]));
$this->resetErrorBag('files');
}
return;
}
/**
* Fill in a generated password as protection is switched on, when the admin chose "Prefilled".
* A password already in the field stays.
*/
public function updatedUsePassword(bool $value): void
{
$passwordGenerator = app(PasswordGeneratorService::class);
if ($value && $this->password === '' && $passwordGenerator->mode() === 'prefill') {
$this->password = $passwordGenerator->generate();
}
}
public function removeFile(int $index): void
public function generatePassword(PasswordGeneratorService $passwordGenerator): void
{
unset($this->files[$index], $this->relativePaths[$index]);
$this->files = array_values($this->files);
$this->relativePaths = array_values($this->relativePaths);
if ($passwordGenerator->mode() === 'off') {
return;
}
$this->password = $passwordGenerator->generate();
$this->resetErrorBag('password');
}
public function createShare(ShareService $shareService): void
{
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
$maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024);
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
$rules = [];
$rules = [
'files' => ['required', 'array', 'min:1', 'max:'.$maxFilesPerShare],
'files.*' => ['required', 'file', 'max:'.($maxFileSize / 1024)],
];
$allowNeverExpire = (bool) Setting::get('allow_never_expire', false);
if (! $allowNeverExpire) {
if (! Setting::get('allow_never_expire', false)) {
$rules['expiration'] = ['required', 'string', 'in:1h,24h,48h,7d,14d,30d'];
}
@@ -129,74 +142,71 @@ class FileUploader extends Component
$rules['password'] = ['required', 'string', 'min:8'];
}
$this->validate($rules, [
'expiration.required' => __('An expiration time is required.'),
'files.required' => __('Please select at least one file to upload.'),
'files.max' => __('Too many files. Maximum :max files allowed per share.'),
'files.*.max' => __('A file exceeds the maximum size of :max KB.'),
]);
if ($rules !== []) {
$this->validate($rules, [
'expiration.required' => __('An expiration time is required.'),
]);
}
if ($shareService->isStorageFull()) {
$this->addError('files', __('Storage is full. Please contact the administrator.'));
$pendingShare = $this->pendingShare();
if ($pendingShare === null) {
$this->addError('files', __('Please select at least one file to upload.'));
return;
}
$totalSize = collect($this->files)->sum(fn ($file) => $file->getSize());
if ($totalSize > $maxSizePerShare) {
$this->addError('files', __('Total file size exceeds the maximum allowed per share.'));
return;
}
$fileData = [];
foreach ($this->files as $index => $file) {
$relativePath = $this->relativePaths[$index] ?? null;
if ($relativePath !== null) {
$relativePath = str_replace('\\', '/', $relativePath);
if (str_starts_with($relativePath, '/') || str_contains($relativePath, '..')) {
$relativePath = null;
}
}
$fileData[] = [
'file' => $file,
'relativePath' => $relativePath,
];
}
$expiresAt = match ($this->expiration) {
'1h' => now()->addHour(),
'24h' => now()->addDay(),
'48h' => now()->addDays(2),
'7d' => now()->addWeek(),
'14d' => now()->addDays(14),
'30d' => now()->addMonth(),
default => null,
};
$share = $shareService->createShare($fileData, [
$share = $shareService->completeShare($pendingShare, [
'password' => $this->usePassword ? $this->password : null,
'expires_at' => $expiresAt,
'expires_at' => match ($this->expiration) {
'1h' => now()->addHour(),
'24h' => now()->addDay(),
'48h' => now()->addDays(2),
'7d' => now()->addWeek(),
'14d' => now()->addDays(14),
'30d' => now()->addMonth(),
default => null,
},
'max_downloads' => $this->maxDownloads ?: null,
]);
session()->put('pending_shares', array_values(array_diff(session('pending_shares', []), [$share->token])));
// The page the upload leads to offers the password once more, next to the link; it is
// never stored in the clear, so this flash is the only way it gets there.
if ($this->usePassword) {
session()->flash('share_password', [
'token' => $share->token,
'password' => Crypt::encryptString($this->password),
]);
}
$this->redirect(route('share.created', $share), navigate: true);
}
public function render(): mixed
{
$shareService = app(ShareService::class);
$pendingFiles = $this->pendingShare()?->files()->orderBy('id')->get() ?? collect();
return view('livewire.file-uploader', [
'pendingFiles' => $pendingFiles,
'allFilesUploaded' => $pendingFiles->isNotEmpty() && $pendingFiles->every(fn ($file): bool => $file->completed_at !== null),
'isStorageFull' => $shareService->isStorageFull(),
'siteTitle' => Setting::get('site_title'),
'siteDescription' => Setting::get('site_description'),
'siteLogo' => Setting::get('site_logo'),
'allowNeverExpire' => (bool) Setting::get('allow_never_expire', false),
'passwordGeneratorMode' => app(PasswordGeneratorService::class)->mode(),
]);
}
/**
* This page's pending share, while it is still pending and this session started it.
*/
private function pendingShare(): ?Share
{
if ($this->pendingToken === null || ! in_array($this->pendingToken, session('pending_shares', []), true)) {
return null;
}
return Share::query()->where('token', $this->pendingToken)->whereNull('completed_at')->first();
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.auth')]
#[Layout('layouts.app')]
class SetupWizard extends Component
{
#[Validate('required|string|max:255')]
+23 -1
View File
@@ -2,8 +2,12 @@
namespace App\Livewire;
use App\Models\Setting;
use App\Models\Share;
use App\Services\QrCodeService;
use Illuminate\Support\Facades\Crypt;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Component;
#[Layout('layouts.app')]
@@ -11,13 +15,31 @@ class ShareCreated extends Component
{
public Share $share;
/** The share's password, offered once to the uploader who just set it; `null` on any other visit. */
#[Locked]
public ?string $password = null;
public function mount(Share $share): void
{
abort_unless($share->isCompleted(), 404);
$this->share = $share;
$flashedPassword = session('share_password');
if (is_array($flashedPassword) && ($flashedPassword['token'] ?? null) === $share->token) {
$this->password = Crypt::decryptString($flashedPassword['password']);
}
}
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'),
]);
}
}
+2 -7
View File
@@ -2,7 +2,6 @@
namespace App\Livewire;
use App\Models\Setting;
use App\Models\Share;
use App\Services\ShareService;
use Illuminate\Support\Facades\RateLimiter;
@@ -24,7 +23,7 @@ class ShareDownload extends Component
{
$this->share = $share->load('files');
if ($share->isExpired() || $share->hasReachedDownloadLimit()) {
if (! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit()) {
abort(404);
}
@@ -66,10 +65,6 @@ class ShareDownload extends Component
public function render(): mixed
{
return view('livewire.share-download', [
'siteTitle' => Setting::get('site_title'),
'siteDescription' => Setting::get('site_description'),
'siteLogo' => Setting::get('site_logo'),
]);
return view('livewire.share-download');
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.auth')]
#[Layout('layouts.app')]
class SystemPasswordPrompt extends Component
{
#[Validate('required|string')]
+12
View File
@@ -15,10 +15,12 @@ class Share extends Model
'password',
'encryption_key',
'encryption_salt',
'wrapped_key',
'expires_at',
'max_downloads',
'download_count',
'total_size',
'completed_at',
];
/**
@@ -32,6 +34,7 @@ class Share extends Model
'download_count' => 'integer',
'total_size' => 'integer',
'encryption_key' => 'encrypted',
'completed_at' => 'datetime',
];
}
@@ -43,6 +46,15 @@ class Share extends Model
return $this->hasMany(ShareFile::class);
}
/**
* Whether the share was created: until then its files are still being uploaded and nobody
* but the uploader's page may reach it.
*/
public function isCompleted(): bool
{
return $this->completed_at !== null;
}
public function isExpired(): bool
{
return $this->expires_at && $this->expires_at->isPast();
+4
View File
@@ -17,6 +17,8 @@ class ShareFile extends Model
'stored_path',
'file_size',
'mime_type',
'uploaded_chunks',
'completed_at',
];
/**
@@ -26,6 +28,8 @@ class ShareFile extends Model
{
return [
'file_size' => 'integer',
'uploaded_chunks' => 'integer',
'completed_at' => 'datetime',
];
}
+6
View File
@@ -2,11 +2,13 @@
namespace App\Providers;
use App\Models\Setting;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use NoNameWeb\LivewireMaterial\Support\Scheme;
class AppServiceProvider extends ServiceProvider
{
@@ -24,6 +26,10 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void
{
$this->configureDefaults();
// The colour profile the admin chose in Admin settings; asked each time a page, mail or
// error page draws its colours, so a new choice applies at once in every Octane worker.
Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
}
/**
-1
View File
@@ -49,7 +49,6 @@ class FortifyServiceProvider extends ServiceProvider
Fortify::verifyEmailView(fn () => view('pages::auth.verify-email'));
Fortify::twoFactorChallengeView(fn () => view('pages::auth.two-factor-challenge'));
Fortify::confirmPasswordView(fn () => view('pages::auth.confirm-password'));
Fortify::registerView(fn () => view('pages::auth.register'));
Fortify::resetPasswordView(fn () => view('pages::auth.reset-password'));
Fortify::requestPasswordResetLinkView(fn () => view('pages::auth.forgot-password'));
}
+290 -184
View File
@@ -4,11 +4,30 @@ namespace App\Services;
use Generator;
use RuntimeException;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* The encrypted file formats and the keys behind them.
*
* New files are `SEALCHK2`, written chunk by chunk as the uploader's browser sends them:
*
* [8 bytes: "SEALCHK2" magic]
* [4 bytes: chunk size S, uint32 big-endian]
* [7 bytes: random nonce prefix]
* Per chunk i: [ciphertext (S bytes, fewer on the last chunk)][16 bytes: GCM tag]
*
* Chunk i's nonce is the prefix, i as uint32 big-endian and a byte that is 1 on the last chunk
* and 0 on every other (the STREAM construction), so dropping, reordering or appending chunks
* fails authentication. The browser encrypts with the same layout (resources/js/share-uploader.js).
*
* `SEALCHK1` (a tag before each chunk, the index XORed into a 12-byte nonce, no last-chunk flag)
* and the single-block legacy format are still read for shares created before.
*/
class FileEncryptionService
{
public const HEADER_LENGTH = 19;
public const TAG_LENGTH = 16;
private const CIPHER = 'aes-256-gcm';
private const PBKDF2_ITERATIONS = 100000;
@@ -17,14 +36,17 @@ class FileEncryptionService
private const NONCE_LENGTH = 12;
private const TAG_LENGTH = 16;
private const NONCE_PREFIX_LENGTH = 7;
private const MAGIC_HEADER = 'SEALCHK1';
private const MAGIC = 'SEALCHK2';
private const DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB
private const LEGACY_CHUNKED_MAGIC = 'SEALCHK1';
private const WRAPPED_KEY_ALGORITHM = 'argon2id';
/**
* Derive an encryption key from a password and salt using PBKDF2-SHA256.
* Derive a key from a password and salt using PBKDF2-SHA256, as shares created before
* envelope encryption were keyed.
*/
public function deriveKey(string $password, string $salt): string
{
@@ -48,17 +70,147 @@ class FileEncryptionService
}
/**
* Encrypt a file using chunked AES-256-GCM.
* Wrap a share's data key with a key derived from its password (Argon2id).
*
* Output format:
* [8 bytes: "SEALCHK1" magic]
* [4 bytes: chunk size, uint32 big-endian]
* [12 bytes: base nonce]
* Per chunk:
* [16 bytes: GCM auth tag]
* [N bytes: ciphertext (up to chunk_size)]
* The result names its algorithm and parameters, so they can be raised later without breaking
* shares wrapped before: `argon2id$<opslimit>$<memlimit>$<salt>$<nonce>$<box>`, in hex.
*/
public function encryptFile(string $sourcePath, string $destPath, string $key): void
public function wrapKey(string $dataKeyHex, string $password): string
{
$salt = random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES);
$opslimit = SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE;
$memlimit = SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE;
$wrappingKey = $this->deriveWrappingKey($password, $salt, $opslimit, $memlimit);
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$box = sodium_crypto_secretbox(hex2bin($dataKeyHex), $nonce, $wrappingKey);
sodium_memzero($wrappingKey);
return implode('$', [self::WRAPPED_KEY_ALGORITHM, $opslimit, $memlimit, bin2hex($salt), bin2hex($nonce), bin2hex($box)]);
}
/**
* Unwrap a share's data key with its password; returns the key as hex.
*/
public function unwrapKey(string $wrappedKey, string $password): string
{
$parts = explode('$', $wrappedKey);
if (count($parts) !== 6 || $parts[0] !== self::WRAPPED_KEY_ALGORITHM) {
throw new RuntimeException('Unsupported wrapped key');
}
[, $opslimit, $memlimit, $salt, $nonce, $box] = $parts;
$wrappingKey = $this->deriveWrappingKey($password, hex2bin($salt), (int) $opslimit, (int) $memlimit);
$dataKey = sodium_crypto_secretbox_open(hex2bin($box), hex2bin($nonce), $wrappingKey);
sodium_memzero($wrappingKey);
if ($dataKey === false) {
throw new RuntimeException('Unwrapping failed - wrong password or corrupted key');
}
return bin2hex($dataKey);
}
/**
* The header a new encrypted file starts with, with a fresh random nonce prefix.
*/
public function createHeader(int $chunkSize): string
{
return self::MAGIC.pack('N', $chunkSize).random_bytes(self::NONCE_PREFIX_LENGTH);
}
/**
* Read a `SEALCHK2` header.
*
* @return array{chunkSize: int, noncePrefix: string}
*/
public function parseHeader(string $header): array
{
if (strlen($header) !== self::HEADER_LENGTH || ! str_starts_with($header, self::MAGIC)) {
throw new RuntimeException('Invalid encrypted file header');
}
return [
'chunkSize' => unpack('N', substr($header, 8, 4))[1],
'noncePrefix' => substr($header, 12, self::NONCE_PREFIX_LENGTH),
];
}
/**
* How many chunks a file of this size is sent in; an empty file is one empty chunk.
*/
public function chunkCount(int $size, int $chunkSize): int
{
return max(1, intdiv($size + $chunkSize - 1, $chunkSize));
}
/**
* Where chunk `$index` starts in the encrypted file.
*/
public function chunkOffset(int $index, int $chunkSize): int
{
return self::HEADER_LENGTH + $index * ($chunkSize + self::TAG_LENGTH);
}
/**
* Encrypt one chunk: its ciphertext followed by its tag, as WebCrypto returns it.
*/
public function encryptChunk(string $plaintext, string $key, string $noncePrefix, int $index, bool $isLast): string
{
$tag = '';
$ciphertext = openssl_encrypt(
$plaintext,
self::CIPHER,
$this->normalizeToBinaryKey($key),
OPENSSL_RAW_DATA,
$this->chunkNonce($noncePrefix, $index, $isLast),
$tag,
'',
self::TAG_LENGTH,
);
if ($ciphertext === false) {
throw new RuntimeException('Encryption failed at chunk '.$index);
}
return $ciphertext.$tag;
}
/**
* Decrypt one chunk, which fails unless its index and last-chunk flag are the ones it was
* encrypted with.
*/
public function decryptChunk(string $chunk, string $key, string $noncePrefix, int $index, bool $isLast): string
{
if (strlen($chunk) < self::TAG_LENGTH) {
throw new RuntimeException('Invalid encrypted file: truncated chunk '.$index);
}
$plaintext = openssl_decrypt(
substr($chunk, 0, -self::TAG_LENGTH),
self::CIPHER,
$this->normalizeToBinaryKey($key),
OPENSSL_RAW_DATA,
$this->chunkNonce($noncePrefix, $index, $isLast),
substr($chunk, -self::TAG_LENGTH),
);
if ($plaintext === false) {
throw new RuntimeException('Decryption failed - wrong key or corrupted data');
}
return $plaintext;
}
/**
* Encrypt a file on the server in the `SEALCHK2` format.
*/
public function encryptFile(string $sourcePath, string $destPath, string $key, int $chunkSize): void
{
$source = fopen($sourcePath, 'rb');
@@ -75,45 +227,16 @@ class FileEncryptionService
}
try {
$binaryKey = $this->normalizeToBinaryKey($key);
$baseNonce = random_bytes(self::NONCE_LENGTH);
$chunkSize = self::DEFAULT_CHUNK_SIZE;
$header = $this->createHeader($chunkSize);
$noncePrefix = $this->parseHeader($header)['noncePrefix'];
$chunkCount = $this->chunkCount((int) filesize($sourcePath), $chunkSize);
// Write header
fwrite($dest, self::MAGIC_HEADER);
fwrite($dest, pack('N', $chunkSize));
fwrite($dest, $baseNonce);
fwrite($dest, $header);
$chunkIndex = 0;
for ($index = 0; $index < $chunkCount; $index++) {
$plaintext = (string) fread($source, $chunkSize);
while (! feof($source)) {
$plaintext = fread($source, $chunkSize);
if ($plaintext === false || $plaintext === '') {
break;
}
$nonce = $this->deriveChunkNonce($baseNonce, $chunkIndex);
$tag = '';
$ciphertext = openssl_encrypt(
$plaintext,
self::CIPHER,
$binaryKey,
OPENSSL_RAW_DATA,
$nonce,
$tag,
'',
self::TAG_LENGTH,
);
if ($ciphertext === false) {
throw new RuntimeException('Encryption failed at chunk '.$chunkIndex);
}
fwrite($dest, $tag);
fwrite($dest, $ciphertext);
$chunkIndex++;
fwrite($dest, $this->encryptChunk($plaintext, $key, $noncePrefix, $index, $index === $chunkCount - 1));
}
} catch (RuntimeException $e) {
fclose($source);
@@ -127,74 +250,33 @@ class FileEncryptionService
fclose($dest);
}
/**
* Decrypt a file and return the plaintext content.
*/
public function decryptFile(string $encryptedPath, string $key): string
{
if ($this->isChunkedFormat($encryptedPath)) {
$parts = [];
foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) {
$parts[] = $chunk;
}
return implode('', $parts);
}
return $this->decryptLegacy($encryptedPath, $key);
}
/**
* Decrypt a file and stream the response.
*/
public function decryptFileStream(string $encryptedPath, string $key, string $filename, string $mimeType, ?int $fileSize = null): StreamedResponse
{
$headers = [
'Content-Type' => $mimeType ?: 'application/octet-stream',
'Content-Disposition' => HeaderUtils::makeDisposition('attachment', $filename, 'download'),
];
if ($fileSize !== null) {
$headers['Content-Length'] = $fileSize;
}
if ($this->isChunkedFormat($encryptedPath)) {
return new StreamedResponse(function () use ($encryptedPath, $key): void {
foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) {
echo $chunk;
flush();
}
}, 200, $headers);
}
$content = $this->decryptLegacy($encryptedPath, $key);
if (! isset($headers['Content-Length'])) {
$headers['Content-Length'] = strlen($content);
}
return new StreamedResponse(function () use ($content): void {
echo $content;
}, 200, $headers);
}
/**
* Stream decrypted file content directly to output (echo).
* Use this when you need to add post-streaming logic inside a StreamedResponse callback.
*/
public function streamDecryptedFile(string $encryptedPath, string $key): void
{
if ($this->isChunkedFormat($encryptedPath)) {
foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) {
echo $chunk;
flush();
}
return;
foreach ($this->decryptedChunks($encryptedPath, $key) as $chunk) {
echo $chunk;
flush();
}
}
echo $this->decryptLegacy($encryptedPath, $key);
/**
* The decrypted content of a file in any of the three formats, chunk by chunk.
*
* @return Generator<int, string>
*/
public function decryptedChunks(string $encryptedPath, string $key): Generator
{
$magic = (string) file_get_contents($encryptedPath, false, null, 0, 8);
if ($magic === self::MAGIC) {
yield from $this->decryptChunks($encryptedPath, $key);
} elseif ($magic === self::LEGACY_CHUNKED_MAGIC) {
yield from $this->decryptLegacyChunks($encryptedPath, $key);
} else {
yield $this->decryptLegacy($encryptedPath, $key);
}
}
/**
@@ -205,73 +287,29 @@ class FileEncryptionService
return strlen($key) === 64 ? hex2bin($key) : $key;
}
/**
* Derive a unique nonce for a chunk by XORing the chunk index into the last 4 bytes.
*/
private function deriveChunkNonce(string $baseNonce, int $chunkIndex): string
private function deriveWrappingKey(string $password, string $salt, int $opslimit, int $memlimit): string
{
$nonce = $baseNonce;
$indexBytes = pack('N', $chunkIndex);
for ($i = 0; $i < 4; $i++) {
$nonce[self::NONCE_LENGTH - 4 + $i] = $nonce[self::NONCE_LENGTH - 4 + $i] ^ $indexBytes[$i];
}
return $nonce;
}
/**
* Check if a file uses the chunked encryption format.
*/
private function isChunkedFormat(string $path): bool
{
$handle = fopen($path, 'rb');
if ($handle === false) {
return false;
}
$magic = fread($handle, 8);
fclose($handle);
return $magic === self::MAGIC_HEADER;
}
/**
* Decrypt a legacy single-block encrypted file.
* Format: [12-byte nonce][16-byte auth tag][ciphertext]
*/
private function decryptLegacy(string $encryptedPath, string $key): string
{
$data = file_get_contents($encryptedPath);
if ($data === false) {
throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}");
}
$binaryKey = $this->normalizeToBinaryKey($key);
$nonce = substr($data, 0, self::NONCE_LENGTH);
$tag = substr($data, self::NONCE_LENGTH, self::TAG_LENGTH);
$ciphertext = substr($data, self::NONCE_LENGTH + self::TAG_LENGTH);
$plaintext = openssl_decrypt(
$ciphertext,
self::CIPHER,
$binaryKey,
OPENSSL_RAW_DATA,
$nonce,
$tag,
return sodium_crypto_pwhash(
SODIUM_CRYPTO_SECRETBOX_KEYBYTES,
$password,
$salt,
$opslimit,
$memlimit,
SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13,
);
if ($plaintext === false) {
throw new RuntimeException('Decryption failed - wrong key or corrupted data');
}
return $plaintext;
}
/**
* Generator that yields decrypted plaintext chunks from a chunked encrypted file.
* A `SEALCHK2` chunk's nonce: the file's prefix, the chunk index and the last-chunk flag.
*/
private function chunkNonce(string $noncePrefix, int $index, bool $isLast): string
{
return $noncePrefix.pack('N', $index).($isLast ? "\x01" : "\x00");
}
/**
* Decrypt a `SEALCHK2` file; the chunk count comes from the file's length, so a file cut short
* at a chunk boundary fails on its new last chunk.
*
* @return Generator<int, string>
*/
@@ -284,17 +322,44 @@ class FileEncryptionService
}
try {
// Read header
$magic = fread($handle, 8);
['chunkSize' => $chunkSize, 'noncePrefix' => $noncePrefix] = $this->parseHeader((string) fread($handle, self::HEADER_LENGTH));
if ($magic !== self::MAGIC_HEADER) {
throw new RuntimeException('Invalid chunked file format');
$storedChunkSize = $chunkSize + self::TAG_LENGTH;
$payloadLength = (int) filesize($encryptedPath) - self::HEADER_LENGTH;
$chunkCount = intdiv($payloadLength + $storedChunkSize - 1, $storedChunkSize);
if ($chunkCount === 0) {
throw new RuntimeException('Invalid encrypted file: no chunks');
}
$chunkSizeData = fread($handle, 4);
$chunkSize = unpack('N', $chunkSizeData)[1];
for ($index = 0; $index < $chunkCount; $index++) {
$chunk = (string) fread($handle, $storedChunkSize);
$baseNonce = fread($handle, self::NONCE_LENGTH);
yield $this->decryptChunk($chunk, $key, $noncePrefix, $index, $index === $chunkCount - 1);
}
} finally {
fclose($handle);
}
}
/**
* Decrypt a `SEALCHK1` file.
*
* @return Generator<int, string>
*/
private function decryptLegacyChunks(string $encryptedPath, string $key): Generator
{
$handle = fopen($encryptedPath, 'rb');
if ($handle === false) {
throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}");
}
try {
fread($handle, 8);
$chunkSize = unpack('N', (string) fread($handle, 4))[1];
$baseNonce = (string) fread($handle, self::NONCE_LENGTH);
if (strlen($baseNonce) !== self::NONCE_LENGTH) {
throw new RuntimeException('Invalid chunked file: truncated header');
@@ -320,14 +385,12 @@ class FileEncryptionService
throw new RuntimeException('Invalid chunked file: missing ciphertext at chunk '.$chunkIndex);
}
$nonce = $this->deriveChunkNonce($baseNonce, $chunkIndex);
$plaintext = openssl_decrypt(
$ciphertext,
self::CIPHER,
$binaryKey,
OPENSSL_RAW_DATA,
$nonce,
$this->legacyChunkNonce($baseNonce, $chunkIndex),
$tag,
);
@@ -342,4 +405,47 @@ class FileEncryptionService
fclose($handle);
}
}
/**
* A `SEALCHK1` chunk's nonce: the chunk index XORed into the last 4 bytes of the base nonce.
*/
private function legacyChunkNonce(string $baseNonce, int $chunkIndex): string
{
$nonce = $baseNonce;
$indexBytes = pack('N', $chunkIndex);
for ($i = 0; $i < 4; $i++) {
$nonce[self::NONCE_LENGTH - 4 + $i] = $nonce[self::NONCE_LENGTH - 4 + $i] ^ $indexBytes[$i];
}
return $nonce;
}
/**
* Decrypt a legacy single-block encrypted file.
* Format: [12-byte nonce][16-byte auth tag][ciphertext]
*/
private function decryptLegacy(string $encryptedPath, string $key): string
{
$data = file_get_contents($encryptedPath);
if ($data === false) {
throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}");
}
$plaintext = openssl_decrypt(
substr($data, self::NONCE_LENGTH + self::TAG_LENGTH),
self::CIPHER,
$this->normalizeToBinaryKey($key),
OPENSSL_RAW_DATA,
substr($data, 0, self::NONCE_LENGTH),
substr($data, self::NONCE_LENGTH, self::TAG_LENGTH),
);
if ($plaintext === false) {
throw new RuntimeException('Decryption failed - wrong key or corrupted data');
}
return $plaintext;
}
}
+197
View File
@@ -0,0 +1,197 @@
<?php
namespace App\Services;
use App\Models\Setting;
use InvalidArgumentException;
use Random\Randomizer;
/**
* Random share passwords, drawn the way Admin settings say.
*
* Every draw comes from `Random\Randomizer`'s default engine, which is the operating system's
* CSPRNG. Passphrases come from EFF's large word list (CC BY 3.0 US), without its four hyphenated
* words so a separator always splits a passphrase into its words.
*/
class PasswordGeneratorService
{
/** Off: uploaders type their own. Button: a Generate button fills one in. Prefill: filled in as protection is switched on. */
public const MODES = ['off', 'button', 'prefill'];
public const TYPES = ['characters', 'passphrase'];
/**
* The characters each set draws from. The symbols leave out what chat apps turn into formatting
* (`* _ ~ \``) and what breaks once pasted into quotes or markup (`' " \ < >`).
*
* @var array<string, string>
*/
public const CHARACTER_SETS = [
'uppercase' => 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
'lowercase' => 'abcdefghijklmnopqrstuvwxyz',
'numbers' => '0123456789',
'symbols' => '!#$%&()+,-./:;=?@[]{}',
];
/** Characters that read alike in many typefaces. */
public const AMBIGUOUS_CHARACTERS = '0O1lI';
/** @var array<string, string> */
public const SEPARATORS = [
'hyphen' => '-',
'dot' => '.',
'underscore' => '_',
'space' => ' ',
];
public const MIN_LENGTH = 12;
public const MAX_LENGTH = 64;
public const MIN_WORDS = 4;
public const MAX_WORDS = 10;
/**
* @var array{mode: string, type: string, length: int, characterSets: list<string>, avoidAmbiguous: bool, words: int, separator: string}
*/
public const DEFAULTS = [
'mode' => 'button',
'type' => 'characters',
'length' => 20,
'characterSets' => ['uppercase', 'lowercase', 'numbers'],
'avoidAmbiguous' => true,
'words' => 6,
'separator' => 'hyphen',
];
/** @var list<string>|null */
private ?array $wordList = null;
/**
* How the upload page offers generated passwords.
*/
public function mode(): string
{
$mode = Setting::get('password_generator_mode');
return in_array($mode, self::MODES, true) ? $mode : self::DEFAULTS['mode'];
}
/**
* The saved generator settings, with the default for anything missing or no longer allowed.
*
* @return array{mode: string, type: string, length: int, characterSets: list<string>, avoidAmbiguous: bool, words: int, separator: string}
*/
public function options(): array
{
$type = Setting::get('password_generator_type');
$length = (int) Setting::get('password_generator_length', self::DEFAULTS['length']);
$words = (int) Setting::get('password_generator_words', self::DEFAULTS['words']);
$separator = Setting::get('password_generator_separator');
$characterSets = array_values(array_intersect(
array_keys(self::CHARACTER_SETS),
explode(',', (string) Setting::get('password_generator_character_sets')),
));
return [
'mode' => $this->mode(),
'type' => in_array($type, self::TYPES, true) ? $type : self::DEFAULTS['type'],
'length' => $length >= self::MIN_LENGTH && $length <= self::MAX_LENGTH ? $length : self::DEFAULTS['length'],
'characterSets' => $characterSets ?: self::DEFAULTS['characterSets'],
'avoidAmbiguous' => (bool) Setting::get('password_generator_avoid_ambiguous', self::DEFAULTS['avoidAmbiguous'] ? '1' : '0'),
'words' => $words >= self::MIN_WORDS && $words <= self::MAX_WORDS ? $words : self::DEFAULTS['words'],
'separator' => is_string($separator) && array_key_exists($separator, self::SEPARATORS) ? $separator : self::DEFAULTS['separator'],
];
}
/**
* Generate a password from the given options, or from the saved settings.
*
* @param array{type: string, length: int, characterSets: list<string>, avoidAmbiguous: bool, words: int, separator: string}|null $options
*/
public function generate(?array $options = null): string
{
$options ??= $this->options();
return $options['type'] === 'passphrase'
? $this->passphrase($options['words'], self::SEPARATORS[$options['separator']])
: $this->characters($options['length'], $options['characterSets'], $options['avoidAmbiguous']);
}
/**
* Draw characters uniformly from the chosen sets, drawing again until every set shows up at
* least once. Redrawing keeps each valid password equally likely, where placing one character
* of each set first would not.
*
* @param list<string> $characterSets
*/
public function characters(int $length, array $characterSets, bool $avoidAmbiguous): string
{
$alphabets = $this->alphabets($characterSets, $avoidAmbiguous);
if ($alphabets === [] || $length < count($alphabets)) {
throw new InvalidArgumentException('A password needs at least one character set and room for each of them.');
}
$randomizer = new Randomizer;
do {
$password = $randomizer->getBytesFromString(implode('', $alphabets), $length);
} while (array_filter($alphabets, fn (string $alphabet): bool => strpbrk($password, $alphabet) === false) !== []);
return $password;
}
/**
* Draw words from the word list, each independently of the others.
*/
public function passphrase(int $words, string $separator): string
{
$wordList = $this->wordList();
$randomizer = new Randomizer;
return implode($separator, array_map(
fn (): string => $wordList[$randomizer->getInt(0, count($wordList) - 1)],
range(1, max(1, $words)),
));
}
/**
* Roughly how many bits of entropy a password from these options carries.
*
* @param array{type: string, length: int, characterSets: list<string>, avoidAmbiguous: bool, words: int} $options
*/
public function entropyBits(array $options): int
{
if ($options['type'] === 'passphrase') {
return (int) floor($options['words'] * log(count($this->wordList()), 2));
}
$alphabetSize = strlen(implode('', $this->alphabets($options['characterSets'], $options['avoidAmbiguous'])));
return $alphabetSize > 0 ? (int) floor($options['length'] * log($alphabetSize, 2)) : 0;
}
/**
* @return list<string>
*/
public function wordList(): array
{
return $this->wordList ??= file(resource_path('wordlists/eff-large-wordlist.txt'), FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
}
/**
* The characters of each chosen set, without the look-alikes when asked.
*
* @param list<string> $characterSets
* @return array<string, string>
*/
private function alphabets(array $characterSets, bool $avoidAmbiguous): array
{
return collect(self::CHARACTER_SETS)
->only($characterSets)
->map(fn (string $alphabet): string => $avoidAmbiguous ? str_replace(str_split(self::AMBIGUOUS_CHARACTERS), '', $alphabet) : $alphabet)
->all();
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Services;
use BaconQrCode\Common\ErrorCorrectionLevel;
use BaconQrCode\Encoder\Encoder;
use BaconQrCode\Renderer\Color\Rgb;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\Fill;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
class QrCodeService
{
/**
* The QR code's width and height in the SVG, in pixels: the size a canvas draws it at.
*/
public const SIZE = 1024;
/**
* Draw the contents as a QR code in SVG: black on white with a four-module quiet zone and
* error correction M, the most reliable to scan from a screen or a print. The XML declaration
* is dropped so the markup can sit inline in a page.
*/
public function svg(string $contents): string
{
$svg = (new Writer(
new ImageRenderer(
new RendererStyle(self::SIZE, 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());
return trim(substr($svg, strpos($svg, "\n") + 1));
}
}
+276 -48
View File
@@ -5,12 +5,20 @@ namespace App\Services;
use App\Models\Setting;
use App\Models\Share;
use App\Models\ShareFile;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use League\MimeTypeDetection\FinfoMimeTypeDetector;
use RuntimeException;
/**
* A share's life: files registered into a pending share, their encrypted chunks stored as the
* uploader's browser sends them, and the share completed with its options.
*/
class ShareService
{
public function __construct(
@@ -18,67 +26,236 @@ class ShareService
) {}
/**
* Create a new share with encrypted files.
* 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.
*
* @param array<int, array{file: UploadedFile, relativePath: string|null}> $files
* @param array{password?: string|null, expires_at?: string|null, max_downloads?: int|null} $options
* @throws ValidationException when the file breaks an admin limit
*/
public function createShare(array $files, array $options = []): Share
public function registerFile(?Share $pendingShare, string $name, int $size, ?string $relativePath): ShareFile
{
$token = $this->generateUniqueToken();
$salt = $this->encryptionService->generateSalt();
$password = $options['password'] ?? null;
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
$maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024);
if ($password) {
$encryptionKey = $this->encryptionService->deriveKey($password, $salt);
$encryptionKeyHex = bin2hex($encryptionKey);
$storedEncryptionKey = null;
} else {
$encryptionKeyHex = $this->encryptionService->generateRandomKey();
$storedEncryptionKey = $encryptionKeyHex;
if ($name === '' || mb_strlen($name) > 255 || $size < 0) {
$this->rejectFile(__('The file could not be added.'));
}
$share = Share::query()->create([
'token' => $token,
'password' => $password ? Hash::make($password) : null,
'encryption_key' => $storedEncryptionKey,
'encryption_salt' => $salt,
'expires_at' => $options['expires_at'] ?? null,
'max_downloads' => $options['max_downloads'] ?? null,
if ($size > $maxFileSize) {
$this->rejectFile(__('":name" is too large (:size MB). Maximum file size is :max MB.', [
'name' => $name,
'size' => round($size / (1024 * 1024), 1),
'max' => intdiv($maxFileSize, 1024 * 1024),
]));
}
if ($pendingShare && $pendingShare->files()->count() >= $maxFilesPerShare) {
$this->rejectFile(__('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare]));
}
if (($pendingShare?->total_size ?? 0) + $size > $maxSizePerShare) {
$this->rejectFile(__('Total file size exceeds the maximum allowed per share.'));
}
if ($this->getTotalUsedSpace() + $size > $this->getMaxStorageQuota()) {
$this->rejectFile(__('Storage is full. Please contact the administrator.'));
}
$share = $pendingShare ?? Share::query()->create([
'token' => $this->generateUniqueToken(),
'encryption_key' => $this->encryptionService->generateRandomKey(),
'total_size' => 0,
]);
$totalSize = 0;
$storedName = Str::uuid().'.enc';
foreach ($files as $fileData) {
/** @var UploadedFile $file */
$file = $fileData['file'];
$relativePath = $fileData['relativePath'] ?? null;
$storedName = Str::uuid().'.enc';
$storedPath = 'shares/'.$share->token.'/'.$storedName;
Storage::disk('shares')->makeDirectory($share->token);
Storage::disk('shares')->put($share->token.'/'.$storedName, $this->encryptionService->createHeader((int) config('uploads.chunk_size')));
$tempPath = $file->getRealPath();
$destPath = Storage::disk('shares')->path($share->token.'/'.$storedName);
$file = $share->files()->create([
'original_name' => $name,
'relative_path' => $this->sanitizeRelativePath($relativePath),
'stored_path' => 'shares/'.$share->token.'/'.$storedName,
'file_size' => $size,
]);
Storage::disk('shares')->makeDirectory($share->token);
$share->increment('total_size', $size);
$this->encryptionService->encryptFile($tempPath, $destPath, $encryptionKeyHex);
return $file;
}
ShareFile::query()->create([
'share_id' => $share->id,
'original_name' => $file->getClientOriginalName(),
'relative_path' => $relativePath,
'stored_path' => $storedPath,
'file_size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
]);
/**
* Verify the encrypted chunk that comes next for a file and write it into place; returns how
* many of the file's chunks are stored. The plaintext only exists in memory, to be checked.
*
* @throws InvalidArgumentException when the chunk has the wrong length or fails authentication
* @throws ModelNotFoundException when the file was removed meanwhile
*/
public function storeChunk(ShareFile $file, int $index, string $chunk): int
{
$share = $file->share;
$handle = @fopen($this->storedFilePath($file), 'r+b');
$totalSize += $file->getSize();
if ($handle === false) {
throw (new ModelNotFoundException)->setModel(ShareFile::class, [$file->id]);
}
$share->update(['total_size' => $totalSize]);
try {
['chunkSize' => $chunkSize, 'noncePrefix' => $noncePrefix] = $this->encryptionService->parseHeader(
(string) fread($handle, FileEncryptionService::HEADER_LENGTH),
);
return $share->fresh();
$chunkCount = $this->encryptionService->chunkCount($file->file_size, $chunkSize);
$isLast = $index === $chunkCount - 1;
if ($index >= $chunkCount) {
throw new InvalidArgumentException('Chunk '.$index.' is beyond the end of the file');
}
$plaintextLength = $isLast ? $file->file_size - $index * $chunkSize : $chunkSize;
if (strlen($chunk) !== $plaintextLength + FileEncryptionService::TAG_LENGTH) {
throw new InvalidArgumentException('Chunk '.$index.' has the wrong length');
}
try {
$plaintext = $this->encryptionService->decryptChunk($chunk, $share->encryption_key, $noncePrefix, $index, $isLast);
} catch (RuntimeException) {
throw new InvalidArgumentException('Chunk '.$index.' failed authentication');
}
$mimeType = $index === 0
? ((new FinfoMimeTypeDetector)->detectMimeType($file->original_name, $plaintext) ?? 'application/octet-stream')
: $file->mime_type;
unset($plaintext);
if (fseek($handle, $this->encryptionService->chunkOffset($index, $chunkSize)) !== 0
|| fwrite($handle, $chunk) !== strlen($chunk)
|| ! fflush($handle)) {
throw new RuntimeException('Cannot write chunk '.$index.' of file '.$file->id);
}
} finally {
fclose($handle);
}
// Counted only once, even when a retry of the same chunk raced this request.
$stored = ShareFile::query()
->whereKey($file->id)
->where('uploaded_chunks', $index)
->update([
'uploaded_chunks' => $index + 1,
'mime_type' => $mimeType,
'completed_at' => $isLast ? now() : null,
]);
if ($stored === 0) {
return ShareFile::query()->findOrFail($file->id)->uploaded_chunks;
}
$share->touch();
return $index + 1;
}
/**
* Remove a file from a pending share, whether or not its upload finished.
*/
public function removeFile(ShareFile $file): void
{
Storage::disk('shares')->delete($file->share->token.'/'.basename($file->stored_path));
$file->share->decrement('total_size', $file->file_size);
$file->delete();
}
/**
* Complete a pending share once every file has arrived: with a password the data key is
* wrapped and no longer stored as it is.
*
* @param array{password?: string|null, expires_at?: mixed, max_downloads?: int|null} $options
*
* @throws ValidationException when files are missing, unfinished or break an admin limit
*/
public function completeShare(Share $share, array $options = []): Share
{
$files = $share->files()->get();
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
$maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024);
if ($files->isEmpty()) {
$this->rejectFile(__('Please select at least one file to upload.'));
}
if ($files->contains(fn (ShareFile $file): bool => $file->completed_at === null)) {
$this->rejectFile(__('Wait until every file has finished uploading, or remove the ones that failed.'));
}
if ($files->count() > $maxFilesPerShare) {
$this->rejectFile(__('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare]));
}
if ($files->sum('file_size') > $maxSizePerShare) {
$this->rejectFile(__('Total file size exceeds the maximum allowed per share.'));
}
$password = $options['password'] ?? null;
$share->update([
'password' => $password ? Hash::make($password) : null,
'wrapped_key' => $password ? $this->encryptionService->wrapKey($share->encryption_key, $password) : null,
'encryption_key' => $password ? null : $share->encryption_key,
'expires_at' => $options['expires_at'] ?? null,
'max_downloads' => $options['max_downloads'] ?? null,
'total_size' => $files->sum('file_size'),
'completed_at' => now(),
]);
return $share;
}
/**
* Create a share from files already on the server, through the same steps an upload from the
* browser takes. Used by tests and demo data.
*
* @param array<int, array{file: UploadedFile, relativePath: string|null}> $files
* @param array{password?: string|null, expires_at?: mixed, max_downloads?: int|null} $options
*/
public function createShare(array $files, array $options = []): Share
{
$share = null;
foreach ($files as $fileData) {
$file = $fileData['file'];
$shareFile = $this->registerFile($share, $file->getClientOriginalName(), $file->getSize(), $fileData['relativePath'] ?? null);
$share = $shareFile->share;
$header = $this->readHeader($shareFile);
$source = fopen($file->getRealPath(), 'rb');
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);
}
fclose($source);
}
if ($share === null) {
$this->rejectFile(__('Please select at least one file to upload.'));
}
return $this->completeShare($share, $options);
}
/**
@@ -108,7 +285,8 @@ class ShareService
}
/**
* Get the decryption key for a share.
* Get the decryption key for a share: unwrapped with the password, derived from it for shares
* created before key wrapping, or stored for shares without a password.
*/
public function getDecryptionKey(Share $share, ?string $password = null): string
{
@@ -117,6 +295,10 @@ class ShareService
throw new RuntimeException('Password required for this share');
}
if ($share->wrapped_key !== null) {
return $this->encryptionService->unwrapKey($share->wrapped_key, $password);
}
return bin2hex($this->encryptionService->deriveKey($password, $share->encryption_salt));
}
@@ -148,7 +330,7 @@ class ShareService
}
/**
* Get total used space in bytes.
* Get total used space in bytes, files still being uploaded included.
*/
public function getTotalUsedSpace(): int
{
@@ -160,9 +342,7 @@ class ShareService
*/
public function isStorageFull(): bool
{
$maxQuota = (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024);
return $this->getTotalUsedSpace() >= $maxQuota;
return $this->getTotalUsedSpace() >= $this->getMaxStorageQuota();
}
/**
@@ -172,4 +352,52 @@ class ShareService
{
return (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024);
}
/**
* A registered file's chunk size, nonce prefix and chunk count, from its encrypted file's header.
*
* @return array{chunkSize: int, noncePrefix: string, chunkCount: int}
*/
public function readHeader(ShareFile $file): array
{
$header = $this->encryptionService->parseHeader(
(string) file_get_contents($this->storedFilePath($file), false, null, 0, FileEncryptionService::HEADER_LENGTH),
);
return [...$header, 'chunkCount' => $this->encryptionService->chunkCount($file->file_size, $header['chunkSize'])];
}
/**
* Where a file's encrypted content is stored on disk.
*/
public function storedFilePath(ShareFile $file): string
{
return Storage::disk('shares')->path($file->share->token.'/'.basename($file->stored_path));
}
/**
* A relative path from a dropped folder, or null when it could reach outside the share.
*/
private function sanitizeRelativePath(?string $relativePath): ?string
{
if ($relativePath === null) {
return null;
}
$relativePath = str_replace('\\', '/', $relativePath);
if (str_starts_with($relativePath, '/') || str_contains($relativePath, '..')) {
return null;
}
return $relativePath;
}
/**
* @throws ValidationException
*/
private function rejectFile(string $message): never
{
throw ValidationException::withMessages(['files' => $message]);
}
}
+5 -1
View File
@@ -6,6 +6,9 @@
"guidelines": true,
"herd_mcp": false,
"mcp": true,
"packages": [
"nonameweb/livewire-material"
],
"sail": false,
"skills": [
"infer-conventions",
@@ -14,6 +17,7 @@
"testing-best-practices",
"octane-development",
"livewire-development",
"tailwindcss-development"
"livewire-material-development",
"material-3-design"
]
}
+16 -2
View File
@@ -10,12 +10,14 @@
"license": "MIT",
"require": {
"php": "^8.5",
"bacon/bacon-qr-code": "^3.0",
"laravel/fortify": "^1.30",
"laravel/framework": "^13.0",
"laravel/octane": "^2.13",
"laravel/tinker": "^3.0",
"livewire/livewire": "^4.0",
"robsontenorio/mary": "^2.7"
"maennchen/zipstream-php": "^3.2",
"nonameweb/livewire-material": "^2.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
@@ -26,6 +28,7 @@
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^5.1",
"pestphp/pest-plugin-browser": "^5.0",
"pestphp/pest-plugin-laravel": "^5.0"
},
"autoload": {
@@ -82,6 +85,11 @@
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
],
"screenshots": [
"Composer\\Config::disableProcessTimeout",
"npm run build",
"@php vendor/bin/pest tests/Screenshots"
]
},
"extra": {
@@ -99,5 +107,11 @@
}
},
"minimum-stability": "stable",
"prefer-stable": true
"prefer-stable": true,
"repositories": {
"livewire-material": {
"type": "vcs",
"url": "https://gitea.nonameweb.ch/noNameWEB/livewire-material.git"
}
}
}
Generated
+2008 -655
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -73,7 +73,7 @@ return [
|
*/
'home' => '/dashboard',
'home' => '/admin/dashboard',
/*
|--------------------------------------------------------------------------
+178
View File
@@ -0,0 +1,178 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Component prefix
|--------------------------------------------------------------------------
|
| Every component is an anonymous Blade component. Without a prefix they are
| <x-button>, <x-card> and so on; set a prefix such as 'm' when a name
| clashes with one of the application's own components, and they become
| <x-m::button>, <x-m::card>. They are always <x-livewire-material::button>
| as well.
|
*/
'prefix' => '',
/*
|--------------------------------------------------------------------------
| Theme
|--------------------------------------------------------------------------
|
| The head script decides the theme before the first paint and writes it to
| <html data-theme>. 'default' is used until the visitor chooses: 'light',
| 'dark' or 'system' (follow the operating system). The choice is kept in
| localStorage under 'storage_key'; values found under 'legacy_keys' (an
| earlier theme toggle's key) are adopted once and then removed.
|
*/
'theme' => [
'default' => 'system',
'storage_key' => 'sealshare-theme',
'legacy_keys' => ['mary-theme'],
],
/*
|--------------------------------------------------------------------------
| Navigation rail
|--------------------------------------------------------------------------
|
| Whether a collapsible navigation rail starts 'expanded' or 'collapsed'
| until the visitor toggles it. The head script applies the choice before
| the first paint, from localStorage under 'storage_key'.
|
*/
'rail' => [
'default' => 'expanded',
'storage_key' => 'material-rail',
],
/*
|--------------------------------------------------------------------------
| Fields
|--------------------------------------------------------------------------
|
| Text fields, selects and pickers come in M3's two styles: 'outlined' (a
| notched outline) and 'filled' (a tinted box with an indicator line). This
| is the style a field takes when its `variant` is not given.
|
*/
'fields' => [
'variant' => 'outlined',
],
/*
|--------------------------------------------------------------------------
| Pagination
|--------------------------------------------------------------------------
|
| Draw Laravel's and Livewire's paginators in M3: the package's views are
| put in front of `pagination::tailwind` and `livewire::tailwind` (and
| their simple versions). An application's own published pagination views
| still win.
|
*/
'pagination' => true,
/*
|--------------------------------------------------------------------------
| Node
|--------------------------------------------------------------------------
|
| `php artisan material:scheme` runs Google's colour utilities through Node.
| Set the binary when `node` is not on the PATH of the user running Artisan.
|
*/
'node' => env('MATERIAL_NODE', 'node'),
/*
|--------------------------------------------------------------------------
| Scheme data
|--------------------------------------------------------------------------
|
| The light and dark hexes `php artisan material:scheme` writes beside the
| stylesheet. The mail theme reads its colours here, and so does an error
| page when the build is missing; without the file both use the package's
| default scheme.
|
*/
'scheme' => resource_path('css/material-scheme.json'),
/*
|--------------------------------------------------------------------------
| Colour profiles
|--------------------------------------------------------------------------
|
| The profiles an admin chooses between in Admin settings. Each one is a
| 'label', a 'seed' (#rrggbb), a 'variant' and an optional 'contrast'.
| `php artisan material:scheme` (without a seed) generates them all into
| resources/css/material-scheme.css; regenerate after changing this list.
| 'profile' is the default, until an admin chooses.
|
*/
'profiles' => [
'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'],
'blue' => ['label' => 'Blue', 'seed' => '#0b57d0', 'variant' => 'vibrant'],
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
'green' => ['label' => 'Green', 'seed' => '#2e7d32', 'variant' => 'vibrant'],
'amber' => ['label' => 'Amber', 'seed' => '#e8710a', 'variant' => 'vibrant'],
'rose' => ['label' => 'Rose', 'seed' => '#c2185b', 'variant' => 'vibrant'],
'violet' => ['label' => 'Violet', 'seed' => '#6750a4', 'variant' => 'vibrant'],
'graphite' => ['label' => 'Graphite', 'seed' => '#5f6368', 'variant' => 'neutral'],
],
'profile' => 'indigo',
/*
|--------------------------------------------------------------------------
| Mail
|--------------------------------------------------------------------------
|
| Markdown mail takes the theme when `mail.markdown.theme` (MAIL_MARKDOWN_THEME)
| is 'livewire-material::mail.theme'. 'components' puts this package's mail
| header and message after the application's own mail components or
| publish them with `vendor:publish --tag=livewire-material-mail` instead.
| 'logo' replaces the app name in that header with an image: an absolute
| 'src', with 'width' and 'height' in pixels, which Outlook sizes it by.
|
*/
'mail' => [
'components' => (bool) env('MATERIAL_MAIL_COMPONENTS', false),
'logo' => [
'src' => null,
'width' => null,
'height' => null,
],
],
/*
|--------------------------------------------------------------------------
| Showcase
|--------------------------------------------------------------------------
|
| Every component in every variant, rendered in the application's own
| scheme. Off unless the application runs locally. 'vite' names the entry
| points that import this package's CSS and JavaScript; the error pages
| load them too, showcase or not.
|
*/
'showcase' => [
'enabled' => (bool) env('MATERIAL_SHOWCASE', env('APP_ENV', 'production') === 'local'),
'path' => 'material',
'middleware' => ['web'],
'vite' => ['resources/css/app.css', 'resources/js/app.js'],
],
];
+22
View File
@@ -115,4 +115,26 @@ return [
'name' => env('MAIL_FROM_NAME', 'Example'),
],
/*
|--------------------------------------------------------------------------
| Markdown Mail Settings
|--------------------------------------------------------------------------
|
| Markdown mail wears Livewire Material's theme, coloured from the light
| scheme in resources/css/material-scheme.json.
|
*/
'markdown' => [
'theme' => env('MAIL_MARKDOWN_THEME', 'livewire-material::mail.theme'),
'paths' => [
resource_path('views/vendor/mail'),
],
'extensions' => [
// \League\CommonMark\Extension\Strikethrough\StrikethroughExtension::class,
],
],
];
+19
View File
@@ -0,0 +1,19 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Upload Chunk Size
|--------------------------------------------------------------------------
|
| The uploader's browser encrypts every file in chunks of this many bytes
| and sends each chunk as a request of its own. The size is written into
| each file's header, so changing it never affects files already stored.
| A reverse proxy in front must accept request bodies a little larger.
|
*/
'chunk_size' => (int) env('UPLOAD_CHUNK_SIZE_MB', 16) * 1024 * 1024,
];
+13
View File
@@ -27,9 +27,22 @@ class ShareFactory extends Factory
'max_downloads' => null,
'download_count' => 0,
'total_size' => 0,
'completed_at' => now(),
];
}
/**
* A share whose files are still being uploaded.
*/
public function pending(): static
{
return $this->state(fn (array $attributes) => [
'encryption_key' => bin2hex(random_bytes(32)),
'encryption_salt' => null,
'completed_at' => null,
]);
}
public function withPassword(string $password = 'secret'): static
{
return $this->state(fn (array $attributes) => [
+14
View File
@@ -25,6 +25,20 @@ class ShareFileFactory extends Factory
'stored_path' => 'shares/'.fake()->uuid().'.enc',
'file_size' => fake()->numberBetween(1024, 10485760),
'mime_type' => 'text/plain',
'uploaded_chunks' => 1,
'completed_at' => now(),
];
}
/**
* A file whose chunks have not all arrived yet.
*/
public function uploading(): static
{
return $this->state(fn (array $attributes) => [
'mime_type' => null,
'uploaded_chunks' => 0,
'completed_at' => null,
]);
}
}
@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Shares and files that exist already were complete when they were created.
*/
public function up(): void
{
Schema::table('shares', function (Blueprint $table) {
$table->text('wrapped_key')->nullable()->after('encryption_salt');
$table->timestamp('completed_at')->nullable()->after('total_size');
});
Schema::table('share_files', function (Blueprint $table) {
$table->unsignedInteger('uploaded_chunks')->default(0)->after('mime_type');
$table->timestamp('completed_at')->nullable()->after('uploaded_chunks');
});
DB::table('shares')->update(['completed_at' => DB::raw('created_at')]);
DB::table('share_files')->update(['completed_at' => DB::raw('created_at')]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('shares', function (Blueprint $table) {
$table->dropColumn(['wrapped_key', 'completed_at']);
});
Schema::table('share_files', function (Blueprint $table) {
$table->dropColumn(['uploaded_chunks', 'completed_at']);
});
}
};
+8 -2
View File
@@ -8,18 +8,24 @@ services:
- "5173:5173"
volumes:
- .:/app
# Its own node_modules: npm installs the build tools' native binaries for Linux here and for
# the host's platform there, and a shared folder only ever holds one of them.
- /app/node_modules
environment:
APP_KEY: ${APP_KEY:-}
APP_URL: http://localhost:8000
APP_ENV: local
# Compiled views stay in the container. The host shares storage/ through the mount, and
# compiled Livewire components hold absolute paths (/app/… here, the checkout's path there).
VIEW_COMPILED_PATH: /tmp/views
APP_DEBUG: "true"
SERVER_NAME: ":8000"
DB_CONNECTION: sqlite
LOG_CHANNEL: stack
LOG_LEVEL: debug
OCTANE_MAX_EXECUTION_TIME: "300"
PHP_UPLOAD_MAX_FILESIZE: "4G"
PHP_POST_MAX_SIZE: "4G"
PHP_UPLOAD_MAX_FILESIZE: "64M"
PHP_POST_MAX_SIZE: "64M"
PHP_MAX_EXECUTION_TIME: "300"
PHP_MAX_INPUT_TIME: "300"
PHP_MEMORY_LIMIT: "512M"
+21 -13
View File
@@ -4,7 +4,7 @@
#
# Quick start:
# 1. Copy this file: cp docker-compose.example.yml docker-compose.yml
# 2. Edit the settings below (APP_URL and SERVER_NAME are required)
# 2. Edit the settings below (APP_URL is required; uploads need HTTPS, see below)
# 3. Start: docker compose up -d
# 4. Open your browser to your configured domain
#
@@ -18,11 +18,11 @@ services:
# SealShare Application (FrankenPHP/Octane)
# ------------------------------------------
app:
image: ghcr.io/surtic86/sealshare:latest
image: gitea.nonameweb.ch/nonameweb/sealshare:latest
restart: unless-stopped
ports:
- "80:80" # HTTP
- "443:443" # HTTPS (auto TLS via Let's Encrypt when SERVER_NAME is a real domain)
- "443:443" # HTTPS (a Let's Encrypt certificate with AUTO_HTTPS)
- "443:443/udp" # HTTP/3 (QUIC)
volumes:
- sealshare_storage:/app/storage/app # Uploaded & encrypted files
@@ -32,9 +32,15 @@ services:
environment:
# --- REQUIRED ---
APP_URL: # Your full URL, e.g. https://share.example.com
SERVER_NAME: # Your domain for auto-TLS, e.g. share.example.com (use "localhost" for local testing)
# APP_KEY: # Auto-generated if not set. Copy from logs to persist across restarts.
# --- HTTPS ---
# Files are encrypted in the uploader's browser, which browsers only allow over HTTPS (or on
# localhost). Either let this container fetch a Let's Encrypt certificate (ports 80 and 443
# reachable from the internet), or put a reverse proxy that terminates TLS in front of port 80.
# AUTO_HTTPS: "true"
# SERVER_NAME: share.example.com # The domain to fetch the certificate for (only with AUTO_HTTPS)
# --- Optional: Application ---
# APP_ENV: production
# APP_DEBUG: "false"
@@ -53,15 +59,17 @@ services:
# OCTANE_HTTPS: "false" # Set to "true" when using HTTPS
# OCTANE_MAX_EXECUTION_TIME: 300 # Max request execution time (seconds)
# --- Optional: PHP upload limits ---
# PHP_UPLOAD_MAX_FILESIZE: "4G" # Max single file size
# PHP_POST_MAX_SIZE: "4G" # Max total request size
# PHP_MAX_EXECUTION_TIME: "300" # Upload timeout in seconds
# PHP_MAX_INPUT_TIME: "300" # Input processing timeout
# PHP_MEMORY_LIMIT: "512M" # PHP memory limit
# LIVEWIRE_MAX_UPLOAD_TIME: "30" # Minutes a single upload may take (raise for large files on slow links)
# --- Optional: Uploads ---
# UPLOAD_CHUNK_SIZE_MB: "16" # Each encrypted chunk the browser sends; a reverse proxy must accept a little more
# --- Optional: PHP limits ---
# PHP_UPLOAD_MAX_FILESIZE: "64M" # Only for the admin's logo upload: shares upload in chunks
# PHP_POST_MAX_SIZE: "64M"
# PHP_MAX_EXECUTION_TIME: "300"
# PHP_MAX_INPUT_TIME: "300"
# PHP_MEMORY_LIMIT: "512M"
healthcheck:
test: ["CMD", "curl", "--silent", "--fail", "http://localhost/up"]
test: ["CMD", "/app/docker/healthcheck.sh"]
interval: 30s
timeout: 5s
start_period: 10s
@@ -71,7 +79,7 @@ services:
# Scheduler - Runs cleanup for expired shares
# ------------------------------------------
scheduler:
image: ghcr.io/surtic86/sealshare:latest
image: gitea.nonameweb.ch/nonameweb/sealshare:latest
restart: unless-stopped
entrypoint: ["php", "artisan", "schedule:work"]
volumes:
+7 -6
View File
@@ -1,6 +1,6 @@
services:
app:
image: ghcr.io/surtic86/sealshare:latest
image: gitea.nonameweb.ch/nonameweb/sealshare:latest
build:
context: .
dockerfile: Dockerfile
@@ -19,6 +19,7 @@ services:
APP_URL: ${APP_URL:-http://localhost}
APP_ENV: ${APP_ENV:-production}
APP_DEBUG: ${APP_DEBUG:-false}
AUTO_HTTPS: ${AUTO_HTTPS:-false}
SERVER_NAME: ${SERVER_NAME:-localhost}
DB_CONNECTION: ${DB_CONNECTION:-sqlite}
DB_HOST: ${DB_HOST:-}
@@ -33,21 +34,21 @@ services:
CACHE_STORE: ${CACHE_STORE:-database}
OCTANE_HTTPS: ${OCTANE_HTTPS:-false}
OCTANE_MAX_EXECUTION_TIME: ${OCTANE_MAX_EXECUTION_TIME:-300}
PHP_UPLOAD_MAX_FILESIZE: ${PHP_UPLOAD_MAX_FILESIZE:-4G}
PHP_POST_MAX_SIZE: ${PHP_POST_MAX_SIZE:-4G}
UPLOAD_CHUNK_SIZE_MB: ${UPLOAD_CHUNK_SIZE_MB:-16}
PHP_UPLOAD_MAX_FILESIZE: ${PHP_UPLOAD_MAX_FILESIZE:-64M}
PHP_POST_MAX_SIZE: ${PHP_POST_MAX_SIZE:-64M}
PHP_MAX_EXECUTION_TIME: ${PHP_MAX_EXECUTION_TIME:-300}
PHP_MAX_INPUT_TIME: ${PHP_MAX_INPUT_TIME:-300}
PHP_MEMORY_LIMIT: ${PHP_MEMORY_LIMIT:-512M}
LIVEWIRE_MAX_UPLOAD_TIME: ${LIVEWIRE_MAX_UPLOAD_TIME:-30}
healthcheck:
test: ["CMD", "curl", "--silent", "--fail", "http://localhost/up"]
test: ["CMD", "/app/docker/healthcheck.sh"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
scheduler:
image: ghcr.io/surtic86/sealshare:latest
image: gitea.nonameweb.ch/nonameweb/sealshare:latest
build:
context: .
dockerfile: Dockerfile
-14
View File
@@ -1,14 +0,0 @@
{
frankenphp
order php_server before file_server
admin off
}
{$SERVER_NAME:localhost} {
root * /app/public
encode zstd gzip
request_body {
max_size 4gb
}
php_server
}
+7 -2
View File
@@ -6,13 +6,18 @@ cd /app
# Generate PHP ini from environment variables (with defaults)
echo "[dev] Configuring PHP settings..."
cat > /usr/local/etc/php/conf.d/99-uploads.ini <<EOF
upload_max_filesize = ${PHP_UPLOAD_MAX_FILESIZE:-4G}
post_max_size = ${PHP_POST_MAX_SIZE:-4G}
upload_max_filesize = ${PHP_UPLOAD_MAX_FILESIZE:-64M}
post_max_size = ${PHP_POST_MAX_SIZE:-64M}
max_execution_time = ${PHP_MAX_EXECUTION_TIME:-300}
max_input_time = ${PHP_MAX_INPUT_TIME:-300}
memory_limit = ${PHP_MEMORY_LIMIT:-512M}
EOF
if [ ! -f vendor/autoload.php ]; then
echo "[dev] Installing PHP dependencies..."
composer install --no-interaction 2>&1
fi
echo "[dev] Installing Node dependencies..."
npm install 2>&1
+3
View File
@@ -8,6 +8,9 @@ RUN install-php-extensions \
# Install Node.js for Vite / frontend asset building
RUN apk add --no-cache nodejs npm
# Composer, for a checkout without vendor/: the assets import Livewire Material from it
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY docker/dev-entrypoint.sh /usr/local/bin/dev-entrypoint.sh
+15 -3
View File
@@ -15,8 +15,8 @@ fi
# Generate PHP ini from environment variables (with defaults)
echo "[entrypoint] Configuring PHP settings..."
cat > /usr/local/etc/php/conf.d/99-uploads.ini <<EOF
upload_max_filesize = ${PHP_UPLOAD_MAX_FILESIZE:-4G}
post_max_size = ${PHP_POST_MAX_SIZE:-4G}
upload_max_filesize = ${PHP_UPLOAD_MAX_FILESIZE:-64M}
post_max_size = ${PHP_POST_MAX_SIZE:-64M}
max_execution_time = ${PHP_MAX_EXECUTION_TIME:-300}
max_input_time = ${PHP_MAX_INPUT_TIME:-300}
memory_limit = ${PHP_MEMORY_LIMIT:-512M}
@@ -33,5 +33,17 @@ php artisan config:cache
php artisan route:cache
php artisan view:cache
echo "[entrypoint] Starting Octane (FrankenPHP)..."
# Uploads are encrypted in the browser, which browsers only allow over HTTPS: either this container
# fetches a certificate for SERVER_NAME itself, or a reverse proxy in front terminates TLS.
if [ "${AUTO_HTTPS:-false}" = "true" ]; then
if [ -z "$SERVER_NAME" ]; then
echo "[entrypoint] AUTO_HTTPS=true needs SERVER_NAME, the domain to fetch a certificate for." >&2
exit 1
fi
echo "[entrypoint] Starting Octane (FrankenPHP) with automatic HTTPS for $SERVER_NAME..."
exec php artisan octane:frankenphp --host="$SERVER_NAME" --port=443 --https --http-redirect
fi
echo "[entrypoint] Starting Octane (FrankenPHP) on HTTP..."
exec php artisan octane:frankenphp --host=0.0.0.0 --port=80
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Healthy when the application answers /up: over HTTP on port 80, or over HTTPS for SERVER_NAME when
# AUTO_HTTPS is on (port 80 then only redirects). The certificate is not checked, so a container
# still waiting for Let's Encrypt is judged by the application, not by its certificate.
if [ "${AUTO_HTTPS:-false}" = "true" ]; then
exec curl --silent --fail --insecure --resolve "$SERVER_NAME:443:127.0.0.1" "https://$SERVER_NAME/up"
fi
exec curl --silent --fail http://localhost/up
+2 -2
View File
@@ -2,8 +2,8 @@
; These are default values — overridden at runtime by the entrypoint
; when PHP_UPLOAD_MAX_FILESIZE / PHP_POST_MAX_SIZE / etc. env vars are set.
upload_max_filesize = 4G
post_max_size = 4G
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 300
max_input_time = 300
memory_limit = 512M
-223
View File
@@ -1,223 +0,0 @@
# SealShare on Livewire Material (2.0.0)
> The package itself — its decisions, the wave plan (Phases 110) and its tests — moved to
> the package repo on 2026-09-13: [noNameWEB/livewire-material · docs/plans/livewire-material.md](https://gitea.nonameweb.ch/noNameWEB/livewire-material/src/branch/main/docs/plans/livewire-material.md).
> This file keeps what SealShare does once the package reaches `1.0.0`.
## Goal
SealShare's UI is maryUI 2.9 on daisyUI 5 — a generic web-page look. After this change it runs
on **`nonameweb/livewire-material` `^1.0`**: a clean, calm indigo Material 3 Expressive app with
a top app bar, light / dark / system theme, and two Expressive moments — the upload drop zone
and "link ready" — shipped as SealShare 2.0.0.
## Context
**Stacks.** SealShare: Laravel 13.31, Livewire 4.4, maryUI 2.9.10 (no prefix), daisyUI 5.7,
Tailwind 4.3, Pest 5.1, Octane on FrankenPHP, PHP 8.5; public on GitHub under MIT, image
published to `ghcr.io/surtic86/sealshare`. ReStride: same Laravel / Livewire / Tailwind / Pest,
private on `gitea.nonameweb.ch`, CI through Gitea act_runner.
**SealShare's UI surface** (inventory, 2026-09-13):
- maryUI tags: `button` 30, `input` 18, `password` 16, `icon` 14, `card` 9 (6 `actions`
slots), `menu`/`menu-item` 1/4 (settings nav), `theme-toggle` 3, `toggle` 2, `select` 2,
`modal` 2, `table` 1 (`:headers :rows :sort-by with-pagination`, `@scope`), `textarea` 1,
`toast` 1 (never triggered).
- Raw daisyUI: `btn` (+ `-primary/-ghost/-sm/-xs/-error/-outline/-disabled`), `alert` ×6,
`card`/`card-body` (4 admin stat tiles), `join` (2 copy fields), `progress` ×2,
`loading` ×2, `badge-success/-error`, `divider`, `link link-primary` ×5, `label`,
`file-input`, `checkbox`; tokens `bg-base-*`, `border-base-300`, `text-error/success`,
`border-primary(/50)`, `bg-primary/5`; raw `text-green-600`, `bg-white` (QR code).
Secondary text is `opacity-50/60/70`.
- 20 Heroicons (outline), through `blade-heroicons` pulled in transitively by maryUI.
- No `Mary\` PHP coupling. Admin settings flashes `session('message')` into an alert
(`AdminSettings.php:116,128,135`). 3 `wire:confirm`.
- Layouts: `layouts/app``app/sidebar` (centered `max-w-5xl` + footer nav), used by the
Livewire pages and all settings SFCs (`config/livewire.php:47`); `layouts/auth`
`auth/simple`. The theme script sits *outside* `<head>` and hard-codes dark, while maryUI's
toggle defaults from the OS. `partials/head` loads Instrument Sans from fonts.bunny.net.
- Dead: `/dashboard` (starter placeholder, and Fortify's `home`), `welcome`,
`pages/auth/register` (still referenced by `Fortify::registerView`,
`FortifyServiceProvider.php:52`), `layouts/app/header`, `layouts/auth/{card,split}`,
`components/app-logo`, `components/desktop-user-menu`, `components/placeholder-pattern`;
the `alpinejs` npm dependency; the Flux credentials step in `tests.yml` and `docker.yml`.
- Settings `profile` and `password` show "Saved." through `components/action-message`,
listening for `profile-updated` / `password-updated`; `partials/settings-heading` uses a
daisyUI `divider`. The 3 `wire:confirm` are admin settings (remove logo, clear system
password) and admin dashboard (delete share). `AdminDashboard::headers()` exists only for
maryUI's table.
- Tests assert text only, never markup; no browser tests.
- Docker: the image's caches run in `docker/entrypoint.sh` (`config:cache`, `route:cache`,
`view:cache`); `docker/dev-entrypoint.sh` runs `npm run build` against the host's mounted
`vendor/` without a `composer install`. The Flux credentials step is in `tests.yml`,
`docker.yml` **and** `lint.yml`.
- Screens: setup, system password, upload, share created, share download, admin dashboard,
admin settings, settings (profile, password, appearance, two-factor), Fortify pages (login,
forgot, reset, 2FA challenge, confirm, verify email). Stock Laravel error pages and mails.
**Constraints found.**
- SealShare's `Dockerfile` builds assets (stage 1) **before** `composer install` (stage 2);
CSS imported from `vendor/` needs the order swapped.
- Laravel **replaces** the `errors` view namespace at render time with
`config('view.paths')` + `/errors` and the framework's own
(`Illuminate/Foundation/Exceptions/RegisterErrorViewPaths.php`), so error views a package
adds with `addNamespace('errors', …)` are wiped; only a path in `view.paths` survives.
- The package lives at `https://gitea.nonameweb.ch/noNameWEB/livewire-material.git` (public,
anonymous reads verified 2026-09-13).
## Decisions
The package's decisions are in its own plan. SealShare's:
- **Converts after `1.0.0`, in one pass, by hand** (~150 tags; no codemod), on branch
`material`, released as **2.0.0**.
- **Moving SealShare to Gitea is a separate plan** — this plan works wherever it is hosted.
- **Seed `#4f46e5` (the favicon's indigo), Tonal Spot**; Vibrant generated alongside on the
upload page for one visual comparison before committing.
- **Theme default `system`**, storage key `sealshare-theme`, legacy `mary-theme` adopted once.
Appearance is a Light / Dark / System connected button group.
- **One top app bar everywhere** — logo and site title; a theme toggle for guests, an avatar
account menu (Upload, Admin dashboard, Admin settings, Settings, theme, Log out) for users;
centered content; Admin and Settings sub-pages as secondary tabs (menu picker on a phone);
auth pages a centered card under the same bar. No rail, no bottom bar.
- **Expressive components plus two hero moments** — an Expressive shape behind the upload icon
that morphs while files are dragged over, the wavy progress indicator for uploads, a
shape-backed check when the link is ready; admin stats count up once. Instant under
`prefers-reduced-motion`.
- **The public download page uses no anchored components** (no menus, no tooltips) — it must
work for recipients on iOS below 18.4.
- **Starter-kit cleanup during the conversion** — delete the placeholder `/dashboard`, point
Fortify `home` at the admin dashboard, delete the unused views and the `registerView`
binding, drop `alpinejs` from npm and the Flux step from CI.
- **Confirmations become M3 basic dialogs** (the 3 `wire:confirm`) — the browser's native
confirm cannot be themed and reads as a different app. *(Not asked in the interview; object
in review if you prefer the native confirm.)*
- **Save feedback becomes a snackbar** through the package's `Toasts` concern — admin
settings' flashed `session('message')` alert and settings' "Saved." `action-message` alike.
*(Follows from the snackbar; not asked separately.)*
- **The font is self-hosted** — the fonts.bunny.net request goes, which also suits a
privacy-minded self-hosted app.
- **Tests: updated feature tests, the package's guard as `DesignLanguageTest`, Livewire tests
for changed behaviour, and four browser tests** with `pestphp/pest-plugin-browser` (new dev
dependency, approved).
## Out of scope
- ReStride adopting the package — its own plan, after `1.0.0`.
- Moving SealShare's repository, CI and image registry to Gitea — its own plan.
- Everything the package plan puts out of scope.
- Changes to SealShare's features, routes or information architecture beyond the cleanup above.
## Implementation steps
Step numbers continue the original plan's, so references elsewhere stay valid.
### Phase 11 — SealShare 2.0.0 (after `1.0.0`)
34. **Branch** `material` from `main`; open the PR so CI runs.
35. **Dependencies.** Add the `vcs` repository and `composer require nonameweb/livewire-material:^1.0`;
`composer remove robsontenorio/mary` (drops `blade-heroicons` with it);
`npm remove daisyui alpinejs`; `composer require --dev pestphp/pest-plugin-browser`.
maryUI goes **first** because its class components would shadow the package's same-named
anonymous ones; the branch is therefore red from here until step 44, which is accepted —
it merges once, green (Decisions: one pass).
36. **CI and Docker.** Remove the Flux credentials step from `.github/workflows/tests.yml`,
`docker.yml` and `lint.yml`; install Playwright browsers in `tests.yml`. `Dockerfile`: run
the Composer stage first and `COPY --from=vendor /app/vendor ./vendor` into the Node stage
before `npm run build`. `docker/dev-entrypoint.sh`: run `composer install` when `vendor/` is
missing, before `npm run build`. No `icons:cache` anywhere: the package draws its symbols
without blade-icons.
37. **Styles and scheme.** `resources/css/app.css`: `@import 'tailwindcss'`, the package entry
from `vendor/`, `./material-scheme.css`, `@source '../views'` and the package's views; drop
the daisyUI plugin, maryUI and pagination `@source`s and the swap safelist.
`resources/js/app.js` imports the package JS. Run
`php artisan material:scheme "#4f46e5" --variant=tonal-spot`; generate Vibrant to a
temporary output, compare on the upload page in both themes, commit the chosen one.
38. **Head and theme.** `partials/head`: remove fonts.bunny.net; include `<x-theme-script />`
before `@vite` (it currently sits outside `<head>`). Publish the config with
`theme.default = system`, `storage_key = sealshare-theme`, `legacy_keys = ['mary-theme']`.
39. **Layouts.** Rebuild `layouts/app.blade.php` (absorbing `app/sidebar`): `<x-app-bar>` with
`app-logo-icon` / branding logo and site title, `<x-theme-toggle>` for guests or
`<x-account-menu>` for users (Upload, Admin dashboard, Admin settings, Settings, theme, Log
out through `App\Livewire\Actions\Logout`), centered content, `<x-toast>`.
`layouts/auth.blade.php` (absorbing `auth/simple`): the same bar and a centered card.
40. **Cleanup.** Delete the `/dashboard` route, `dashboard.blade.php`, `placeholder-pattern`,
`welcome`, `pages/auth/register` and its `Fortify::registerView` line,
`layouts/app/{header,sidebar}`, `layouts/auth/{card,split,simple}`, `app-logo`,
`desktop-user-menu`. Fortify `home``/admin/dashboard`. Update `AuthenticationTest:22`
and `EmailVerificationTest:32,63` to the new redirect; `DashboardTest` is rewritten to
assert that a signed-in admin lands on the admin dashboard and `/dashboard` is gone
(replacing its placeholder tests, approved in the interview). `RegistrationTest` stays.
41. **Public pages.** `livewire/file-uploader`: drop zone with `<x-shape>` behind the upload
icon morphing while `dragging`, existing Alpine folder walking and `livewire-upload-*`
wiring kept, wavy `<x-progress>`, `<x-loading>` for processing, selected files as
`<x-list>`, Share Options `<x-card>` (`<x-toggle>`, `<x-select>`, number `<x-input>`s),
`<x-alert>` for storage full, filled primary "Create Share Link".
`share-created`: shape-backed check, `<x-input copyable>` for the link, four `<x-stat>`,
info `<x-alert>`, "Upload More". `share-download`: password `<x-card>` with
`<x-password>`, files as `<x-list>` with download icon buttons, "Download All" — no menus
or tooltips. `system-password-prompt`, `setup-wizard` onto fields and buttons.
42. **Auth pages** (`login`, `forgot-password`, `reset-password`, `two-factor-challenge`,
`confirm-password`, `verify-email`): fields, `<x-checkbox>` for remember me, `link` utility
for text links, `auth-session-status` onto `<x-alert>` (drops `text-green-600`).
43. **Settings.** `pages/settings/layout``<x-section-nav>`; `partials/settings-heading`
drops the daisyUI divider for `<x-divider>`; `profile` and `password` show "Saved." as a
snackbar through `Toasts` (the `profile-updated` / `password-updated` dispatches stay for
any listener) and `components/action-message` is deleted;
`appearance` → Light / Dark / System `<x-group>` on `$store.theme` (the only toggle on the
page); `two-factor``<x-badge>` status, `<x-modal fullscreen>` setup with the QR on a
white token surface, `<x-input copyable>` key, recovery codes; `delete-user-form`
`<x-modal>` with a `danger` action.
44. **Admin.** `admin-dashboard`: four `<x-stat>` (counting up once), disk usage
`<x-progress>`, hand-written `<x-table>` with `<x-sort-header>` and pagination (the
`@scope` cells become plain Blade and `AdminDashboard::headers()` goes), view and delete
icon buttons, delete confirmation in a basic `<x-modal>` instead of `wire:confirm`.
`admin-settings`: cards, `<x-textarea>`, `<x-file>` for the logo with preview,
`<x-toggle>`, `<x-select>`, `<x-input suffix>`; "Remove the logo?" and "Remove the system
password?" become basic dialogs instead of `wire:confirm`; `AdminSettings` uses `Toasts`
instead of `session()->flash('message')` (3 places) and the alert block goes. Keep every
existing `data-test` attribute on the element that now plays its role.
45. **Error pages and mail.** Confirm the package's error views render in SealShare's theme;
set `config/mail.php` `markdown.theme` to `livewire-material::mail.theme`; check the
password-reset and verify-email mails.
46. **Guards.** `tests/Feature/DesignLanguageTest.php` using `DesignGuard` over
`resources/views` and `app/` — no maryUI, no daisyUI, only declared colours, only existing
icons. A grep for `base-content|bg-base|btn|mary` returns nothing.
47. **Rules and AI.** `php artisan boost:update --discover` to install the package guideline and
skill; `record-rule` for SealShare: the scheme is regenerated with `material:scheme`,
never hand-edited; the download page stays free of anchored components; the theme key.
48. **Docs.** README tech stack and the "Dark Mode" feature line; CHANGELOG `2.0.0`.
49. **Ship.** Full suite green on the PR; merge; tag `v2.0.0` (publishes the image through
`docker.yml`).
## Testing
- Feature tests updated where redirects or text change: `AuthenticationTest`,
`EmailVerificationTest`, `DashboardTest` (rewritten), `AdminSettingsTest` (asserts the
toast is dispatched instead of the flash), `TwoFactorAuthenticationTest`,
`AdminDashboardTest`, `ShareDownloadTest`.
- `DesignLanguageTest` through the package guard.
- Livewire tests: admin settings save/remove-logo/clear-password dispatch toasts; profile and
password updates dispatch the "Saved." toast (`ProfileUpdateTest`, `PasswordUpdateTest`);
delete share, remove logo and clear system password go through their dialogs' confirm
actions.
- Browser tests (`tests/Browser`): upload by drop and by Browse → progress → share created →
copy link; the password-protected download page at 393px; admin table sort and delete
dialog; a first visit follows the OS theme and Appearance switches it.
- Narrow runs per step; the full suite on the PR's CI.
## Risks and open questions
- **Scope and time.** The whole catalogue (~45 components plus extras) comes before SealShare
changes at all, so its starter-kit bugs (the placeholder `/dashboard`) stay until then.
Mitigation: waves tagged `0.x`, each reviewed in the showcase; SealShare keeps working
meanwhile.
- **Gitea becomes a build dependency.** Every SealShare CI run and Docker build fetches the
package from `gitea.nonameweb.ch`; an outage or a sign-in setting reverting breaks builds.
Mitigation: dist archives cached by Composer in CI; revisit Packagist if it bites.
- **iOS / Safari below 18.4.** Anchored menus and tooltips do not position there. Mitigation:
SealShare's download page uses none; native `<select>` stays the fallback everywhere.
- **Scheme and spring values are tuned by eye**; Tonal Spot may read washed out on indigo —
the Vibrant comparison in step 37 is the check.
+173 -812
View File
File diff suppressed because it is too large Load Diff
+4 -8
View File
@@ -7,23 +7,19 @@
"dev": "vite"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"alpinejs": "^3.17.2",
"autoprefixer": "^10.5.5",
"autoprefixer": "^10.6.1",
"concurrently": "^10.0.5",
"daisyui": "^5.7.32",
"laravel-vite-plugin": "^3.2.0",
"tailwindcss": "^4.3.3",
"vite": "^8.2.2"
"vite": "^8.3.0"
},
"optionalDependencies": {
"@tailwindcss/oxide-linux-x64-gnu": "^4.0.1",
"lightningcss-linux-x64-gnu": "^1.29.1"
},
"overrides": {
"shell-quote": "^1.9.0"
},
"devDependencies": {
"chokidar": "^5.0.0"
"chokidar": "^5.0.0",
"playwright": "^1.63.0"
}
}
+3
View File
@@ -11,6 +11,9 @@
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
<testsuite name="Browser">
<directory>tests/Browser</directory>
</testsuite>
</testsuites>
<source>
<include>
+321 -7
View File
@@ -1,10 +1,324 @@
@import 'tailwindcss';
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@source '../views';
@source '../../vendor/robsontenorio/mary/src/View/Components/**/*.php';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source inline("swap swap-rotate swap-on swap-off theme-controller");
@import '../../vendor/nonameweb/livewire-material/resources/css/foundation.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/grid.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/pane.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/row.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/stack.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/surface.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/account-menu.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/alert.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/badge.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/button.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/card.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/checkbox.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/divider.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/empty-state.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/file.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/form.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/group.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/icon.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/input.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/list-item.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/list.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/loading.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/menu-item.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/modal.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/pagination.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/password.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/progress.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/scheme-picker.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/section-nav.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/select.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/shape.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/stat.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/textarea.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/theme-toggle.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/toast.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/toggle.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/toolbar.css';
@import './material-scheme.css';
@plugin "daisyui" {
themes: light --default, dark --prefersdark;
/*
* SealShare's own rules, unlayered so they outrank every package rule: one section per view, in
* the order a visitor meets them the layout and the page template, the share flow (upload,
* share created, download), the settings pages in their navigation's order, then admin.
*/
/*
* resources/views/layouts/app.blade.php: every page's main region.
*
* `<x-pane as="main">` gives the region its horizontal M3 margin (16px below `medium`, 24px from
* it); the page inside (components/page.blade.php) sets its own width and centres itself. The
* vertical rhythm is the app's own. The bottom padding clears the floating toolbar in
* partials/toolbar.blade.php by what the toolbar publishes as `--material-bottom-toolbar` (its top
* edge's distance from the window's bottom, safe area included), plus 16px. Never set
* `--material-bottom-bar` here: the toolbar reads it to place itself.
*/
.app-main {
padding-block-start: var(--md-sys-measurement-space400);
padding-block-end: calc(var(--material-bottom-toolbar, 0px) + var(--md-sys-measurement-space200));
}
@media (width >= 600px) {
.app-main {
padding-block-start: var(--md-sys-measurement-space600);
}
}
/* resources/views/components/page.blade.php: the site's own logo above the title on a `brand` page, at 1.x's 5rem-tall size, its width following the image. */
.page-logo {
block-size: 5rem;
}
/*
* resources/views/livewire/file-uploader.blade.php: the drop zone's dashed outline and its
* primary tint while dragging. `data-dragging` is Alpine's, not the package's, since no
* component tracks a native drag over an arbitrary drop target; disabled where uploads cannot run
* (no secure context) blocks pointer events and dims to M3's disabled-content opacity, as a code dims elsewhere while
* busy (.settings-recovery-code--loading).
*/
.upload-drop-zone {
padding: var(--md-sys-measurement-space400);
border: 2px dashed var(--md-sys-color-outline-variant);
border-radius: var(--md-sys-shape-corner-xl);
transition: border-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default), background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
}
.upload-drop-zone[data-dragging='true'] {
border-color: var(--md-sys-color-primary);
background-color: color-mix(in srgb, var(--md-sys-color-primary-container) 40%, transparent);
}
.upload-drop-zone[aria-disabled='true'] {
pointer-events: none;
opacity: var(--md-sys-state-disabled-content-opacity);
}
/*
* resources/views/livewire/file-uploader.blade.php: the drop zone's shape morphs into a burst
* while files are dragged over it SealShare's signature, kept from 1.x (docs/reference/m3/styles.md
* § Shape: "Shape morph should respond to user interaction"). Two `<x-shape>`s sit
* stacked (`inset: 0` on an absolutely positioned element sizes it to the box, no width/height
* class needed) and cross-fade/scale on the spatial-slow spring the shape's size warrants
* (docs/reference/m3/styles.md § Motion: "larger elements may use slow"); opacity rides the
* effects-slow spring beside it, since a colour or fade must never overshoot. Reduced motion needs
* no local override: the tokens themselves zero out under it (tokens/motion.css).
*/
.upload-drop-shapes {
position: relative;
display: grid;
place-items: center;
inline-size: 7rem;
block-size: 7rem;
}
.upload-drop-shape {
position: absolute;
inset: 0;
transition: scale var(--md-sys-motion-spatial-slow-duration) var(--md-sys-motion-spatial-slow), rotate var(--md-sys-motion-spatial-slow-duration) var(--md-sys-motion-spatial-slow), opacity var(--md-sys-motion-effects-slow-duration) var(--md-sys-motion-effects-slow);
}
.upload-drop-shape--idle {
scale: 1;
rotate: 0deg;
opacity: 1;
color: var(--md-sys-color-secondary-container);
}
.upload-drop-zone[data-dragging='true'] .upload-drop-shape--idle {
scale: 0.5;
rotate: 45deg;
opacity: 0;
}
.upload-drop-shape--burst {
scale: 0.5;
rotate: -45deg;
opacity: 0;
color: var(--md-sys-color-primary-container);
}
.upload-drop-zone[data-dragging='true'] .upload-drop-shape--burst {
scale: 1.1;
rotate: 0deg;
opacity: 1;
}
.upload-drop-icon {
position: relative;
color: var(--md-sys-color-on-secondary-container);
transition: color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
}
.upload-drop-zone[data-dragging='true'] .upload-drop-icon {
color: var(--md-sys-color-on-primary-container);
}
/* resources/views/livewire/file-uploader.blade.php: the selected-files list scrolls on its own past 1.x's cap instead of pushing the options and the submit button down the page. */
.upload-file-list {
max-block-size: 18rem;
overflow-y: auto;
}
/*
* resources/views/livewire/share-created.blade.php: the check that settles onto its Expressive
* shape once the link is ready (the `share-ready`/`share-ready-fade` keyframes after it). The shape
* sits at the box's edges (`inset: 0` on an absolutely positioned element sizes it, no width/height
* class needed); both colours are container roles `md-ink-*` has no class for, so they are the
* application's own CSS rather than a component prop.
*/
.share-check {
position: relative;
display: grid;
place-items: center;
inline-size: 6rem;
block-size: 6rem;
animation:
share-ready var(--md-sys-motion-spatial-slow-duration) var(--md-sys-motion-spatial-slow) both,
share-ready-fade var(--md-sys-motion-effects-slow-duration) var(--md-sys-motion-effects-slow) both;
}
.share-check-shape {
position: absolute;
inset: 0;
color: var(--md-sys-color-primary-container);
}
.share-check-icon {
/* Without this the icon, though later in the DOM, is a non-positioned in-flow child: it paints
before the absolutely positioned shape beside it (CSS's stacking order for z-index:auto) and
sits hidden underneath it, as .upload-drop-icon's own position: relative is there to avoid. */
position: relative;
color: var(--md-sys-color-on-primary-container);
}
/*
* resources/views/livewire/share-created.blade.php: the check settling onto its shape, run by
* .share-check rotate and scale on the spatial spring (shape motion), opacity on effects beside
* it, since M3 never lets a colour or fade overshoot; reduced motion needs no local override, the
* duration tokens themselves zero out under it.
*/
@keyframes share-ready {
from {
rotate: -90deg;
scale: 0.4;
}
to {
rotate: 0deg;
scale: 1;
}
}
@keyframes share-ready-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/*
* resources/views/livewire/share-created.blade.php: the QR code dialog. `App\Services\QrCodeService`
* already draws its SVG black on white with a four-module quiet zone, so the container adds no
* colour of its own no colour class or literal colour could give it one that also holds in dark
* mode. The corner only rounds the container that clips it, exactly as .settings-two-factor-qr's does.
*/
.share-qr {
display: grid;
place-items: center;
inline-size: 100%;
max-inline-size: 20rem;
aspect-ratio: 1;
margin-inline: auto;
overflow: hidden;
border-radius: var(--md-sys-shape-corner-lg);
}
.share-qr svg {
inline-size: 100%;
block-size: 100%;
}
/*
* 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
* against the same otpauth URL instead, which bakes in its own white field and four-module quiet
* zone the only way to guarantee one in dark mode, since no colour class or literal colour can
* paint it onto 2.0.0's foundation. Sized at 1.x's 16rem square, corners rounded and clipped to
* match the settings surfaces around it.
*/
.settings-two-factor-qr {
display: grid;
place-items: center;
inline-size: 16rem;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--md-sys-shape-corner-lg);
}
.settings-two-factor-qr svg {
inline-size: 100%;
block-size: 100%;
}
/*
* resources/views/pages/settings/two-factor/recovery-codes.blade.php: a code dims to M3's disabled
* content opacity while regenerateRecoveryCodes() is in flight, and back, on the effects spring
* instead of Tailwind's animate-pulse loop 2.0.0 keeps no keyframe utility for it. The
* transition sits on the code itself so the way back eases too; under reduced motion the token's
* duration is 0ms.
*/
.settings-recovery-code {
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
.settings-recovery-code--loading {
opacity: var(--md-sys-state-disabled-content-opacity);
}
/*
* resources/views/pages/settings/appearance.blade.php: the theme picker stays a comfortable
* width instead of stretching across the settings card. No `<x-group>` width prop caps it, and
* 24rem matches no `<x-pane>` preset.
*/
.settings-appearance-picker {
max-inline-size: 24rem;
}
/*
* resources/views/livewire/admin/admin-dashboard.blade.php: the sort select above the shares list
* keeps to the width its longest option needs instead of spanning the card. No `<x-select>` width
* prop caps it, and 20rem matches no `<x-pane>` preset.
*/
.admin-shares-sort {
max-inline-size: 20rem;
}
/*
* resources/views/livewire/admin/admin-dashboard.blade.php: a share's details are two lines of
* their own (its files, size and downloads; its expiry), and they wrap rather than clip. The
* package clamps a list item's description at two lines with an ellipsis, which on a phone hid
* the expiry with no way to read it.
*/
.admin-shares [data-md-list-item-description] {
display: block;
overflow: visible;
}
.admin-share-detail {
display: block;
}
/*
* resources/views/livewire/admin/admin-settings.blade.php: the current and previewed site logo,
* at 1.x's 4rem height with its width following the image's own ratio. M3 keeps no size scale for
* a plain <img>.
*/
.admin-settings-logo {
block-size: 4rem;
border-radius: var(--md-sys-shape-corner-sm);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -1,2 +1,4 @@
// Alpine.js is bundled and started automatically by Livewire 4.
// Do not import it here to avoid "multiple instances of Alpine" errors.
// 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'
import './share-uploader.js'
+76
View File
@@ -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)
}
}
+317
View File
@@ -0,0 +1,317 @@
/**
* `shareUploader`: the upload page's queue. Files are registered with the Livewire component in one
* batch per selection, then sent one at a time: each chunk is sliced from the file, encrypted here
* with AES-256-GCM and PUT on its own, so the server writes it once, already encrypted.
*
* The encrypted format is App\Services\FileEncryptionService's SEALCHK2: chunk i's nonce is the
* file's 7-byte prefix, i as a big-endian uint32 and a byte that is 1 on the last chunk; WebCrypto
* appends the 16-byte tag to the ciphertext, which is how the server stores it.
*
* A failed request is retried after 1, 2, 4, 8 and 16 seconds; after that the file waits for its
* Retry button, which picks up from the chunk the server last confirmed. The server answers 409
* with its own count when a chunk skips ahead, and acknowledges a chunk it already has.
*/
const RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000]
document.addEventListener('alpine:init', () => {
window.Alpine.data('shareUploader', ({ csrfToken, messages }) => ({
secure: window.isSecureContext && Boolean(window.crypto?.subtle),
dragging: false,
busy: false,
/** Files waiting to be sent, in order: { id, file, target, nextIndex }. */
queue: [],
/** Every file this page registered, by id: { state: 'queued'|'uploading'|'uploaded'|'failed', sent, size }. */
uploads: {},
/** The files that failed, by id, kept for their Retry button. */
failed: {},
/** The request on its way, so Cancel and Remove can abort it. */
request: null,
get progress() {
const unfinished = Object.values(this.uploads).filter((upload) => upload.state !== 'failed')
const size = unfinished.reduce((total, upload) => total + upload.size, 0)
return size === 0 ? 0 : (unfinished.reduce((total, upload) => total + upload.sent, 0) / size) * 100
},
choose(event) {
this.add([...event.target.files].map((file) => ({ file, path: null })))
event.target.value = ''
},
handleDrop(event) {
this.dragging = false
if (! this.secure) {
return
}
const items = event.dataTransfer.items
const files = []
for (let i = 0; i < items.length; i++) {
const entry = items[i].webkitGetAsEntry?.()
if (entry) {
this.traverseEntry(entry, '', files)
} else if (items[i].kind === 'file') {
files.push({ file: items[i].getAsFile(), path: null })
}
}
// Directory entries are read asynchronously.
setTimeout(() => this.add(files), 500)
},
traverseEntry(entry, path, files) {
if (entry.isFile) {
entry.file((file) => files.push({ file, path: path ? `${path}/${file.name}` : null }))
} else if (entry.isDirectory) {
entry.createReader().readEntries((entries) => {
entries.forEach((child) => this.traverseEntry(child, path ? `${path}/${entry.name}` : entry.name, files))
})
}
},
async add(selection) {
if (! this.secure || selection.length === 0) {
return
}
const targets = await this.$wire.registerFiles(selection.map(({ file, path }) => ({ name: file.name, size: file.size, path })))
;(targets ?? []).forEach((target, position) => {
if (! target) {
return
}
this.uploads[target.id] = { state: 'queued', sent: 0, size: selection[position].file.size }
this.queue.push({ id: target.id, file: selection[position].file, target, nextIndex: 0 })
})
this.run()
},
async run() {
if (this.busy) {
return
}
this.busy = true
while (this.queue.length > 0) {
const item = this.queue[0]
const uploaded = await this.upload(item)
if (this.queue[0] === item) {
this.queue.shift()
}
if (uploaded) {
await this.$wire.$refresh()
}
}
this.busy = false
},
/**
* Send one file's remaining chunks; true once the server has them all.
*/
async upload(item) {
const { id, file, target } = item
const upload = this.uploads[id]
if (! upload) {
return false
}
upload.state = 'uploading'
try {
const key = await crypto.subtle.importKey('raw', bytesFromHex(target.key), 'AES-GCM', false, ['encrypt'])
const noncePrefix = bytesFromHex(target.noncePrefix)
while (item.nextIndex < target.chunkCount) {
const index = item.nextIndex
const start = index * target.chunkSize
const plaintext = await file.slice(start, start + target.chunkSize).arrayBuffer()
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: chunkNonce(noncePrefix, index, index === target.chunkCount - 1), tagLength: 128 },
key,
plaintext,
)
item.nextIndex = await this.send(upload, `${target.url}/${index}`, ciphertext, start, plaintext.byteLength)
upload.sent = Math.min(item.nextIndex * target.chunkSize, upload.size)
}
} catch (error) {
if (error?.name === 'AbortError') {
return false
}
upload.state = 'failed'
this.failed[id] = item
if (error?.status === 419) {
window.materialToast(messages.sessionExpired, { type: 'error' })
}
return false
}
upload.state = 'uploaded'
return true
},
/**
* PUT one encrypted chunk, retrying transient failures; resolves with the number of chunks
* the server holds for the file.
*/
async send(upload, url, body, offset, plaintextLength) {
for (let attempt = 0; ; attempt++) {
const response = await this.put(url, body, (loaded) => {
upload.sent = Math.min(offset + (loaded / body.byteLength) * plaintextLength, upload.size)
})
if ((response.status === 200 || response.status === 409) && Number.isInteger(response.uploadedChunks)) {
return response.uploadedChunks
}
if (response.status === 404 || response.status === 419 || attempt === RETRY_DELAYS.length) {
throw Object.assign(new Error('Upload failed'), { status: response.status })
}
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAYS[attempt]))
}
},
put(url, body, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('PUT', url)
xhr.setRequestHeader('Content-Type', 'application/octet-stream')
xhr.setRequestHeader('Accept', 'application/json')
xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken)
xhr.upload.onprogress = (event) => onProgress(event.loaded)
xhr.onload = () => {
this.request = null
resolve({ status: xhr.status, uploadedChunks: parseUploadedChunks(xhr.responseText) })
}
xhr.onerror = () => {
this.request = null
resolve({ status: 0 })
}
xhr.onabort = () => {
this.request = null
reject(new DOMException('Upload cancelled', 'AbortError'))
}
this.request = xhr
// Chromium sends a Blob body about eight times faster than the same ArrayBuffer.
xhr.send(new Blob([body]))
})
},
retry(id) {
const item = this.failed[id]
if (! item) {
return
}
delete this.failed[id]
this.uploads[id].state = 'queued'
this.queue.push(item)
this.run()
},
remove(id) {
this.forget([id])
this.$wire.removeFiles([id])
},
/**
* Stop everything still to send and take those files out of the share.
*/
cancel() {
const unfinished = Object.entries(this.uploads)
.filter(([, upload]) => upload.state !== 'uploaded')
.map(([id]) => Number(id))
this.forget(unfinished)
this.$wire.removeFiles(unfinished)
},
forget(ids) {
const current = this.queue[0]
this.queue = this.queue.filter((item) => ! ids.includes(item.id))
ids.forEach((id) => {
delete this.uploads[id]
delete this.failed[id]
})
if (current && ids.includes(current.id)) {
this.request?.abort()
}
},
statusOf(id, uploaded) {
const upload = this.uploads[id]
if (uploaded || upload?.state === 'uploaded') {
return messages.uploaded
}
if (upload?.state === 'uploading') {
return `${Math.round((upload.sent / Math.max(upload.size, 1)) * 100)}%`
}
return upload?.state === 'failed' ? messages.failed : messages.queued
},
warnBeforeLeaving(event) {
if (this.busy) {
event.preventDefault()
event.returnValue = ''
}
},
}))
})
function bytesFromHex(hex) {
return Uint8Array.from(hex.match(/.{2}/g), (pair) => parseInt(pair, 16))
}
/**
* Chunk `index`'s 12-byte nonce: the file's prefix, the index as a big-endian uint32 and the
* last-chunk flag.
*/
function chunkNonce(prefix, index, isLast) {
const nonce = new Uint8Array(12)
nonce.set(prefix, 0)
new DataView(nonce.buffer).setUint32(7, index)
nonce[11] = isLast ? 1 : 0
return nonce
}
function parseUploadedChunks(responseText) {
try {
return JSON.parse(responseText).uploaded_chunks
} catch {
return undefined
}
}
@@ -1,14 +0,0 @@
@props([
'on',
])
<div
x-data="{ shown: false, timeout: null }"
x-init="@this.on('{{ $on }}', () => { clearTimeout(timeout); shown = true; timeout = setTimeout(() => { shown = false }, 2000); })"
x-show.transition.out.opacity.duration.1500ms="shown"
x-transition:leave.opacity.duration.1500ms
style="display: none"
{{ $attributes->merge(['class' => 'text-sm']) }}
>
{{ $slot->isEmpty() ? __('Saved.') : $slot }}
</div>
@@ -1,4 +0,0 @@
<a href="{{ route('home') }}" {{ $attributes->merge(['class' => 'flex items-center gap-2 font-semibold']) }} wire:navigate>
<x-app-logo-icon class="size-6 fill-current" />
<span>{{ \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare') }}</span>
</a>
@@ -1,9 +0,0 @@
@props([
'title',
'description',
])
<div class="flex w-full flex-col text-center">
<h2 class="text-xl font-bold">{{ $title }}</h2>
<p class="text-sm opacity-60 mt-1">{{ $description }}</p>
</div>
@@ -3,7 +3,5 @@
])
@if ($status)
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600']) }}>
{{ $status }}
</div>
<x-alert color="success" {{ $attributes }}>{{ $status }}</x-alert>
@endif
@@ -1 +0,0 @@
{{-- Desktop user menu - integrated into sidebar layout --}}
+57
View File
@@ -0,0 +1,57 @@
{{-- Every page in SealShare: a centred header over one centred column of cards, the share pages'
shape carried to every other page, sign-in included.
<x-page :title="__('Settings')" :description="__('…')">
<x-slot:navigation><x-section-nav :items="$items" /></x-slot:navigation>
<x-card variant="outlined" heading="h2" ></x-card>
</x-page>
`brand` heads the page with the site's own logo, title and description from Admin settings
instead of `title` and `description`, falling back to the app's name and SealShare's line.
`mark` is a visual above the title (share created's check). Every page is the same 40rem
column (`<x-pane width="narrow">`, M3's cap on a text field), so there is no width prop: content
that needs more room is rearranged to fit, as the admin dashboard's shares became a list. No
page sets a width or a heading of its own. The page's content stacks 24px apart under the
header and the optional `navigation`. --}}
@props([
'title' => null,
'description' => null,
'brand' => false,
])
@php
$logo = null;
if ($brand) {
$title = \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare');
$description = \App\Models\Setting::get('site_description') ?: __('Share your files safely and securely');
$logo = \App\Models\Setting::get('site_logo');
}
@endphp
<x-pane width="narrow" data-test="page" {{ $attributes }}>
<x-stack gap="space400">
<x-stack as="header" align="center" gap="space200">
@if ($logo)
<img src="{{ Storage::disk('public')->url($logo) }}" alt="{{ $title }}" class="page-logo" data-test="page-logo" />
@endif
{{ $mark ?? '' }}
<x-stack align="center" gap="space100">
<h1 class="md-type-headline-lg md-text-center">{{ $title }}</h1>
@if (filled($description))
<p class="md-type-body-lg md-ink-variant md-text-center">{{ $description }}</p>
@endif
</x-stack>
</x-stack>
{{ $navigation ?? '' }}
<x-stack gap="space300">
{{ $slot }}
</x-stack>
</x-stack>
</x-pane>
@@ -1,12 +0,0 @@
@props([
'id' => uniqid(),
])
<svg {{ $attributes }} fill="none">
<defs>
<pattern id="pattern-{{ $id }}" x="0" y="0" width="8" height="8" patternUnits="userSpaceOnUse">
<path d="M-1 5L5 -1M3 9L8.5 3.5" stroke-width="0.5"></path>
</pattern>
</defs>
<rect stroke="none" fill="url(#pattern-{{ $id }})" width="100%" height="100%"></rect>
</svg>
-18
View File
@@ -1,18 +0,0 @@
<x-layouts::app :title="__('Dashboard')">
<div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl">
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<div class="relative aspect-video overflow-hidden rounded-xl border border-base-300">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-current/20" />
</div>
<div class="relative aspect-video overflow-hidden rounded-xl border border-base-300">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-current/20" />
</div>
<div class="relative aspect-video overflow-hidden rounded-xl border border-base-300">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-current/20" />
</div>
</div>
<div class="relative h-full flex-1 overflow-hidden rounded-xl border border-base-300">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-current/20" />
</div>
</div>
</x-layouts::app>
+15 -3
View File
@@ -1,3 +1,15 @@
<x-layouts::app.sidebar :title="$title ?? null">
{{ $slot }}
</x-layouts::app.sidebar>
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
</head>
<body>
<x-pane as="main" class="app-main">
{{ $slot }}
</x-pane>
@include('partials.toolbar')
<x-toast />
</body>
</html>
@@ -1,4 +0,0 @@
{{-- Header layout not used - redirects to sidebar layout --}}
<x-layouts::app.sidebar :title="$title ?? null">
{{ $slot }}
</x-layouts::app.sidebar>
@@ -1,42 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-theme="dark">
<script>document.documentElement.setAttribute('data-theme', localStorage.getItem('mary-theme')?.replaceAll('"','') || 'dark')</script>
<head>
@include('partials.head')
</head>
<body class="min-h-screen font-sans antialiased bg-base-200/50 flex flex-col">
{{-- MAIN CONTENT --}}
<main class="flex-1 w-full max-w-5xl mx-auto px-4 py-8">
{{ $slot }}
</main>
{{-- FOOTER NAV --}}
<footer class="border-t border-base-300 bg-base-100/50">
<div class="max-w-5xl mx-auto px-4 py-3 flex items-center justify-between text-sm">
<a href="{{ route('upload') }}" class="font-medium opacity-70 hover:opacity-100 transition-opacity">
{{ \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare') }}
</a>
<nav class="flex items-center gap-4">
<x-theme-toggle class="opacity-60 hover:opacity-100 transition-opacity" />
@auth
@if(auth()->user()->is_admin)
<a href="{{ route('admin.dashboard') }}" class="opacity-60 hover:opacity-100 transition-opacity">{{ __('Dashboard') }}</a>
<a href="{{ route('admin.settings') }}" class="opacity-60 hover:opacity-100 transition-opacity">{{ __('Settings') }}</a>
@endif
<a href="{{ route('profile.edit') }}" class="opacity-60 hover:opacity-100 transition-opacity">{{ __('Profile') }}</a>
<form method="POST" action="{{ route('logout') }}" class="inline">
@csrf
<button type="submit" class="opacity-60 hover:opacity-100 transition-opacity">{{ __('Logout') }}</button>
</form>
@else
<a href="{{ route('login') }}" class="opacity-60 hover:opacity-100 transition-opacity">{{ __('Login') }}</a>
@endauth
</nav>
</div>
</footer>
{{-- Toast --}}
<x-toast />
</body>
</html>
-3
View File
@@ -1,3 +0,0 @@
<x-layouts::auth.simple :title="$title ?? null">
{{ $slot }}
</x-layouts::auth.simple>
@@ -1,4 +0,0 @@
{{-- Card auth layout - delegates to simple layout --}}
<x-layouts::auth.simple :title="$title ?? null">
{{ $slot }}
</x-layouts::auth.simple>
@@ -1,27 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-theme="dark">
<script>document.documentElement.setAttribute('data-theme', localStorage.getItem('mary-theme')?.replaceAll('"','') || 'dark')</script>
<head>
@include('partials.head')
@livewireStyles
</head>
<body class="min-h-screen bg-base-200 antialiased">
<div class="flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-sm flex-col gap-2">
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate>
<span class="flex h-9 w-9 mb-1 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current" />
</span>
<span class="sr-only">{{ \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare') }}</span>
</a>
<div class="flex flex-col gap-6">
{{ $slot }}
</div>
<div class="flex justify-center">
<x-theme-toggle class="opacity-60 hover:opacity-100 transition-opacity" />
</div>
</div>
</div>
@livewireScripts
</body>
</html>
@@ -1,4 +0,0 @@
{{-- Split auth layout - delegates to simple layout --}}
<x-layouts::auth.simple :title="$title ?? null">
{{ $slot }}
</x-layouts::auth.simple>
@@ -1,70 +1,73 @@
<div>
<h1 class="text-2xl font-bold mb-6">{{ __('Admin Dashboard') }}</h1>
<x-page :title="__('Admin Dashboard')" :description="__('Shares, files and storage at a glance')">
<x-grid :columns="2" gap="space200">
<x-stat :title="__('Total Shares')" :value="$totalShares" icon="link" />
<x-stat :title="__('Active Shares')" :value="$activeShares" icon="schedule" />
<x-stat :title="__('Total Files')" :value="$totalFiles" icon="description" />
<x-stat :title="__('Disk Usage')" :value="Number::fileSize($usedSpace)" icon="hard_drive" :description="Number::fileSize($usedSpace).' / '.Number::fileSize($maxQuota)">
<x-progress :value="$maxQuota > 0 ? min(100, ($usedSpace / $maxQuota) * 100) : 0" :label="__('Disk Usage')" />
</x-stat>
</x-grid>
{{-- Stats --}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-4">
<p class="text-sm opacity-60">{{ __('Total Shares') }}</p>
<p class="text-2xl font-bold">{{ $totalShares }}</p>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-4">
<p class="text-sm opacity-60">{{ __('Active Shares') }}</p>
<p class="text-2xl font-bold">{{ $activeShares }}</p>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-4">
<p class="text-sm opacity-60">{{ __('Total Files') }}</p>
<p class="text-2xl font-bold">{{ $totalFiles }}</p>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-4">
<p class="text-sm opacity-60">{{ __('Disk Usage') }}</p>
<p class="text-2xl font-bold">{{ Number::fileSize($usedSpace) }}</p>
<progress class="progress progress-primary w-full mt-1" value="{{ $maxQuota > 0 ? ($usedSpace / $maxQuota) * 100 : 0 }}" max="100"></progress>
<p class="text-xs opacity-50">{{ Number::fileSize($usedSpace) }} / {{ Number::fileSize($maxQuota) }}</p>
</div>
</div>
</div>
{{-- Shares Table --}}
<x-card title="{{ __('All Shares') }}" shadow>
<x-table :headers="$headers" :rows="$shares" :sort-by="$sortBy" with-pagination>
@scope('cell_total_size', $share)
{{ Number::fileSize($share->total_size) }}
@endscope
@scope('cell_expires_at', $share)
@if ($share->expires_at)
<span class="{{ $share->isExpired() ? 'text-error' : '' }}">
{{ $share->expires_at->diffForHumans() }}
</span>
@else
<span class="opacity-50">{{ __('Never') }}</span>
@endif
@endscope
@scope('cell_created_at', $share)
{{ $share->created_at->diffForHumans() }}
@endscope
@scope('actions', $share)
<div class="flex gap-1">
<a href="{{ route('share.download', $share) }}" class="btn btn-ghost btn-xs" target="_blank">
<x-icon name="o-eye" class="w-4 h-4" />
</a>
<x-button
icon="o-trash"
class="btn-ghost btn-xs text-error"
wire:click="deleteShare({{ $share->id }})"
wire:confirm="{{ __('Are you sure you want to delete this share?') }}"
{{-- The shares as a list, not a table: a table's columns need more than the page's 40rem, and
every page keeps that one width. The sort is a select above the list instead of column headers. --}}
<x-card :title="__('All Shares')" heading="h2" variant="outlined">
<x-stack gap="space200">
@if ($shares->total() === 0)
<x-empty-state icon="link_off" :title="__('No shares yet')" :description="__('Shares appear here once someone uploads files.')" />
@else
<div class="admin-shares-sort">
<x-select
wire:model.live="sort"
:label="__('Sort by')"
:options="[
['id' => 'newest', 'name' => __('Newest first')],
['id' => 'oldest', 'name' => __('Oldest first')],
['id' => 'expiring', 'name' => __('Expiring soonest')],
['id' => 'largest', 'name' => __('Largest')],
['id' => 'most-downloaded', 'name' => __('Most downloads')],
['id' => 'most-files', 'name' => __('Most files')],
]"
data-test="shares-sort"
/>
</div>
@endscope
</x-table>
{{-- Each share fits the column on a phone: the token opens it, so delete is the one button;
the details are two short lines that never clip (admin-shares, app.css). --}}
<x-list dividers :label="__('All Shares')" class="admin-shares">
@foreach ($shares as $share)
<x-list-item :overline="__('Created :time', ['time' => $share->created_at->diffForHumans()])" wire:key="share-{{ $share->id }}" data-test="share-row">
<a href="{{ route('share.download', $share) }}" target="_blank" rel="noopener" class="md-link"><code>{{ $share->token }}</code></a>
<x-slot:description>
<span class="admin-share-detail md-tabular">{{ trans_choice(':count file|:count files', $share->files_count) }} · {{ Number::fileSize($share->total_size) }} · {{ trans_choice(':count download|:count downloads', $share->download_count) }}</span>
@if (! $share->expires_at)
<span class="admin-share-detail">{{ __('Never expires') }}</span>
@elseif ($share->isExpired())
<span class="admin-share-detail md-ink-error">{{ __('Expired :time', ['time' => $share->expires_at->diffForHumans()]) }}</span>
@else
<span class="admin-share-detail">{{ __('Expires :time', ['time' => $share->expires_at->diffForHumans()]) }}</span>
@endif
</x-slot:description>
<x-slot:end>
<x-button icon="delete" :tooltip="__('Delete')" color="error" wire:click="$set('deletingShareId', {{ $share->id }})" data-test="delete-share-{{ $share->id }}" />
</x-slot:end>
</x-list-item>
@endforeach
</x-list>
{{ $shares->links() }}
@endif
</x-stack>
</x-card>
</div>
<x-modal wire:model="deletingShareId" :title="__('Delete this share?')" icon="delete">
{{ __('Are you sure you want to delete this share?') }}
<x-slot:actions>
<x-button :label="__('Cancel')" x-on:click="close()" />
<x-button :label="__('Delete')" danger x-on:click="$wire.deleteShare($wire.deletingShareId)" data-test="confirm-delete-share" />
</x-slot:actions>
</x-modal>
</x-page>
@@ -1,100 +1,165 @@
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold mb-6">{{ __('System Settings') }}</h1>
@if (session('message'))
<div class="alert alert-success mb-6">
<x-icon name="o-check-circle" class="w-5 h-5" />
<span>{{ session('message') }}</span>
</div>
@endif
<form wire:submit="saveSettings">
<x-card title="{{ __('Branding') }}" shadow class="mb-6">
<div class="space-y-4">
<x-input
wire:model="siteTitle"
label="{{ __('Site Title') }}"
hint="{{ __('Displayed as the heading on the upload page.') }}"
/>
<x-textarea
wire:model="siteDescription"
label="{{ __('Site Description') }}"
hint="{{ __('Displayed below the title on the upload page.') }}"
rows="3"
/>
<div>
<label class="label label-text font-semibold">{{ __('Logo') }}</label>
@if ($currentLogo)
<div class="flex items-center gap-4 mb-3">
<img src="{{ Storage::disk('public')->url($currentLogo) }}" alt="{{ __('Site Logo') }}" class="h-16 w-auto rounded" />
<x-button
label="{{ __('Remove Logo') }}"
class="btn-sm btn-ghost text-error"
wire:click="removeLogo"
wire:confirm="{{ __('Remove the logo?') }}"
/>
</div>
@endif
<input type="file" wire:model="siteLogo" accept="image/*,.svg,.svgz" class="file-input file-input-bordered w-full" />
@if ($siteLogo && is_object($siteLogo))
<div class="mt-2">
@if (str_contains($siteLogo->getMimeType(), 'svg'))
<p class="text-sm opacity-60">{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}</p>
@else
<p class="text-sm opacity-60">{{ __('Preview:') }}</p>
<img src="{{ $siteLogo->temporaryUrl() }}" alt="{{ __('Logo preview') }}" class="h-16 w-auto rounded mt-1" />
@endif
</div>
@endif
@error('siteLogo')
<p class="text-error text-sm mt-1">{{ $message }}</p>
@enderror
<p class="text-xs opacity-50 mt-1">{{ __('Max 2MB. Recommended: PNG or SVG.') }}</p>
</div>
</div>
<x-page :title="__('System Settings')" :description="__('How the site looks and what uploaders may do')">
<x-form wire:submit="saveSettings">
<x-card :title="__('Colour profile')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-scheme-picker wire:model="colorProfile" :hint="__('Choosing one previews it here. After saving, every page, mail and error page uses it.')" data-test="color-profile" />
</x-stack>
</x-card>
<x-card title="{{ __('Upload Protection') }}" shadow class="mb-6">
<div class="space-y-4">
<div>
<x-password
<x-card :title="__('Branding')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-input full wire:model="siteTitle" :label="__('Site Title')" :hint="__('Displayed as the heading on the upload, download and sign-in pages.')" />
<x-textarea full wire:model="siteDescription" :label="__('Site Description')" :hint="__('Displayed below the title on the upload, download and sign-in pages.')" rows="3" />
<x-stack gap="space200">
@if ($currentLogo)
<x-row gap="space200" wrap>
<img src="{{ Storage::disk('public')->url($currentLogo) }}" alt="{{ __('Site Logo') }}" class="admin-settings-logo" />
<x-button :label="__('Remove Logo')" icon="delete" color="error" wire:click="$set('confirmingLogoRemoval', true)" data-test="remove-logo" />
</x-row>
@endif
<x-file full wire:model="siteLogo" :label="__('Logo')" accept="image/*,.svg,.svgz" :hint="__('Max 2MB. Recommended: PNG or SVG.')" />
@if ($siteLogo && is_object($siteLogo))
@if (str_contains($siteLogo->getMimeType(), 'svg'))
<p class="md-type-body-md md-ink-variant">{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}</p>
@else
<x-stack gap="space50">
<p class="md-type-label-lg md-ink-variant">{{ __('Preview:') }}</p>
<img src="{{ $siteLogo->temporaryUrl() }}" alt="{{ __('Logo preview') }}" class="admin-settings-logo" />
</x-stack>
@endif
@endif
</x-stack>
</x-stack>
</x-card>
<x-card :title="__('Upload Protection')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-stack gap="space100">
<x-password full
wire:model="systemPassword"
label="{{ __('System Upload Password') }}"
hint="{{ __('Leave blank to keep current. Set a password to require it before uploading.') }}"
:label="__('System Upload Password')"
:hint="__('Leave blank to keep current. Set a password to require it before uploading.')"
autocomplete="new-password"
/>
@if ($hasSystemPassword)
<div class="mt-2">
<x-button
label="{{ __('Clear System Password') }}"
class="btn-sm btn-ghost text-error"
wire:click="clearSystemPassword"
wire:confirm="{{ __('Remove the system password?') }}"
/>
</div>
<x-button :label="__('Clear System Password')" icon="lock_reset" color="error" wire:click="$set('confirmingPasswordRemoval', true)" data-test="clear-system-password" />
@endif
</div>
</div>
</x-stack>
</x-stack>
</x-card>
<x-card title="{{ __('Upload Limits') }}" shadow class="mb-6">
<div class="space-y-4">
<x-toggle
wire:model.live="allowNeverExpire"
label="{{ __('Allow shares to never expire') }}"
hint="{{ __('When disabled, users must select an expiration time.') }}"
{{-- How the upload page offers random share passwords (App\Services\PasswordGeneratorService).
The example is drawn from the form as it stands, before saving. --}}
<x-card :title="__('Share Passwords')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-group
wire:model.live="passwordGeneratorMode"
:label="__('Password generator')"
:hint="match ($passwordGeneratorMode) {
'off' => __('Uploaders type a password themselves.'),
'prefill' => __('A random password is filled in as soon as Password protect is switched on. Generate draws a new one.'),
default => __('A Generate button under the password field fills in a random password.'),
}"
:options="[
['id' => 'off', 'name' => __('Off')],
['id' => 'button', 'name' => __('On request')],
['id' => 'prefill', 'name' => __('Prefilled')],
]"
/>
<x-select
@if ($passwordGeneratorMode !== 'off')
<x-group
wire:model.live="passwordGeneratorType"
:label="__('Kind')"
:hint="$passwordGeneratorType === 'passphrase' ? __('Random words, easy to read out or type on a phone.') : __('Random characters, the most secure for their length.')"
:options="[
['id' => 'characters', 'name' => __('Characters')],
['id' => 'passphrase', 'name' => __('Passphrase')],
]"
/>
@if ($passwordGeneratorType === 'passphrase')
<x-input full
wire:model.live.blur="passphraseWords"
:label="__('Words')"
type="number"
:min="\App\Services\PasswordGeneratorService::MIN_WORDS"
:max="\App\Services\PasswordGeneratorService::MAX_WORDS"
:hint="__('Between :min and :max.', ['min' => \App\Services\PasswordGeneratorService::MIN_WORDS, 'max' => \App\Services\PasswordGeneratorService::MAX_WORDS])"
/>
<x-select full
wire:model.live="passphraseSeparator"
:label="__('Separator')"
:options="[
['id' => 'hyphen', 'name' => __('Hyphen (-)')],
['id' => 'dot', 'name' => __('Dot (.)')],
['id' => 'underscore', 'name' => __('Underscore (_)')],
['id' => 'space', 'name' => __('Space')],
]"
/>
@else
<x-input full
wire:model.live.blur="passwordLength"
:label="__('Length')"
type="number"
:min="\App\Services\PasswordGeneratorService::MIN_LENGTH"
:max="\App\Services\PasswordGeneratorService::MAX_LENGTH"
:suffix="__('characters')"
:hint="__('Between :min and :max.', ['min' => \App\Services\PasswordGeneratorService::MIN_LENGTH, 'max' => \App\Services\PasswordGeneratorService::MAX_LENGTH])"
/>
<x-group
multiple
wire:model.live="passwordCharacterSets"
:label="__('Include')"
:hint="__('Uppercase letters, lowercase letters, numbers and symbols.')"
:options="[
['id' => 'uppercase', 'name' => 'AZ'],
['id' => 'lowercase', 'name' => 'az'],
['id' => 'numbers', 'name' => '09'],
['id' => 'symbols', 'name' => '#$%'],
]"
/>
<x-checkbox
wire:model.live="passwordAvoidAmbiguous"
:label="__('Avoid look-alike characters')"
:hint="__('Leaves out 0, O, 1, l and I.')"
/>
@endif
@if ($passwordExample)
<x-input full
:label="__('Example')"
:value="$passwordExample"
:hint="__('About :bits bits of entropy.', ['bits' => $passwordEntropy])"
readonly
mono
data-test="password-example"
/>
@endif
@endif
</x-stack>
</x-card>
<x-card :title="__('Upload Limits')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-toggle
wire:model.live="allowNeverExpire"
:label="__('Allow shares to never expire')"
:hint="__('When disabled, users must select an expiration time.')"
right
/>
<x-select full
wire:model="defaultExpiration"
label="{{ __('Default Expiration') }}"
:label="__('Default Expiration')"
:placeholder="$allowNeverExpire ? __('None') : null"
:options="[
['id' => '1h', 'name' => __('1 Hour')],
@@ -106,46 +171,53 @@
]"
/>
<x-input
<x-input full
wire:model="maxFileSize"
label="{{ __('Max file size (MB)') }}"
:label="__('Max file size (MB)')"
type="number"
min="1"
max="{{ $phpMaxUploadMb }}"
suffix="MB"
hint="{{ __('PHP limit: :max MB (upload_max_filesize / post_max_size)', ['max' => $phpMaxUploadMb]) }}"
/>
<x-input
wire:model="maxFilesPerShare"
label="{{ __('Max files per share') }}"
type="number"
min="1"
/>
<x-input full wire:model="maxFilesPerShare" :label="__('Max files per share')" type="number" min="1" />
<x-input
wire:model="maxSizePerShare"
label="{{ __('Max total size per share (GB)') }}"
type="number"
min="1"
suffix="GB"
/>
</div>
<x-input full wire:model="maxSizePerShare" :label="__('Max total size per share (GB)')" type="number" min="1" suffix="GB" />
</x-stack>
</x-card>
<x-card title="{{ __('Storage') }}" shadow class="mb-6">
<div class="space-y-4">
<x-input
<x-card :title="__('Storage')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-input full
wire:model="maxStorageQuota"
label="{{ __('Max storage quota (GB)') }}"
:label="__('Max storage quota (GB)')"
type="number"
min="1"
suffix="GB"
hint="{{ __('When reached, new uploads are blocked.') }}"
:hint="__('When reached, new uploads are blocked.')"
/>
</div>
</x-stack>
</x-card>
<x-button type="submit" label="{{ __('Save Settings') }}" class="btn-primary w-full" icon="o-check" spinner="saveSettings" />
</form>
</div>
<x-slot:actions>
<x-button type="submit" :label="__('Save Settings')" variant="filled" icon="check" spinner="saveSettings" data-test="save-settings" />
</x-slot:actions>
</x-form>
<x-modal wire:model="confirmingLogoRemoval" :title="__('Remove the logo?')" icon="delete">
{{ __('The upload, download and sign-in pages show only the site title.') }}
<x-slot:actions>
<x-button :label="__('Cancel')" x-on:click="close()" />
<x-button :label="__('Remove')" danger wire:click="removeLogo" data-test="confirm-remove-logo" />
</x-slot:actions>
</x-modal>
<x-modal wire:model="confirmingPasswordRemoval" :title="__('Remove the system password?')" icon="lock_open">
{{ __('Anyone who can reach the upload page can upload files again.') }}
<x-slot:actions>
<x-button :label="__('Cancel')" x-on:click="close()" />
<x-button :label="__('Remove')" danger wire:click="clearSystemPassword" data-test="confirm-clear-system-password" />
</x-slot:actions>
</x-modal>
</x-page>
+124 -174
View File
@@ -1,184 +1,131 @@
<div class="max-w-3xl mx-auto">
<div class="text-center mb-6">
@if ($siteLogo)
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="h-20 w-auto mx-auto mb-4" />
@else
<x-app-logo-icon class="h-16 w-16 mx-auto mb-4" />
@endif
<h1 class="text-2xl font-bold">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
<p class="mt-2 opacity-70">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
</div>
@if ($isStorageFull)
<div class="alert alert-warning mb-6">
<x-icon name="o-exclamation-triangle" class="w-5 h-5" />
<span>{{ __('Storage is full. Uploads are temporarily disabled.') }}</span>
</div>
<x-page brand>
{{-- Files this page already uploaded count towards the quota: they can still become a share. --}}
@if ($isStorageFull && $pendingFiles->isEmpty())
<x-alert color="warning" :title="__('Storage is full. Uploads are temporarily disabled.')" />
@else
<form
<x-form
wire:submit="createShare"
x-data="{
uploading: false,
progress: 0,
dragging: false,
handleDrop(e) {
this.dragging = false;
const items = e.dataTransfer.items;
const files = [];
for (let i = 0; i < items.length; i++) {
const entry = items[i].webkitGetAsEntry?.();
if (entry) {
this.traverseEntry(entry, '', files);
} else if (items[i].kind === 'file') {
files.push({ file: items[i].getAsFile(), path: null });
}
}
setTimeout(() => {
if (! files.length) {
return;
}
const dt = new DataTransfer();
const paths = [];
files.forEach(f => {
dt.items.add(f.file);
paths.push(f.path);
});
$wire.relativePaths = [...($wire.relativePaths ?? []), ...paths];
this.uploading = true;
this.progress = 0;
$wire.uploadMultiple(
'files',
dt.files,
() => this.progress = 100,
() => this.resetUpload(),
(event) => this.progress = event.detail.progress,
() => this.resetUpload(),
);
}, 500);
},
resetUpload() {
this.uploading = false;
this.progress = 0;
},
traverseEntry(entry, path, files) {
if (entry.isFile) {
entry.file(file => {
files.push({ file, path: path ? path + '/' + file.name : null });
});
} else if (entry.isDirectory) {
const reader = entry.createReader();
reader.readEntries(entries => {
entries.forEach(e => this.traverseEntry(e, path ? path + '/' + entry.name : entry.name, files));
});
}
}
}"
x-init="$wire.$on('files-processed', () => resetUpload())"
x-on:livewire-upload-start="uploading = true; progress = 0"
x-on:livewire-upload-finish="progress = 100"
x-on:livewire-upload-cancel="resetUpload()"
x-on:livewire-upload-error="resetUpload()"
x-on:livewire-upload-progress="progress = $event.detail.progress"
x-data="shareUploader({
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
messages: {{ \Illuminate\Support\Js::from([
'queued' => __('Waiting'),
'uploaded' => __('Uploaded'),
'failed' => __('Upload failed'),
'sessionExpired' => __('Your session expired. Reload the page to upload again.'),
]) }},
})"
x-on:beforeunload.window="warnBeforeLeaving($event)"
>
{{-- Drop Zone --}}
{{-- WebCrypto, which encrypts the files in the browser, only exists on HTTPS (or localhost). --}}
<div x-show="! secure" x-cloak data-test="insecure-context">
<x-alert color="warning" :title="__('Uploads need a secure connection (HTTPS).')" :description="__('Ask the administrator to serve this site over HTTPS.')" />
</div>
{{-- Drop zone: the shape behind the icon turns into a burst while files are over it. --}}
<div
class="border-2 border-dashed rounded-xl p-8 text-center transition-colors mb-6"
:class="{
'border-primary bg-primary/5': dragging,
'border-base-300 hover:border-primary/50': !dragging,
'opacity-50 pointer-events-none': uploading
}"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="handleDrop($event)"
class="upload-drop-zone"
x-bind:data-dragging="dragging ? 'true' : 'false'"
x-bind:aria-disabled="secure ? 'false' : 'true'"
x-on:dragover.prevent="dragging = true"
x-on:dragleave.prevent="dragging = false"
x-on:drop.prevent="handleDrop($event)"
data-test="drop-zone"
>
<x-icon name="o-cloud-arrow-up" class="w-12 h-12 mx-auto opacity-40 mb-3" />
<p class="font-medium">{{ __('Drag & drop files or folders here') }}</p>
<p class="text-sm opacity-60 mt-1">{{ __('or click to browse') }}</p>
<x-stack align="center" gap="space200">
<div class="upload-drop-shapes">
<x-shape name="cookie-9" class="upload-drop-shape upload-drop-shape--idle" />
<x-shape name="soft-burst" class="upload-drop-shape upload-drop-shape--burst" data-test="drop-zone-burst" />
<x-icon name="upload" size="48" class="upload-drop-icon" />
</div>
<label class="btn btn-outline btn-sm mt-4 cursor-pointer" :class="uploading && 'btn-disabled'">
{{ __('Browse Files') }}
<input
type="file"
wire:model="files"
multiple
class="hidden"
:disabled="uploading"
/>
</label>
<x-stack align="center" gap="space50">
<p class="md-type-title-md md-text-center">{{ __('Drag & drop files or folders here') }}</p>
<p class="md-type-body-md md-ink-variant md-text-center">{{ __('or click to browse') }}</p>
</x-stack>
{{-- The button is the tab stop and opens the browser's own picker; the input only carries the selection. --}}
<x-button :label="__('Browse Files')" icon="folder_open" variant="outlined" x-on:click="$refs.picker.click()" x-bind:disabled="! secure" />
<input type="file" multiple hidden x-ref="picker" x-on:change="choose($event)" x-bind:disabled="! secure" data-test="file-input" />
</x-stack>
</div>
{{-- Upload Progress --}}
<div x-show="uploading" x-cloak class="mb-6">
<template x-if="progress < 100">
<div>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium">{{ __('Uploading...') }} <span x-text="Math.round(progress)"></span>%</span>
<button type="button" class="btn btn-ghost btn-xs" x-on:click="$wire.cancelUpload('files')">
{{ __('Cancel') }}
</button>
</div>
<progress class="progress progress-primary w-full" max="100" x-bind:value="progress"></progress>
</div>
</template>
<template x-if="progress >= 100">
<div class="flex items-center gap-3 text-sm font-medium">
<span class="loading loading-spinner loading-sm"></span>
{{ __('Processing files...') }}
</div>
</template>
{{-- Upload progress, over every file still to send --}}
<div x-show="busy" x-cloak data-test="upload-progress">
<x-stack gap="space100">
<x-row justify="between">
<span class="md-type-label-lg">{{ __('Uploading...') }} <span x-text="Math.round(progress)"></span>%</span>
<x-button :label="__('Cancel')" size="xs" x-on:click="cancel()" />
</x-row>
<x-progress bind="progress" wavy :label="__('Uploading')" />
</x-stack>
</div>
{{-- Errors --}}
@error('files')
<div class="alert alert-error mb-4">{{ $message }}</div>
<x-alert color="error">{{ $message }}</x-alert>
@enderror
{{-- File List --}}
@if (count($files))
<div class="mb-6">
<h3 class="font-semibold mb-2">{{ __('Selected Files') }} ({{ count($files) }})</h3>
<div class="space-y-1 max-h-60 overflow-y-auto">
@foreach ($files as $index => $file)
<div class="flex items-center justify-between px-3 py-2 rounded-lg bg-base-200 text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-icon name="o-document" class="w-4 h-4 flex-shrink-0" />
<span class="truncate">
{{ $relativePaths[$index] ?? $file->getClientOriginalName() }}
</span>
<span class="opacity-50 flex-shrink-0">
({{ Number::fileSize($file->getSize()) }})
</span>
</div>
<button type="button" wire:click="removeFile({{ $index }})" class="btn btn-ghost btn-xs">
<x-icon name="o-x-mark" class="w-4 h-4" />
</button>
</div>
@endforeach
{{-- Selected files --}}
@if ($pendingFiles->isNotEmpty())
<x-stack gap="space100">
<h2 class="md-type-title-lg">{{ __('Selected Files') }} ({{ $pendingFiles->count() }})</h2>
<div class="upload-file-list">
<x-list segmented :label="__('Selected Files')">
@foreach ($pendingFiles as $file)
<x-list-item
:title="$file->relative_path ?? $file->original_name"
icon="description"
wire:key="selected-file-{{ $file->id }}"
data-test="selected-file"
>
<x-slot:description>
<span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span>
· <span class="md-tabular" x-text="statusOf({{ $file->id }}, {{ $file->completed_at ? 'true' : 'false' }})" data-test="file-status"></span>
</x-slot:description>
<x-slot:end>
<span x-show="uploads[{{ $file->id }}]?.state === 'failed'" x-cloak>
<x-button icon="refresh" :aria-label="__('Retry')" x-on:click="retry({{ $file->id }})" />
</span>
<x-button icon="close" :aria-label="__('Remove')" x-on:click="remove({{ $file->id }})" />
</x-slot:end>
</x-list-item>
@endforeach
</x-list>
</div>
</div>
</x-stack>
@endif
{{-- Options --}}
<x-card title="{{ __('Share Options') }}" class="mb-6" shadow>
<div class="space-y-4">
<x-toggle wire:model.live="usePassword" label="{{ __('Password protect') }}" />
<x-card :title="__('Share Options')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-toggle wire:model.live="usePassword" :label="__('Password protect')" right />
@if ($usePassword)
<x-password wire:model="password" label="{{ __('Password') }}" />
<x-stack gap="space100">
<x-password full wire:model="password" :label="__('Password')" autocomplete="new-password" />
{{-- Generate draws one as Admin settings say (App\Services\PasswordGeneratorService); Copy takes
whatever is in the field, typed or generated, with the snackbar a copyable field shows. --}}
<x-row gap="space100" wrap>
@if ($passwordGeneratorMode !== 'off')
<x-button :label="__('Generate')" icon="password" variant="tonal" wire:click="generatePassword" spinner="generatePassword" data-test="generate-password" />
@endif
<x-button
:label="__('Copy')"
icon="content_copy"
variant="tonal"
x-on:click="navigator.clipboard.writeText($wire.password).then(() => window.materialToast({{ \Illuminate\Support\Js::from(__('Copied to the clipboard')) }}, { type: 'success' }))"
x-bind:disabled="! $wire.password"
data-test="copy-password"
/>
</x-row>
</x-stack>
@endif
<x-select
<x-select full
wire:model="expiration"
label="{{ __('Expiration') }}"
:label="__('Expiration')"
:placeholder="$allowNeverExpire ? __('Never') : null"
:options="[
['id' => '1h', 'name' => __('1 Hour')],
@@ -190,25 +137,28 @@
]"
/>
<x-input
<x-input full
wire:model="maxDownloads"
label="{{ __('Max downloads') }}"
:label="__('Max downloads')"
type="number"
min="1"
placeholder="{{ __('Unlimited') }}"
:placeholder="__('Unlimited')"
/>
</div>
</x-stack>
</x-card>
{{-- Submit --}}
<x-button
type="submit"
label="{{ __('Create Share Link') }}"
class="btn-primary w-full"
icon="o-link"
spinner="createShare"
x-bind:disabled="uploading || {{ count($files) === 0 ? 'true' : 'false' }}"
/>
</form>
<x-slot:actions>
<x-button
type="submit"
:label="__('Create Share Link')"
variant="filled"
size="md"
icon="link"
spinner="createShare"
x-bind:disabled="busy || {{ $allFilesUploaded ? 'false' : 'true' }}"
data-test="create-share"
/>
</x-slot:actions>
</x-form>
@endif
</div>
</x-page>
+38 -36
View File
@@ -1,40 +1,42 @@
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Setup SealShare')" :description="__('Create your admin account to get started')" />
<x-page brand>
<x-card :title="__('Set up SealShare')" :subtitle="__('Create your admin account to get started')" heading="h2" variant="outlined">
<x-form wire:submit="createAdmin">
<x-input
wire:model="name"
:label="__('Name')"
type="text"
required
autofocus
:placeholder="__('Admin name')"
icon="person"
/>
<form wire:submit="createAdmin" class="flex flex-col gap-6">
<x-input
wire:model="name"
label="{{ __('Name') }}"
type="text"
required
autofocus
placeholder="{{ __('Admin name') }}"
icon="o-user"
/>
<x-input
wire:model="email"
:label="__('Email address')"
type="email"
required
placeholder="admin@example.com"
icon="mail"
/>
<x-input
wire:model="email"
label="{{ __('Email address') }}"
type="email"
required
placeholder="admin@example.com"
icon="o-envelope"
/>
<x-password
wire:model="password"
:label="__('Password')"
required
:placeholder="__('Password')"
/>
<x-password
wire:model="password"
label="{{ __('Password') }}"
required
placeholder="{{ __('Password') }}"
/>
<x-password
wire:model="password_confirmation"
:label="__('Confirm password')"
required
:placeholder="__('Confirm password')"
/>
<x-password
wire:model="password_confirmation"
label="{{ __('Confirm password') }}"
required
placeholder="{{ __('Confirm password') }}"
/>
<x-button type="submit" label="{{ __('Create Admin Account') }}" class="btn-primary w-full" spinner="createAdmin" />
</form>
</div>
<x-slot:actions>
<x-button type="submit" :label="__('Create Admin Account')" variant="filled" spinner="createAdmin" />
</x-slot:actions>
</x-form>
</x-card>
</x-page>
@@ -1,65 +1,90 @@
<div class="max-w-lg mx-auto">
<x-card title="{{ __('Share Created!') }}" subtitle="{{ __('Your files are ready to share') }}" shadow>
<div class="space-y-4">
{{-- Share URL --}}
<div
x-data="{
copied: false,
url: '{{ route('share.download', $share) }}',
async copy() {
try {
await navigator.clipboard.writeText(this.url);
this.copied = true;
setTimeout(() => this.copied = false, 2000);
} catch (e) {}
}
}"
>
<label class="label font-medium text-sm">{{ __('Share Link') }}</label>
<div class="join w-full">
<input
type="text"
readonly
:value="url"
class="input input-bordered join-item w-full"
/>
<button type="button" @click="copy()" class="btn join-item">
<span x-show="!copied">{{ __('Copy') }}</span>
<span x-show="copied" class="text-success">{{ __('Copied!') }}</span>
</button>
</div>
</div>
{{-- Details --}}
<div class="grid grid-cols-2 gap-3 text-sm">
<div class="bg-base-200 rounded-lg p-3">
<span class="opacity-60">{{ __('Files') }}</span>
<p class="font-semibold">{{ $share->files->count() }}</p>
</div>
<div class="bg-base-200 rounded-lg p-3">
<span class="opacity-60">{{ __('Total Size') }}</span>
<p class="font-semibold">{{ Number::fileSize($share->total_size) }}</p>
</div>
<div class="bg-base-200 rounded-lg p-3">
<span class="opacity-60">{{ __('Expires') }}</span>
<p class="font-semibold">{{ $share->expires_at ? $share->expires_at->diffForHumans() : __('Never') }}</p>
</div>
<div class="bg-base-200 rounded-lg p-3">
<span class="opacity-60">{{ __('Max Downloads') }}</span>
<p class="font-semibold">{{ $share->max_downloads ?? __('Unlimited') }}</p>
</div>
</div>
@if ($share->isPasswordProtected())
<div class="alert alert-info">
<x-icon name="o-lock-closed" class="w-5 h-5" />
<span>{{ __('This share is password protected') }}</span>
</div>
@endif
<x-page :title="__('Share Created!')" :description="__('Your files are ready to share')">
{{-- The link is ready: a check on an Expressive shape that settles in (share-ready, app.css). --}}
<x-slot:mark>
<div class="share-check">
<x-shape name="soft-burst" class="share-check-shape" />
<x-icon name="check" size="48" class="share-check-icon" />
</div>
</x-slot:mark>
<x-slot:actions>
<x-button label="{{ __('Upload More') }}" link="{{ route('upload') }}" icon="o-plus" />
</x-slot:actions>
</x-card>
</div>
<x-stack gap="space200">
{{-- 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. --}}
<x-stack
gap="space100"
x-data="shareActions({
url: {{ \Illuminate\Support\Js::from($shareUrl) }},
title: {{ \Illuminate\Support\Js::from($siteTitle) }},
filename: {{ \Illuminate\Support\Js::from('share-'.$share->token.'.png') }},
messages: {{ \Illuminate\Support\Js::from(['shareFailed' => __('The share sheet could not open.'), 'downloadFailed' => __('The QR code could not be saved.')]) }},
})"
data-test="share-actions"
>
<x-input
:label="__('Share Link')"
:value="$shareUrl"
readonly
copyable
mono
icon="link"
data-test="share-link"
/>
{{-- Only on the visit the upload redirects to: the password is flashed once (FileUploader::createShare).
Masked, with the link's copy button at its end, so it is copied without reaching the screen. --}}
@if ($password)
<x-input
type="password"
:label="__('Password')"
:value="$password"
:hint="__('Available only this once. Send it separately from the link.')"
readonly
copyable
icon="key"
autocomplete="off"
data-test="share-password"
/>
@endif
<x-row gap="space100" wrap>
<x-button :label="__('Show QR code')" icon="qr_code_2" variant="tonal" x-on:click="open = true" data-test="show-qr-code" />
<span x-show="canShare" x-cloak>
<x-button :label="__('Share…')" icon="share" variant="tonal" x-on:click="share()" data-test="share-sheet" />
</span>
</x-row>
<x-modal fullscreen :title="__('Scan to open the share')" data-test="qr-code-dialog">
<x-stack gap="space200">
{{-- The quiet zone is baked into the SVG (App\Services\QrCodeService), white in
either theme so a scanner keeps its contrast; the container adds no colour. --}}
<div data-qr-code class="share-qr">{!! $qrCodeSvg !!}</div>
@if ($share->isPasswordProtected())
<x-alert color="info" icon="lock" :title="__('Recipients also need the password.')" />
@endif
</x-stack>
<x-slot:actions>
<x-button :label="__('Close')" x-on:click="close()" />
<x-button :label="__('Download')" icon="download" variant="tonal" x-on:click="downloadQrCode($el.closest('dialog').querySelector('[data-qr-code] svg'))" data-test="download-qr-code" />
</x-slot:actions>
</x-modal>
</x-stack>
<x-grid :columns="2" gap="space200">
<x-stat :title="__('Files')" :value="$share->files->count()" icon="description" />
<x-stat :title="__('Total Size')" :value="Number::fileSize($share->total_size)" icon="hard_drive" />
<x-stat :title="__('Expires')" :value="$share->expires_at ? $share->expires_at->diffForHumans() : __('Never')" icon="schedule" />
<x-stat :title="__('Max Downloads')" :value="$share->max_downloads ?? __('Unlimited')" icon="download" />
</x-grid>
@if ($share->isPasswordProtected())
<x-alert color="info" icon="lock" :title="__('This share is password protected')" />
@endif
<x-row justify="end">
<x-button :label="__('Upload More')" :link="route('upload')" icon="add" variant="tonal" />
</x-row>
</x-stack>
</x-page>
@@ -1,69 +1,58 @@
<div class="w-full max-w-lg mx-auto">
<div class="text-center mb-6">
@if ($siteLogo)
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="h-20 w-auto mx-auto mb-4" />
@else
<x-app-logo-icon class="h-16 w-16 mx-auto mb-4" />
@endif
<h1 class="text-2xl font-bold">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
<p class="mt-2 opacity-70">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
</div>
{{-- The page a recipient opens. No anchored components (menus, tooltips) on it: it has to work on
iOS before Safari 18.4, which cannot position them. --}}
<x-page brand>
{{-- Each state is one card under the page's h1: the card holds everything the recipient acts
on, and it is the shape SealShare has always shown them. --}}
@if (! $authenticated)
{{-- Password form --}}
<form wire:submit="verifyPassword">
<x-card title="{{ __('Password Required') }}" subtitle="{{ __('Enter the password to access these files') }}" shadow>
<x-card :title="__('Password Required')" :subtitle="__('Enter the password to access these files')" heading="h2" variant="outlined">
<x-form wire:submit="verifyPassword">
<x-password
full
wire:model="password"
label="{{ __('Password') }}"
:label="__('Password')"
required
placeholder="{{ __('Enter share password') }}"
autocomplete="off"
:placeholder="__('Enter share password')"
/>
<x-slot:actions>
<x-button type="submit" label="{{ __('Unlock') }}" class="btn-primary w-full mt-4" icon="o-lock-open" spinner="verifyPassword" />
<x-button type="submit" :label="__('Unlock')" variant="filled" icon="lock_open" spinner="verifyPassword" />
</x-slot:actions>
</x-card>
</form>
</x-form>
</x-card>
@else
{{-- File list --}}
<x-card title="{{ __('Shared Files') }}" shadow>
<div class="space-y-2 mb-4">
@foreach ($share->files as $file)
<div class="flex items-center justify-between px-3 py-2 rounded-lg bg-base-200 text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-icon name="o-document" class="w-4 h-4 flex-shrink-0" />
<span class="truncate">{{ $file->relative_path ?: $file->original_name }}</span>
<span class="opacity-50 flex-shrink-0">({{ Number::fileSize($file->file_size) }})</span>
</div>
<a href="{{ route('share.download.file', [$share, $file]) }}" class="btn btn-ghost btn-xs">
<x-icon name="o-arrow-down-tray" class="w-4 h-4" />
</a>
</div>
@endforeach
</div>
<x-card :title="__('Shared Files')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-stack gap="space100">
<x-list :label="__('Shared Files')">
@foreach ($share->files as $file)
<x-list-item
:title="$file->relative_path ?: $file->original_name"
icon="description"
wire:key="file-{{ $file->id }}"
>
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span></x-slot:description>
<x-slot:end>
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" />
</x-slot:end>
</x-list-item>
@endforeach
</x-list>
@if ($share->expires_at)
<p class="text-xs opacity-50 mb-2">
{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}
</p>
@endif
@if ($share->expires_at)
<p class="md-type-body-sm md-ink-variant">{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}</p>
@endif
</x-stack>
<x-slot:actions>
@if ($share->files->count() > 1)
<a href="{{ route('share.download.all', $share) }}" class="btn btn-primary w-full">
<x-icon name="o-arrow-down-tray" class="w-4 h-4" />
{{ __('Download All as ZIP') }}
</a>
@else
<a href="{{ route('share.download.file', [$share, $share->files->first()]) }}" class="btn btn-primary w-full">
<x-icon name="o-arrow-down-tray" class="w-4 h-4" />
{{ __('Download') }}
</a>
@endif
</x-slot:actions>
<x-row justify="end">
@if ($share->files->count() > 1)
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate />
@else
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $share->files->first()])" no-wire-navigate />
@endif
</x-row>
</x-stack>
</x-card>
@endif
</div>
</x-page>
@@ -1,14 +1,16 @@
<div class="flex flex-col gap-6">
<x-auth-header :title="__('System Password Required')" :description="__('Enter the system password to access the upload page')" />
<x-page brand>
<x-card :title="__('System password required')" :subtitle="__('Enter the system password to access the upload page')" heading="h2" variant="outlined">
<x-form wire:submit="verify">
<x-password
wire:model="password"
:label="__('Password')"
required
:placeholder="__('System password')"
/>
<form wire:submit="verify" class="flex flex-col gap-6">
<x-password
wire:model="password"
label="{{ __('Password') }}"
required
placeholder="{{ __('System password') }}"
/>
<x-button type="submit" label="{{ __('Continue') }}" class="btn-primary w-full" spinner="verify" />
</form>
</div>
<x-slot:actions>
<x-button type="submit" :label="__('Continue')" variant="filled" spinner="verify" />
</x-slot:actions>
</x-form>
</x-card>
</x-page>
@@ -1,24 +1,30 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<x-auth-header
<x-layouts::app :title="__('Confirm password')">
<x-page brand>
<x-card
:title="__('Confirm password')"
:description="__('This is a secure area of the application. Please confirm your password before continuing.')"
/>
:subtitle="__('This is a secure area of the application. Please confirm your password before continuing.')"
heading="h2"
variant="outlined"
>
<x-stack gap="space300">
<x-auth-session-status :status="session('status')" />
<x-auth-session-status class="text-center" :status="session('status')" />
<x-form method="POST" action="{{ route('password.confirm.store') }}">
@csrf
<form method="POST" action="{{ route('password.confirm.store') }}" class="flex flex-col gap-6">
@csrf
<x-password
name="password"
:label="__('Password')"
required
autofocus
autocomplete="current-password"
/>
<x-password
name="password"
label="{{ __('Password') }}"
required
autocomplete="current-password"
placeholder="{{ __('Password') }}"
/>
<x-button type="submit" label="{{ __('Confirm') }}" class="btn-primary w-full" data-test="confirm-password-button" />
</form>
</div>
</x-layouts::auth>
<x-slot:actions>
<x-button type="submit" :label="__('Confirm')" variant="filled" data-test="confirm-password-button" />
</x-slot:actions>
</x-form>
</x-stack>
</x-card>
</x-page>
</x-layouts::app>
@@ -1,30 +1,33 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Forgot password')" :description="__('Enter your email to receive a password reset link')" />
<x-layouts::app :title="__('Forgot password')">
<x-page brand>
<x-card :title="__('Forgot password')" :subtitle="__('Enter your email to receive a password reset link')" heading="h2" variant="outlined">
<x-stack gap="space300">
<x-auth-session-status :status="session('status')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<x-form method="POST" action="{{ route('password.email') }}">
@csrf
<form method="POST" action="{{ route('password.email') }}" class="flex flex-col gap-6">
@csrf
<x-input
name="email"
:label="__('Email Address')"
:value="old('email')"
type="email"
required
autofocus
placeholder="email@example.com"
icon="mail"
/>
<!-- Email Address -->
<x-input
name="email"
label="{{ __('Email Address') }}"
type="email"
required
autofocus
placeholder="email@example.com"
icon="o-envelope"
/>
<x-slot:actions>
<x-button type="submit" :label="__('Email password reset link')" variant="filled" data-test="email-password-reset-link-button" />
</x-slot:actions>
</x-form>
<x-button type="submit" label="{{ __('Email password reset link') }}" class="btn-primary w-full" data-test="email-password-reset-link-button" />
</form>
<div class="space-x-1 rtl:space-x-reverse text-center text-sm opacity-60">
<span>{{ __('Or, return to') }}</span>
<a href="{{ route('login') }}" class="link link-primary" wire:navigate>{{ __('log in') }}</a>
</div>
</div>
</x-layouts::auth>
<p class="md-type-body-md md-ink-variant md-text-center">
{{ __('Or, return to') }}
<a href="{{ route('login') }}" class="md-link" wire:navigate>{{ __('log in') }}</a>
</p>
</x-stack>
</x-card>
</x-page>
</x-layouts::app>
+42 -53
View File
@@ -1,59 +1,48 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Log in to your account')" :description="__('Enter your email and password below to log in')" />
<x-layouts::app :title="__('Log in')">
<x-page brand>
<x-card :title="__('Log in')" :subtitle="__('Enter your email and password below to log in')" heading="h2" variant="outlined">
<x-stack gap="space300">
<x-auth-session-status :status="session('status')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<x-form method="POST" action="{{ route('login.store') }}">
@csrf
<form method="POST" action="{{ route('login.store') }}" class="flex flex-col gap-6">
@csrf
<x-input
name="email"
:label="__('Email address')"
:value="old('email')"
type="email"
required
autofocus
autocomplete="email"
placeholder="email@example.com"
icon="mail"
/>
<!-- Email Address -->
<x-input
name="email"
label="{{ __('Email address') }}"
:value="old('email')"
type="email"
required
autofocus
autocomplete="email"
placeholder="email@example.com"
icon="o-envelope"
/>
<x-stack gap="space50">
<x-password
name="password"
:label="__('Password')"
required
autocomplete="current-password"
/>
<!-- Password -->
<div class="relative">
<x-password
name="password"
label="{{ __('Password') }}"
required
autocomplete="current-password"
placeholder="{{ __('Password') }}"
/>
@if (Route::has('password.request'))
<x-row justify="end">
<a class="md-link md-type-label-lg" href="{{ route('password.request') }}" wire:navigate>
{{ __('Forgot your password?') }}
</a>
</x-row>
@endif
</x-stack>
@if (Route::has('password.request'))
<a class="absolute top-0 text-sm end-0 link link-primary" href="{{ route('password.request') }}" wire:navigate>
{{ __('Forgot your password?') }}
</a>
@endif
</div>
<x-checkbox name="remember" :label="__('Remember me')" :checked="(bool) old('remember')" />
<!-- Remember Me -->
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="remember" class="checkbox checkbox-sm" {{ old('remember') ? 'checked' : '' }} />
<span class="text-sm">{{ __('Remember me') }}</span>
</label>
<div class="flex items-center justify-end">
<x-button type="submit" label="{{ __('Log in') }}" class="btn-primary w-full" data-test="login-button" />
</div>
</form>
@if (Route::has('register'))
<div class="space-x-1 text-sm text-center rtl:space-x-reverse opacity-60">
<span>{{ __('Don\'t have an account?') }}</span>
<a href="{{ route('register') }}" class="link link-primary" wire:navigate>{{ __('Sign up') }}</a>
</div>
@endif
</div>
</x-layouts::auth>
<x-slot:actions>
<x-button type="submit" :label="__('Log in')" variant="filled" data-test="login-button" />
</x-slot:actions>
</x-form>
</x-stack>
</x-card>
</x-page>
</x-layouts::app>
@@ -1,63 +0,0 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Create an account')" :description="__('Enter your details below to create your account')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('register.store') }}" class="flex flex-col gap-6">
@csrf
<!-- Name -->
<x-input
name="name"
label="{{ __('Name') }}"
:value="old('name')"
type="text"
required
autofocus
autocomplete="name"
placeholder="{{ __('Full name') }}"
icon="o-user"
/>
<!-- Email Address -->
<x-input
name="email"
label="{{ __('Email address') }}"
:value="old('email')"
type="email"
required
autocomplete="email"
placeholder="email@example.com"
icon="o-envelope"
/>
<!-- Password -->
<x-password
name="password"
label="{{ __('Password') }}"
required
autocomplete="new-password"
placeholder="{{ __('Password') }}"
/>
<!-- Confirm Password -->
<x-password
name="password_confirmation"
label="{{ __('Confirm password') }}"
required
autocomplete="new-password"
placeholder="{{ __('Confirm password') }}"
/>
<div class="flex items-center justify-end">
<x-button type="submit" label="{{ __('Create account') }}" class="btn-primary w-full" data-test="register-user-button" />
</div>
</form>
<div class="space-x-1 rtl:space-x-reverse text-center text-sm opacity-60">
<span>{{ __('Already have an account?') }}</span>
<a href="{{ route('login') }}" class="link link-primary" wire:navigate>{{ __('Log in') }}</a>
</div>
</div>
</x-layouts::auth>
@@ -1,47 +1,42 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Reset password')" :description="__('Please enter your new password below')" />
<x-layouts::app :title="__('Reset password')">
<x-page brand>
<x-card :title="__('Reset password')" :subtitle="__('Please enter your new password below')" heading="h2" variant="outlined">
<x-stack gap="space300">
<x-auth-session-status :status="session('status')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<x-form method="POST" action="{{ route('password.update') }}">
@csrf
<input type="hidden" name="token" value="{{ request()->route('token') }}">
<form method="POST" action="{{ route('password.update') }}" class="flex flex-col gap-6">
@csrf
<!-- Token -->
<input type="hidden" name="token" value="{{ request()->route('token') }}">
<x-input
name="email"
:value="old('email', request('email'))"
:label="__('Email')"
type="email"
required
autocomplete="email"
icon="mail"
/>
<!-- Email Address -->
<x-input
name="email"
value="{{ request('email') }}"
label="{{ __('Email') }}"
type="email"
required
autocomplete="email"
icon="o-envelope"
/>
<x-password
name="password"
:label="__('Password')"
required
autocomplete="new-password"
/>
<!-- Password -->
<x-password
name="password"
label="{{ __('Password') }}"
required
autocomplete="new-password"
placeholder="{{ __('Password') }}"
/>
<x-password
name="password_confirmation"
:label="__('Confirm password')"
required
autocomplete="new-password"
/>
<!-- Confirm Password -->
<x-password
name="password_confirmation"
label="{{ __('Confirm password') }}"
required
autocomplete="new-password"
placeholder="{{ __('Confirm password') }}"
/>
<div class="flex items-center justify-end">
<x-button type="submit" label="{{ __('Reset password') }}" class="btn-primary w-full" data-test="reset-password-button" />
</div>
</form>
</div>
</x-layouts::auth>
<x-slot:actions>
<x-button type="submit" :label="__('Reset password')" variant="filled" data-test="reset-password-button" />
</x-slot:actions>
</x-form>
</x-stack>
</x-card>
</x-page>
</x-layouts::app>
@@ -1,90 +1,65 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<div
class="relative w-full h-auto"
x-cloak
x-data="{
showRecoveryInput: @js($errors->has('recovery_code')),
code: '',
recovery_code: '',
toggleInput() {
this.showRecoveryInput = !this.showRecoveryInput;
this.code = '';
this.recovery_code = '';
$nextTick(() => {
this.showRecoveryInput
? this.$refs.recovery_code?.focus()
: this.$refs.code?.focus();
});
},
}"
>
<div x-show="!showRecoveryInput">
<x-auth-header
:title="__('Authentication Code')"
:description="__('Enter the authentication code provided by your authenticator application.')"
/>
</div>
<x-layouts::app :title="__('Two-factor authentication')">
<x-page brand>
<x-card :title="__('Two-factor authentication')" heading="h2" variant="outlined">
<x-stack
gap="space300"
x-data="{
showRecoveryInput: {{ \Illuminate\Support\Js::from($errors->has('recovery_code')) }},
toggleInput() {
this.showRecoveryInput = ! this.showRecoveryInput;
$nextTick(() => {
requestAnimationFrame(() => {
(this.showRecoveryInput ? $refs.recovery : $refs.code)?.querySelector('input')?.focus();
});
});
},
}"
>
<p class="md-type-body-md md-ink-variant" x-show="! showRecoveryInput">
{{ __('Enter the authentication code provided by your authenticator application.') }}
</p>
<div x-show="showRecoveryInput">
<x-auth-header
:title="__('Recovery Code')"
:description="__('Please confirm access to your account by entering one of your emergency recovery codes.')"
/>
</div>
<p class="md-type-body-md md-ink-variant" x-show="showRecoveryInput" x-cloak>
{{ __('Please confirm access to your account by entering one of your emergency recovery codes.') }}
</p>
<form method="POST" action="{{ route('two-factor.login.store') }}">
@csrf
<x-form method="POST" action="{{ route('two-factor.login.store') }}">
@csrf
<div class="space-y-5 text-center">
<div x-show="!showRecoveryInput">
<div class="my-5">
<x-input
type="text"
name="code"
x-ref="code"
x-model="code"
x-bind:required="!showRecoveryInput"
autocomplete="one-time-code"
placeholder="000000"
class="text-center text-2xl tracking-widest"
maxlength="6"
/>
</div>
<div x-ref="code" x-show="! showRecoveryInput">
<x-input
name="code"
:label="__('Code')"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
mono
autofocus
x-bind:disabled="showRecoveryInput"
/>
</div>
<div x-show="showRecoveryInput">
<div class="my-5">
<x-input
type="text"
name="recovery_code"
x-ref="recovery_code"
x-bind:required="showRecoveryInput"
autocomplete="one-time-code"
x-model="recovery_code"
/>
</div>
@error('recovery_code')
<p class="text-error text-sm">{{ $message }}</p>
@enderror
<div x-ref="recovery" x-show="showRecoveryInput" x-cloak>
<x-input
name="recovery_code"
:label="__('Recovery code')"
autocomplete="one-time-code"
mono
x-bind:disabled="! showRecoveryInput"
/>
</div>
<x-button
type="submit"
label="{{ __('Continue') }}"
class="btn-primary w-full"
/>
</div>
<x-slot:actions>
<x-button type="submit" :label="__('Continue')" variant="filled" />
</x-slot:actions>
</x-form>
<div class="mt-5 space-x-0.5 text-sm leading-5 text-center">
<span class="opacity-50">{{ __('or you can') }}</span>
<div class="inline font-medium underline cursor-pointer opacity-80">
<span x-show="!showRecoveryInput" @click="toggleInput()">{{ __('login using a recovery code') }}</span>
<span x-show="showRecoveryInput" @click="toggleInput()">{{ __('login using an authentication code') }}</span>
</div>
</div>
</form>
</div>
</div>
</x-layouts::auth>
<p class="md-type-body-md md-ink-variant md-text-center">
{{ __('or you can') }}
<button type="button" class="md-link" x-show="! showRecoveryInput" x-on:click="toggleInput()">{{ __('login using a recovery code') }}</button>
<button type="button" class="md-link" x-show="showRecoveryInput" x-cloak x-on:click="toggleInput()">{{ __('login using an authentication code') }}</button>
</p>
</x-stack>
</x-card>
</x-page>
</x-layouts::app>

Some files were not shown because too many files have changed in this diff Show More