15 Commits
Author SHA1 Message Date
surtic86andClaude Opus 5 e051aa1aec Release 2.2.0
linter / quality (push) Successful in 58s
tests / ci (8.5) (push) Successful in 3m11s
docker / build-and-push (push) Successful in 7m10s
docker / test (8.5) (push) Successful in 3m10s
docker / release (push) Successful in 3s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 06:13:04 +02:00
surtic86andClaude Opus 5 4530298398 Cut over-engineering found by a repo-wide audit
docker / test (8.5) (push) Successful in 3m10s
linter / quality (push) Successful in 1m5s
tests / ci (8.5) (push) Successful in 3m9s
docker / build-and-push (push) Successful in 21m5s
docker / release (push) Skipped
- Config: auth, services, logging, queue and database only repeated the
  framework's own files and are gone; the others keep only the keys that
  differ (app version, cache serializable_classes, session cookie name,
  Markdown mail theme, the shares disk, three Octane values, Livewire's
  pagination theme and payload guards).
- Email verification is removed: User never implemented MustVerifyEmail,
  so it was never enforced, and SealShare has a single admin and no
  registration. CreateNewUser goes with it.
- FileEncryptionService::encryptFile() and generateSalt() were only used
  by tests; tests build files with encryptTestFile() in tests/Pest.php.
- The expiration options are defined once, as Share::EXPIRATIONS. "30 Days"
  now lasts 30 days instead of a calendar month, and Admin settings only
  save a default expiration that is one of the options.
- One-caller helpers are inlined, the uploader reads chunk responses with
  XHR's responseType, and starter-kit leftovers are removed.
- Docker: PHP reads the PHP_* limits from the environment itself
  (${VAR:-default} in uploads.ini); both entrypoints stop writing the ini.
  docker-compose.yml shares the app and scheduler variables through one
  anchor. The dev image installs gd for the screenshot publisher and fake
  test images.
- Development runs in Docker only: the composer dev script, concurrently,
  laravel/pail, laravel/sail, autoprefixer and the shell-quote override
  are gone.
- phpunit.xml forces the test environment with <server> entries, so tests
  run in the dev container no longer use its real database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 23:14:33 +02:00
surtic86andClaude Opus 5 a62edbcefb Update PHP and npm dependencies
Patch releases of laravel/boost, pestphp/pest, phpunit/phpunit and
filp/whoops, plus refreshed transitive npm packages. Boost's update
refreshed its laravel-best-practices skill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 22:24:01 +02:00
surtic86andClaude Opus 5 026912f98a Run development on the production stack with a Vite dev server
docker-compose.dev.yml extends docker-compose.yml, so development runs the
scheduler too, and takes its settings from .env, which selects the file
through COMPOSE_FILE. The dev image is a stage of the Dockerfile and shares
the production image's PHP extensions; the app listens on port 80 as in
production.

A vite service runs the dev server with hot reload. No ports are published:
OrbStack serves https://app.sealshare.orb.local and
https://vite.sealshare.orb.local, with the ports pinned by label. Without
OrbStack, docker-compose.ports.yml publishes APP_PORT and VITE_PORT on
127.0.0.1. vite.config.js takes the dev server's address from
VITE_DEV_SERVER_URL and listens on IPv4 and IPv6, as OrbStack's proxy
connects over either.

Each start installs Composer packages, clears caches, migrates and links
storage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 22:17:36 +02:00
surtic86andClaude Opus 5 9bb144577b Stop reporting the scheduler container as unhealthy
The scheduler inherited the image's healthcheck, which asks the web server
that only the app container runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 22:17:35 +02:00
surtic86andClaude Opus 5 0e362447db Name the npm package so the lock file stops flipping
Without a name, npm writes the folder's name into package-lock.json: "app" in
the dev container, "SealShare" on the host, so every install changed the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 22:17:35 +02:00
surtic86andClaude Opus 5 82eeacea28 Show the SealShare version and links on the admin dashboard
A quiet line under the shares card reads "SealShare 2.1.0 · Release
notes · Website · Made by noNameWEB": the release notes link goes to the
installed version's Gitea release, the others to sealshare.nonameweb.ch
and nonameweb.ch, each in a new tab. It is page chrome, not content, so
it is a footer line rather than a card.

The image has neither .git nor CHANGELOG.md, so the version is a
constant in config/app.php, bumped with each release. AppVersionTest
fails while it differs from the newest released heading in
CHANGELOG.md, so a forgotten bump stops CI before the tag's image is
built. CHANGELOG (Unreleased) notes it.

The dashboard screenshots are unchanged: the line sits below their fold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 21:13:19 +02:00
Andreas Reinhold / reiniandClaude Opus 5 5d9e72fd06 Move onto livewire-material 2.2.0 and choose the material pagination theme
Set pagination_theme explicitly now that the package would otherwise take
it over itself; boost:update refreshed the package's guideline and skill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:53:03 +02:00
Andreas Reinhold / reiniandClaude Opus 5 5658ac148c Drop the Tailwind framing from a stale comment in app.css
The recovery-codes pulse comment still explained itself against
Tailwind's animate-pulse, which left the stack in 2.0.0. The reason
stands on its own: the package keeps no keyframe utility for a pulse
loop, so the loading state animates on the effects spring instead.

config/livewire.php:237 stays on Livewire's own "tailwind" default:
SealShare still installs livewire-material ^2.0 from Gitea, whose
vendor copy has no `pagination::material` view yet, so pointing
pagination_theme at it now would break pagination. The package takes
the theme over itself once 2.2.0 is tagged and pulled.
DesignLanguageTest carried no Tailwind wording to update.

Full suite (322) and Pint green, unchanged — run against the old
vendor copy, so the guard's new undeclared-class check is not
exercised here yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:23:47 +02:00
Andreas Reinhold / reiniandClaude Opus 5 845bf062ed Let the dashboard's sort dropdown span the shares card
The sort select above the admin dashboard's shares list was capped at
20rem by the admin-shares-sort wrapper. The wrapper and its rule in
app.css are gone, so the select stretches with the card's stack like
the other fields. CHANGELOG (Unreleased) notes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:38:11 +02:00
Andreas Reinhold / reiniandClaude Opus 5 e057cada3d Count a share's download limit per recipient, not per file
With a download limit of 1, downloading one file of a share with
several files deleted the share and the files not yet downloaded.
ShareService::recordDownload() ran at the end of every download
request, one file or the ZIP alike, and deleted the share as soon as
download_count reached max_downloads. It has worked that way since
the first commit.

- One recipient's visit is one download. The first file or ZIP a
  session downloads is counted when it starts, in one conditional
  UPDATE that also checks the limit, so two recipients starting at
  once can't both take the last download. The session remembers the
  time, and for ShareService::DOWNLOAD_WINDOW_MINUTES (60) it may
  start more downloads of the share without counting them, even once
  the limit is reached. The claim happens in the controller before
  streaming, because the session is saved before the body is sent,
  and after the share key is resolved, so a request without the key
  uses nothing.
- A share at its limit is closed to everyone else at once. The hourly
  cleanup deletes it 24 hours after shares.last_downloaded_at (new
  column), since a ZIP opens each file only when it reaches it and a
  large download can outlast the hour.
- The download page of a limited share says how many downloads are
  left, switches to "You have 1 hour" on the first press (Alpine, as
  a download link does not render the page again), and shows the time
  left on the next visit.
- The admin dashboard shows "2 of 3 downloads", marks shares at their
  limit "Download limit reached" and leaves them out of Active Shares.
- Tests: the regression (3 files, limit 1: every file and the ZIP
  download, counted once), another recipient, the end of the hour,
  the last download going to one of two recipients, requests refused
  before streaming, unlimited shares, the page notes in PHP and in
  Chromium, the dashboard, and the cleanup at 23 and 25 hours. The
  tests of recordDownload() and of the instant deletion are gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:35:13 +02:00
Andreas Reinhold / reiniandClaude Opus 5 c1906d2009 Stop the SQLite volume from hiding new migrations
Uploads on share.kadenpartner.ch failed with 409 on every chunk after
the update to 2.1. The example docker-compose.yml mounts the SQLite
volume over all of /app/database. Docker fills a volume from the image
only when it is created, so the container kept the 2.0 migrations and
never saw the 2.1 one. share_files.uploaded_chunks was never created,
so the chunk endpoint read null and answered 409. SQLite takes the
unknown "completed_at" in whereNull() as a string, so nothing failed
earlier.

- The image keeps a copy of its migrations in docker/migrations. On
  startup the entrypoint adds the ones missing from database/migrations
  before migrating, so installs with the old mount recover by pulling
  the new image.
- docker-compose.example.yml and docker-compose.yml mount
  sealshare_database at /app/database/sqlite and set DB_DATABASE to the
  file in it. An existing volume can be moved there without losing
  data: its database.sqlite lands at exactly that path.
- Tested with a locally built image: a volume created by 2.0.1 with the
  old mount gets the migration once (not again on restart) and keeps
  its settings; the same volume moved to the new path keeps its data;
  a fresh volume with the new layout starts healthy.
- README and CHANGELOG (Unreleased) describe the new path and how to
  switch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:53:27 +02:00
Andreas Reinhold / reiniandClaude Opus 5 7ffc1c1ead Give the website the app's Material 3 look
docker / test (8.5) (push) Successful in 3m29s
linter / quality (push) Successful in 1m21s
tests / ci (8.5) (push) Successful in 3m27s
docker / build-and-push (push) Successful in 7m38s
docker / release (push) Skipped
The site's tokens now carry the app's --md-sys-* names, with shape,
elevation, motion, state, type and spacing values copied from Livewire
Material 2.1 and the colours from the indigo profile, including its
high-contrast light and dark values for prefers-contrast: more. Cards
are outlined, the gallery uses a connected button group instead of
segmented buttons, buttons use M3 Expressive sizes, state layers and
pressed shapes, and layouts follow the M3 window classes with logical
properties. A Light/Dark/System toggle in the nav sets data-theme
before the first paint, and no text is dimmed with opacity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:58:24 +02:00
Andreas Reinhold / reiniandClaude Opus 5 1bcb868897 Describe SealShare 2.1 on the website
The site now says files are encrypted in the browser and sent in chunks,
describes the password generator and its admin settings, explains why
uploads need HTTPS and how to serve it, and replaces the old PHP upload
cap with the admin's file size limits and the chunk size. The alt texts
and captions match the retaken screenshots. The README no longer lists
Tailwind CSS, and the website rules describe the encryption and the
copied colours accurately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:37:21 +02:00
Andreas Reinhold / reiniandClaude Opus 5 e833647768 Show the password generator in the website's screenshots
The desktop upload shot fills the password with Generate, and the share
created shot now comes from creating that share, so the password is
offered beside the link. The created share takes a fixed token, so its
link and QR code read the same on every run, and is deleted before the
dashboard shot, which no longer counts the unfinished upload in its disk
usage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:33:26 +02:00
103 changed files with 2223 additions and 3764 deletions
+1 -1
View File
@@ -6,4 +6,4 @@ paths:
# Website # Website
## website/ is the live site, uploaded by hand ## 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. 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 in resources/css/material-scheme.json: the standard light and dark values (profiles.indigo.light/dark) and the high-contrast light and dark values (profiles.indigo.contrast.high) — copy them again if indigo is regenerated differently; the site does not follow the admin's colour profile. Light or dark is <html data-theme>, written before the first paint by each page's inline head script from the nav's Light/Dark/System toggle (localStorage sealshare-website-theme); the high-contrast values apply under prefers-contrast: more. 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 (files are encrypted in the browser with a key the server issues, and the server decrypts them for downloads). Nothing may load from another host except plausible.io. tests/Feature/WebsiteTest.php guards all of this.
@@ -30,8 +30,8 @@ Incorrect:
```bash ```bash
# A plaintext .env file committed to the repository # A plaintext .env file committed to the repository
STRIPE_SECRET=sk_live_abc123 STRIPE_SECRET=<your-stripe-secret>
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI AWS_SECRET_ACCESS_KEY=<your-aws-secret>
``` ```
Encrypted environment file: Encrypted environment file:
@@ -90,7 +90,7 @@ Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
## Tokens ## Tokens
2.0.0's vocabulary is plain CSS. There are no utility classes: a Tailwind class, or one of 1.x's own colour, type, shape, elevation, motion or breakpoint utilities, compiles to nothing, and the design guard names the replacement for each (see Testing the design). `resources/css/foundation.css` is the one required import, first in the entry, followed by the stylesheets of the components the views render (see Setup; 1.x's `material.css` shortcut is gone). It declares the layer order `material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility`, so an application's unlayered CSS beats every package rule, and `hidden` or `x-cloak` hides any element. It brings the reset, the page's `surface` and `on-surface` in the brand typeface smoothed in grayscale (drop Tailwind's `antialiased`), every `--md-sys-*` token, and the classes below. An application's views reach for four things, in this order: 2.0.0's vocabulary is plain CSS. There are no utility classes: one of 1.x's own colour, type, shape, elevation, motion or breakpoint utilities, or any other class no stylesheet declares, compiles to nothing, and the design guard fails on it (see Testing the design). `resources/css/foundation.css` is the one required import, first in the entry, followed by the stylesheets of the components the views render (see Setup; 1.x's `material.css` shortcut is gone). It declares the layer order `material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility`, so an application's unlayered CSS beats every package rule, and `hidden` or `x-cloak` hides any element. It brings the reset, the page's `surface` and `on-surface` in the brand typeface smoothed in grayscale, every `--md-sys-*` token, and the classes below. An application's views reach for four things, in this order:
- **A component's own props**`color`, `variant`, `size`, `dense` — before anything else. - **A component's own props**`color`, `variant`, `size`, `dense` — before anything else.
- **Layout components** for arrangement and spacing: `<x-row>`, `<x-stack>`, `<x-grid>`, `<x-feed>`, `<x-surface>` inside `<x-pane>`s (see Layout), with `gap` and `padding` as spacing tokens (`space25``space900`, 272px) and `hide-below`, `hide-from` and `stack-below` for the window size classes. Nothing arranges by class. - **Layout components** for arrangement and spacing: `<x-row>`, `<x-stack>`, `<x-grid>`, `<x-feed>`, `<x-surface>` inside `<x-pane>`s (see Layout), with `gap` and `padding` as spacing tokens (`space25``space900`, 272px) and `hide-below`, `hide-from` and `stack-below` for the window size classes. Nothing arranges by class.
@@ -99,14 +99,8 @@ Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
- Ink, from the same file: `md-ink` (on-surface), `md-ink-variant` (on-surface-variant: body copy beside a title, metadata), `md-ink-quiet` (outline: decoration only), `md-ink-primary`, `md-ink-error`, `md-ink-success`, `md-ink-warning`, `md-ink-info`, `md-ink-inverse`. On a container the ink is the container's `on-` role, which the component sets. - Ink, from the same file: `md-ink` (on-surface), `md-ink-variant` (on-surface-variant: body copy beside a title, metadata), `md-ink-quiet` (outline: decoration only), `md-ink-primary`, `md-ink-error`, `md-ink-success`, `md-ink-warning`, `md-ink-info`, `md-ink-inverse`. On a container the ink is the container's `on-` role, which the component sets.
- Text layout: `md-text-start|center|end`, `md-truncate`, `md-line-clamp-2|3`, `md-nowrap`, `md-tabular`, `md-mono`, `md-visually-hidden`. Monospace is for a value read or typed character by character (a token, a recovery code, a file name, a key): put it in `<code>`, `<kbd>` or `<samp>`, which take `--md-ref-typeface-mono` already, or give another element `md-mono` after its `md-type-*` class; `<x-input mono>` for a field. `--md-ref-typeface-mono` is the package's addition, the platform's monospace faces, not an M3 token. - Text layout: `md-text-start|center|end`, `md-truncate`, `md-line-clamp-2|3`, `md-nowrap`, `md-tabular`, `md-mono`, `md-visually-hidden`. Monospace is for a value read or typed character by character (a token, a recovery code, a file name, a key): put it in `<code>`, `<kbd>` or `<samp>`, which take `--md-ref-typeface-mono` already, or give another element `md-mono` after its `md-type-*` class; `<x-input mono>` for a field. `--md-ref-typeface-mono` is the package's addition, the platform's monospace faces, not an M3 token.
- Interaction, from `foundation/interaction.css`: `md-state-layer` (M3's hover, focus, press and dragged overlay in the content colour; makes the element `position: relative` and `isolation: isolate`; `data-md-dragged` draws the dragged layer), `md-focus-ring` (the keyboard focus indicator), `md-touch-target` (a 48px target around a smaller drawing), `md-link` (a link in running text, underlined; add `md-ink-primary` where it should read as primary). - Interaction, from `foundation/interaction.css`: `md-state-layer` (M3's hover, focus, press and dragged overlay in the content colour; makes the element `position: relative` and `isolation: isolate`; `data-md-dragged` draws the dragged layer), `md-focus-ring` (the keyboard focus indicator), `md-touch-target` (a 48px target around a smaller drawing), `md-link` (a link in running text, underlined; add `md-ink-primary` where it should read as primary).
- **The application's own stylesheet** for the rest, on the tokens, never on a literal value: - **The application's own stylesheet** for the rest, on the tokens (`var(--md-sys-color-…)`, `-shape-corner-…`, `-elevation-…`, `-motion-spatial-…`/`-motion-effects-…` each with its own `-duration`, `-measurement-space…`, `-state-…-opacity`), never on a literal value — no white, black, hex, pixel radius, shadow, size, weight, line height or duration of your own. Which family names which role, corner, level, spring or space is the `material-3-design` skill's tables, not repeated here. `motion.scheme` in the config picks `expressive` (the default) or `standard`, written to `<html data-motion>`; a rule names a spring, never a scheme.
- Colour roles, `var(--md-sys-color-…)`: `primary`, `on-primary`, `primary-container`, `on-primary-container`, `primary-dim`, `inverse-primary`, `primary-fixed`, `primary-fixed-dim`, `on-primary-fixed`, `on-primary-fixed-variant`; the same for `secondary` and `tertiary`; `error`, `on-error`, `error-container`, `on-error-container`, `error-dim`; `success`, `warning` and `info` with their `on-`, `-container` and `on-…-container`; `inverse-error|success|warning|info`; `surface`, `surface-dim`, `surface-bright`, `surface-container-lowest|low||high|highest`, `on-surface`, `on-surface-variant`, `inverse-surface`, `inverse-on-surface`, `outline` (a boundary that must be read), `outline-variant` (a divider, a card's edge), `scrim`, `shadow`. There is no white or black. - Names those tables leave out: the weights `--md-ref-typeface-weight-regular|medium|bold`; the state layers `--md-sys-state-{hover|focus|pressed|dragged}-state-layer-opacity` and `--md-sys-state-disabled-{content|container}-opacity`, mixed into a role with `color-mix()`; and the colour roles `inverse-error|success|warning|info` and `shadow`.
- Type: `font: var(--md-sys-typescale-body-md)` with `letter-spacing: var(--md-sys-typescale-body-md-tracking)`, never a size, weight or line height of your own. The typefaces are `--md-ref-typeface-brand` (Google Sans Flex), `--md-ref-typeface-plain` and `--md-ref-typeface-mono`; the weights `--md-ref-typeface-weight-regular|medium|bold`.
- Shape: `var(--md-sys-shape-corner-{none|xs|sm|md|lg|lg-increased|xl|xl-increased|xxl|full})`.
- Elevation: `box-shadow: var(--md-sys-elevation-{1…5})` — for what floats over content, not for panels (a panel separates by its container tone).
- Motion: `var(--md-sys-motion-spatial-{fast|default|slow})` (position, size, shape; springs that overshoot) and `var(--md-sys-motion-effects-{fast|default|slow})` (colour, opacity, which never overshoot), each always with its own `-duration`: `transition: transform var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast)` — M3's published web durations, spatial 350/500/650 ms and effects 150/200/300 ms. `motion.scheme` in the config picks `expressive` (the default, with the bounce) or `standard` (minimal bounce), which the head script writes to `<html data-motion>` and which swaps the three spatial springs; a rule names a spring, never a scheme. Reduced motion zeroes every duration in both schemes.
- Spacing: `var(--md-sys-measurement-space25)``var(--md-sys-measurement-space900)`.
- States: `--md-sys-state-{hover|focus|pressed|dragged}-state-layer-opacity` and `--md-sys-state-disabled-{content|container}-opacity`, mixed into a role: `color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent)`. A scrim is `color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent)`.
The rules behind these names — which role, surface container, corner, type style, elevation level, motion spring, state and window size class to use, and M3's don'ts — are the `material-3` guideline (always on) and the `material-3-design` skill (the tables and Google's source pages); activate that skill before designing a screen. The rules behind these names — which role, surface container, corner, type style, elevation level, motion spring, state and window size class to use, and M3's don'ts — are the `material-3` guideline (always on) and the `material-3-design` skill (the tables and Google's source pages); activate that skill before designing a screen.
@@ -124,7 +118,12 @@ The rules behind these names — which role, surface container, corner, type sty
} }
``` ```
- Breakpoints are M3's window size classes, and only those: compact below 600px, medium 600px, expanded 840px, large 1200px, extra-large 1600px. A layout component takes them as props; your own CSS writes them as range media queries (`@media (width >= 840px)`, `@media (width < 600px)`), and the design guard rejects any other width; a script asks `resources/js/breakpoints.js` (`from('expanded')`, `upTo('medium')`), so a stylesheet and a script never disagree at the boundary pixel. A component's *own* width is a container query (`@container (width >= 28rem)`), which is a different thing. - Breakpoints are M3's window size classes, and only those (the guideline has the five numbers). A
layout component takes them as props; your own CSS writes them as range media queries, and the
design guard rejects any other width; a script asks `resources/js/breakpoints.js`
(`from('expanded')`, `upTo('medium')`), so a stylesheet and a script never disagree at the
boundary pixel. A component's *own* width is a container query (`@container (width >= 28rem)`),
a different thing from a breakpoint.
- The roles switch with the theme by themselves. A rule of your own that only applies in dark keys on `[data-theme='dark']` (the page's theme), never on `prefers-color-scheme` (the operating system's). - The roles switch with the theme by themselves. A rule of your own that only applies in dark keys on `[data-theme='dark']` (the page's theme), never on `prefers-color-scheme` (the operating system's).
- `x-figure` on an element holding one number counts it up on first appearance and on change. - `x-figure` on an element holding one number counts it up on first appearance and on change.
@@ -263,7 +262,7 @@ Label button, icon button, toggle and responsive FAB in one component.
| `link`, `external`, `no-wire-navigate` | | renders `<a>`, with `wire:navigate` unless external | | `link`, `external`, `no-wire-navigate` | | renders `<a>`, with `wire:navigate` unless external |
| `spinner` | | `true` shows the loading indicator while its `wire:click` runs; a string names the action | | `spinner` | | `true` shows the loading indicator while its `wire:click` runs; a string names the action |
| `tooltip`, `tooltip-left`, `tooltip-right`, `tooltip-bottom` | | plain tooltip; also the icon button's accessible name | | `tooltip`, `tooltip-left`, `tooltip-right`, `tooltip-bottom` | | plain tooltip; also the icon button's accessible name |
| `disabled`, `type`, `responsive`, `fab` | | `responsive` hides the label below `expanded`; `fab` is an extended FAB on a compact window (below `medium`), a filled button from there | | `disabled`, `type`, `responsive`, `fab` | | `responsive` hides the label below `expanded`; `fab` is an extended FAB on a compact window (below `medium`), a filled button from there; a `disabled` fab is not drawn below `medium` — M3 removes a FAB whose action is unavailable — and is the disabled button from there (a `spinner` disabling it while its action runs leaves it on screen) |
```blade ```blade
<x-button label="Create link" icon="link" variant="filled" size="md" wire:click="create" spinner /> <x-button label="Create link" icon="link" variant="filled" size="md" wire:click="create" spinner />
@@ -273,7 +272,7 @@ Label button, icon button, toggle and responsive FAB in one component.
### `<x-tooltip>` ### `<x-tooltip>`
M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link" side="bottom"><button>…</button></x-tooltip>`. `side`: `top` (default), `bottom`, `left`, `right`. Shows on hover (fine pointers) and keyboard focus, and goes 1.5s after the pointer or the focus leaves it (M3's transient tooltip); only one is on screen at a time. It is `aria-hidden`, so the trigger has to carry the same words itself — as an icon button's `aria-label` does. Where the tip says something the trigger does not, use `<x-rich-tooltip>`, which points the trigger at its text. Buttons and FABs take a `tooltip` prop instead. M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link" side="bottom"><button>…</button></x-tooltip>`. `side`: `top` (default), `bottom`, `left`, `right`. Shows on hover (fine pointers) and keyboard focus, and goes 1.5s after the pointer or the focus leaves it (M3's transient tooltip); the focus a sheet, a dialog or the modal rail moves to its first control as it opens does not show it, so a close button's tooltip waits for a Tab; only one is on screen at a time. It is `aria-hidden`, so the trigger has to carry the same words itself — as an icon button's `aria-label` does. Where the tip says something the trigger does not, use `<x-rich-tooltip>`, which points the trigger at its text. Buttons and FABs take a `tooltip` prop instead.
### `<x-menu>`, `<x-menu-item>`, `<x-menu-group>`, `<x-menu-separator>` ### `<x-menu>`, `<x-menu-item>`, `<x-menu-group>`, `<x-menu-separator>`
@@ -491,7 +490,7 @@ A few lines of context around a trigger, with an optional `title` and `actions`
</x-rich-tooltip> </x-rich-tooltip>
``` ```
Shows on hover and keyboard focus and goes 1.5s after the pointer or the focus leaves, as M3 times a plain tooltip too; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). The trigger is pointed at the bubble with `aria-describedby`, so its words are read out with the control. An open bubble stays open while the Livewire component around it renders, its actions' `wire:click` included. `side`: `bottom` (default), `top`, `left`, `right`. Shows on hover and keyboard focus (not the focus a sheet or dialog moves to its trigger as it opens) and goes 1.5s after the pointer or the focus leaves, as M3 times a plain tooltip too; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). The trigger is pointed at the bubble with `aria-describedby`, so its words are read out with the control. An open bubble stays open while the Livewire component around it renders, its actions' `wire:click` included. `side`: `bottom` (default), `top`, `left`, `right`.
### `<x-stat>` ### `<x-stat>`
@@ -514,7 +513,7 @@ The `illustration` slot draws the application's own artwork in place of the shap
`variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`, `heading` (the title's element, `h3` by default: pass `h2` for a card straight under the page's `h1`); slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). A card holds content and actions about one subject (M3); a table of many rows or a group of settings is a headed section, not a card. Choose its fill with `variant`, not a background of your own. `variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`, `heading` (the title's element, `h3` by default: pass `h2` for a card straight under the page's `h1`); slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). A card holds content and actions about one subject (M3); a table of many rows or a group of settings is a headed section, not a card. Choose its fill with `variant`, not a background of your own.
A card or list item that opens something is a **row**: `data-md-list-row` on it and `data-md-list-open` on its one opener (the title link or a button). A press anywhere else on the row reaches the opener; its other controls keep their own presses. Never wrap a card in `<a>` or use a stretched link. A row answers with the state layer and one step of elevation; its corner does not move. A card or list item that opens something is a **row**: `data-md-list-row` on it and `data-md-list-open` on its one opener (the title link or a button). A press anywhere else on the row reaches the opener; its other controls keep their own presses. Never wrap a card in `<a>` or use a stretched link. A row answers with the state layer and one step of elevation; its corner does not move. `data-md-selected` on a row that is not a card — your own `<li>`, `<div>` or `<tr>`, the one whose sheet is open — fills it secondary-container with on-secondary-container ink, the state layer over the fill (`<x-list-item selected>` writes it itself); a card takes no selected fill. A row that is not a card draws its state layer and that fill from `components/list-item.css`, so the CSS entry imports it even where no `<x-list-item>` or `<x-table>` renders (the design guard's `missingStylesheets()` asks for it).
```blade ```blade
<x-card variant="outlined" data-md-list-row wire:key="share-{{ $share->id }}"> <x-card variant="outlined" data-md-list-row wire:key="share-{{ $share->id }}">
@@ -583,7 +582,9 @@ Props: `title`, `subtitle`, `icon` (centred hero icon), `separator` (draw the di
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`; mirrored in RTL), `width` (`400px`), `with-close-button` (**default true** — M3 requires a close affordance; `:with-close-button="false"` is ignored when Escape or the scrim is off, or on a `standard` sheet), `close-on-escape` (default true), `without-backdrop-close`, `actions` slot (**left**-aligned in a 72dp row, which is what the side-sheet spec says; a dialog's are trailing-aligned). For the second pane of a list-detail layout use `<x-list-detail>` instead — `<x-drawer>` no longer has a `pane` prop. Its body is a size container — lay out inside it with a container query in your own CSS (`@container (width >= 28rem)`), never a window size class. An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`; mirrored in RTL), `width` (`400px`), `with-close-button` (**default true** — M3 requires a close affordance; `:with-close-button="false"` is ignored when Escape or the scrim is off, or on a `standard` sheet), `close-on-escape` (default true), `without-backdrop-close`, `actions` slot (**left**-aligned in a 72dp row, which is what the side-sheet spec says; a dialog's are trailing-aligned). For the second pane of a list-detail layout use `<x-list-detail>` instead — `<x-drawer>` no longer has a `pane` prop. Its body is a size container — lay out inside it with a container query in your own CSS (`@container (width >= 28rem)`), never a window size class.
`standard` is M3's other side-sheet variant: supplementary content beside the primary content — filters, details, a list of actions — co-planar from `expanded`, flat on `surface` with 0dp elevation and no corner, the window's full height, an outline-variant rule down its inner edge instead of a scrim, nothing inert and no focus trap. Below `expanded` it is the modal sheet. Capped at M3's 400dp whatever `width` says, and it always draws the close button. Render it beside its content in a row that only lays out side by side from `expanded` (`<x-supporting-pane>`, or a caller's own row). Modal surfaces stack: a `<x-modal>`, menu, select or searchable choice opened inside or over a modal side sheet, bottom sheet or the modal rail — or one sheet opened from another, nested or rendered elsewhere on the page — takes its own Escape, so one press closes one layer, and the layer on top stays readable to a screen reader while the sheet under it keeps the rest of the page hidden. Render the dialog wherever suits the page; nothing needs to be moved inside the sheet.
`standard` is M3's other side-sheet variant: supplementary content beside the primary content — filters, details, a list of actions — co-planar from `expanded`, flat on `surface` with 0dp elevation and no corner, sticky and as tall as the window below the top safe area (and below `<x-scaffold>`'s sticky `top` app bar), an outline-variant rule down its inner edge instead of a scrim, nothing inert and no focus trap. Below `expanded` it is the modal sheet. Capped at M3's 400dp whatever `width` says, and it always draws the close button. Render it beside its content in a row that only lays out side by side from `expanded` (`<x-supporting-pane>`, or a caller's own row).
### `<x-bottom-sheet>` ### `<x-bottom-sheet>`
@@ -667,9 +668,9 @@ M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire
- `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `counter`, `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`. - `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `counter`, `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
- `<x-password>`: a reveal button; `icon`, `size`. - `<x-password>`: a reveal button; `icon`, `size`.
- `<x-textarea>`: grows from `rows` (3) to `max-rows`, then scrolls; `:autogrow="false"` for a fixed, hand-resizable one; `counter`. - `<x-textarea>`: grows from `rows` (3) to `max-rows`, then scrolls; `:autogrow="false"` for a fixed, hand-resizable one; `counter`.
- Width: M3 asks that a text field never span the full width of a large screen, so from `medium` (600px) every field stops at **40rem**; below that it fills its pane. A width rule of your own on its `class` (`max-inline-size: 24rem`) narrows or widens it, because your unlayered CSS outranks the package's, and `full` (on `<x-field>`, `<x-input>`, `<x-textarea>`) takes the bound off for a field that really is the width of its pane — a search row, an editor. `<x-search>`'s bar carries M3's own bound, 720px. - Width: M3 asks that a text field never span the full width of a large screen, so from `medium` (600px) every field stops at **40rem**; below that it fills its pane. A width rule of your own on its `class` (`max-inline-size: 24rem`) narrows or widens it, because your unlayered CSS outranks the package's, and `full` (on `<x-field>`, `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`, `<x-choices searchable>`, `<x-datepicker>` and `<x-timepicker>`) takes the bound off for a field that really is the width of its pane — a search row, an editor. `<x-search>`'s bar carries M3's own bound, 720px.
- `counter` (on `<x-input>` and `<x-textarea>`) puts M3's character counter at the end of the supporting-text row, beside the hint or the error: `n/max`, counted on every keystroke against the field's own `maxlength`, and in the error colour once the value is past it. It needs `maxlength` — without one there is nothing to count against and nothing is drawn. It is said as "Character count, 5/20" from a polite region a second after typing stops. - `counter` (on `<x-input>` and `<x-textarea>`) puts M3's character counter at the end of the supporting-text row, beside the hint or the error: `n/max`, counted on every keystroke against the field's own `maxlength`, and in the error colour once the value is past it. It needs `maxlength` — without one there is nothing to count against and nothing is drawn. It is said as "Character count, 5/20" from a polite region a second after typing stops.
- `<x-select>`: native `<select>` (M3 menu where the browser supports customizable selects). `options` as `['id' => …, 'name' => …, 'disabled' => bool]`, `option-value`, `option-label`, `placeholder` + `placeholder-value`, or `<option>`s in the slot; `icon`, `size`. - `<x-select>`: native `<select>` (M3 menu where the browser supports customizable selects). `options` as `['id' => …, 'name' => …, 'disabled' => bool]`, `option-value`, `option-label`, `placeholder` + `placeholder-value`, or `<option>`s in the slot; `icon`, `size`. A value too long for the field ends before the arrow with an ellipsis; the customizable select draws it in a `<button>` holding `<selectedcontent>`, which the view renders as the select's first child.
- `<x-file>`: native file input; errors from `photos` and `photos.*`. Show previews of what was chosen yourself. - `<x-file>`: native file input; errors from `photos` and `photos.*`. Show previews of what was chosen yourself.
- `<x-field id="…" label="…" :messages="$messages">` wraps a custom control given `data-md-field-control`; only for controls the package does not have. - `<x-field id="…" label="…" :messages="$messages">` wraps a custom control given `data-md-field-control`; only for controls the package does not have.
@@ -680,6 +681,7 @@ M3 selection controls on native inputs; the whole row is the label.
- `<x-checkbox label hint right indeterminate />``indeterminate` for a "select all" whose items are partly ticked (bind it to a server expression; it follows every render). Grouping is yours: from `expanded` (840px) M3 wants a set of related checkboxes gathered into a contained region rather than one long column, so wrap the set in `<x-grid :columns="['compact' => 1, 'expanded' => 2]" gap="space200">` (or a card or side sheet) under a heading that names what the group asks. - `<x-checkbox label hint right indeterminate />``indeterminate` for a "select all" whose items are partly ticked (bind it to a server expression; it follows every render). Grouping is yours: from `expanded` (840px) M3 wants a set of related checkboxes gathered into a contained region rather than one long column, so wrap the set in `<x-grid :columns="['compact' => 1, 'expanded' => 2]" gap="space200">` (or a card or side sheet) under a heading that names what the group asks.
- `<x-radio label wire:model :options inline />` — options `['id', 'name', 'hint', 'disabled']` (`option-value`, `option-label`, `option-hint`); `value` checks an option without `wire:model`; `name` names an unbound group. M3 stacks radios and cautions against a row at any width, so reach for `inline` only for two or three short labels; it also wants five options or fewer and one of them chosen when the page loads. - `<x-radio label wire:model :options inline />` — options `['id', 'name', 'hint', 'disabled']` (`option-value`, `option-label`, `option-hint`); `value` checks an option without `wire:model`; `name` names an unbound group. M3 stacks radios and cautions against a row at any width, so reach for `inline` only for two or three short labels; it also wants five options or fewer and one of them chosen when the page loads.
- `<x-toggle label hint right icons />` — M3 switch (`role="switch"`); `icons` puts a check and a cross on the handle, `icons="selected"` only the check. Without `label`, pass `aria-label`. - `<x-toggle label hint right icons />` — M3 switch (`role="switch"`); `icons` puts a check and a cross on the handle, `icons="selected"` only the check. Without `label`, pass `aria-label`.
- Beside text in a row — a setting's words and its switch in `<x-row justify="between">` — a switch keeps its 52px track and a checkbox its box: the text beside it wraps, and so does the control's own label.
```blade ```blade
<x-checkbox label="All files" :checked="count($selected) === $files->count()" :indeterminate="$selected && count($selected) < $files->count()" wire:click="toggleAll" /> <x-checkbox label="All files" :checked="count($selected) === $files->count()" :indeterminate="$selected && count($selected) < $files->count()" wire:click="toggleAll" />
@@ -736,7 +738,7 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
| `mode` | `docked` | `docked`: type a date (in the locale's numeric format) or pick one from a calendar under the field, which opens as a dialog on a compact window (below `medium`), as M3 asks; `modal`: the field opens a calendar dialog; `input`: the dialog opens on a text field. Both dialogs switch between calendar and typing | | `mode` | `docked` | `docked`: type a date (in the locale's numeric format) or pick one from a calendar under the field, which opens as a dialog on a compact window (below `medium`), as M3 asks; `modal`: the field opens a calendar dialog; `input`: the dialog opens on a text field. Both dialogs switch between calendar and typing |
| `range` | `false` | binds one array property, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null); errors for `trip`, `trip.start` and `trip.end` show on the field. On a compact window (below `medium`) it opens as M3's full-screen range picker: an app bar with a close button and **Save**, the range as the headline, and the months in one scrolling list instead of stepped one at a time | | `range` | `false` | binds one array property, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null); errors for `trip`, `trip.start` and `trip.end` show on the field. On a compact window (below `medium`) it opens as M3's full-screen range picker: an app bar with a close button and **Save**, the range as the headline, and the months in one scrolling list instead of stepped one at a time |
| `min`, `max` | `null` | `Y-m-d` or a date; days outside are disabled and the keyboard stays inside | | `min`, `max` | `null` | `Y-m-d` or a date; days outside are disabled and the keyboard stays inside |
| `label`, `hint`, `icon`, `variant`, `size` | | the field's | | `label`, `hint`, `icon`, `variant`, `size`, `full` | | the field's |
| `value` | `null` | the initial value without `wire:model` | | `value` | `null` | the initial value without `wire:model` |
| `name` | | adds hidden inputs with `Y-m-d` for a plain form post (`name[start]`, `name[end]` for a range) | | `name` | | adds hidden inputs with `Y-m-d` for a plain form post (`name[start]`, `name[end]` for a range) |
| `clearable` | `false` | a button that empties the field (both ends of a range) once it holds a date | | `clearable` | `false` | a button that empties the field (both ends of a range) once it holds a date |
@@ -763,7 +765,7 @@ M3's time picker in a modal dialog, opened from a read-only text field (a press,
| `min`, `max` | | `H:i`, inclusive; `min` later than `max` spans midnight. Outside values are greyed out and refused in the picker — validate on the server as well | | `min`, `max` | | `H:i`, inclusive; `min` later than `max` spans midnight. Outside values are greyed out and refused in the picker — validate on the server as well |
| `clearable` | `false` | a button that empties the field | | `clearable` | `false` | a button that empties the field |
| `name` | | posts the value from a hidden input | | `name` | | posts the value from a hidden input |
| `label`, `hint`, `icon`, `variant`, `size` | | the field's; `required`, `disabled` and `placeholder` reach its input | | `label`, `hint`, `icon`, `variant`, `size`, `full` | | the field's; `required`, `disabled` and `placeholder` reach its input |
Errors under the `wire:model` name replace the hint. The dialog is `wire:ignore`: a Livewire render leaves an open picker open with its draft. Never name a Livewire property `$slot`: it renders empty in the component's view. Errors under the `wire:model` name replace the hint. The dialog is `wire:ignore`: a Livewire render leaves an open picker open with its draft. Never name a Livewire property `$slot`: it renders empty in the component's view.
@@ -772,7 +774,7 @@ Errors under the `wire:model` name replace the hint. The dialog is `wire:ignore`
Choosing from a list, with typed values (an array of integers stays integers). `options` (`id`, `name`, `disabled`; `option-value`, `option-label`), `label`, `hint`, `single`. Errors for the property and its items replace the hint. Choosing from a list, with typed values (an array of integers stays integers). `options` (`id`, `name`, `disabled`; `option-value`, `option-label`), `label`, `hint`, `single`. Errors for the property and its items replace the hint.
- Default: filter chips, every option on screen — `single` for choice chips. - Default: filter chips, every option on screen — `single` for choice chips.
- `searchable`: a text field that filters a menu as you type (single value; arrow keys, Enter, Escape); `icon`, `variant`, `placeholder`. Its list is a popover, so it is never clipped by a card. - `searchable`: a text field that filters a menu as you type (single value; arrow keys, Enter, Escape); `icon`, `variant`, `placeholder`, `full`. Its list is a popover, so it is never clipped by a card.
```blade ```blade
<x-choices label="Days you are free" wire:model.live="days" :options="$weekdays" /> <x-choices label="Days you are free" wire:model.live="days" :options="$weekdays" />
@@ -1046,7 +1048,7 @@ M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (
- A `collapsible` rail is held to the collapsed 96px below `medium` (600px), where M3 says to use a navigation bar rather than a standard rail. `collapsed` and `expanded` are fixed-width by design: wrap one in `<x-stack hide-below="medium">` if it must not show on a phone. - A `collapsible` rail is held to the collapsed 96px below `medium` (600px), where M3 says to use a navigation bar rather than a standard rail. `collapsed` and `expanded` are fixed-width by design: wrap one in `<x-stack hide-below="medium">` if it must not show on a phone.
- `<x-navigation-rail-item>`: the same props as `<x-navigation-bar-item>`. `<x-navigation-rail-section label="…">`: a group with a heading that shows only while the rail is expanded; it names the group for screen readers either way. - `<x-navigation-rail-item>`: the same props as `<x-navigation-bar-item>`. `<x-navigation-rail-section label="…">`: a group with a heading that shows only while the rail is expanded; it names the group for screen readers either way.
- `$store.rail`: `collapsed`, `toggle()`, `collapse()`, `expand()` (the remembered choice; `auto` is true while nothing is stored, so an adaptive rail takes its window size class's default instead, and the first choice clears it), `open`, `show()`, `hide()` (the modal rail; closed on every `wire:navigate`). `config/livewire-material.php``rail.default` (`expanded` or `collapsed`) and `rail.storage_key` (`material-rail`). - `$store.rail`: `collapsed`, `collapse()`, `expand()` (the remembered choice; `auto` is true while nothing is stored, so an adaptive rail takes its window size class's default instead, and the first choice clears it), `open`, `show()`, `hide()` (the modal rail; closed on every `wire:navigate`), and `toggle()` — for a keyboard shortcut — which flips what the first rail on the page draws, as its menu button does: the choice where the rail stands in the layout (from `expanded` for `<x-scaffold>`'s, where a collapsed rail with nothing stored expands), the modal where the window leaves it no room. `config/livewire-material.php``rail.default` (`expanded` or `collapsed`) and `rail.storage_key` (`material-rail`).
### `<x-app-bar>` ### `<x-app-bar>`
@@ -1141,7 +1143,7 @@ A choice of colour profile (see Colour profiles): a swatch per generated profile
### `<x-table>`, `<x-sort-header>` ### `<x-table>`, `<x-sort-header>`
A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>`; the text classes on a cell (`md-text-end`, `md-nowrap`, `md-tabular`) always win over the table's own alignment. Rows are 52px — a target a finger can hit. `dense` tightens them to 36px and `size="xs"` is for a table inside a panel inside a panel (32px rows); M3 says density is always an opt-in, so neither is a default and both are yours to justify. Scrolling is yours: wrap it in an element that scrolls sideways (`overflow-x: auto` in your own CSS). A row that opens something is `data-md-list-row` with one `data-md-list-open` control; a selected row is `aria-selected="true"`. A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>`; the text classes on a cell (`md-text-end`, `md-nowrap`, `md-tabular`) always win over the table's own alignment. Rows are 52px — a target a finger can hit. `dense` tightens them to 36px and `size="xs"` is for a table inside a panel inside a panel (32px rows); M3 says density is always an opt-in, so neither is a default and both are yours to justify. Scrolling is yours: wrap it in an element that scrolls sideways (`overflow-x: auto` in your own CSS). A row that opens something is `data-md-list-row` with one `data-md-list-open` control; a selected row is `data-md-selected`, or `aria-selected="true"` in a table that is a grid — the only place ARIA allows it on a row.
`<x-sort-header column="size" :sort-by="$sortBy">Size</x-sort-header>` sorts through the Livewire property `sortBy` (`['column' => …, 'direction' => 'asc'|'desc']`; `model` names another), with `aria-sort`. `<x-sort-header column="size" :sort-by="$sortBy">Size</x-sort-header>` sorts through the Livewire property `sortBy` (`['column' => …, 'direction' => 'asc'|'desc']`; `model` names another), with `aria-sort`.
@@ -1162,7 +1164,7 @@ A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>`;
{{ $shares->links() }} {{ $shares->links() }}
``` ```
Pagination: `$paginator->links()` (Laravel and Livewire, full and simple/cursor) is drawn in M3 — current page in secondary-container, "Page 2 of 7" on a phone. Turn off with `config('livewire-material.pagination')` = `false`; published `vendor/pagination` or `vendor/livewire` views still win. Pagination: `$paginator->links()` (Laravel and Livewire, full and simple/cursor) is drawn in M3 — current page in secondary-container, "Page 2 of 7" on a phone. Turn off with `config('livewire-material.pagination')` = `false`; published `vendor/pagination` or `vendor/livewire` views still win. The provider also sets `livewire.pagination_theme` to `material` whenever it still reads as Livewire's own default (`tailwind`); an application's own theme, or a component's `$paginationTheme` property or `paginationView()` method, still wins.
## Testing the design ## Testing the design
@@ -1178,35 +1180,13 @@ it('uses only what compiles', function () {
}); });
``` ```
It reads every path it is given and fails with `path:line` on unknown symbol names, Blade directives written inside a component tag (where they do not compile), and every Tailwind utility or variant — none compiles in a Tailwind-free application — or 1.x utility, each with its 2.0.0 replacement. A class the application's own stylesheets declare is exempt, and so is every `md-*` class: It reads every path it is given and fails with `path:line` on unknown symbol names, Blade directives written inside a component tag (where they do not compile), and a class no stylesheet declares — not your own CSS, not the package's `md-*` — whatever it came from: a leftover utility, a typo, a class whose rule was deleted. Without a `.css` file among the scanned paths or a `missingStylesheets()` entry to supply your import graph, it has no declared class to compare against and reports every one of them.
| Written | Use |
| --- | --- |
| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` (and `max-`) | `@media (width >= 600px)` …, or a layout component's `hide-below`/`hide-from`/`stack-below` prop |
| `hover:`, `dark:`, `group-hover:`, `rtl:` … | `md-state-layer`/`md-focus-ring`, `[data-theme='dark']`, `:has()`, `:is([dir='rtl'], [dir='rtl'] *)` in your own CSS |
| `flex`, `flex-col`, `flex-wrap`, `items-center`, `justify-between` | `<x-row>`, `<x-stack>`, `<x-row wrap>`, `align="center"`, `justify="between"` |
| `grid`, `grid-cols-3` | `<x-grid :columns="3">` (or a per-breakpoint map), `<x-feed>` for cards |
| `gap-4`, `space-y-4`, `p-4`, `mt-2` | the M3 step at 4px per unit: `gap="space200"` (16px), `<x-stack gap="space200">`, `<x-surface padding="space200">`, `var(--md-sys-measurement-space100)` |
| `w-full`, `max-w-5xl`, `size-6` | `<x-pane width>`, `<x-icon size>`, or a length in your own CSS — M3 keeps no size scale |
| `hidden` | a layout component's `hide-below`/`hide-from` prop, the `hidden` attribute, or `x-show` |
| `block`, `relative`, `inset-0`, `z-10`, `overflow-hidden`, `shrink-0`, `border`, `opacity-50`, `cursor-pointer` … | the rule in your own CSS |
| `text-center`, `truncate`, `sr-only`, `whitespace-nowrap`, `line-clamp-2`, `tabular-nums` | `md-text-center`, `md-truncate`, `md-visually-hidden`, `md-nowrap`, `md-line-clamp-2`, `md-tabular` |
| `type-body-md`, `focus-ring`, `state-layer`, `link` (1.x) | `md-type-body-md`, `md-focus-ring`, `md-state-layer`, `md-link` |
| `text-on-surface-variant`, `text-primary`, `text-meta` … | `md-ink-variant`, `md-ink-primary` … |
| `border-outline-variant`, `border-divider` | `<x-divider>` or `<x-surface outlined>` |
| `bg-surface-container` | `<x-surface level="surface-container">` |
| `bg-primary`, `border-error`, `text-red-500` … | `var(--md-sys-color-*)` |
| `bg-white`, `text-black` | a surface or `on-` role — M3 has no white or black |
| `text-on-surface/60`, `bg-on-surface/8` | `md-ink-variant`/`md-ink-quiet`, or `md-state-layer` for an overlay |
| `rounded-lg`, `rounded-corner-lg` (1.x) | `var(--md-sys-shape-corner-lg)`, or `<x-surface corner="lg">` |
| `shadow-md`, `shadow-elevation-2` (1.x) | `var(--md-sys-elevation-2)` |
| `text-sm`, `leading-6`, `tracking-wide`, `font-medium` | one of the `md-type-*`/`md-type-emphasized-*` classes |
| `ease-in-out`, `ease-spatial-fast` (1.x), `duration-300` | `var(--md-sys-motion-spatial-fast)` with its `-duration`, in your own `transition` |
| `bg-[#1d7afc]`, `w-[320px]`, `[--x:1]` | an M3 role, or the value in your own CSS |
In the `.css` files it is given (`material-scheme.css` skipped) it fails on a literal colour, radius, shadow, font, font size, weight, line height, letter spacing, easing or duration, and on a media query width off 600/840/1200/1600px; a `var()`, or a `calc()`/`min()`/`max()`/`clamp()` built on one, is fine. In the `.css` files it is given (`material-scheme.css` skipped) it fails on a literal colour, radius, shadow, font, font size, weight, line height, letter spacing, easing or duration, and on a media query width off 600/840/1200/1600px; a `var()`, or a `calc()`/`min()`/`max()`/`clamp()` built on one, is fine.
`missingStylesheets($cssEntry)` follows the entry's relative `@import`s through every package file's own imports (`split-button.css` counts `button.css` and `menu.css` too; `tailwindcss` or a font URL is skipped) and checks them against the package tags a scanned view renders (plain, prefixed or `<x-livewire-material::…>`) and `->links()`; each missing stylesheet names its `@import` line once, and a tag the application shadows with its own component is reported instead — the application's component wins in Blade. It reads imports only: leave `resource_path('css')` out of `scan()` until the stylesheets are on tokens. `unusedStylesheets($cssEntry)` is the other way round: a package stylesheet the entry imports directly that no scanned view needs, even through a needed stylesheet's own imports, named at its `@import` line to remove (an entry importing `all.css` is left alone). `forbidColours([...])` fails wherever a left-out role (with its `on-` and container roles) is still written: `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop. `forbid($pattern, $reason)` adds a pattern of your own. Markdown mail components — whatever lies under `config('mail.markdown.paths')`, `resources/views/vendor/mail` by default — are drawn by the mail theme, not by your CSS entry. So the guard skips the class check there (it still reads their icon names, the directives in their component tags and your own bans), and a mail theme stylesheet under that path, which has to write literal values, is neither checked for literals nor a source of declared classes.
`missingStylesheets($cssEntry)` follows the entry's relative `@import`s through every package file's own imports (`split-button.css` counts `button.css` and `menu.css` too; a package name or a font URL is skipped) and checks them against the package tags a scanned view renders (plain, prefixed or `<x-livewire-material::…>`), `->links()`, and a row a view of yours writes by hand (`data-md-list-row` on anything but `<x-card>`, which needs `components/list-item.css`; the package's own views are not read for it); each missing stylesheet names its `@import` line once, and a tag the application shadows with its own component is reported instead — the application's component wins in Blade. It reads imports only: leave `resource_path('css')` out of `scan()` until the stylesheets are on tokens, as long as this entry is what `missingStylesheets()` reads — its import graph is still what supplies the declared classes above. `unusedStylesheets($cssEntry)` is the other way round: a package stylesheet the entry imports directly that no scanned view needs — no tag, `->links()` or row written by hand — even through a needed stylesheet's own imports, named at its `@import` line to remove (an entry importing `all.css` is left alone). `forbidColours([...])` fails wherever a left-out role (with its `on-` and container roles) is still written: `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop. `forbid($pattern, $reason)` adds a pattern of your own.
## Conventions ## Conventions
+11 -21
View File
@@ -271,32 +271,22 @@ Sources: https://m3.material.io/styles/icons/overview · https://m3.material.io/
## Accessibility ## Accessibility
- Native elements before ARIA: `<button>`, `<a href>`, `<dialog>`, `<input>`, `<select>`; a styled `div` that fakes one needs everything re-implemented and tested. The guideline's own Accessibility line has the rule; beyond it: every repeated landmark —
- 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"). `search`, `complementary`, `form`, `region`, not just `nav` — is labelled the same way; an
- 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. ambiguous button ("Save", "Learn more") needs a name that says what it does, not just what kind
- 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`. of control it is; DOM order is reading order, a dialog returns focus to its opener, and a group of
- 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. related controls is one Tab stop with the arrows moving inside it; an invalid field also carries
- 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. `aria-invalid`, and a loading state has a name too.
- 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 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 ## Don'ts
What Google's pages say not to do, and this library follows: The guideline's Don'ts, Type and Motion bullets name them; where they name no replacement — a vertical
group or chips for radios in a row, `<x-divider>` for the outline case, the `md-type-*`/`md-ink-*`
| Don't | Because | Instead | classes and `--md-sys-*` tokens for the utility-class case, wrap/grow/a tooltip instead of a bare
| --- | --- | --- | ellipsis, the paired motion tokens instead of a literal duration — the components and layout
| 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 | sections above have it.
| 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 ## Attribution
+13 -1
View File
@@ -72,5 +72,17 @@ VITE_APP_NAME="${APP_NAME}"
# Uploads: each encrypted chunk the browser sends, in MB # Uploads: each encrypted chunk the browser sends, in MB
# UPLOAD_CHUNK_SIZE_MB=16 # UPLOAD_CHUNK_SIZE_MB=16
# Docker (used only when deploying with docker-compose.yml) # Docker development: `docker compose up` runs this file. On OrbStack, also set
# APP_URL=https://app.sealshare.orb.local and VITE_DEV_SERVER_URL=https://vite.sealshare.orb.local.
# Without OrbStack, append :docker-compose.ports.yml and set APP_URL=http://localhost:8000.
COMPOSE_FILE=docker-compose.dev.yml
# Where the browser reaches the Vite dev server, when not on http://localhost
# VITE_DEV_SERVER_URL=https://vite.sealshare.orb.local
# Ports on this machine: the Vite dev server's, and the app's with docker-compose.ports.yml
# VITE_PORT=5173
# APP_PORT=8000
# Docker production (docker compose -f docker-compose.yml), with AUTO_HTTPS=true
# SERVER_NAME=share.example.com # SERVER_NAME=share.example.com
+31
View File
@@ -5,6 +5,36 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.2.0] - 2026-09-19
### Added
- The admin dashboard shows the installed SealShare version, with links to its release notes, the SealShare website and noNameWEB.
### Changed
- `docker-compose.example.yml` mounts `sealshare_database` at `/app/database/sqlite` instead of `/app/database`, and sets `DB_DATABASE: /app/database/sqlite/database.sqlite`. Existing compose files keep working. To switch, mount the same volume at the new path and set `DB_DATABASE` in both services; the database is kept.
- A share that reaches its download limit is closed at once, but deleted by the hourly cleanup 24 hours after its last download instead of immediately, so downloads still running can finish. Until then its files count towards the storage quota.
- The download page of a share with a download limit says how many downloads are left, or how long the recipient can still download.
- The admin dashboard shows downloads as "2 of 3 downloads" and marks shares at their limit as "Download limit reached". These no longer count as active shares.
- The sort dropdown on the admin dashboard spans the full width of the shares card.
- PHP reads the Docker image's limits (`PHP_UPLOAD_MAX_FILESIZE`, `PHP_POST_MAX_SIZE`, `PHP_MAX_EXECUTION_TIME`, `PHP_MAX_INPUT_TIME`, `PHP_MEMORY_LIMIT`) from the environment itself; the entrypoint no longer writes an ini file on start. The variables and their defaults are unchanged.
- Updated to Livewire Material 2.2.0.
- Development: `docker-compose.dev.yml` extends `docker-compose.yml`, so the dev stack runs the scheduler and the production image's PHP extensions, plus a Vite dev server with hot reload. It takes its settings from `.env`, and publishes ports only with `docker-compose.ports.yml`. `docker/dev.Dockerfile` became the `dev` stage of the `Dockerfile`.
### Fixed
- Docker installs set up before 2.1.0 answered every upload with "409 Conflict" after the update. The example `docker-compose.yml` mounted the SQLite volume over all of `/app/database`, which hid the image's new migrations, so they never ran. The container now adds the migrations the volume is missing before migrating.
- A share with several files and a download limit was deleted as soon as one file was downloaded, because every file counted as a whole download. Now one recipient's visit counts once, and they have 1 hour to download all the files and the ZIP. Two recipients who start at the same moment can no longer both get the last download.
- The scheduler container no longer shows as "unhealthy". It inherited the image's healthcheck, which asks the web server that only the app container runs. For an existing install, add `healthcheck: { disable: true }` to the scheduler service in your `docker-compose.yml`.
- The "30 Days" expiration lasted a calendar month; it now lasts 30 days.
- Admin settings only save a default expiration that is one of the offered options.
### Removed
- Email verification (`/email/verify`), which was never enforced: SealShare has a single admin account and no registration.
- The `composer dev` script and the packages only it used (`concurrently`, `laravel/pail`, `laravel/sail`, `autoprefixer`), with the `shell-quote` override that `concurrently` needed. Development runs in Docker (`docker-compose.dev.yml`).
## [2.1.0] - 2026-09-16 ## [2.1.0] - 2026-09-16
### Added ### Added
@@ -138,6 +168,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Dark themed UI built with Livewire, Alpine.js, Tailwind CSS and DaisyUI. - Dark themed UI built with Livewire, Alpine.js, Tailwind CSS and DaisyUI.
- Docker images published to `ghcr.io/surtic86/sealshare`, served by FrankenPHP via Laravel Octane. - Docker images published to `ghcr.io/surtic86/sealshare`, served by FrankenPHP via Laravel Octane.
[2.2.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.1.0...v2.2.0
[2.1.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.1...v2.1.0 [2.1.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.1...v2.1.0
[2.0.1]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.0...v2.0.1 [2.0.1]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.0...v2.0.1
[2.0.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/releases/tag/v2.0.0 [2.0.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/releases/tag/v2.0.0
+2 -3
View File
@@ -192,11 +192,10 @@ When working on Octane-specific features (concurrency, shared tables, memory, dr
## Livewire Material ## 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. This application uses `nonameweb/livewire-material`: Material 3 Expressive components for Laravel and Livewire, in plain CSS. No utility classes — Tailwind, daisyUI or any other — belong here: a class your CSS does not declare does nothing, and `DesignGuard` fails it.
- 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. - 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`. - 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()`. - 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). - 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`. - 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`.
@@ -207,7 +206,7 @@ This application uses `nonameweb/livewire-material`: Material 3 Expressive compo
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. 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: The library is plain CSS on M3's tokens. No utility classes Tailwind, daisyUI or any other — belong here: a class your CSS does not declare does nothing, and `DesignGuard` fails it. 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. - 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`. - 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. - The application's own CSS, named by the application, whose values are `--md-sys-*` custom properties.
+41 -14
View File
@@ -39,20 +39,48 @@ COPY --from=vendor /app/vendor/nonameweb ./vendor/nonameweb
RUN npm run build RUN npm run build
# ============================================ # ============================================
# Stage 3: Production image (FrankenPHP/Octane) # Stage 3: PHP runtime, shared by development and production
# ============================================ # ============================================
FROM dunglas/frankenphp:php8.5-alpine AS production FROM dunglas/frankenphp:php8.5-alpine AS base
LABEL maintainer="surtic86"
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
RUN install-php-extensions \ RUN install-php-extensions \
intl \ intl \
pcntl \ pcntl \
zip zip
# PHP limits, read from PHP_* environment variables by PHP itself
COPY docker/php/uploads.ini /usr/local/etc/php/conf.d/99-uploads.ini
WORKDIR /app
# ============================================
# Stage 4: Development image (docker-compose.dev.yml)
# ============================================
# Holds only the tools: the checkout is mounted at /app, and its entrypoint runs from there.
FROM base AS dev
# For the dev packages: Pest's browser plugin needs sockets; the screenshot publisher and fake test
# images need gd (the app itself never processes images, so production goes without)
RUN install-php-extensions sockets gd
# Node.js for the Vite dev server
RUN apk add --no-cache nodejs npm
# Composer: the entrypoint installs the packages on every start
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
ENTRYPOINT ["docker/dev-entrypoint.sh"]
# ============================================
# Stage 5: Production image (FrankenPHP/Octane)
# ============================================
# The last stage, so a build without --target builds this one.
FROM base AS production
LABEL maintainer="surtic86"
LABEL org.opencontainers.image.source="https://gitea.nonameweb.ch/noNameWEB/SealShare"
LABEL org.opencontainers.image.description="Self-hosted encrypted file sharing"
# Laravel environment defaults # Laravel environment defaults
ENV APP_NAME="SealShare" \ ENV APP_NAME="SealShare" \
APP_ENV="production" \ APP_ENV="production" \
@@ -69,11 +97,6 @@ ENV APP_NAME="SealShare" \
BCRYPT_ROUNDS="12" \ BCRYPT_ROUNDS="12" \
OCTANE_SERVER="frankenphp" OCTANE_SERVER="frankenphp"
WORKDIR /app
# Copy PHP ini for upload limits
COPY docker/php/uploads.ini /usr/local/etc/php/conf.d/99-uploads.ini
# Copy application code # Copy application code
COPY . . COPY . .
@@ -84,13 +107,17 @@ COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build COPY --from=assets /app/public/build ./public/build
# Remove dev/build files and stale cache not needed in production # Remove dev/build files and stale cache not needed in production
RUN rm -rf node_modules tests .gitea docker/dev.Dockerfile docker/dev-entrypoint.sh .env .env.example \ RUN rm -rf node_modules tests .gitea docker/dev-entrypoint.sh .env .env.example \
bootstrap/cache/*.php \ bootstrap/cache/*.php \
&& mkdir -p storage/app/shares storage/app/public storage/framework/cache \ && mkdir -p storage/app/shares storage/app/public storage/framework/cache \
storage/framework/sessions storage/framework/testing storage/framework/views \ storage/framework/sessions storage/framework/testing storage/framework/views \
storage/logs database \ storage/logs database/sqlite \
&& chmod -R 777 storage database bootstrap/cache && chmod -R 777 storage database bootstrap/cache
# A docker-compose.yml from before 2.2.0 mounts the SQLite volume over all of database/, which hides
# the migrations of every later image; the entrypoint adds the ones the volume is missing from here.
RUN cp -R database/migrations docker/migrations
# Create SQLite database file if it doesn't exist # Create SQLite database file if it doesn't exist
RUN touch database/database.sqlite \ RUN touch database/database.sqlite \
&& chmod 666 database/database.sqlite && chmod 666 database/database.sqlite
+30 -8
View File
@@ -40,7 +40,7 @@ A simple, self-hosted file sharing solution built with Laravel. Upload files, ge
|-------|-----------| |-------|-----------|
| **Framework** | Laravel 13 | | **Framework** | Laravel 13 |
| **Application Server** | FrankenPHP (via Laravel Octane) | | **Application Server** | FrankenPHP (via Laravel Octane) |
| **Frontend** | Livewire 4, Tailwind CSS 4, [Livewire Material](https://gitea.nonameweb.ch/noNameWEB/livewire-material) (Material 3 Expressive) | | **Frontend** | Livewire 4, [Livewire Material](https://gitea.nonameweb.ch/noNameWEB/livewire-material) (Material 3 Expressive) |
| **Authentication** | Laravel Fortify | | **Authentication** | Laravel Fortify |
| **Encryption** | Chunked AES-256-GCM (WebCrypto in the browser), keys wrapped with Argon2id | | **Encryption** | Chunked AES-256-GCM (WebCrypto in the browser), keys wrapped with Argon2id |
| **ZIP Downloads** | [ZipStream-PHP](https://packagist.org/packages/maennchen/zipstream-php) | | **ZIP Downloads** | [ZipStream-PHP](https://packagist.org/packages/maennchen/zipstream-php) |
@@ -52,15 +52,35 @@ A simple, self-hosted file sharing solution built with Laravel. Upload files, ge
### Docker (recommended) ### Docker (recommended)
```bash `docker-compose.dev.yml` extends the production stack (`docker-compose.yml`, app and scheduler): the checkout mounted at `/app`, Octane reloading on PHP changes, and a Vite dev server with HMR. `.env` selects it through `COMPOSE_FILE`, so plain `docker compose` commands work.
# Build and start the dev container
docker compose -f docker-compose.dev.yml up -d --build
# View logs (including Vite output) ```bash
docker compose -f docker-compose.dev.yml logs -f cp .env.example .env
# Set APP_KEY (composer setup generates one) and the values for your setup (below)
# Build and start the app, the scheduler and Vite
docker compose up -d --build
# View logs
docker compose logs -f
``` ```
The app is available at `http://localhost:8000` with Vite HMR on port `5173`. With [OrbStack](https://orbstack.dev), no ports are published: set these in `.env` and open `https://app.sealshare.orb.local`. Uploads need HTTPS or `localhost`, because browsers only encrypt files there.
```dotenv
COMPOSE_FILE=docker-compose.dev.yml
APP_URL=https://app.sealshare.orb.local
VITE_DEV_SERVER_URL=https://vite.sealshare.orb.local
```
Without OrbStack, publish the ports on `127.0.0.1` and open `http://localhost:8000` (change the ports with `APP_PORT` and `VITE_PORT`):
```dotenv
COMPOSE_FILE=docker-compose.dev.yml:docker-compose.ports.yml
APP_URL=http://localhost:8000
```
The containers read `.env` when they are created: run `docker compose up -d` again after changing it.
## Installation — Production ## Installation — Production
@@ -99,10 +119,12 @@ Migrations run automatically on startup. Open your configured domain — the Set
| Volume | Path | Purpose | | Volume | Path | Purpose |
|--------|------|---------| |--------|------|---------|
| `sealshare_storage` | `/app/storage/app` | Encrypted uploaded files | | `sealshare_storage` | `/app/storage/app` | Encrypted uploaded files |
| `sealshare_database` | `/app/database` | SQLite database | | `sealshare_database` | `/app/database/sqlite` | SQLite database (`DB_DATABASE: /app/database/sqlite/database.sqlite`) |
| `caddy_data` | `/data` | TLS certificates | | `caddy_data` | `/data` | TLS certificates |
| `caddy_config` | `/config` | Caddy configuration | | `caddy_config` | `/config` | Caddy configuration |
A `docker-compose.yml` from before 2.2.0 mounts `sealshare_database` at `/app/database`, which also hides the image's migrations; the container adds the ones the volume is missing on startup, so it keeps working. To move to the layout above, mount the same volume at `/app/database/sqlite` and set `DB_DATABASE: /app/database/sqlite/database.sqlite` in both services — the existing database is at that path then, and nothing is lost.
**Large files:** **Large files:**
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: 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:
-33
View File
@@ -1,33 +0,0 @@
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Concerns\ProfileValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules, ProfileValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*/
public function create(array $input): User
{
Validator::make($input, [
...$this->profileRules(),
'password' => $this->passwordRules(),
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
]);
}
}
+3 -5
View File
@@ -12,7 +12,7 @@ trait ProfileValidationRules
* *
* @return array<string, array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>> * @return array<string, array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>>
*/ */
protected function profileRules(?int $userId = null): array protected function profileRules(int $userId): array
{ {
return [ return [
'name' => $this->nameRules(), 'name' => $this->nameRules(),
@@ -35,16 +35,14 @@ trait ProfileValidationRules
* *
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string> * @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/ */
protected function emailRules(?int $userId = null): array protected function emailRules(int $userId): array
{ {
return [ return [
'required', 'required',
'string', 'string',
'email', 'email',
'max:255', 'max:255',
$userId === null Rule::unique(User::class)->ignore($userId),
? Rule::unique(User::class)
: Rule::unique(User::class)->ignore($userId),
]; ];
} }
} }
+14 -1
View File
@@ -14,6 +14,12 @@ class CleanupExpiredShares extends Command
*/ */
private const ABANDONED_AFTER_HOURS = 4; private const ABANDONED_AFTER_HOURS = 4;
/**
* How long a share at its download limit is kept after its last download, so that downloads its
* last recipients started can finish: a ZIP opens each file only when it reaches it.
*/
private const DELETE_AFTER_LIMIT_HOURS = 24;
protected $signature = 'shares:cleanup'; protected $signature = 'shares:cleanup';
protected $description = 'Delete expired shares, shares that have reached their download limit, abandoned uploads and old temporary uploads'; protected $description = 'Delete expired shares, shares that have reached their download limit, abandoned uploads and old temporary uploads';
@@ -23,7 +29,14 @@ class CleanupExpiredShares extends Command
$expiredShares = Share::query() $expiredShares = Share::query()
->where(function ($query): void { ->where(function ($query): void {
$query->where('expires_at', '<', now()) $query->where('expires_at', '<', now())
->orWhereRaw('max_downloads IS NOT NULL AND download_count >= max_downloads'); ->orWhere(function ($query): void {
$query->whereNotNull('max_downloads')
->whereColumn('download_count', '>=', 'max_downloads')
->where(function ($query): void {
$query->whereNull('last_downloaded_at')
->orWhere('last_downloaded_at', '<', now()->subHours(self::DELETE_AFTER_LIMIT_HOURS));
});
});
}) })
->get(); ->get();
+15 -10
View File
@@ -7,6 +7,7 @@ use App\Models\ShareFile;
use App\Services\FileEncryptionService; use App\Services\FileEncryptionService;
use App\Services\ShareService; use App\Services\ShareService;
use GuzzleHttp\Psr7\PumpStream; use GuzzleHttp\Psr7\PumpStream;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -24,13 +25,16 @@ class DownloadController extends Controller
* Download all files as a ZIP archive, streamed file by file as it is decrypted: stored without * Download all files as a ZIP archive, streamed file by file as it is decrypted: stored without
* compression, with ZIP64 for files over 4 GB, and never held in memory or written to disk. * compression, with ZIP64 for files over 4 GB, and never held in memory or written to disk.
*/ */
public function download(Share $share): StreamedResponse public function download(Request $request, Share $share): StreamedResponse
{ {
abort_if(! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit(), 404); abort_if(! $share->isCompleted() || $share->isExpired(), 404);
$share->load('files'); $share->load('files');
$key = $this->resolveDecryptionKey($share); $key = $this->resolveDecryptionKey($share);
// Counted before the body streams: the session is saved by then.
abort_unless($this->shareService->claimDownload($share, $request->session()), 404);
return new StreamedResponse(function () use ($share, $key): void { return new StreamedResponse(function () use ($share, $key): void {
$zip = new ZipStream( $zip = new ZipStream(
defaultCompressionMethod: CompressionMethod::STORE, defaultCompressionMethod: CompressionMethod::STORE,
@@ -62,8 +66,6 @@ class DownloadController extends Controller
} }
$zip->finish(); $zip->finish();
$this->shareService->recordDownload($share);
}, 200, [ }, 200, [
'Content-Type' => 'application/zip', 'Content-Type' => 'application/zip',
'Content-Disposition' => HeaderUtils::makeDisposition('attachment', 'share-'.$share->token.'.zip'), 'Content-Disposition' => HeaderUtils::makeDisposition('attachment', 'share-'.$share->token.'.zip'),
@@ -73,13 +75,15 @@ class DownloadController extends Controller
/** /**
* Download a single file. * Download a single file.
*/ */
public function downloadFile(Share $share, ShareFile $shareFile): StreamedResponse public function downloadFile(Request $request, Share $share, ShareFile $shareFile): StreamedResponse
{ {
abort_if(! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit(), 404); abort_if(! $share->isCompleted() || $share->isExpired(), 404);
abort_if($shareFile->share_id !== $share->id, 404); abort_if($shareFile->share_id !== $share->id, 404);
$key = $this->resolveDecryptionKey($share); $key = $this->resolveDecryptionKey($share);
abort_unless($this->shareService->claimDownload($share, $request->session()), 404);
$encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($shareFile->stored_path)); $encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($shareFile->stored_path));
$mimeType = $shareFile->mime_type ?? 'application/octet-stream'; $mimeType = $shareFile->mime_type ?? 'application/octet-stream';
@@ -96,10 +100,11 @@ class DownloadController extends Controller
$headers['Content-Length'] = $shareFile->file_size; $headers['Content-Length'] = $shareFile->file_size;
} }
return new StreamedResponse(function () use ($encryptedPath, $key, $share): void { return new StreamedResponse(function () use ($encryptedPath, $key): void {
$this->encryptionService->streamDecryptedFile($encryptedPath, $key); foreach ($this->encryptionService->decryptedChunks($encryptedPath, $key) as $chunk) {
echo $chunk;
$this->shareService->recordDownload($share); flush();
}
}, 200, $headers); }, 200, $headers);
} }
+3
View File
@@ -71,10 +71,13 @@ class AdminDashboard extends Component
'totalShares' => Share::query()->whereNotNull('completed_at')->count(), 'totalShares' => Share::query()->whereNotNull('completed_at')->count(),
'activeShares' => Share::query()->whereNotNull('completed_at')->where(function ($q) { 'activeShares' => Share::query()->whereNotNull('completed_at')->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now()); $q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})->where(function ($q) {
$q->whereNull('max_downloads')->orWhereColumn('download_count', '<', 'max_downloads');
})->count(), })->count(),
'totalFiles' => ShareFile::query()->whereHas('share', fn ($query) => $query->whereNotNull('completed_at'))->count(), 'totalFiles' => ShareFile::query()->whereHas('share', fn ($query) => $query->whereNotNull('completed_at'))->count(),
'usedSpace' => $shareService->getTotalUsedSpace(), 'usedSpace' => $shareService->getTotalUsedSpace(),
'maxQuota' => $shareService->getMaxStorageQuota(), 'maxQuota' => $shareService->getMaxStorageQuota(),
'version' => config('app.version'),
]); ]);
} }
} }
+3 -11
View File
@@ -3,6 +3,7 @@
namespace App\Livewire\Admin; namespace App\Livewire\Admin;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Share;
use App\Services\PasswordGeneratorService; use App\Services\PasswordGeneratorService;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -92,6 +93,7 @@ class AdminSettings extends Component
{ {
$validated = $this->validate([ $validated = $this->validate([
'colorProfile' => ['required', 'string', Rule::in(array_keys(Scheme::profiles()))], 'colorProfile' => ['required', 'string', Rule::in(array_keys(Scheme::profiles()))],
'defaultExpiration' => ['nullable', 'string', Rule::in(array_keys(Share::EXPIRATIONS))],
'maxFileSize' => ['required', 'integer', 'min:1'], 'maxFileSize' => ['required', 'integer', 'min:1'],
'maxStorageQuota' => ['required', 'integer', 'min:1'], 'maxStorageQuota' => ['required', 'integer', 'min:1'],
'maxFilesPerShare' => ['required', 'integer', 'min:1'], 'maxFilesPerShare' => ['required', 'integer', 'min:1'],
@@ -101,7 +103,7 @@ class AdminSettings extends Component
'siteLogo' => ['nullable', 'file', 'mimes:png,jpg,jpeg,gif,webp', 'max:2048'], 'siteLogo' => ['nullable', 'file', 'mimes:png,jpg,jpeg,gif,webp', 'max:2048'],
...$this->passwordGeneratorRules(), ...$this->passwordGeneratorRules(),
], [ ], [
...$this->passwordGeneratorMessages(), 'passwordCharacterSets.required' => __('Choose at least one kind of character.'),
]); ]);
if ($this->systemPassword) { if ($this->systemPassword) {
@@ -160,16 +162,6 @@ class AdminSettings extends Component
]; ];
} }
/**
* @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. * Store the generator settings that passed validation; excluded ones keep their saved value.
* *
+6 -10
View File
@@ -6,8 +6,10 @@ use App\Models\Setting;
use App\Models\Share; use App\Models\Share;
use App\Services\PasswordGeneratorService; use App\Services\PasswordGeneratorService;
use App\Services\ShareService; use App\Services\ShareService;
use Carbon\CarbonInterval;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
@@ -135,7 +137,7 @@ class FileUploader extends Component
$rules = []; $rules = [];
if (! Setting::get('allow_never_expire', false)) { if (! Setting::get('allow_never_expire', false)) {
$rules['expiration'] = ['required', 'string', 'in:1h,24h,48h,7d,14d,30d']; $rules['expiration'] = ['required', 'string', Rule::in(array_keys(Share::EXPIRATIONS))];
} }
if ($this->usePassword) { if ($this->usePassword) {
@@ -158,15 +160,9 @@ class FileUploader extends Component
$share = $shareService->completeShare($pendingShare, [ $share = $shareService->completeShare($pendingShare, [
'password' => $this->usePassword ? $this->password : null, 'password' => $this->usePassword ? $this->password : null,
'expires_at' => match ($this->expiration) { 'expires_at' => isset(Share::EXPIRATIONS[$this->expiration])
'1h' => now()->addHour(), ? now()->add(CarbonInterval::make(Share::EXPIRATIONS[$this->expiration]['interval']))
'24h' => now()->addDay(), : null,
'48h' => now()->addDays(2),
'7d' => now()->addWeek(),
'14d' => now()->addDays(14),
'30d' => now()->addMonth(),
default => null,
},
'max_downloads' => $this->maxDownloads ?: null, 'max_downloads' => $this->maxDownloads ?: null,
]); ]);
-4
View File
@@ -2,7 +2,6 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\Setting;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@@ -45,14 +44,11 @@ class SetupWizard extends Component
'name' => $this->name, 'name' => $this->name,
'email' => $this->email, 'email' => $this->email,
'password' => Hash::make($this->password), 'password' => Hash::make($this->password),
'email_verified_at' => now(),
]); ]);
$user->is_admin = true; $user->is_admin = true;
$user->save(); $user->save();
Setting::set('setup_complete', 'true');
Auth::login($user); Auth::login($user);
$this->redirect(route('admin.dashboard'), navigate: true); $this->redirect(route('admin.dashboard'), navigate: true);
+13 -10
View File
@@ -4,6 +4,7 @@ namespace App\Livewire;
use App\Models\Share; use App\Models\Share;
use App\Services\ShareService; use App\Services\ShareService;
use Carbon\CarbonInterval;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
@@ -19,21 +20,17 @@ class ShareDownload extends Component
#[Validate('required|string')] #[Validate('required|string')]
public string $password = ''; public string $password = '';
public function mount(Share $share): void public function mount(Share $share, ShareService $shareService): void
{ {
$this->share = $share->load('files'); $this->share = $share->load('files');
if (! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit()) { // A share at its download limit stays open for the recipient who took its last download.
if (! $share->isCompleted() || $share->isExpired()
|| ($share->hasReachedDownloadLimit() && $shareService->downloadWindowEndsAt($share, session()->driver()) === null)) {
abort(404); abort(404);
} }
if (! $share->isPasswordProtected()) { $this->authenticated = ! $share->isPasswordProtected() || (bool) session('share_key_'.$share->token);
$this->authenticated = true;
}
if ($share->isPasswordProtected() && session('share_key_'.$share->token)) {
$this->authenticated = true;
}
} }
public function verifyPassword(ShareService $shareService): void public function verifyPassword(ShareService $shareService): void
@@ -65,6 +62,12 @@ class ShareDownload extends Component
public function render(): mixed public function render(): mixed
{ {
return view('livewire.share-download'); $shareService = app(ShareService::class);
return view('livewire.share-download', [
'downloadWindowEndsAt' => $shareService->downloadWindowEndsAt($this->share, session()->driver()),
'remainingDownloads' => $this->share->max_downloads ? max($this->share->max_downloads - $this->share->download_count, 0) : null,
'downloadWindow' => CarbonInterval::minutes(ShareService::DOWNLOAD_WINDOW_MINUTES)->cascade()->forHumans(),
]);
} }
} }
+16
View File
@@ -10,6 +10,21 @@ class Share extends Model
{ {
use HasFactory; use HasFactory;
/**
* The expiration times an uploader can choose, by the id the upload form and Admin settings
* store: each one's label and how long a share lasts with it.
*
* @var array<string, array{label: string, interval: string}>
*/
public const EXPIRATIONS = [
'1h' => ['label' => '1 Hour', 'interval' => '1 hour'],
'24h' => ['label' => '24 Hours', 'interval' => '1 day'],
'48h' => ['label' => '48 Hours', 'interval' => '2 days'],
'7d' => ['label' => '7 Days', 'interval' => '7 days'],
'14d' => ['label' => '14 Days', 'interval' => '14 days'],
'30d' => ['label' => '30 Days', 'interval' => '30 days'],
];
protected $fillable = [ protected $fillable = [
'token', 'token',
'password', 'password',
@@ -32,6 +47,7 @@ class Share extends Model
'expires_at' => 'datetime', 'expires_at' => 'datetime',
'max_downloads' => 'integer', 'max_downloads' => 'integer',
'download_count' => 'integer', 'download_count' => 'integer',
'last_downloaded_at' => 'datetime',
'total_size' => 'integer', 'total_size' => 'integer',
'encryption_key' => 'encrypted', 'encryption_key' => 'encrypted',
'completed_at' => 'datetime', 'completed_at' => 'datetime',
-14
View File
@@ -2,12 +2,10 @@
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
class User extends Authenticatable class User extends Authenticatable
@@ -51,16 +49,4 @@ class User extends Authenticatable
'is_admin' => 'boolean', 'is_admin' => 'boolean',
]; ];
} }
/**
* Get the user's initials
*/
public function initials(): string
{
return Str::of($this->name)
->explode(' ')
->take(2)
->map(fn ($word) => Str::substr($word, 0, 1))
->implode('');
}
} }
-8
View File
@@ -12,14 +12,6 @@ use NoNameWeb\LivewireMaterial\Support\Scheme;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
/**
* Register any application services.
*/
public function register(): void
{
//
}
/** /**
* Bootstrap any application services. * Bootstrap any application services.
*/ */
-11
View File
@@ -2,7 +2,6 @@
namespace App\Providers; namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword; use App\Actions\Fortify\ResetUserPassword;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -13,14 +12,6 @@ use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider class FortifyServiceProvider extends ServiceProvider
{ {
/**
* Register any application services.
*/
public function register(): void
{
//
}
/** /**
* Bootstrap any application services. * Bootstrap any application services.
*/ */
@@ -37,7 +28,6 @@ class FortifyServiceProvider extends ServiceProvider
private function configureActions(): void private function configureActions(): void
{ {
Fortify::resetUserPasswordsUsing(ResetUserPassword::class); Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::createUsersUsing(CreateNewUser::class);
} }
/** /**
@@ -46,7 +36,6 @@ class FortifyServiceProvider extends ServiceProvider
private function configureViews(): void private function configureViews(): void
{ {
Fortify::loginView(fn () => view('pages::auth.login')); Fortify::loginView(fn () => view('pages::auth.login'));
Fortify::verifyEmailView(fn () => view('pages::auth.verify-email'));
Fortify::twoFactorChallengeView(fn () => view('pages::auth.two-factor-challenge')); Fortify::twoFactorChallengeView(fn () => view('pages::auth.two-factor-challenge'));
Fortify::confirmPasswordView(fn () => view('pages::auth.confirm-password')); Fortify::confirmPasswordView(fn () => view('pages::auth.confirm-password'));
Fortify::resetPasswordView(fn () => view('pages::auth.reset-password')); Fortify::resetPasswordView(fn () => view('pages::auth.reset-password'));
-62
View File
@@ -53,14 +53,6 @@ class FileEncryptionService
return hash_pbkdf2('sha256', $password, hex2bin($salt), self::PBKDF2_ITERATIONS, self::KEY_LENGTH, true); return hash_pbkdf2('sha256', $password, hex2bin($salt), self::PBKDF2_ITERATIONS, self::KEY_LENGTH, true);
} }
/**
* Generate a random hex salt (32 bytes = 64 hex chars).
*/
public function generateSalt(): string
{
return bin2hex(random_bytes(32));
}
/** /**
* Generate a random encryption key (32 bytes, returned as hex). * Generate a random encryption key (32 bytes, returned as hex).
*/ */
@@ -207,60 +199,6 @@ class FileEncryptionService
return $plaintext; 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');
if ($source === false) {
throw new RuntimeException("Cannot read source file: {$sourcePath}");
}
$dest = fopen($destPath, 'wb');
if ($dest === false) {
fclose($source);
throw new RuntimeException("Cannot write encrypted file: {$destPath}");
}
try {
$header = $this->createHeader($chunkSize);
$noncePrefix = $this->parseHeader($header)['noncePrefix'];
$chunkCount = $this->chunkCount((int) filesize($sourcePath), $chunkSize);
fwrite($dest, $header);
for ($index = 0; $index < $chunkCount; $index++) {
$plaintext = (string) fread($source, $chunkSize);
fwrite($dest, $this->encryptChunk($plaintext, $key, $noncePrefix, $index, $index === $chunkCount - 1));
}
} catch (RuntimeException $e) {
fclose($source);
fclose($dest);
@unlink($destPath);
throw $e;
}
fclose($source);
fclose($dest);
}
/**
* Stream decrypted file content directly to output (echo).
*/
public function streamDecryptedFile(string $encryptedPath, string $key): void
{
foreach ($this->decryptedChunks($encryptedPath, $key) as $chunk) {
echo $chunk;
flush();
}
}
/** /**
* The decrypted content of a file in any of the three formats, chunk by chunk. * The decrypted content of a file in any of the three formats, chunk by chunk.
* *
+53 -5
View File
@@ -5,8 +5,11 @@ namespace App\Services;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Share; use App\Models\Share;
use App\Models\ShareFile; use App\Models\ShareFile;
use Carbon\CarbonInterface;
use Illuminate\Contracts\Session\Session;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -21,6 +24,11 @@ use RuntimeException;
*/ */
class ShareService class ShareService
{ {
/**
* How long a recipient may keep starting downloads of a share after their download was counted.
*/
public const DOWNLOAD_WINDOW_MINUTES = 60;
public function __construct( public function __construct(
private FileEncryptionService $encryptionService, private FileEncryptionService $encryptionService,
) {} ) {}
@@ -318,15 +326,55 @@ class ShareService
} }
/** /**
* Record a download and auto-delete if limit reached. * When this session's download window for a share ends, or null while it has none open. The
* window opens with the session's counted download; until it ends, the session may start more
* downloads of the share without counting them, even once the share has reached its limit.
*/ */
public function recordDownload(Share $share): void public function downloadWindowEndsAt(Share $share, Session $session): ?CarbonInterface
{ {
$share->increment('download_count'); $countedAt = $session->get($this->downloadSessionKey($share));
if ($share->hasReachedDownloadLimit()) { if (! is_int($countedAt)) {
$this->deleteShare($share); return null;
} }
$endsAt = Carbon::createFromTimestamp($countedAt)->addMinutes(self::DOWNLOAD_WINDOW_MINUTES);
return $endsAt->isFuture() ? $endsAt : null;
}
/**
* Let this session download from a share: one recipient's visit is one download, so a session
* without an open window counts one and opens its window. The limit is checked in the same
* update that counts, so two recipients who start at once cannot both take the last download.
* False when no download is left for this session.
*/
public function claimDownload(Share $share, Session $session): bool
{
if ($this->downloadWindowEndsAt($share, $session) !== null) {
return true;
}
$counted = Share::query()
->whereKey($share->id)
->where(fn ($query) => $query->whereNull('max_downloads')->orWhereColumn('download_count', '<', 'max_downloads'))
->increment('download_count', 1, ['last_downloaded_at' => now()]);
if ($counted === 0) {
return false;
}
$session->put($this->downloadSessionKey($share), now()->getTimestamp());
return true;
}
/**
* The session key holding when this session's download of a share was counted.
*/
private function downloadSessionKey(Share $share): string
{
return 'share_download_'.$share->token;
} }
/** /**
+2 -4
View File
@@ -5,7 +5,6 @@ use App\Http\Middleware\EnsureSetupComplete;
use App\Http\Middleware\SecurityHeaders; use App\Http\Middleware\SecurityHeaders;
use App\Http\Middleware\SystemPasswordGate; use App\Http\Middleware\SystemPasswordGate;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
@@ -27,6 +26,5 @@ return Application::configure(basePath: dirname(__DIR__))
'admin' => EnsureAdmin::class, 'admin' => EnsureAdmin::class,
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions()
// ->create();
})->create();
-6
View File
@@ -22,9 +22,7 @@
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"laravel/boost": "^2.0", "laravel/boost": "^2.0",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24", "laravel/pint": "^1.24",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6", "nunomaduro/collision": "^8.6",
"pestphp/pest": "^5.1", "pestphp/pest": "^5.1",
@@ -52,10 +50,6 @@
"npm install", "npm install",
"npm run build" "npm run build"
], ],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan octane:frankenphp --host=127.0.0.1 --port=8000 --watch\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"lint": [ "lint": [
"pint --parallel" "pint --parallel"
], ],
Generated
+35 -185
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "ac00b1ec9288209410d7a370eabfb8ef", "content-hash": "77735015384f764397037325aeca7d63",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@@ -3081,11 +3081,11 @@
}, },
{ {
"name": "nonameweb/livewire-material", "name": "nonameweb/livewire-material",
"version": "2.1.0", "version": "2.2.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://gitea.nonameweb.ch/noNameWEB/livewire-material.git", "url": "https://gitea.nonameweb.ch/noNameWEB/livewire-material.git",
"reference": "252c649c4515e409be480e6ba0cfe7376b253f6a" "reference": "ebdc2ef2e1696b416e2cdd5ef0abe91b42abded1"
}, },
"require": { "require": {
"laravel/framework": "^13.0", "laravel/framework": "^13.0",
@@ -3097,8 +3097,7 @@
"laravel/pint": "^1.32", "laravel/pint": "^1.32",
"orchestra/testbench": "^11.2", "orchestra/testbench": "^11.2",
"pestphp/pest": "^5.1", "pestphp/pest": "^5.1",
"pestphp/pest-plugin-browser": "^5.0", "pestphp/pest-plugin-browser": "^5.0"
"pestphp/pest-plugin-laravel": "^5.0"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@@ -3137,12 +3136,6 @@
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"@build", "@build",
"@php vendor/bin/testbench serve --ansi" "@php vendor/bin/testbench serve --ansi"
],
"lint": [
"pint"
],
"test": [
"pest"
] ]
}, },
"license": [ "license": [
@@ -3164,7 +3157,7 @@
"material-3", "material-3",
"material-design" "material-design"
], ],
"time": "2026-09-16T19:02:48+00:00" "time": "2026-09-18T14:15:25+00:00"
}, },
{ {
"name": "nunomaduro/termwind", "name": "nunomaduro/termwind",
@@ -9598,16 +9591,16 @@
}, },
{ {
"name": "filp/whoops", "name": "filp/whoops",
"version": "2.18.4", "version": "2.18.5",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filp/whoops.git", "url": "https://github.com/filp/whoops.git",
"reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" "reference": "baec851ad6ae00db8bd0fdd30d208afc1b71c56e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", "url": "https://api.github.com/repos/filp/whoops/zipball/baec851ad6ae00db8bd0fdd30d208afc1b71c56e",
"reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", "reference": "baec851ad6ae00db8bd0fdd30d208afc1b71c56e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -9616,8 +9609,8 @@
}, },
"require-dev": { "require-dev": {
"mockery/mockery": "^1.0", "mockery/mockery": "^1.0",
"phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3 || ^10.5.58",
"symfony/var-dumper": "^4.0 || ^5.0" "symfony/var-dumper": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0"
}, },
"suggest": { "suggest": {
"symfony/var-dumper": "Pretty print complex values better with var-dumper available", "symfony/var-dumper": "Pretty print complex values better with var-dumper available",
@@ -9657,7 +9650,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/filp/whoops/issues", "issues": "https://github.com/filp/whoops/issues",
"source": "https://github.com/filp/whoops/tree/2.18.4" "source": "https://github.com/filp/whoops/tree/2.18.5"
}, },
"funding": [ "funding": [
{ {
@@ -9665,7 +9658,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2025-08-08T12:00:00+00:00" "time": "2026-09-18T12:00:00+00:00"
}, },
{ {
"name": "hamcrest/hamcrest-php", "name": "hamcrest/hamcrest-php",
@@ -9842,16 +9835,16 @@
}, },
{ {
"name": "laravel/boost", "name": "laravel/boost",
"version": "v2.9.0", "version": "v2.9.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/boost.git", "url": "https://github.com/laravel/boost.git",
"reference": "ebe59d97cbc66b66f735e6ec7ff859233e7934e0" "reference": "2da6cbfdc6399d69b49a2dfb1e4f4eaf6bb419f0"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/boost/zipball/ebe59d97cbc66b66f735e6ec7ff859233e7934e0", "url": "https://api.github.com/repos/laravel/boost/zipball/2da6cbfdc6399d69b49a2dfb1e4f4eaf6bb419f0",
"reference": "ebe59d97cbc66b66f735e6ec7ff859233e7934e0", "reference": "2da6cbfdc6399d69b49a2dfb1e4f4eaf6bb419f0",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -9904,7 +9897,7 @@
"issues": "https://github.com/laravel/boost/issues", "issues": "https://github.com/laravel/boost/issues",
"source": "https://github.com/laravel/boost" "source": "https://github.com/laravel/boost"
}, },
"time": "2026-09-14T14:40:10+00:00" "time": "2026-09-17T02:40:42+00:00"
}, },
{ {
"name": "laravel/mcp", "name": "laravel/mcp",
@@ -9980,86 +9973,6 @@
}, },
"time": "2026-09-14T14:35:19+00:00" "time": "2026-09-14T14:35:19+00:00"
}, },
{
"name": "laravel/pail",
"version": "v1.2.7",
"source": {
"type": "git",
"url": "https://github.com/laravel/pail.git",
"reference": "2f7d27dada8effc48b8c424445a69cca7007daaa"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa",
"reference": "2f7d27dada8effc48b8c424445a69cca7007daaa",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"illuminate/console": "^10.24|^11.0|^12.0|^13.0",
"illuminate/contracts": "^10.24|^11.0|^12.0|^13.0",
"illuminate/log": "^10.24|^11.0|^12.0|^13.0",
"illuminate/process": "^10.24|^11.0|^12.0|^13.0",
"illuminate/support": "^10.24|^11.0|^12.0|^13.0",
"nunomaduro/termwind": "^1.15|^2.0",
"php": "^8.2",
"symfony/console": "^6.0|^7.0|^8.0"
},
"require-dev": {
"laravel/framework": "^10.24|^11.0|^12.0|^13.0",
"laravel/pint": "^1.13",
"orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0",
"pestphp/pest": "^2.20|^3.0|^4.0",
"pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0",
"phpstan/phpstan": "^1.12.27",
"symfony/var-dumper": "^6.3|^7.0|^8.0",
"symfony/yaml": "^6.3|^7.0|^8.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Pail\\PailServiceProvider"
]
},
"branch-alias": {
"dev-main": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Pail\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
},
{
"name": "Nuno Maduro",
"email": "enunomaduro@gmail.com"
}
],
"description": "Easily delve into your Laravel application's log files directly from the command line.",
"homepage": "https://github.com/laravel/pail",
"keywords": [
"dev",
"laravel",
"logs",
"php",
"tail"
],
"support": {
"issues": "https://github.com/laravel/pail/issues",
"source": "https://github.com/laravel/pail"
},
"time": "2026-05-20T22:24:57+00:00"
},
{ {
"name": "laravel/pint", "name": "laravel/pint",
"version": "v1.32.1", "version": "v1.32.1",
@@ -10192,69 +10105,6 @@
}, },
"time": "2026-07-18T17:53:15+00:00" "time": "2026-07-18T17:53:15+00:00"
}, },
{
"name": "laravel/sail",
"version": "v1.67.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/sail.git",
"reference": "639e03ac12cf23def171770bcab05758045b2642"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sail/zipball/639e03ac12cf23def171770bcab05758045b2642",
"reference": "639e03ac12cf23def171770bcab05758045b2642",
"shasum": ""
},
"require": {
"illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0",
"illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0",
"php": "^8.0",
"symfony/console": "^6.0|^7.0|^8.0",
"symfony/yaml": "^6.0|^7.0|^8.0"
},
"require-dev": {
"orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0",
"phpstan/phpstan": "^2.0"
},
"bin": [
"bin/sail"
],
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Sail\\SailServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Sail\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Docker files for running a basic Laravel application.",
"keywords": [
"docker",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/sail/issues",
"source": "https://github.com/laravel/sail"
},
"time": "2026-08-12T13:55:56+00:00"
},
{ {
"name": "league/uri-components", "name": "league/uri-components",
"version": "7.8.1", "version": "7.8.1",
@@ -10579,16 +10429,16 @@
}, },
{ {
"name": "pestphp/pest", "name": "pestphp/pest",
"version": "v5.2.0", "version": "v5.2.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/pestphp/pest.git", "url": "https://github.com/pestphp/pest.git",
"reference": "942d6aead1c5ae407998eaf4964667423e77fd43" "reference": "94f4f1ff4835dae2519f8b13f349d1042318e076"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/pestphp/pest/zipball/942d6aead1c5ae407998eaf4964667423e77fd43", "url": "https://api.github.com/repos/pestphp/pest/zipball/94f4f1ff4835dae2519f8b13f349d1042318e076",
"reference": "942d6aead1c5ae407998eaf4964667423e77fd43", "reference": "94f4f1ff4835dae2519f8b13f349d1042318e076",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -10600,13 +10450,13 @@
"pestphp/pest-plugin-mutate": "^5.0.2", "pestphp/pest-plugin-mutate": "^5.0.2",
"pestphp/pest-plugin-profanity": "^5.0.0", "pestphp/pest-plugin-profanity": "^5.0.0",
"php": "^8.4", "php": "^8.4",
"phpunit/phpunit": "^13.3.3", "phpunit/phpunit": "^13.3.4",
"symfony/process": "^8.1.6" "symfony/process": "^8.1.7"
}, },
"conflict": { "conflict": {
"filp/whoops": "<2.18.3", "filp/whoops": "<2.18.3",
"laravel/boost": "<2.6.0", "laravel/boost": "<2.6.0",
"phpunit/phpunit": ">13.3.3", "phpunit/phpunit": ">13.3.4",
"sebastian/exporter": "<7.0.0", "sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0" "webmozart/assert": "<1.11.0"
}, },
@@ -10683,7 +10533,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/pestphp/pest/issues", "issues": "https://github.com/pestphp/pest/issues",
"source": "https://github.com/pestphp/pest/tree/v5.2.0" "source": "https://github.com/pestphp/pest/tree/v5.2.1"
}, },
"funding": [ "funding": [
{ {
@@ -10695,7 +10545,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-09-14T14:41:18+00:00" "time": "2026-09-17T11:41:10+00:00"
}, },
{ {
"name": "pestphp/pest-plugin", "name": "pestphp/pest-plugin",
@@ -11643,16 +11493,16 @@
}, },
{ {
"name": "phpunit/phpunit", "name": "phpunit/phpunit",
"version": "13.3.3", "version": "13.3.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git", "url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "0d8711067516c1f6ba1a66f762e16c2fb1a8ccd1" "reference": "d19d90cf860efb7e4d96cee8243e06a248578085"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0d8711067516c1f6ba1a66f762e16c2fb1a8ccd1", "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d19d90cf860efb7e4d96cee8243e06a248578085",
"reference": "0d8711067516c1f6ba1a66f762e16c2fb1a8ccd1", "reference": "d19d90cf860efb7e4d96cee8243e06a248578085",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11666,7 +11516,7 @@
"phar-io/manifest": "^2.0.4", "phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1", "phar-io/version": "^3.2.1",
"php": ">=8.4.1", "php": ">=8.4.1",
"phpunit/php-code-coverage": "^14.3.2", "phpunit/php-code-coverage": "^14.3.3",
"phpunit/php-file-iterator": "^7.0.2", "phpunit/php-file-iterator": "^7.0.2",
"phpunit/php-invoker": "^7.0.0", "phpunit/php-invoker": "^7.0.0",
"phpunit/php-text-template": "^6.0.0", "phpunit/php-text-template": "^6.0.0",
@@ -11723,7 +11573,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues", "issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy", "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/13.3.3" "source": "https://github.com/sebastianbergmann/phpunit/tree/13.3.4"
}, },
"funding": [ "funding": [
{ {
@@ -11731,7 +11581,7 @@
"type": "other" "type": "other"
} }
], ],
"time": "2026-09-09T04:50:32+00:00" "time": "2026-09-15T07:37:05+00:00"
}, },
{ {
"name": "revolt/event-loop", "name": "revolt/event-loop",
@@ -13158,5 +13008,5 @@
"php": "^8.5" "php": "^8.5"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.6.0" "plugin-api-version": "2.9.0"
} }
+15 -111
View File
@@ -1,126 +1,30 @@
<?php <?php
return [ /*
|--------------------------------------------------------------------------
| Application
|--------------------------------------------------------------------------
|
| Only what differs from the framework's config/app.php; Laravel merges
| every other key from its own defaults.
|
*/
/* return [
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'SealShare'), 'name' => env('APP_NAME', 'SealShare'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Application Environment | SealShare Version
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| This value determines the "environment" your application is currently | The release this code is, shown on the admin dashboard. Bump it together
| running in. This may determine how you prefer to configure various | with the release's heading in CHANGELOG.md: tests/Feature/AppVersionTest
| services the application utilizes. Set this in your ".env" file. | fails while the two differ.
| |
*/ */
'env' => env('APP_ENV', 'production'), 'version' => '2.2.0',
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
]; ];
-117
View File
@@ -1,117 +0,0 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];
+10 -122
View File
@@ -1,130 +1,18 @@
<?php <?php
use Illuminate\Support\Str; /*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| No PHP classes are unserialized from the cache, to prevent gadget chain
| attacks if the APP_KEY is leaked. The framework's default (null) would
| allow every class. Every other key comes from the framework's defaults.
|
*/
return [ return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane",
| "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false, 'serializable_classes' => false,
]; ];
-184
View File
@@ -1,184 +0,0 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];
+10 -71
View File
@@ -1,52 +1,19 @@
<?php <?php
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| The encrypted share files. Laravel merges this disk into its own default
| disks (local, public, s3).
|
*/
return [ return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [ 'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
'shares' => [ 'shares' => [
'driver' => 'local', 'driver' => 'local',
'root' => storage_path('app/shares'), 'root' => storage_path('app/shares'),
@@ -54,34 +21,6 @@ return [
'report' => false, 'report' => false,
], ],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
], ],
]; ];
-2
View File
@@ -144,9 +144,7 @@ return [
*/ */
'features' => [ 'features' => [
// Features::registration(), // Disabled - admin created via setup wizard
Features::resetPasswords(), Features::resetPasswords(),
Features::emailVerification(),
Features::twoFactorAuthentication([ Features::twoFactorAuthentication([
'confirm' => true, 'confirm' => true,
'confirmPassword' => true, 'confirmPassword' => true,
+19 -254
View File
@@ -1,275 +1,39 @@
<?php <?php
/*
|--------------------------------------------------------------------------
| Livewire
|--------------------------------------------------------------------------
|
| Only what differs from Livewire's own config; every other key comes from
| its defaults. Livewire merges top-level keys only, so a nested key such
| as "payload" is given whole.
|
*/
return [ return [
/*
|---------------------------------------------------------------------------
| Component Locations
|---------------------------------------------------------------------------
|
| This value sets the root directories that'll be used to resolve view-based
| components like single and multi-file components. The make command will
| use the first directory in this array to add new component files to.
|
*/
'component_locations' => [
resource_path('views/components'),
resource_path('views/livewire'),
],
/*
|---------------------------------------------------------------------------
| Component Namespaces
|---------------------------------------------------------------------------
|
| This value sets default namespaces that will be used to resolve view-based
| components like single-file and multi-file components. These folders'll
| also be referenced when creating new components via the make command.
|
*/
'component_namespaces' => [
'layouts' => resource_path('views/layouts'),
'pages' => resource_path('views/pages'),
],
/*
|---------------------------------------------------------------------------
| Page Layout
|---------------------------------------------------------------------------
| The view that will be used as the layout when rendering a single component as
| an entire page via `Route::livewire('/post/create', 'pages::create-post')`.
| In this case, the content of pages::create-post will render into $slot.
|
*/
'component_layout' => 'layouts::app',
/*
|---------------------------------------------------------------------------
| Lazy Loading Placeholder
|---------------------------------------------------------------------------
| Livewire allows you to lazy load components that would otherwise slow down
| the initial page load. Every component can have a custom placeholder or
| you can define the default placeholder view for all components below.
|
*/
'component_placeholder' => null, // Example: 'placeholders::skeleton'
/*
|---------------------------------------------------------------------------
| Make Command
|---------------------------------------------------------------------------
| This value determines the default configuration for the artisan make command
| You can configure the component type (sfc, mfc, class) and whether to use
| the high-voltage () emoji as a prefix in the sfc|mfc component names.
|
*/
'make_command' => [
'type' => 'sfc', // Options: 'sfc', 'mfc', 'class'
'emoji' => true, // Options: true, false
'with' => [
'js' => false,
'css' => false,
'test' => false,
],
],
/*
|---------------------------------------------------------------------------
| Class Namespace
|---------------------------------------------------------------------------
|
| This value sets the root class namespace for Livewire component classes in
| your application. This value will change where component auto-discovery
| finds components. It's also referenced by the file creation commands.
|
*/
'class_namespace' => 'App\\Livewire',
/*
|---------------------------------------------------------------------------
| Class Path
|---------------------------------------------------------------------------
|
| This value is used to specify the path where Livewire component class files
| are created when running creation commands like `artisan make:livewire`.
| This path is customizable to match your projects directory structure.
|
*/
'class_path' => app_path('Livewire'),
/*
|---------------------------------------------------------------------------
| View Path
|---------------------------------------------------------------------------
|
| This value is used to specify where Livewire component Blade templates are
| stored when running file creation commands like `artisan make:livewire`.
| It is also used if you choose to omit a component's render() method.
|
*/
'view_path' => resource_path('views/livewire'),
/*
|---------------------------------------------------------------------------
| Temporary File Uploads
|---------------------------------------------------------------------------
|
| Livewire handles file uploads by storing uploads in a temporary directory
| before the file is stored permanently. All file uploads are directed to
| a global endpoint for temporary storage. You may configure this below:
|
*/
'temporary_file_upload' => [
'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default'
'rules' => ['required', 'file'], // No size cap: PHP's upload_max_filesize is the hard limit, the admin limit is enforced per-component
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
],
'max_upload_time' => (int) env('LIVEWIRE_MAX_UPLOAD_TIME', 30), // Max duration (in minutes) before an upload is invalidated...
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
],
/*
|---------------------------------------------------------------------------
| Render On Redirect
|---------------------------------------------------------------------------
|
| This value determines if Livewire will run a component's `render()` method
| after a redirect has been triggered using something like `redirect(...)`
| Setting this to true will render the view once more before redirecting
|
*/
'render_on_redirect' => false,
/*
|---------------------------------------------------------------------------
| Eloquent Model Binding
|---------------------------------------------------------------------------
|
| Previous versions of Livewire supported binding directly to eloquent model
| properties using wire:model by default. However, this behavior has been
| deemed too "magical" and has therefore been put under a feature flag.
|
*/
'legacy_model_binding' => false,
/*
|---------------------------------------------------------------------------
| Auto-inject Frontend Assets
|---------------------------------------------------------------------------
|
| By default, Livewire automatically injects its JavaScript and CSS into the
| <head> and <body> of pages containing Livewire components. By disabling
| this behavior, you need to use @livewireStyles and @livewireScripts.
|
*/
'inject_assets' => true,
/*
|---------------------------------------------------------------------------
| Navigate (SPA mode)
|---------------------------------------------------------------------------
|
| By adding `wire:navigate` to links in your Livewire application, Livewire
| will prevent the default link handling and instead request those pages
| via AJAX, creating an SPA-like effect. Configure this behavior here.
|
*/
'navigate' => [
'show_progress_bar' => true,
'progress_bar_color' => '#2299dd',
],
/*
|---------------------------------------------------------------------------
| HTML Morph Markers
|---------------------------------------------------------------------------
|
| Livewire intelligently "morphs" existing HTML into the newly rendered HTML
| after each update. To make this process more reliable, Livewire injects
| "markers" into the rendered Blade surrounding @if, @class & @foreach.
|
*/
'inject_morph_markers' => true,
/*
|---------------------------------------------------------------------------
| Smart Wire Keys
|---------------------------------------------------------------------------
|
| Livewire uses loops and keys used within loops to generate smart keys that
| are applied to nested components that don't have them. This makes using
| nested components more reliable by ensuring that they all have keys.
|
*/
'smart_wire_keys' => true,
/* /*
|--------------------------------------------------------------------------- |---------------------------------------------------------------------------
| Pagination Theme | Pagination Theme
|--------------------------------------------------------------------------- |---------------------------------------------------------------------------
| |
| When enabling Livewire's pagination feature by using the `WithPagination` | livewire-material takes this over itself while it still reads as
| trait, Livewire will use Tailwind templates to render pagination views | Livewire's own default ("tailwind", or the key missing), so this stays
| on the page. If you want Bootstrap CSS, you can specify: "bootstrap" | explicit: SealShare states the choice itself rather than relying on
| the package to silently switch it.
| |
*/ */
'pagination_theme' => 'tailwind', 'pagination_theme' => 'material',
/*
|---------------------------------------------------------------------------
| Release Token
|---------------------------------------------------------------------------
|
| This token is stored client-side and sent along with each request to check
| a users session to see if a new release has invalidated it. If there is
| a mismatch it will throw an error and prompt for a browser refresh.
|
*/
'release_token' => 'a',
/*
|---------------------------------------------------------------------------
| CSP Safe
|---------------------------------------------------------------------------
|
| This config is used to determine if Livewire will use the CSP-safe version
| of Alpine in its bundle. This is useful for applications that are using
| strict Content Security Policy (CSP) to protect against XSS attacks.
|
*/
'csp_safe' => false,
/* /*
|--------------------------------------------------------------------------- |---------------------------------------------------------------------------
| Payload Guards | Payload Guards
|--------------------------------------------------------------------------- |---------------------------------------------------------------------------
| |
| These settings protect against malicious or oversized payloads that could | Livewire's defaults, with at most 20 components per batch request
| cause denial of service. The default values should feel reasonable for | instead of 200.
| most web applications. Each can be set to null to disable the limit.
| |
*/ */
@@ -279,4 +43,5 @@ return [
'max_calls' => 50, // Maximum method calls per request 'max_calls' => 50, // Maximum method calls per request
'max_components' => 20, // Maximum components per batch request 'max_components' => 20, // Maximum components per batch request
], ],
]; ];
-132
View File
@@ -1,132 +0,0 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];
+11 -127
View File
@@ -1,140 +1,24 @@
<?php <?php
/*
|--------------------------------------------------------------------------
| Markdown Mail Settings
|--------------------------------------------------------------------------
|
| Markdown mail wears Livewire Material's theme, coloured from the light
| scheme in resources/css/material-scheme.json. Every other key comes from
| the framework's defaults.
|
*/
return [ return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'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' => [ 'markdown' => [
'theme' => env('MAIL_MARKDOWN_THEME', 'livewire-material::mail.theme'), 'theme' => env('MAIL_MARKDOWN_THEME', 'livewire-material::mail.theme'),
'paths' => [ 'paths' => [
resource_path('views/vendor/mail'), resource_path('views/vendor/mail'),
], ],
'extensions' => [
// \League\CommonMark\Extension\Strikethrough\StrikethroughExtension::class,
],
], ],
]; ];
+11 -206
View File
@@ -1,222 +1,27 @@
<?php <?php
use Laravel\Octane\Contracts\OperationTerminated; /*
use Laravel\Octane\Events\RequestHandled; |--------------------------------------------------------------------------
use Laravel\Octane\Events\RequestReceived; | Octane
use Laravel\Octane\Events\RequestTerminated; |--------------------------------------------------------------------------
use Laravel\Octane\Events\TaskReceived; |
use Laravel\Octane\Events\TaskTerminated; | Only what differs from Octane's own config; every other key (listeners,
use Laravel\Octane\Events\TickReceived; | warm and flush lists, watch paths, ...) comes from its defaults.
use Laravel\Octane\Events\TickTerminated; |
use Laravel\Octane\Events\WorkerErrorOccurred; */
use Laravel\Octane\Events\WorkerStarting;
use Laravel\Octane\Events\WorkerStopping;
use Laravel\Octane\Listeners\CloseMonologHandlers;
use Laravel\Octane\Listeners\CollectGarbage;
use Laravel\Octane\Listeners\DisconnectFromDatabases;
use Laravel\Octane\Listeners\EnsureUploadedFilesAreValid;
use Laravel\Octane\Listeners\EnsureUploadedFilesCanBeMoved;
use Laravel\Octane\Listeners\FlushOnce;
use Laravel\Octane\Listeners\FlushTemporaryContainerInstances;
use Laravel\Octane\Listeners\FlushUploadedFiles;
use Laravel\Octane\Listeners\ReportException;
use Laravel\Octane\Listeners\StopWorkerIfNecessary;
use Laravel\Octane\Octane;
return [ return [
/*
|--------------------------------------------------------------------------
| Octane Server
|--------------------------------------------------------------------------
|
| This value determines the default "server" that will be used by Octane
| when starting, restarting, or stopping your server via the CLI. You
| are free to change this to the supported server of your choosing.
|
| Supported: "roadrunner", "swoole", "frankenphp"
|
*/
'server' => env('OCTANE_SERVER', 'frankenphp'), 'server' => env('OCTANE_SERVER', 'frankenphp'),
/* /*
|-------------------------------------------------------------------------- | Absolute links use HTTPS whenever APP_URL does.
| Force HTTPS
|--------------------------------------------------------------------------
|
| When this configuration value is set to "true", Octane will inform the
| framework that all absolute links must be generated using the HTTPS
| protocol. Otherwise your links may be generated using plain HTTP.
|
*/ */
'https' => env('OCTANE_HTTPS', str_starts_with(env('APP_URL', ''), 'https://')), 'https' => env('OCTANE_HTTPS', str_starts_with(env('APP_URL', ''), 'https://')),
/* /*
|-------------------------------------------------------------------------- | Requests may run for up to 300 seconds instead of Octane's default 30.
| Octane Listeners
|--------------------------------------------------------------------------
|
| All of the event listeners for Octane's events are defined below. These
| listeners are responsible for resetting your application's state for
| the next request. You may even add your own listeners to the list.
|
*/
'listeners' => [
WorkerStarting::class => [
EnsureUploadedFilesAreValid::class,
EnsureUploadedFilesCanBeMoved::class,
],
RequestReceived::class => [
...Octane::prepareApplicationForNextOperation(),
...Octane::prepareApplicationForNextRequest(),
//
],
RequestHandled::class => [
//
],
RequestTerminated::class => [
// FlushUploadedFiles::class,
],
TaskReceived::class => [
...Octane::prepareApplicationForNextOperation(),
//
],
TaskTerminated::class => [
//
],
TickReceived::class => [
...Octane::prepareApplicationForNextOperation(),
//
],
TickTerminated::class => [
//
],
OperationTerminated::class => [
FlushOnce::class,
FlushTemporaryContainerInstances::class,
// DisconnectFromDatabases::class,
// CollectGarbage::class,
],
WorkerErrorOccurred::class => [
ReportException::class,
StopWorkerIfNecessary::class,
],
WorkerStopping::class => [
CloseMonologHandlers::class,
],
],
/*
|--------------------------------------------------------------------------
| Warm / Flush Bindings
|--------------------------------------------------------------------------
|
| The bindings listed below will either be pre-warmed when a worker boots
| or they will be flushed before every new request. Flushing a binding
| will force the container to resolve that binding again when asked.
|
*/
'warm' => [
...Octane::defaultServicesToWarm(),
],
'flush' => [
//
],
/*
|--------------------------------------------------------------------------
| Octane Swoole Tables
|--------------------------------------------------------------------------
|
| While using Swoole, you may define additional tables as required by the
| application. These tables can be used to store data that needs to be
| quickly accessed by other workers on the particular Swoole server.
|
*/
'tables' => [
'example:1000' => [
'name' => 'string:1000',
'votes' => 'int',
],
],
/*
|--------------------------------------------------------------------------
| Octane Swoole Cache Table
|--------------------------------------------------------------------------
|
| While using Swoole, you may leverage the Octane cache, which is powered
| by a Swoole table. You may set the maximum number of rows as well as
| the number of bytes per row using the configuration options below.
|
*/
'cache' => [
'rows' => 1000,
'bytes' => 10000,
],
/*
|--------------------------------------------------------------------------
| File Watching
|--------------------------------------------------------------------------
|
| The following list of files and directories will be watched when using
| the --watch option offered by Octane. If any of the directories and
| files are changed, Octane will automatically reload your workers.
|
*/
'watch' => [
'app',
'bootstrap',
'config/**/*.php',
'database/**/*.php',
'public/**/*.php',
'resources/**/*.php',
'routes',
'composer.lock',
'.env',
],
/*
|--------------------------------------------------------------------------
| Garbage Collection Threshold
|--------------------------------------------------------------------------
|
| When executing long-lived PHP scripts such as Octane, memory can build
| up before being cleared by PHP. You can force Octane to run garbage
| collection if your application consumes this amount of megabytes.
|
*/
'garbage' => 50,
/*
|--------------------------------------------------------------------------
| Maximum Execution Time
|--------------------------------------------------------------------------
|
| The following setting configures the maximum execution time for requests
| being handled by Octane. You may set this value to 0 to indicate that
| there isn't a specific time limit on Octane request execution time.
|
*/ */
'max_execution_time' => env('OCTANE_MAX_EXECUTION_TIME', 300), 'max_execution_time' => env('OCTANE_MAX_EXECUTION_TIME', 300),
-129
View File
@@ -1,129 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];
-38
View File
@@ -1,38 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];
+11 -205
View File
@@ -2,216 +2,22 @@
use Illuminate\Support\Str; use Illuminate\Support\Str;
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Kept from earlier releases, where it differs from the framework's
| "<app>_session": renaming the cookie would sign everyone out. Every
| other key comes from the framework's defaults.
|
*/
return [ return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env( 'cookie' => env(
'SESSION_COOKIE', 'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session' Str::slug((string) env('APP_NAME', 'laravel')).'-session'
), ),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
]; ];
-10
View File
@@ -36,16 +36,6 @@ class UserFactory extends Factory
]; ];
} }
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
/** /**
* Indicate that the user is an admin. * Indicate that the user is an admin.
*/ */
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* When a recipient's download was last counted: a share at its download limit is deleted a while
* after that, so downloads still running can finish.
*/
public function up(): void
{
Schema::table('shares', function (Blueprint $table) {
$table->timestamp('last_downloaded_at')->nullable()->after('download_count');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('shares', function (Blueprint $table) {
$table->dropColumn('last_downloaded_at');
});
}
};
-3
View File
@@ -4,7 +4,6 @@ namespace Database\Seeders;
use App\Models\Setting; use App\Models\Setting;
use App\Models\User; use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
@@ -14,8 +13,6 @@ class DatabaseSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
// User::factory(10)->create();
User::factory()->create([ User::factory()->create([
'name' => 'Test User', 'name' => 'Test User',
'email' => 'test@example.com', 'email' => 'test@example.com',
+68 -27
View File
@@ -1,37 +1,78 @@
# ============================================
# SealShare - Development (extends docker-compose.yml)
# ============================================
#
# The app and the scheduler extend the production services and change only what development needs.
# Select this file in .env, then use plain `docker compose` commands:
# COMPOSE_FILE=docker-compose.dev.yml
#
# The checkout is mounted at /app, so changes apply without a rebuild: Octane reloads on PHP changes,
# the Vite dev server hot-reloads CSS and JavaScript and reloads the page on Blade changes. Every other
# value comes from .env through docker-compose.yml.
#
# No ports are published: OrbStack serves https://app.sealshare.orb.local and
# https://vite.sealshare.orb.local. Elsewhere, add docker-compose.ports.yml to COMPOSE_FILE.
#
# ============================================
# Only what differs between the host and the container: the host reads .env too.
x-container-environment: &container-environment
# 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
# The database file the host uses, not the production volume's path
DB_DATABASE: /app/database/database.sqlite
services: services:
app: app:
extends:
file: docker-compose.yml
service: app
# Its own name, so a development build never tags the published image
image: sealshare-dev
build:
target: dev
ports: !reset []
volumes: !override
- .:/app
environment: *container-environment
labels:
# OrbStack's port for https://app.sealshare.orb.local, instead of detecting it (it can keep a stale one)
dev.orbstack.http-port: "80"
healthcheck:
# The first start installs Composer packages
start_period: 5m
start_interval: 2s
scheduler:
extends:
file: docker-compose.yml
service: scheduler
image: sealshare-dev
build:
target: dev
volumes: !override
- .:/app
environment: *container-environment
vite:
image: sealshare-dev
build: build:
context: . context: .
dockerfile: docker/dev.Dockerfile dockerfile: Dockerfile
ports: target: dev
- "8000:8000" entrypoint: ["sh", "-c", "npm install --no-audit --no-fund && exec node_modules/.bin/vite"]
- "5173:5173"
volumes: volumes:
- .:/app - .:/app
# Its own node_modules: npm installs the build tools' native binaries for Linux here and for # 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. # the host's platform there, and a shared folder only ever holds one of them.
- /app/node_modules - /app/node_modules
environment: labels:
APP_KEY: ${APP_KEY:-} dev.orbstack.http-port: "${VITE_PORT:-5173}"
APP_URL: http://localhost:8000 # The image's healthcheck asks the web server, which only the app service runs
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: "64M"
PHP_POST_MAX_SIZE: "64M"
PHP_MAX_EXECUTION_TIME: "300"
PHP_MAX_INPUT_TIME: "300"
PHP_MEMORY_LIMIT: "512M"
healthcheck: healthcheck:
test: ["CMD", "curl", "--silent", "--fail", "http://localhost:8000/up"] disable: true
interval: 30s depends_on:
timeout: 5s # The stylesheet imports Livewire Material from vendor/, which the app's first start installs
start_period: 30s app:
retries: 3 condition: service_healthy
+8 -3
View File
@@ -26,13 +26,14 @@ services:
- "443:443/udp" # HTTP/3 (QUIC) - "443:443/udp" # HTTP/3 (QUIC)
volumes: volumes:
- sealshare_storage:/app/storage/app # Uploaded & encrypted files - sealshare_storage:/app/storage/app # Uploaded & encrypted files
- sealshare_database:/app/database # SQLite database - sealshare_database:/app/database/sqlite # SQLite database
- caddy_data:/data # TLS certificates - caddy_data:/data # TLS certificates
- caddy_config:/config # Caddy configuration - caddy_config:/config # Caddy configuration
environment: environment:
# --- REQUIRED --- # --- REQUIRED ---
APP_URL: # Your full URL, e.g. https://share.example.com APP_URL: # Your full URL, e.g. https://share.example.com
# APP_KEY: # Auto-generated if not set. Copy from logs to persist across restarts. # APP_KEY: # Auto-generated if not set. Copy from logs to persist across restarts.
DB_DATABASE: /app/database/sqlite/database.sqlite # The SQLite file in sealshare_database
# --- HTTPS --- # --- HTTPS ---
# Files are encrypted in the uploader's browser, which browsers only allow over HTTPS (or on # Files are encrypted in the uploader's browser, which browsers only allow over HTTPS (or on
@@ -51,7 +52,7 @@ services:
# DB_CONNECTION: sqlite # Options: sqlite, mysql, pgsql # DB_CONNECTION: sqlite # Options: sqlite, mysql, pgsql
# DB_HOST: # Required for mysql/pgsql # DB_HOST: # Required for mysql/pgsql
# DB_PORT: # Required for mysql/pgsql # DB_PORT: # Required for mysql/pgsql
# DB_DATABASE: # Required for mysql/pgsql # DB_DATABASE: # For mysql/pgsql the database's name, in place of the SQLite file above
# DB_USERNAME: # Required for mysql/pgsql # DB_USERNAME: # Required for mysql/pgsql
# DB_PASSWORD: # Required for mysql/pgsql # DB_PASSWORD: # Required for mysql/pgsql
@@ -82,12 +83,16 @@ services:
image: gitea.nonameweb.ch/nonameweb/sealshare:latest image: gitea.nonameweb.ch/nonameweb/sealshare:latest
restart: unless-stopped restart: unless-stopped
entrypoint: ["php", "artisan", "schedule:work"] entrypoint: ["php", "artisan", "schedule:work"]
# The image's healthcheck asks the web server, which only the app service runs
healthcheck:
disable: true
volumes: volumes:
- sealshare_storage:/app/storage/app - sealshare_storage:/app/storage/app
- sealshare_database:/app/database - sealshare_database:/app/database/sqlite
environment: environment:
# APP_KEY: # Same key as the app service above (auto-generated if not set) # APP_KEY: # Same key as the app service above (auto-generated if not set)
APP_URL: # Same URL as the app service above APP_URL: # Same URL as the app service above
DB_DATABASE: /app/database/sqlite/database.sqlite # Same as the app service above
depends_on: depends_on:
app: app:
condition: service_healthy condition: service_healthy
+20
View File
@@ -0,0 +1,20 @@
# ============================================
# SealShare - Development ports (layered on docker-compose.dev.yml)
# ============================================
#
# Publishes the app and the Vite dev server on this machine, for Docker without OrbStack's domains:
# COMPOSE_FILE=docker-compose.dev.yml:docker-compose.ports.yml
# APP_URL=http://localhost:8000
#
# Bound to 127.0.0.1: a debug build does not belong on the network.
#
# ============================================
services:
app:
ports:
- "127.0.0.1:${APP_PORT:-8000}:80"
vite:
ports:
- "127.0.0.1:${VITE_PORT:-5173}:${VITE_PORT:-5173}"
+22 -27
View File
@@ -1,3 +1,18 @@
# What the app and the scheduler both need
x-environment: &environment
APP_KEY: ${APP_KEY:?Set APP_KEY in .env or environment}
APP_URL: ${APP_URL:-http://localhost}
APP_ENV: ${APP_ENV:-production}
APP_DEBUG: ${APP_DEBUG:-false}
DB_CONNECTION: ${DB_CONNECTION:-sqlite}
DB_HOST: ${DB_HOST:-}
DB_PORT: ${DB_PORT:-}
DB_DATABASE: ${DB_DATABASE:-/app/database/sqlite/database.sqlite}
DB_USERNAME: ${DB_USERNAME:-}
DB_PASSWORD: ${DB_PASSWORD:-}
LOG_CHANNEL: ${LOG_CHANNEL:-stderr}
LOG_LEVEL: ${LOG_LEVEL:-warning}
services: services:
app: app:
image: gitea.nonameweb.ch/nonameweb/sealshare:latest image: gitea.nonameweb.ch/nonameweb/sealshare:latest
@@ -11,24 +26,13 @@ services:
- "443:443/udp" - "443:443/udp"
volumes: volumes:
- sealshare_storage:/app/storage/app - sealshare_storage:/app/storage/app
- sealshare_database:/app/database - sealshare_database:/app/database/sqlite
- caddy_data:/data - caddy_data:/data
- caddy_config:/config - caddy_config:/config
environment: environment:
APP_KEY: ${APP_KEY:?Set APP_KEY in .env or environment} <<: *environment
APP_URL: ${APP_URL:-http://localhost}
APP_ENV: ${APP_ENV:-production}
APP_DEBUG: ${APP_DEBUG:-false}
AUTO_HTTPS: ${AUTO_HTTPS:-false} AUTO_HTTPS: ${AUTO_HTTPS:-false}
SERVER_NAME: ${SERVER_NAME:-localhost} SERVER_NAME: ${SERVER_NAME:-localhost}
DB_CONNECTION: ${DB_CONNECTION:-sqlite}
DB_HOST: ${DB_HOST:-}
DB_PORT: ${DB_PORT:-}
DB_DATABASE: ${DB_DATABASE:-/app/database/database.sqlite}
DB_USERNAME: ${DB_USERNAME:-}
DB_PASSWORD: ${DB_PASSWORD:-}
LOG_CHANNEL: ${LOG_CHANNEL:-stderr}
LOG_LEVEL: ${LOG_LEVEL:-warning}
SESSION_DRIVER: ${SESSION_DRIVER:-database} SESSION_DRIVER: ${SESSION_DRIVER:-database}
QUEUE_CONNECTION: ${QUEUE_CONNECTION:-database} QUEUE_CONNECTION: ${QUEUE_CONNECTION:-database}
CACHE_STORE: ${CACHE_STORE:-database} CACHE_STORE: ${CACHE_STORE:-database}
@@ -54,22 +58,13 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
entrypoint: ["php", "artisan", "schedule:work"] entrypoint: ["php", "artisan", "schedule:work"]
# The image's healthcheck asks the web server, which only the app service runs
healthcheck:
disable: true
volumes: volumes:
- sealshare_storage:/app/storage/app - sealshare_storage:/app/storage/app
- sealshare_database:/app/database - sealshare_database:/app/database/sqlite
environment: environment: *environment
APP_KEY: ${APP_KEY:?Set APP_KEY in .env or environment}
APP_URL: ${APP_URL:-http://localhost}
APP_ENV: ${APP_ENV:-production}
APP_DEBUG: ${APP_DEBUG:-false}
DB_CONNECTION: ${DB_CONNECTION:-sqlite}
DB_HOST: ${DB_HOST:-}
DB_PORT: ${DB_PORT:-}
DB_DATABASE: ${DB_DATABASE:-/app/database/database.sqlite}
DB_USERNAME: ${DB_USERNAME:-}
DB_PASSWORD: ${DB_PASSWORD:-}
LOG_CHANNEL: ${LOG_CHANNEL:-stderr}
LOG_LEVEL: ${LOG_LEVEL:-warning}
depends_on: depends_on:
app: app:
condition: service_healthy condition: service_healthy
+8 -20
View File
@@ -3,26 +3,13 @@ set -e
cd /app cd /app
# Generate PHP ini from environment variables (with defaults) # Every start, so a pull with new packages needs no extra step; with nothing new it takes a second
echo "[dev] Configuring PHP settings..." echo "[dev] Installing PHP dependencies..."
cat > /usr/local/etc/php/conf.d/99-uploads.ini <<EOF composer install --no-interaction 2>&1
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 # A config or route cache left by `php artisan optimize` would hide changes to the checkout
echo "[dev] Installing PHP dependencies..." echo "[dev] Clearing caches..."
composer install --no-interaction 2>&1 php artisan optimize:clear
fi
echo "[dev] Installing Node dependencies..."
npm install 2>&1
echo "[dev] Building frontend assets..."
npm run build 2>&1
echo "[dev] Running database migrations..." echo "[dev] Running database migrations..."
php artisan migrate --force php artisan migrate --force
@@ -30,5 +17,6 @@ php artisan migrate --force
echo "[dev] Creating storage link..." echo "[dev] Creating storage link..."
php artisan storage:link --force php artisan storage:link --force
# Port 80 as in production, so the same healthcheck applies. The vite service serves the assets.
echo "[dev] Starting Octane (FrankenPHP) with --watch..." echo "[dev] Starting Octane (FrankenPHP) with --watch..."
exec php artisan octane:frankenphp --host=0.0.0.0 --port=8000 --watch --workers=1 --max-requests=1 exec php artisan octane:frankenphp --host=0.0.0.0 --port=80 --watch --workers=1 --max-requests=1
-19
View File
@@ -1,19 +0,0 @@
FROM dunglas/frankenphp:php8.5-alpine
# Install required PHP extensions
RUN install-php-extensions \
intl \
pcntl
# 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
RUN chmod +x /usr/local/bin/dev-entrypoint.sh
ENTRYPOINT ["dev-entrypoint.sh"]
+9 -9
View File
@@ -12,15 +12,15 @@ if [ -z "$APP_KEY" ]; then
echo "[entrypoint] WARNING: Set this APP_KEY in your docker-compose.yml to persist across restarts!" echo "[entrypoint] WARNING: Set this APP_KEY in your docker-compose.yml to persist across restarts!"
fi fi
# Generate PHP ini from environment variables (with defaults) # A docker-compose.yml from before 2.1.1 mounts the SQLite volume over all of /app/database, so the
echo "[entrypoint] Configuring PHP settings..." # migrations folder is the one the volume was created with: add this image's newer migrations to it.
cat > /usr/local/etc/php/conf.d/99-uploads.ini <<EOF for migration in docker/migrations/*.php; do
upload_max_filesize = ${PHP_UPLOAD_MAX_FILESIZE:-64M} if [ ! -e "database/migrations/${migration##*/}" ]; then
post_max_size = ${PHP_POST_MAX_SIZE:-64M} echo "[entrypoint] Adding migration ${migration##*/} to the database volume..."
max_execution_time = ${PHP_MAX_EXECUTION_TIME:-300} mkdir -p database/migrations
max_input_time = ${PHP_MAX_INPUT_TIME:-300} cp "$migration" database/migrations/
memory_limit = ${PHP_MEMORY_LIMIT:-512M} fi
EOF done
echo "[entrypoint] Running database migrations..." echo "[entrypoint] Running database migrations..."
php artisan migrate --force php artisan migrate --force
+7 -8
View File
@@ -1,9 +1,8 @@
; PHP settings for file uploads. ; PHP limits for the admin's logo upload and long requests. PHP reads each value from its
; These are default values — overridden at runtime by the entrypoint ; environment variable when set (docker-compose.yml passes them), otherwise the default after ":-".
; when PHP_UPLOAD_MAX_FILESIZE / PHP_POST_MAX_SIZE / etc. env vars are set.
upload_max_filesize = 64M upload_max_filesize = ${PHP_UPLOAD_MAX_FILESIZE:-64M}
post_max_size = 64M post_max_size = ${PHP_POST_MAX_SIZE:-64M}
max_execution_time = 300 max_execution_time = ${PHP_MAX_EXECUTION_TIME:-300}
max_input_time = 300 max_input_time = ${PHP_MAX_INPUT_TIME:-300}
memory_limit = 512M memory_limit = ${PHP_MEMORY_LIMIT:-512M}
+2 -410
View File
@@ -1,12 +1,11 @@
{ {
"name": "SealShare", "name": "sealshare",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sealshare",
"dependencies": { "dependencies": {
"autoprefixer": "^10.6.1",
"concurrently": "^10.0.5",
"laravel-vite-plugin": "^3.2.0", "laravel-vite-plugin": "^3.2.0",
"vite": "^8.3.0" "vite": "^8.3.0"
}, },
@@ -291,143 +290,6 @@
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/ansi-regex": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/autoprefixer": {
"version": "10.6.1",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.1.tgz",
"integrity": "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"browserslist": "^4.28.9",
"caniuse-lite": "^1.0.30001810",
"fraction.js": "^5.3.4",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
},
"bin": {
"autoprefixer": "bin/autoprefixer"
},
"engines": {
"node": "^10 || ^12 || >=14"
},
"peerDependencies": {
"postcss": "^8.1.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.24",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz",
"integrity": "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/browserslist": {
"version": "4.29.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz",
"integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.11.23",
"caniuse-lite": "^1.0.30001810",
"electron-to-chromium": "^1.5.427",
"node-releases": "^2.0.55",
"update-browserslist-db": "^1.3.3"
},
"bin": {
"browserslist": "cli.js"
},
"engines": {
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001810",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -444,44 +306,6 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/concurrently": {
"version": "10.0.5",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz",
"integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==",
"license": "MIT",
"dependencies": {
"chalk": "5.6.2",
"rxjs": "7.8.2",
"shell-quote": "1.9.0",
"supports-color": "10.2.2",
"tree-kill": "1.2.2",
"yargs": "18.0.0"
},
"bin": {
"conc": "dist/bin/index.js",
"concurrently": "dist/bin/index.js"
},
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -491,27 +315,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/electron-to-chromium": {
"version": "1.5.430",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.430.tgz",
"integrity": "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==",
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -529,19 +332,6 @@
} }
} }
}, },
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -556,27 +346,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/laravel-vite-plugin": { "node_modules/laravel-vite-plugin": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.2.0.tgz", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.2.0.tgz",
@@ -882,15 +651,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/node-releases": {
"version": "2.0.55",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz",
"integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -966,12 +726,6 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
@@ -1019,27 +773,6 @@
"@rolldown/binding-win32-x64-msvc": "1.2.9" "@rolldown/binding-win32-x64-msvc": "1.2.9"
} }
}, },
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/shell-quote": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz",
"integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1049,50 +782,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/supports-color": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1109,51 +798,6 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/update-browserslist-db": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
"integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"escalade": "^3.2.0",
"picocolors": "^1.1.1"
},
"bin": {
"update-browserslist-db": "cli.js"
},
"peerDependencies": {
"browserslist": ">= 4.21.0"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.3.0", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
@@ -1252,58 +896,6 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^7.2.0",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
} }
} }
} }
+1 -5
View File
@@ -1,5 +1,6 @@
{ {
"$schema": "https://www.schemastore.org/package.json", "$schema": "https://www.schemastore.org/package.json",
"name": "sealshare",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -7,17 +8,12 @@
"dev": "vite" "dev": "vite"
}, },
"dependencies": { "dependencies": {
"autoprefixer": "^10.6.1",
"concurrently": "^10.0.5",
"laravel-vite-plugin": "^3.2.0", "laravel-vite-plugin": "^3.2.0",
"vite": "^8.3.0" "vite": "^8.3.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"lightningcss-linux-x64-gnu": "^1.29.1" "lightningcss-linux-x64-gnu": "^1.29.1"
}, },
"overrides": {
"shell-quote": "^1.9.0"
},
"devDependencies": { "devDependencies": {
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"playwright": "^1.63.0" "playwright": "^1.63.0"
+13 -13
View File
@@ -21,18 +21,18 @@
</include> </include>
</source> </source>
<php> <php>
<env name="APP_ENV" value="testing"/> <server name="APP_ENV" value="testing" force="true"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <server name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
<env name="BCRYPT_ROUNDS" value="4"/> <server name="BCRYPT_ROUNDS" value="4" force="true"/>
<env name="BROADCAST_CONNECTION" value="null"/> <server name="BROADCAST_CONNECTION" value="null" force="true"/>
<env name="CACHE_STORE" value="array"/> <server name="CACHE_STORE" value="array" force="true"/>
<env name="DB_CONNECTION" value="sqlite"/> <server name="DB_CONNECTION" value="sqlite" force="true"/>
<env name="DB_DATABASE" value=":memory:"/> <server name="DB_DATABASE" value=":memory:" force="true"/>
<env name="MAIL_MAILER" value="array"/> <server name="MAIL_MAILER" value="array" force="true"/>
<env name="QUEUE_CONNECTION" value="sync"/> <server name="QUEUE_CONNECTION" value="sync" force="true"/>
<env name="SESSION_DRIVER" value="array"/> <server name="SESSION_DRIVER" value="array" force="true"/>
<env name="PULSE_ENABLED" value="false"/> <server name="PULSE_ENABLED" value="false" force="true"/>
<env name="TELESCOPE_ENABLED" value="false"/> <server name="TELESCOPE_ENABLED" value="false" force="true"/>
<env name="NIGHTWATCH_ENABLED" value="false"/> <server name="NIGHTWATCH_ENABLED" value="false" force="true"/>
</php> </php>
</phpunit> </phpunit>
+1 -10
View File
@@ -268,7 +268,7 @@
/* /*
* resources/views/pages/settings/two-factor/recovery-codes.blade.php: a code dims to M3's disabled * 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 * 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 * rather than a keyframe pulse loop 2.0.0 keeps no keyframe utility for one. The
* transition sits on the code itself so the way back eases too; under reduced motion the token's * transition sits on the code itself so the way back eases too; under reduced motion the token's
* duration is 0ms. * duration is 0ms.
*/ */
@@ -289,15 +289,6 @@
max-inline-size: 24rem; 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 * 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 * their own (its files, size and downloads; its expiry), and they wrap rather than clip. The
+2 -9
View File
@@ -202,10 +202,11 @@ document.addEventListener('alpine:init', () => {
xhr.setRequestHeader('Content-Type', 'application/octet-stream') xhr.setRequestHeader('Content-Type', 'application/octet-stream')
xhr.setRequestHeader('Accept', 'application/json') xhr.setRequestHeader('Accept', 'application/json')
xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken) xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken)
xhr.responseType = 'json'
xhr.upload.onprogress = (event) => onProgress(event.loaded) xhr.upload.onprogress = (event) => onProgress(event.loaded)
xhr.onload = () => { xhr.onload = () => {
this.request = null this.request = null
resolve({ status: xhr.status, uploadedChunks: parseUploadedChunks(xhr.responseText) }) resolve({ status: xhr.status, uploadedChunks: xhr.response?.uploaded_chunks })
} }
xhr.onerror = () => { xhr.onerror = () => {
this.request = null this.request = null
@@ -307,11 +308,3 @@ function chunkNonce(prefix, index, isLast) {
return nonce return nonce
} }
function parseUploadedChunks(responseText) {
try {
return JSON.parse(responseText).uploaded_chunks
} catch {
return undefined
}
}
@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" {{ $attributes }}>
{{-- Document outline with folded corner --}}
<path d="M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z" stroke="currentColor" stroke-width="1.5" />
<path d="M14 2v5a1 1 0 0 0 1 1h5" stroke="currentColor" stroke-width="1.5" />
{{-- Upload arrow --}}
<path d="M12 17v-6m0 0-2.5 2.5M12 11l2.5 2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>

Before

Width:  |  Height:  |  Size: 517 B

@@ -9,27 +9,25 @@
</x-grid> </x-grid>
{{-- The shares as a list, not a table: a table's columns need more than the page's 40rem, and {{-- 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. --}} every page keeps that one width. The sort is a full-width select above the list instead of column headers. --}}
<x-card :title="__('All Shares')" heading="h2" variant="outlined"> <x-card :title="__('All Shares')" heading="h2" variant="outlined">
<x-stack gap="space200"> <x-stack gap="space200">
@if ($shares->total() === 0) @if ($shares->total() === 0)
<x-empty-state icon="link_off" :title="__('No shares yet')" :description="__('Shares appear here once someone uploads files.')" /> <x-empty-state icon="link_off" :title="__('No shares yet')" :description="__('Shares appear here once someone uploads files.')" />
@else @else
<div class="admin-shares-sort"> <x-select
<x-select wire:model.live="sort"
wire:model.live="sort" :label="__('Sort by')"
:label="__('Sort by')" :options="[
:options="[ ['id' => 'newest', 'name' => __('Newest first')],
['id' => 'newest', 'name' => __('Newest first')], ['id' => 'oldest', 'name' => __('Oldest first')],
['id' => 'oldest', 'name' => __('Oldest first')], ['id' => 'expiring', 'name' => __('Expiring soonest')],
['id' => 'expiring', 'name' => __('Expiring soonest')], ['id' => 'largest', 'name' => __('Largest')],
['id' => 'largest', 'name' => __('Largest')], ['id' => 'most-downloaded', 'name' => __('Most downloads')],
['id' => 'most-downloaded', 'name' => __('Most downloads')], ['id' => 'most-files', 'name' => __('Most files')],
['id' => 'most-files', 'name' => __('Most files')], ]"
]" data-test="shares-sort"
data-test="shares-sort" />
/>
</div>
{{-- Each share fits the column on a phone: the token opens it, so delete is the one button; {{-- 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). --}} the details are two short lines that never clip (admin-shares, app.css). --}}
@@ -39,9 +37,12 @@
<a href="{{ route('share.download', $share) }}" target="_blank" rel="noopener" class="md-link"><code>{{ $share->token }}</code></a> <a href="{{ route('share.download', $share) }}" target="_blank" rel="noopener" class="md-link"><code>{{ $share->token }}</code></a>
<x-slot:description> <x-slot:description>
<span class="admin-share-detail md-tabular">{{ trans_choice(':count file|:count files', $share->files_count) }} · {{ Number::fileSize($share->total_size) }} · {{ trans_choice(':count download|:count downloads', $share->download_count) }}</span> <span class="admin-share-detail md-tabular">{{ trans_choice(':count file|:count files', $share->files_count) }} · {{ Number::fileSize($share->total_size) }} · {{ $share->max_downloads ? trans_choice(':count of :max download|:count of :max downloads', $share->max_downloads, ['count' => $share->download_count, 'max' => $share->max_downloads]) : trans_choice(':count download|:count downloads', $share->download_count) }}</span>
@if (! $share->expires_at) {{-- A share at its limit is closed; the cleanup deletes it a day after its last download. --}}
@if ($share->hasReachedDownloadLimit())
<span class="admin-share-detail md-ink-error">{{ __('Download limit reached') }}</span>
@elseif (! $share->expires_at)
<span class="admin-share-detail">{{ __('Never expires') }}</span> <span class="admin-share-detail">{{ __('Never expires') }}</span>
@elseif ($share->isExpired()) @elseif ($share->isExpired())
<span class="admin-share-detail md-ink-error">{{ __('Expired :time', ['time' => $share->expires_at->diffForHumans()]) }}</span> <span class="admin-share-detail md-ink-error">{{ __('Expired :time', ['time' => $share->expires_at->diffForHumans()]) }}</span>
@@ -62,6 +63,14 @@
</x-stack> </x-stack>
</x-card> </x-card>
{{-- Page chrome, not content, so a quiet line instead of a card: the installed version, its release notes and who makes SealShare. --}}
<footer class="md-type-body-sm md-ink-variant md-text-center" data-test="dashboard-about">
SealShare {{ $version }}
· <a href="https://gitea.nonameweb.ch/noNameWEB/SealShare/releases/tag/v{{ $version }}" target="_blank" rel="noopener" class="md-link md-ink-primary">{{ __('Release notes') }}</a>
· <a href="https://sealshare.nonameweb.ch" target="_blank" rel="noopener" class="md-link md-ink-primary">{{ __('Website') }}</a>
· {{ __('Made by') }} <a href="https://nonameweb.ch" target="_blank" rel="noopener" class="md-link md-ink-primary">noNameWEB</a>
</footer>
<x-modal wire:model="deletingShareId" :title="__('Delete this share?')" icon="delete"> <x-modal wire:model="deletingShareId" :title="__('Delete this share?')" icon="delete">
{{ __('Are you sure you want to delete this share?') }} {{ __('Are you sure you want to delete this share?') }}
@@ -161,14 +161,7 @@
wire:model="defaultExpiration" wire:model="defaultExpiration"
:label="__('Default Expiration')" :label="__('Default Expiration')"
:placeholder="$allowNeverExpire ? __('None') : null" :placeholder="$allowNeverExpire ? __('None') : null"
:options="[ :options="collect(\App\Models\Share::EXPIRATIONS)->map(fn (array $option, string $id): array => ['id' => $id, 'name' => __($option['label'])])->values()->all()"
['id' => '1h', 'name' => __('1 Hour')],
['id' => '24h', 'name' => __('24 Hours')],
['id' => '48h', 'name' => __('48 Hours')],
['id' => '7d', 'name' => __('7 Days')],
['id' => '14d', 'name' => __('14 Days')],
['id' => '30d', 'name' => __('30 Days')],
]"
/> />
<x-input full <x-input full
@@ -127,14 +127,7 @@
wire:model="expiration" wire:model="expiration"
:label="__('Expiration')" :label="__('Expiration')"
:placeholder="$allowNeverExpire ? __('Never') : null" :placeholder="$allowNeverExpire ? __('Never') : null"
:options="[ :options="collect(\App\Models\Share::EXPIRATIONS)->map(fn (array $option, string $id): array => ['id' => $id, 'name' => __($option['label'])])->values()->all()"
['id' => '1h', 'name' => __('1 Hour')],
['id' => '24h', 'name' => __('24 Hours')],
['id' => '48h', 'name' => __('48 Hours')],
['id' => '7d', 'name' => __('7 Days')],
['id' => '14d', 'name' => __('14 Days')],
['id' => '30d', 'name' => __('30 Days')],
]"
/> />
<x-input full <x-input full
@@ -23,7 +23,9 @@
</x-card> </x-card>
@else @else
<x-card :title="__('Shared Files')" heading="h2" variant="outlined"> <x-card :title="__('Shared Files')" heading="h2" variant="outlined">
<x-stack gap="space200"> {{-- A download link does not render the page again: the download limit's note switches
on the first press here, and the server draws the open window on the next visit. --}}
<x-stack gap="space200" x-data="{ downloaded: false }">
<x-stack gap="space100"> <x-stack gap="space100">
<x-list :label="__('Shared Files')"> <x-list :label="__('Shared Files')">
@foreach ($share->files as $file) @foreach ($share->files as $file)
@@ -34,7 +36,7 @@
> >
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span></x-slot:description> <x-slot:description><span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span></x-slot:description>
<x-slot:end> <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-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" x-on:click="downloaded = true" />
</x-slot:end> </x-slot:end>
</x-list-item> </x-list-item>
@endforeach @endforeach
@@ -43,13 +45,22 @@
@if ($share->expires_at) @if ($share->expires_at)
<p class="md-type-body-sm md-ink-variant">{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}</p> <p class="md-type-body-sm md-ink-variant">{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}</p>
@endif @endif
@if ($share->max_downloads)
@if ($downloadWindowEndsAt)
<p class="md-type-body-sm md-ink-variant">{{ __('You can download these files for another :time.', ['time' => $downloadWindowEndsAt->diffForHumans(syntax: \Carbon\CarbonInterface::DIFF_ABSOLUTE)]) }}</p>
@elseif ($remainingDownloads > 0)
<p class="md-type-body-sm md-ink-variant" x-show="! downloaded">{{ trans_choice('{1} Downloading uses the last remaining download. You then have :window to download the files.|[2,*] Downloading uses 1 of :count remaining downloads. You then have :window to download the files.', $remainingDownloads, ['window' => $downloadWindow]) }}</p>
<p class="md-type-body-sm md-ink-variant" x-show="downloaded" x-cloak>{{ __('You have :window to download the files.', ['window' => $downloadWindow]) }}</p>
@endif
@endif
</x-stack> </x-stack>
<x-row justify="end"> <x-row justify="end">
@if ($share->files->count() > 1) @if ($share->files->count() > 1)
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate /> <x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate x-on:click="downloaded = true" />
@else @else
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $share->files->first()])" no-wire-navigate /> <x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $share->files->first()])" no-wire-navigate x-on:click="downloaded = true" />
@endif @endif
</x-row> </x-row>
</x-stack> </x-stack>
@@ -1,34 +0,0 @@
<x-layouts::app :title="__('Verify email')">
<x-page brand>
<x-card
:title="__('Verify your email')"
:subtitle="__('Please verify your email address by clicking on the link we just emailed to you.')"
heading="h2"
variant="outlined"
>
<x-stack gap="space300">
@if (session('status') == 'verification-link-sent')
<x-alert color="success">
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
</x-alert>
@endif
<x-stack gap="space100">
<x-form method="POST" action="{{ route('verification.send') }}">
@csrf
<x-slot:actions>
<x-button type="submit" :label="__('Resend verification email')" variant="filled" />
</x-slot:actions>
</x-form>
{{-- Log out posts elsewhere, so it is a form of its own; it sits under Resend at the same end
edge, the card's two actions end-aligned below its content. --}}
<x-row as="form" justify="end" method="POST" action="{{ route('logout') }}">
@csrf
<x-button type="submit" :label="__('Log out')" data-test="logout-button" />
</x-row>
</x-stack>
</x-stack>
</x-card>
</x-page>
</x-layouts::app>
@@ -1,12 +1,7 @@
<?php <?php
use App\Concerns\ProfileValidationRules; use App\Concerns\ProfileValidationRules;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Computed;
use Livewire\Component; use Livewire\Component;
use NoNameWeb\LivewireMaterial\Concerns\Toasts; use NoNameWeb\LivewireMaterial\Concerns\Toasts;
@@ -35,72 +30,19 @@ new class extends Component {
$validated = $this->validate($this->profileRules($user->id)); $validated = $this->validate($this->profileRules($user->id));
$user->fill($validated); $user->fill($validated)->save();
if ($user->isDirty('email')) {
$user->email_verified_at = null;
}
$user->save();
$this->dispatch('profile-updated', name: $user->name); $this->dispatch('profile-updated', name: $user->name);
$this->success(__('Saved.')); $this->success(__('Saved.'));
} }
/**
* Send an email verification notification to the current user.
*/
public function resendVerificationNotification(): void
{
$user = Auth::user();
if ($user->hasVerifiedEmail()) {
$this->redirectIntended(default: route('admin.dashboard', absolute: false));
return;
}
$user->sendEmailVerificationNotification();
Session::flash('status', 'verification-link-sent');
}
#[Computed]
public function hasUnverifiedEmail(): bool
{
return Auth::user() instanceof MustVerifyEmail && ! Auth::user()->hasVerifiedEmail();
}
#[Computed]
public function showDeleteUser(): bool
{
return ! Auth::user() instanceof MustVerifyEmail
|| (Auth::user() instanceof MustVerifyEmail && Auth::user()->hasVerifiedEmail());
}
}; ?> }; ?>
<x-pages::settings.layout :heading="__('Profile')" :subheading="__('Update your name and email address')"> <x-pages::settings.layout :heading="__('Profile')" :subheading="__('Update your name and email address')">
<x-form wire:submit="updateProfileInformation"> <x-form wire:submit="updateProfileInformation">
<x-input full wire:model="name" :label="__('Name')" type="text" required autofocus autocomplete="name" icon="person" /> <x-input full wire:model="name" :label="__('Name')" type="text" required autofocus autocomplete="name" icon="person" />
<x-stack gap="space100"> <x-input full wire:model="email" :label="__('Email')" type="email" required autocomplete="email" icon="mail" />
<x-input full wire:model="email" :label="__('Email')" type="email" required autocomplete="email" icon="mail" />
@if ($this->hasUnverifiedEmail)
<p class="md-type-body-md md-ink-variant">
{{ __('Your email address is unverified.') }}
<button type="button" class="md-link" wire:click.prevent="resendVerificationNotification">
{{ __('Click here to re-send the verification email.') }}
</button>
</p>
@if (session('status') === 'verification-link-sent')
<x-alert color="success">{{ __('A new verification link has been sent to your email address.') }}</x-alert>
@endif
@endif
</x-stack>
<x-slot:actions> <x-slot:actions>
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updateProfileInformation" data-test="update-profile-button" /> <x-button type="submit" :label="__('Save')" variant="filled" spinner="updateProfileInformation" data-test="update-profile-button" />
@@ -108,8 +50,6 @@ new class extends Component {
</x-form> </x-form>
<x-slot:after> <x-slot:after>
@if ($this->showDeleteUser) <livewire:pages::settings.delete-user-form />
<livewire:pages::settings.delete-user-form />
@endif
</x-slot:after> </x-slot:after>
</x-pages::settings.layout> </x-pages::settings.layout>
-6
View File
@@ -1,11 +1,5 @@
<?php <?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule; use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
Schedule::command('shares:cleanup')->hourly(); Schedule::command('shares:cleanup')->hourly();
-3
View File
@@ -7,9 +7,6 @@ Route::middleware(['auth'])->group(function () {
Route::redirect('settings', 'settings/profile'); Route::redirect('settings', 'settings/profile');
Route::livewire('settings/profile', 'pages::settings.profile')->name('profile.edit'); Route::livewire('settings/profile', 'pages::settings.profile')->name('profile.edit');
});
Route::middleware(['auth', 'verified'])->group(function () {
Route::livewire('settings/password', 'pages::settings.password')->name('user-password.edit'); Route::livewire('settings/password', 'pages::settings.password')->name('user-password.edit');
Route::livewire('settings/appearance', 'pages::settings.appearance')->name('appearance.edit'); Route::livewire('settings/appearance', 'pages::settings.appearance')->name('appearance.edit');
-6
View File
@@ -171,12 +171,6 @@ test('the two-factor challenge page holds at every breakpoint', function () {
walkBreakpoints(ready(visit(route('two-factor.login', [], false))), 'button[type="submit"]'); walkBreakpoints(ready(visit(route('two-factor.login', [], false))), 'button[type="submit"]');
}); });
test('the email verification prompt holds at every breakpoint', function () {
$this->actingAs(User::factory()->unverified()->create());
walkBreakpoints(ready(visit(route('verification.notice', [], false))));
});
test('the password confirmation page holds at every breakpoint', function () { test('the password confirmation page holds at every breakpoint', function () {
$this->actingAs(User::factory()->create()); $this->actingAs(User::factory()->create());
+19
View File
@@ -172,6 +172,25 @@ test('a recipient on a phone unlocks a password-protected share and sees its fil
->assertScript('document.documentElement.scrollWidth <= window.innerWidth'); ->assertScript('document.documentElement.scrollWidth <= window.innerWidth');
}); });
test('pressing a download on a limited share turns the note about remaining downloads into the recipient\'s hour', function () {
$share = app(ShareService::class)->createShare(
[['file' => UploadedFile::fake()->createWithContent('report.pdf', 'report'), 'relativePath' => null]],
['max_downloads' => 3],
);
$page = ready(visit(route('share.download', $share, false)));
$page->assertSee('Downloading uses 1 of 3 remaining downloads.')
->assertDontSee('You have 1 hour to download the files.');
// The press is what the page reacts to; the download itself stays out of the browser.
$page->script("window.eval(\"document.addEventListener('click', (event) => event.preventDefault(), true); document.querySelector('[aria-label=\\\"Download report.pdf\\\"]').click()\")");
$page->assertSee('You have 1 hour to download the files.')
->assertDontSee('Downloading uses 1 of 3 remaining downloads.')
->assertNoJavaScriptErrors();
});
test('an admin sorts the shares list and deletes a share through its dialog', function () { test('an admin sorts the shares list and deletes a share through its dialog', function () {
$admin = User::factory()->admin()->create(); $admin = User::factory()->admin()->create();
Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 1, 'created_at' => now()->subDay()]); Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 1, 'created_at' => now()->subDay()]);
@@ -44,6 +44,18 @@ test('admin dashboard shows stats', function () {
$response->assertSee('Disk Usage'); $response->assertSee('Disk Usage');
}); });
test('admin dashboard shows the installed version with its release notes and links to the website and noNameWEB', function () {
$admin = User::query()->where('is_admin', true)->first();
config(['app.version' => '9.8.7']);
$response = $this->actingAs($admin)->get(route('admin.dashboard'));
$response->assertSee('SealShare 9.8.7');
$response->assertSee('href="https://gitea.nonameweb.ch/noNameWEB/SealShare/releases/tag/v9.8.7"', false);
$response->assertSee('href="https://sealshare.nonameweb.ch"', false);
$response->assertSee('href="https://nonameweb.ch"', false);
});
test('admin can delete share', function () { test('admin can delete share', function () {
Storage::fake('shares'); Storage::fake('shares');
@@ -73,6 +85,20 @@ test('admin dashboard lists the shares', function () {
$response->assertSee('href="'.route('share.download', $share).'"', false); $response->assertSee('href="'.route('share.download', $share).'"', false);
}); });
test('the shares list shows downloads against the limit, and a share at its limit as closed and not active', function () {
$admin = User::query()->where('is_admin', true)->first();
Share::factory()->withMaxDownloads(3)->create(['token' => 'limitedshare0000', 'download_count' => 2, 'expires_at' => null]);
Share::factory()->withMaxDownloads(1)->create(['token' => 'limitreached0000', 'download_count' => 1, 'expires_at' => null]);
Share::factory()->create(['token' => 'unlimitedshare00', 'download_count' => 1, 'expires_at' => null]);
$dashboard = Livewire::actingAs($admin)->test(AdminDashboard::class);
$dashboard->assertSeeInOrder(['limitedshare0000', '2 of 3 downloads', 'Never expires'])
->assertSeeInOrder(['limitreached0000', '1 of 1 download', 'Download limit reached'])
->assertSeeInOrder(['unlimitedshare00', '1 download', 'Never expires'])
->assertViewHas('activeShares', 2);
});
test('the shares list sorts only by its own orders', function () { test('the shares list sorts only by its own orders', function () {
$admin = User::query()->where('is_admin', true)->first(); $admin = User::query()->where('is_admin', true)->first();
+17
View File
@@ -51,6 +51,23 @@ test('admin can save settings', function () {
expect(Setting::get('default_expiration'))->toBe('7d'); expect(Setting::get('default_expiration'))->toBe('7d');
}); });
test('the default expiration only accepts the offered options', function () {
$admin = User::query()->where('is_admin', true)->first();
$component = Livewire::actingAs($admin)
->test(AdminSettings::class)
->set('defaultExpiration', '99y')
->call('saveSettings')
->assertHasErrors(['defaultExpiration' => 'in']);
expect(Setting::get('default_expiration'))->toBeNull();
$component
->set('defaultExpiration', '')
->call('saveSettings')
->assertHasNoErrors();
});
test('admin can set system password', function () { test('admin can set system password', function () {
$admin = User::query()->where('is_admin', true)->first(); $admin = User::query()->where('is_admin', true)->first();
Livewire::actingAs($admin) Livewire::actingAs($admin)
+12
View File
@@ -0,0 +1,12 @@
<?php
/*
* The admin dashboard shows config('app.version'), which is bumped by hand with each release. The
* changelog's newest released heading is the release, so the two must agree before a tag is built.
*/
test('the version is the newest release in the changelog', function () {
preg_match('/^## \[(\d+\.\d+\.\d+)\]/m', file_get_contents(base_path('CHANGELOG.md')), $release);
expect(config('app.version'))->toBe($release[1]);
});
@@ -1,67 +0,0 @@
<?php
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
test('email verification screen can be rendered', function () {
$user = User::factory()->unverified()->create();
$response = $this->actingAs($user)->get(route('verification.notice'));
$response->assertOk();
});
test('email can be verified', function () {
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
);
$response = $this->actingAs($user)->get($verificationUrl);
Event::assertDispatched(Verified::class);
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
$response->assertRedirect(route('admin.dashboard', absolute: false).'?verified=1');
});
test('email is not verified with invalid hash', function () {
$user = User::factory()->unverified()->create();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1('wrong-email')]
);
$this->actingAs($user)->get($verificationUrl);
expect($user->fresh()->hasVerifiedEmail())->toBeFalse();
});
test('already verified user visiting verification link is redirected without firing event again', function () {
$user = User::factory()->create([
'email_verified_at' => now(),
]);
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
);
$this->actingAs($user)->get($verificationUrl)
->assertRedirect(route('admin.dashboard', absolute: false).'?verified=1');
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
Event::assertNotDispatched(Verified::class);
});
@@ -34,6 +34,19 @@ test('cleanup removes shares that reached download limit', function () {
expect(Share::query()->find($underLimit->id))->not->toBeNull(); expect(Share::query()->find($underLimit->id))->not->toBeNull();
}); });
test('cleanup keeps a share at its download limit for a day after its last download', function () {
Storage::fake('shares');
$recentlyDownloaded = Share::factory()->withMaxDownloads(1)->create(['download_count' => 1, 'last_downloaded_at' => now()->subHours(23)]);
$downloadedYesterday = Share::factory()->withMaxDownloads(1)->create(['download_count' => 1, 'last_downloaded_at' => now()->subHours(25)]);
$this->artisan('shares:cleanup')
->expectsOutputToContain('Cleaned up 1 expired share(s)')
->assertExitCode(0);
$this->assertModelExists($recentlyDownloaded);
$this->assertModelMissing($downloadedYesterday);
});
test('cleanup handles no expired shares', function () { test('cleanup handles no expired shares', function () {
Storage::fake('shares'); Storage::fake('shares');
+15
View File
@@ -249,6 +249,21 @@ test('file upload with expiration sets expires_at', function () {
expect($share->expires_at)->not->toBeNull(); expect($share->expires_at)->not->toBeNull();
}); });
test('a 30 day expiration lasts 30 days, not a calendar month', function () {
Storage::fake('shares');
$this->travelTo(new DateTimeImmutable('2026-02-01 12:00:00'));
$component = Livewire::test(FileUploader::class);
uploadThroughPage($component, ['file.txt' => 'content']);
$component
->set('expiration', '30d')
->call('createShare')
->assertRedirectContains('/share/');
expect(Share::query()->first()->expires_at->toDateTimeString())->toBe('2026-03-03 12:00:00');
});
test('file upload with max downloads sets limit', function () { test('file upload with max downloads sets limit', function () {
Storage::fake('shares'); Storage::fake('shares');
-1
View File
@@ -41,7 +41,6 @@ test('every page is one page template, one h1 and the same width', function (Clo
return $test->get(route('system-password')); return $test->get(route('system-password'));
}], }],
'verify email' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->unverified()->create())->get(route('verification.notice'))],
'confirm password' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->create())->get(route('password.confirm'))], 'confirm password' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->create())->get(route('password.confirm'))],
'profile' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->create())->get(route('profile.edit'))], 'profile' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->create())->get(route('profile.edit'))],
'password' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->create())->get(route('user-password.edit'))], 'password' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->create())->get(route('user-password.edit'))],
+2 -3
View File
@@ -27,10 +27,9 @@ test('profile information can be updated', function () {
expect($user->name)->toEqual('Test User'); expect($user->name)->toEqual('Test User');
expect($user->email)->toEqual('test@example.com'); expect($user->email)->toEqual('test@example.com');
expect($user->email_verified_at)->toBeNull();
}); });
test('email verification status is unchanged when email address is unchanged', function () { test('the profile saves with its own unchanged email address', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
@@ -42,7 +41,7 @@ test('email verification status is unchanged when email address is unchanged', f
$response->assertHasNoErrors(); $response->assertHasNoErrors();
expect($user->refresh()->email_verified_at)->not->toBeNull(); expect($user->refresh()->name)->toEqual('Test User');
}); });
test('user can delete their account', function () { test('user can delete their account', function () {
+152 -21
View File
@@ -3,7 +3,6 @@
use App\Livewire\ShareDownload; use App\Livewire\ShareDownload;
use App\Models\Share; use App\Models\Share;
use App\Models\ShareFile; use App\Models\ShareFile;
use App\Services\FileEncryptionService;
use App\Services\ShareService; use App\Services\ShareService;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -112,15 +111,149 @@ test('zip download streams a valid archive with every file\'s original content',
unlink($zipPath); unlink($zipPath);
}); });
test('last download streams successfully before auto-delete', function () { test('the last download streams and the share stays for the cleanup, with the time of that download', function () {
Storage::fake('shares'); Storage::fake('shares');
$this->freezeSecond();
$share = createShareWithFile(); $share = createShareWithFile();
$share->update(['max_downloads' => 1]); $share->update(['max_downloads' => 1]);
$content = $this->get(route('share.download.all', $share))->streamedContent(); $content = $this->get(route('share.download.all', $share))->streamedContent();
expect($content)->toStartWith("PK\x03\x04"); expect($content)->toStartWith("PK\x03\x04");
$this->assertModelMissing($share); expect($share->fresh()->last_downloaded_at)->toEqual(now());
});
test('a recipient downloads every file and the zip of a share limited to one download, which counts once', function () {
Storage::fake('shares');
$share = createShareWithFiles(maxDownloads: 1);
[$first, $second, $third] = $share->files->all();
$files = [
$this->get(route('share.download.file', [$share, $first]))->streamedContent(),
$this->get(route('share.download.file', [$share, $second]))->streamedContent(),
$this->get(route('share.download.file', [$share, $third]))->streamedContent(),
];
$zip = $this->get(route('share.download.all', $share))->streamedContent();
expect($files)->toBe(['first file', 'second file', 'third file']);
expect($zip)->toStartWith("PK\x03\x04");
expect($share->fresh()->download_count)->toBe(1);
});
test('another recipient cannot open a share whose last download was taken', function (string $route) {
Storage::fake('shares');
$share = createShareWithFiles(maxDownloads: 1);
$this->get(route('share.download.file', [$share, $share->files->first()]));
$this->flushSession();
$response = $this->get(route($route, ['share' => $share, 'shareFile' => $share->files->last()]));
$response->assertNotFound();
})->with([
'download page' => 'share.download',
'download all' => 'share.download.all',
'download one file' => 'share.download.file',
]);
test('the recipient who took the last download can no longer open the share an hour later', function (string $route) {
Storage::fake('shares');
$share = createShareWithFiles(maxDownloads: 1);
$this->get(route('share.download.file', [$share, $share->files->first()]));
$this->travel(61)->minutes();
$response = $this->get(route($route, ['share' => $share, 'shareFile' => $share->files->last()]));
$response->assertNotFound();
})->with([
'download page' => 'share.download',
'download all' => 'share.download.all',
'download one file' => 'share.download.file',
]);
test('a recipient whose window has ended uses another download when one is left', function () {
Storage::fake('shares');
$share = createShareWithFiles(maxDownloads: 2);
$this->get(route('share.download.file', [$share, $share->files->first()]));
$this->travel(61)->minutes();
$response = $this->get(route('share.download.file', [$share, $share->files->last()]));
$response->assertOk();
expect($share->fresh()->download_count)->toBe(2);
});
test('a recipient is refused the last download once another recipient has taken it', function () {
Storage::fake('shares');
$share = createShareWithFiles(maxDownloads: 2);
$share->update(['download_count' => 1]);
$this->get(route('share.download.file', [$share, $share->files->first()]));
$this->flushSession();
$response = $this->get(route('share.download.file', [$share, $share->files->first()]));
$response->assertNotFound();
expect($share->fresh()->download_count)->toBe(2);
});
test('a download refused before it starts uses no download', function (?string $password, Closure $file, string $assertion) {
Storage::fake('shares');
$share = createShareWithFiles(maxDownloads: 1, password: $password);
$otherShare = createShareWithFiles();
$response = $this->get(route('share.download.file', [$share, $file($share, $otherShare)]));
$response->{$assertion}();
expect($share->fresh()->download_count)->toBe(0);
})->with([
'password share without its key' => ['secret-pass', fn (Share $share) => $share->files->first(), 'assertForbidden'],
'file of another share' => [null, fn (Share $share, Share $otherShare) => $otherShare->files->first(), 'assertNotFound'],
]);
test('a share without a limit counts each recipient once', function () {
Storage::fake('shares');
$share = createShareWithFiles();
$this->get(route('share.download.file', [$share, $share->files->first()]));
$this->get(route('share.download.file', [$share, $share->files->last()]));
$this->flushSession();
$this->get(route('share.download.file', [$share, $share->files->first()]));
expect($share->fresh()->download_count)->toBe(2);
});
test('the download page tells a recipient how many downloads are left', function (int $maxDownloads, string $note) {
Storage::fake('shares');
$share = createShareWithFiles(maxDownloads: $maxDownloads);
$response = $this->get(route('share.download', $share));
$response->assertSee($note);
})->with([
'several left' => [3, 'Downloading uses 1 of 3 remaining downloads. You then have 1 hour to download the files.'],
'one left' => [1, 'Downloading uses the last remaining download. You then have 1 hour to download the files.'],
]);
test('the download page tells the recipient who took the last download how long they have left', function () {
Storage::fake('shares');
$this->freezeSecond();
$share = createShareWithFiles(maxDownloads: 1);
$this->get(route('share.download.file', [$share, $share->files->first()]));
$this->travel(2)->minutes();
$response = $this->get(route('share.download', $share));
$response->assertSee('You can download these files for another 58 minutes.');
$response->assertDontSee('remaining download');
});
test('the download page says nothing about downloads on a share without a limit', function () {
Storage::fake('shares');
$share = createShareWithFiles();
$response = $this->get(route('share.download', $share));
$response->assertDontSee('to download the files');
$response->assertDontSee('You can download these files');
}); });
test('a share whose files are still uploading is not found anywhere a recipient or uploader could open it', function (string $route) { test('a share whose files are still uploading is not found anywhere a recipient or uploader could open it', function (string $route) {
@@ -146,7 +279,7 @@ test('a password share created before key wrapping still unlocks and downloads',
$source = tempnam(sys_get_temp_dir(), 'old'); $source = tempnam(sys_get_temp_dir(), 'old');
file_put_contents($source, 'old content'); file_put_contents($source, 'old content');
Storage::disk('shares')->makeDirectory($share->token); Storage::disk('shares')->makeDirectory($share->token);
app(FileEncryptionService::class)->encryptFile($source, Storage::disk('shares')->path($share->token.'/old.enc'), bin2hex(hash_pbkdf2('sha256', 'old-password', hex2bin($salt), 100000, 32, true)), 1024); encryptTestFile($source, Storage::disk('shares')->path($share->token.'/old.enc'), bin2hex(hash_pbkdf2('sha256', 'old-password', hex2bin($salt), 100000, 32, true)), 1024);
unlink($source); unlink($source);
Livewire::test(ShareDownload::class, ['share' => $share]) Livewire::test(ShareDownload::class, ['share' => $share])
@@ -157,23 +290,6 @@ test('a password share created before key wrapping still unlocks and downloads',
expect($this->get(route('share.download.file', [$share, $file]))->streamedContent())->toBe('old content'); expect($this->get(route('share.download.file', [$share, $file]))->streamedContent())->toBe('old content');
}); });
test('share auto-deletes after reaching download limit', function () {
Storage::fake('shares');
$service = app(ShareService::class);
$file = UploadedFile::fake()->create('file.txt', 100);
$share = $service->createShare([
['file' => $file, 'relativePath' => null],
], [
'max_downloads' => 1,
]);
$service->recordDownload($share);
expect(Share::query()->find($share->id))->toBeNull();
});
/** /**
* Helper to create a share with an actual encrypted file. * Helper to create a share with an actual encrypted file.
*/ */
@@ -185,3 +301,18 @@ function createShareWithFile(?string $password = null, string $content = 'test c
'password' => $password, 'password' => $password,
]); ]);
} }
/**
* Helper to create a share with three encrypted files: "first file", "second file" and "third file".
*/
function createShareWithFiles(?int $maxDownloads = null, ?string $password = null): Share
{
return app(ShareService::class)->createShare([
['file' => UploadedFile::fake()->createWithContent('first.txt', 'first file'), 'relativePath' => null],
['file' => UploadedFile::fake()->createWithContent('second.txt', 'second file'), 'relativePath' => null],
['file' => UploadedFile::fake()->createWithContent('third.txt', 'third file'), 'relativePath' => null],
], [
'password' => $password,
'max_downloads' => $maxDownloads,
]);
}
+19 -20
View File
@@ -26,21 +26,6 @@ pest()->extend(TestCase::class)
}) })
->in('Feature', 'Browser', 'Screenshots'); ->in('Feature', 'Browser', 'Screenshots');
/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/
expect()->extend('toBeOne', function () {
return $this->toBe(1);
});
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Functions | Functions
@@ -52,11 +37,6 @@ expect()->extend('toBeOne', function () {
| |
*/ */
function something()
{
// ..
}
/** /**
* A page of SealShare, once it can be used: loaded, with Alpine and Livewire started. Shared by * A page of SealShare, once it can be used: loaded, with Alpine and Livewire started. Shared by
* every file under tests/Browser, so a browser test needs no visit() of its own to define it. * every file under tests/Browser, so a browser test needs no visit() of its own to define it.
@@ -77,3 +57,22 @@ function encryptedChunk(ShareFile $file, string $plaintext, int $index, bool $is
return $encryption->encryptChunk($plaintext, $file->share->encryption_key, $header['noncePrefix'], $index, $isLast); return $encryption->encryptChunk($plaintext, $file->share->encryption_key, $header['noncePrefix'], $index, $isLast);
} }
/**
* Encrypt a whole file in the SEALCHK2 format, as the uploader's browser does chunk by chunk.
*/
function encryptTestFile(string $sourcePath, string $destinationPath, string $key, int $chunkSize): void
{
$encryption = new FileEncryptionService;
$header = $encryption->createHeader($chunkSize);
$noncePrefix = $encryption->parseHeader($header)['noncePrefix'];
$plaintext = (string) file_get_contents($sourcePath);
$chunkCount = $encryption->chunkCount(strlen($plaintext), $chunkSize);
$chunks = array_map(
fn (int $index): string => $encryption->encryptChunk(substr($plaintext, $index * $chunkSize, $chunkSize), $key, $noncePrefix, $index, $index === $chunkCount - 1),
range(0, $chunkCount - 1),
);
file_put_contents($destinationPath, $header.implode('', $chunks));
}
+3
View File
@@ -24,6 +24,9 @@ final class DemoData
public const PROTECTED_PASSWORD = 'autumn-harbour-42'; public const PROTECTED_PASSWORD = 'autumn-harbour-42';
/** The share the desktop upload creates, so its link and QR code read the same on every run. */
public const CREATED_TOKEN = 'Tf8gH2jK4mN6pQ3r';
public static function admin(): User public static function admin(): User
{ {
return User::factory()->admin()->create([ return User::factory()->admin()->create([
+27 -8
View File
@@ -5,6 +5,7 @@ use App\Services\QrCodeService;
use App\Services\ShareService; use App\Services\ShareService;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\Screenshots\DemoData; use Tests\Screenshots\DemoData;
use Tests\Screenshots\Publisher; use Tests\Screenshots\Publisher;
@@ -24,6 +25,10 @@ beforeEach(function () {
DemoData::shares(); DemoData::shares();
}); });
afterEach(function () {
Str::createRandomStringsNormally();
});
/** /**
* A visited page on a device in a theme, once it can be used. Tests call `visit()` themselves, on a * A visited page on a device in a theme, once it can be used. Tests call `visit()` themselves, on a
* line of its own: Pest starts its browser only for tests under tests/Browser or whose body calls * line of its own: Pest starts its browser only for tests under tests/Browser or whose body calls
@@ -45,10 +50,10 @@ function shotPage(mixed $visit, string $device, string $theme): mixed
* every run: the page is shown as it reads on an installation at https://files.example.com, the * every run: the page is shown as it reads on an installation at https://files.example.com, the
* server's origin replaced in text and fields, and the QR code drawn for that address. * server's origin replaced in text and fields, and the QR code drawn for that address.
*/ */
function shoot(mixed $page, string $device, string $theme, string $name): void function shoot(mixed $page, string $device, string $theme, string $name, string $token = DemoData::DELIVERY_TOKEN): void
{ {
$origin = 'https://files.example.com'; $origin = 'https://files.example.com';
$qrCode = app(QrCodeService::class)->svg($origin.'/s/'.DemoData::DELIVERY_TOKEN); $qrCode = app(QrCodeService::class)->svg($origin.'/s/'.$token);
$page->script("document.head.insertAdjacentHTML('beforeend', '<style>*{caret-color:transparent!important}</style>')"); $page->script("document.head.insertAdjacentHTML('beforeend', '<style>*{caret-color:transparent!important}</style>')");
$page->script('(() => { const from = location.origin, to = '.json_encode($origin).'; document.querySelectorAll("input").forEach((input) => { input.value = input.value.replaceAll(from, to) }); const text = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); while (text.nextNode()) { text.currentNode.nodeValue = text.currentNode.nodeValue.replaceAll(from, to) } document.querySelectorAll("[data-qr-code]").forEach((panel) => { panel.innerHTML = '.json_encode($qrCode).' }) })()'); $page->script('(() => { const from = location.origin, to = '.json_encode($origin).'; document.querySelectorAll("input").forEach((input) => { input.value = input.value.replaceAll(from, to) }); const text = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); while (text.nextNode()) { text.currentNode.nodeValue = text.currentNode.nodeValue.replaceAll(from, to) } document.querySelectorAll("[data-qr-code]").forEach((panel) => { panel.innerHTML = '.json_encode($qrCode).' }) })()');
@@ -101,23 +106,37 @@ function selectFiles(mixed $page, array $files): void
$files = ['Q3 Report.pdf' => 2400, 'Contract 2026.pdf' => 380, 'Product photos/hero-shot.jpg' => 4800, 'Product photos/detail.jpg' => 3900]; $files = ['Q3 Report.pdf' => 2400, 'Contract 2026.pdf' => 380, 'Product photos/hero-shot.jpg' => 4800, 'Product photos/detail.jpg' => 3900];
test('desktop', function (string $theme) use ($files) { test('desktop', function (string $theme) use ($files) {
// The share this upload creates takes a fixed token (share tokens are the only 16-character random
// strings); sessions and everything else stay random.
Str::createRandomStringsUsing(fn (int $length): string => $length === 16
? DemoData::CREATED_TOKEN
: substr(bin2hex(random_bytes($length)), 0, $length));
$upload = visit('/upload'); $upload = visit('/upload');
$page = shotPage($upload, 'desktop', $theme); $page = shotPage($upload, 'desktop', $theme);
selectFiles($page, $files); selectFiles($page, $files);
$page->click('label:has-text("Password protect")') $page->click('label:has-text("Password protect")')
->type('input[wire\:model="password"]', DemoData::PROTECTED_PASSWORD) ->click('[data-test="generate-password"]')
->type('input[wire\:model="maxDownloads"]', '5'); ->wait(1)
->assertScript("document.querySelector('input[autocomplete=\"new-password\"]').value.length > 0");
$page->type('input[wire\:model="maxDownloads"]', '5');
// The drop zone, the files and the options fill the window; typing left the page wherever it scrolled. // The drop zone, the files and the options fill the window; typing left the page wherever it scrolled.
$page->script("document.activeElement?.blur(); window.scrollTo(0, document.querySelector('[data-test=drop-zone]').getBoundingClientRect().top + window.scrollY - 24)"); $page->script("document.activeElement?.blur(); window.scrollTo(0, document.querySelector('[data-test=drop-zone]').getBoundingClientRect().top + window.scrollY - 24)");
shoot($page, 'desktop', $theme, '01-upload'); shoot($page, 'desktop', $theme, '01-upload');
$created = visit(route('share.created', DemoData::DELIVERY_TOKEN, false)); // Creating the share is what offers the password once more beside the link.
$page = shotPage($created, 'desktop', $theme); $page->click('[data-test="create-share"]')
shoot($page, 'desktop', $theme, '02-share-created'); ->waitForText('Share Created!');
$page->waitForEvent('networkidle')
->assertScript("document.querySelector('[data-test=\"share-password\"]') !== null");
shoot($page, 'desktop', $theme, '02-share-created', DemoData::CREATED_TOKEN);
$page->click('[data-test="show-qr-code"]') $page->click('[data-test="show-qr-code"]')
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open"); ->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open");
shoot($page, 'desktop', $theme, '03-qr-code'); shoot($page, 'desktop', $theme, '03-qr-code', DemoData::CREATED_TOKEN);
// The dashboard shows the eight demo shares, as before.
app(ShareService::class)->deleteShare(Share::query()->where('token', DemoData::CREATED_TOKEN)->firstOrFail());
$download = visit(route('share.download', DemoData::DELIVERY_TOKEN, false)); $download = visit(route('share.download', DemoData::DELIVERY_TOKEN, false));
shoot(shotPage($download, 'desktop', $theme), 'desktop', $theme, '04-download'); shoot(shotPage($download, 'desktop', $theme), 'desktop', $theme, '04-download');
+14 -21
View File
@@ -45,7 +45,7 @@ test('encrypt and decrypt round-trip works', function () {
$key = $this->service->generateRandomKey(); $key = $this->service->generateRandomKey();
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1024); encryptTestFile($sourcePath, $encryptedPath, $key, 1024);
expect(file_get_contents($encryptedPath))->not->toContain($content); expect(file_get_contents($encryptedPath))->not->toContain($content);
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content); expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
@@ -57,14 +57,14 @@ test('decrypt with wrong key fails', function () {
file_put_contents($sourcePath, 'Secret data'); file_put_contents($sourcePath, 'Secret data');
$this->service->encryptFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1024); encryptTestFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1024);
decryptToString($this->service, $encryptedPath, $this->service->generateRandomKey()); decryptToString($this->service, $encryptedPath, $this->service->generateRandomKey());
})->throws(RuntimeException::class, 'Decryption failed'); })->throws(RuntimeException::class, 'Decryption failed');
test('derive key produces consistent results', function () { test('derive key produces consistent results', function () {
$password = 'my-secure-password'; $password = 'my-secure-password';
$salt = $this->service->generateSalt(); $salt = bin2hex(random_bytes(32));
$key1 = $this->service->deriveKey($password, $salt); $key1 = $this->service->deriveKey($password, $salt);
$key2 = $this->service->deriveKey($password, $salt); $key2 = $this->service->deriveKey($password, $salt);
@@ -73,7 +73,7 @@ test('derive key produces consistent results', function () {
}); });
test('derive key with different passwords produces different keys', function () { test('derive key with different passwords produces different keys', function () {
$salt = $this->service->generateSalt(); $salt = bin2hex(random_bytes(32));
$key1 = $this->service->deriveKey('password1', $salt); $key1 = $this->service->deriveKey('password1', $salt);
$key2 = $this->service->deriveKey('password2', $salt); $key2 = $this->service->deriveKey('password2', $salt);
@@ -84,8 +84,8 @@ test('derive key with different passwords produces different keys', function ()
test('derive key with different salts produces different keys', function () { test('derive key with different salts produces different keys', function () {
$password = 'same-password'; $password = 'same-password';
$key1 = $this->service->deriveKey($password, $this->service->generateSalt()); $key1 = $this->service->deriveKey($password, bin2hex(random_bytes(32)));
$key2 = $this->service->deriveKey($password, $this->service->generateSalt()); $key2 = $this->service->deriveKey($password, bin2hex(random_bytes(32)));
expect($key1)->not->toBe($key2); expect($key1)->not->toBe($key2);
}); });
@@ -97,13 +97,6 @@ test('generate random key returns 64 char hex string', function () {
expect(ctype_xdigit($key))->toBeTrue(); expect(ctype_xdigit($key))->toBeTrue();
}); });
test('generate salt returns 64 char hex string', function () {
$salt = $this->service->generateSalt();
expect(strlen($salt))->toBe(64);
expect(ctype_xdigit($salt))->toBeTrue();
});
test('password-derived key encrypt/decrypt round-trip works', function () { test('password-derived key encrypt/decrypt round-trip works', function () {
$sourcePath = $this->tempDir.'/source.txt'; $sourcePath = $this->tempDir.'/source.txt';
$encryptedPath = $this->tempDir.'/encrypted.enc'; $encryptedPath = $this->tempDir.'/encrypted.enc';
@@ -111,9 +104,9 @@ test('password-derived key encrypt/decrypt round-trip works', function () {
file_put_contents($sourcePath, $content); file_put_contents($sourcePath, $content);
$key = bin2hex($this->service->deriveKey('user-password', $this->service->generateSalt())); $key = bin2hex($this->service->deriveKey('user-password', bin2hex(random_bytes(32))));
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1024); encryptTestFile($sourcePath, $encryptedPath, $key, 1024);
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content); expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
}); });
@@ -124,7 +117,7 @@ test('an encrypted file starts with the SEALCHK2 header and its chunk size', fun
file_put_contents($sourcePath, 'test content'); file_put_contents($sourcePath, 'test content');
$this->service->encryptFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1024); encryptTestFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1024);
expect(file_get_contents($encryptedPath, false, null, 0, 12))->toBe('SEALCHK2'.pack('N', 1024)); expect(file_get_contents($encryptedPath, false, null, 0, 12))->toBe('SEALCHK2'.pack('N', 1024));
}); });
@@ -137,7 +130,7 @@ test('multi-chunk round-trip works', function () {
file_put_contents($sourcePath, $content); file_put_contents($sourcePath, $content);
$key = $this->service->generateRandomKey(); $key = $this->service->generateRandomKey();
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000); encryptTestFile($sourcePath, $encryptedPath, $key, 1000);
expect(filesize($encryptedPath))->toBe(19 + 3 * 16 + 2500); expect(filesize($encryptedPath))->toBe(19 + 3 * 16 + 2500);
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content); expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
@@ -151,7 +144,7 @@ test('exact chunk boundary round-trip works', function () {
file_put_contents($sourcePath, $content); file_put_contents($sourcePath, $content);
$key = $this->service->generateRandomKey(); $key = $this->service->generateRandomKey();
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000); encryptTestFile($sourcePath, $encryptedPath, $key, 1000);
expect(filesize($encryptedPath))->toBe(19 + 2 * 16 + 2000); expect(filesize($encryptedPath))->toBe(19 + 2 * 16 + 2000);
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content); expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
@@ -164,7 +157,7 @@ test('empty file round-trip works', function () {
file_put_contents($sourcePath, ''); file_put_contents($sourcePath, '');
$key = $this->service->generateRandomKey(); $key = $this->service->generateRandomKey();
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000); encryptTestFile($sourcePath, $encryptedPath, $key, 1000);
expect(filesize($encryptedPath))->toBe(19 + 16); expect(filesize($encryptedPath))->toBe(19 + 16);
expect(decryptToString($this->service, $encryptedPath, $key))->toBe(''); expect(decryptToString($this->service, $encryptedPath, $key))->toBe('');
@@ -197,7 +190,7 @@ test('a file cut short at a chunk boundary fails to decrypt', function () {
file_put_contents($sourcePath, random_bytes(3000)); file_put_contents($sourcePath, random_bytes(3000));
$key = $this->service->generateRandomKey(); $key = $this->service->generateRandomKey();
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000); encryptTestFile($sourcePath, $encryptedPath, $key, 1000);
$handle = fopen($encryptedPath, 'r+b'); $handle = fopen($encryptedPath, 'r+b');
ftruncate($handle, 19 + 2 * (1000 + 16)); ftruncate($handle, 19 + 2 * (1000 + 16));
@@ -265,7 +258,7 @@ test('wrong key on chunked file throws exception', function () {
file_put_contents($sourcePath, random_bytes(2500)); file_put_contents($sourcePath, random_bytes(2500));
$this->service->encryptFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1000); encryptTestFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1000);
decryptToString($this->service, $encryptedPath, $this->service->generateRandomKey()); decryptToString($this->service, $encryptedPath, $this->service->generateRandomKey());
})->throws(RuntimeException::class, 'Decryption failed'); })->throws(RuntimeException::class, 'Decryption failed');
-16
View File
@@ -118,22 +118,6 @@ test('verify password returns true for non-password share', function () {
expect($this->service->verifyPassword($share, 'any'))->toBeTrue(); expect($this->service->verifyPassword($share, 'any'))->toBeTrue();
}); });
test('record download increments counter', function () {
$share = Share::factory()->create(['download_count' => 0]);
$this->service->recordDownload($share);
expect($share->fresh()->download_count)->toBe(1);
});
test('record download auto-deletes when limit reached', function () {
$share = Share::factory()->withMaxDownloads(1)->create(['download_count' => 0]);
$this->service->recordDownload($share);
expect(Share::query()->find($share->id))->toBeNull();
});
test('get total used space sums share sizes', function () { test('get total used space sums share sizes', function () {
Share::factory()->create(['total_size' => 1000]); Share::factory()->create(['total_size' => 1000]);
Share::factory()->create(['total_size' => 2000]); Share::factory()->create(['total_size' => 2000]);
+35 -18
View File
@@ -1,24 +1,41 @@
import { import {
defineConfig defineConfig,
loadEnv
} from 'vite'; } from 'vite';
import laravel from 'laravel-vite-plugin'; import laravel from 'laravel-vite-plugin';
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [ const env = loadEnv(mode, process.cwd(), '');
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'], // Where the browser reaches the dev server when it is not http://localhost, e.g. OrbStack's
refresh: true, // https://vite.sealshare.orb.local. Laravel loads the assets from it, and HMR connects to it.
}), const devServerUrl = env.VITE_DEV_SERVER_URL ? new URL(env.VITE_DEV_SERVER_URL) : null;
], const secure = devServerUrl?.protocol === 'https:';
server: {
host: '0.0.0.0', return {
port: 5173, plugins: [
cors: true, laravel({
hmr: { input: ['resources/css/app.css', 'resources/js/app.js'],
host: 'localhost', refresh: true,
}),
],
server: {
// IPv4 and IPv6: OrbStack's HTTPS proxy connects over either
host: true,
port: Number(env.VITE_PORT) || 5173,
cors: true,
origin: devServerUrl?.origin,
allowedHosts: devServerUrl ? [devServerUrl.hostname] : [],
hmr: devServerUrl ? {
protocol: secure ? 'wss' : 'ws',
host: devServerUrl.hostname,
clientPort: Number(devServerUrl.port) || (secure ? 443 : 80),
} : {
host: 'localhost',
},
watch: {
ignored: ['**/storage/framework/views/**'],
},
}, },
watch: { };
ignored: ['**/storage/framework/views/**'],
},
},
}); });
+18 -7
View File
@@ -4,6 +4,10 @@
* Pro viewport). The screen keeps the capture's aspect ratio, so the frame holds its shape while an * Pro viewport). The screen keeps the capture's aspect ratio, so the frame holds its shape while an
* image loads. The screenshots come from `composer screenshots` (tests/Screenshots/). * image loads. The screenshots come from `composer screenshots` (tests/Screenshots/).
* *
* The bezels and the deck are hardware, drawn in fixed tints of their own; what shows through the
* screen before an image paints and the shadow under a device are colour roles from theme.css, which
* must be loaded first. Dark follows <html data-theme>, and the system setting without JavaScript.
*
* <div class="device device--laptop"> * <div class="device device--laptop">
* <div class="device__screen"><picture><img ></picture></div> * <div class="device__screen"><picture><img ></picture></div>
* </div> * </div>
@@ -14,13 +18,21 @@
--bezel-edge: #3a3355; --bezel-edge: #3a3355;
--deck: #c9c2dd; --deck: #c9c2dd;
--deck-shadow: #8f88a8; --deck-shadow: #8f88a8;
--device-shadow: color-mix(in srgb, var(--md-sys-color-shadow) 40%, transparent);
position: relative; position: relative;
width: 100%; width: 100%;
} }
:root[data-theme="dark"] .device {
--bezel: #05020f;
--bezel-edge: #2a2244;
--deck: #4b426a;
--deck-shadow: #231649;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.device { :root:not([data-theme]) .device {
--bezel: #05020f; --bezel: #05020f;
--bezel-edge: #2a2244; --bezel-edge: #2a2244;
--deck: #4b426a; --deck: #4b426a;
@@ -31,7 +43,7 @@
.device__screen { .device__screen {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
background: var(--surface-container-high, #e8deff); background-color: var(--md-sys-color-surface-container-high);
} }
.device__screen img { .device__screen img {
@@ -54,7 +66,7 @@
padding: 2.4% 2.4% 3%; padding: 2.4% 2.4% 3%;
border-radius: 3.2% 3.2% 0 0 / 4.8% 4.8% 0 0; border-radius: 3.2% 3.2% 0 0 / 4.8% 4.8% 0 0;
background: var(--bezel); background: var(--bezel);
box-shadow: inset 0 0 0 1px var(--bezel-edge), 0 24px 48px -24px rgb(16 6 45 / 0.45); box-shadow: inset 0 0 0 1px var(--bezel-edge), 0 24px 48px -24px var(--device-shadow);
} }
.device--laptop .device__screen { .device--laptop .device__screen {
@@ -79,8 +91,7 @@
.device--laptop::after { .device--laptop::after {
content: ''; content: '';
position: absolute; position: absolute;
left: -4%; inset-inline: -4%;
right: -4%;
bottom: 0; bottom: 0;
height: 3.2%; height: 3.2%;
border-radius: 0 0 40% 40% / 0 0 100% 100%; border-radius: 0 0 40% 40% / 0 0 100% 100%;
@@ -95,7 +106,7 @@
z-index: 1; z-index: 1;
width: 14%; width: 14%;
height: 1.4%; height: 1.4%;
border-radius: 0 0 12px 12px; border-radius: 0 0 var(--md-sys-shape-corner-md) var(--md-sys-shape-corner-md);
background: var(--deck-shadow); background: var(--deck-shadow);
translate: -50% 0; translate: -50% 0;
} }
@@ -106,7 +117,7 @@
padding: 3.4%; padding: 3.4%;
border-radius: 15% / 7%; border-radius: 15% / 7%;
background: var(--bezel); background: var(--bezel);
box-shadow: inset 0 0 0 2px var(--bezel-edge), 0 24px 48px -20px rgb(16 6 45 / 0.5); box-shadow: inset 0 0 0 2px var(--bezel-edge), 0 24px 48px -20px var(--device-shadow);
} }
.device--phone .device__screen { .device--phone .device__screen {
+914 -621
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" width="96" height="96"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" width="96" height="96">
<!-- The SealShare mark: a document with a folded corner and an upload arrow (resources/views/components/app-logo-icon.blade.php). --> <!-- The SealShare mark: a document with a folded corner and an upload arrow (public/favicon.svg). -->
<path d="M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z" stroke="#4a3fe2" stroke-width="1.5" /> <path d="M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z" stroke="#4a3fe2" stroke-width="1.5" />
<path d="M14 2v5a1 1 0 0 0 1 1h5" stroke="#4a3fe2" stroke-width="1.5" /> <path d="M14 2v5a1 1 0 0 0 1 1h5" stroke="#4a3fe2" stroke-width="1.5" />
<path d="M12 17v-6m0 0-2.5 2.5M12 11l2.5 2.5" stroke="#4a3fe2" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /> <path d="M12 17v-6m0 0-2.5 2.5M12 11l2.5 2.5" stroke="#4a3fe2" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />

Before

Width:  |  Height:  |  Size: 569 B

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

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