Compare commits
@@ -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`, 2–72px) 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`, 2–72px) 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
|
||||||
|
|
||||||
|
|||||||
@@ -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 H2–H6 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,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();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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('');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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();
|
|
||||||
|
|||||||
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
|
||||||
],
|
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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),
|
|
||||||
|
|
||||||
];
|
|
||||||
@@ -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,
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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),
|
|
||||||
],
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
];
|
|
||||||
@@ -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'),
|
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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'),
|
|
||||||
],
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
];
|
|
||||||
@@ -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,
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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',
|
|
||||||
],
|
|
||||||
|
|
||||||
];
|
|
||||||
@@ -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'),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
];
|
|
||||||
@@ -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),
|
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 +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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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'))],
|
||||||
|
|||||||
@@ -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 () {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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/**'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |