Compare commits
24
Commits
21bea9646d
...
main
@@ -8,4 +8,6 @@ Before planning or editing, find the row whose globs match the file's path and r
|
|||||||
| resources/css/material-scheme.* | .ai/rules/css.md |
|
| resources/css/material-scheme.* | .ai/rules/css.md |
|
||||||
| resources/views/livewire/share-download.blade.php | .ai/rules/livewire.md |
|
| resources/views/livewire/share-download.blade.php | .ai/rules/livewire.md |
|
||||||
| tests/Screenshots/** | .ai/rules/screenshots.md |
|
| tests/Screenshots/** | .ai/rules/screenshots.md |
|
||||||
|
| app/Services/** | .ai/rules/services.md |
|
||||||
|
| resources/views/** | .ai/rules/views.md |
|
||||||
| website/** | .ai/rules/website.md |
|
| website/** | .ai/rules/website.md |
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ paths:
|
|||||||
# Screenshots
|
# Screenshots
|
||||||
|
|
||||||
## Screenshots come from composer screenshots, before a release
|
## Screenshots come from composer screenshots, before a release
|
||||||
Run `composer screenshots` whenever the interface changes and before a release; it builds assets and runs tests/Screenshots (not part of any test suite or CI), publishing WebP files to website/img/screenshots. Demo data (DemoData) and the clock are fixed so runs are reproducible. Traps: Pest only starts its browser for a test whose body calls `visit(` after whitespace; Livewire's temporary-upload cleanup must stay off under the frozen clock or it deletes the selected files; the in-process server's random port is shown as https://files.example.com and the QR redrawn for it; upload_max_filesize/post_max_size are set to 4G by the script so the admin settings hint does not show the machine's PHP limit.
|
Run `composer screenshots` whenever the interface changes and before a release; it builds assets and runs tests/Screenshots (not part of any test suite or CI), publishing WebP files to website/img/screenshots. Demo data (DemoData) and the clock are fixed so runs are reproducible. Traps: Pest only starts its browser for a test whose body calls `visit(` after whitespace; the upload shot's files are registered through the page's `registerFiles` and their encrypted chunks stored server-side (the in-process server takes request bodies up to 128 KB only), then the list refreshed; the in-process server's random port is shown as https://files.example.com and the QR redrawn for it.
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- 'app/Services/**'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Services
|
||||||
|
|
||||||
|
## Uploads are encrypted in the browser, never on the server
|
||||||
|
Share files are encrypted chunk by chunk in the uploader's browser (resources/js/share-uploader.js, WebCrypto) in the SEALCHK2 format and PUT to UploadChunkController, which verifies each chunk in memory and writes it once. Never add a server-side upload path that puts plaintext on disk (Livewire temp uploads, multipart spooling): PHP spools every request body to upload_tmp_dir. ShareService::createShare() exists only for tests and demo data. Send chunk bodies as a Blob, not an ArrayBuffer: Chromium uploads an ArrayBuffer about 8x slower.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- 'resources/views/**'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Views
|
||||||
|
|
||||||
|
## `<x-group>` drops data-test and other attributes
|
||||||
|
`<x-group>` (Livewire Material) keeps only class, style and wire:key on its fieldset and wire:model/x-model on its inputs; data-test, id and every other attribute are silently dropped. Tests reach a group through its binding instead, e.g. assertSeeHtml('wire:model.live="passwordGeneratorType"') or input[value="…"]. Rendering `<x-group>` also needs components/group.css imported in resources/css/app.css (DesignLanguageTest's missingStylesheets guards it).
|
||||||
|
|
||||||
|
## Every page renders <x-page>
|
||||||
|
Every page (Livewire page, settings SFC via pages/settings/layout, Fortify auth view) has <x-page> (resources/views/components/page.blade.php) at its root, inside layouts/app — the only layout. It draws the centred h1 header (`brand` for the site's logo/title/description on public and sign-in pages, or title/description, optional `mark` and `navigation` slots) over one centred column. Every page is the same 40rem column and <x-page> has no width prop: content that needs more room is rearranged to fit (the admin dashboard's shares are a list with a sort select, not a table). Content goes in outlined cards (`<x-card variant="outlined" heading="h2">`). Never give a page its own width class, h1 or header stack. tests/Feature/PageTemplateTest.php lists every page.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: infer-conventions
|
name: infer-conventions
|
||||||
description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand."
|
description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Only run this skill when the user explicitly asks for it; never start a sweep as part of another task. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand."
|
||||||
|
disable-model-invocation: true
|
||||||
license: MIT
|
license: MIT
|
||||||
metadata:
|
metadata:
|
||||||
author: laravel
|
author: laravel
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,293 @@
|
|||||||
|
---
|
||||||
|
name: material-3-design
|
||||||
|
description: Material 3 Expressive's design system as Livewire Material implements it — colour roles and surface containers, elevation, shape, type, motion, states and targets, window size classes, spacing, icons, accessibility — each M3 name beside the class, prop or token that draws it and Google's source page, for deciding how a screen should look and behave before writing it.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Material 3 design
|
||||||
|
|
||||||
|
## When to use this skill
|
||||||
|
|
||||||
|
Use this skill when deciding how a screen, panel or control should look or behave — which colour, container, corner, type style, motion, breakpoint or spacing — in an application that requires `nonameweb/livewire-material`, and when reviewing a view against Material 3. The props and slots of each component are in the `livewire-material-development` skill; this one is the design language they implement. The rules an agent must always follow are in the `material-3` guideline; the tables here are what those rules compress.
|
||||||
|
|
||||||
|
Every table pairs the M3 name with what the library gives for it. The library is plain CSS with no utility classes, so that is one of three things: a component or layout component prop (`color="error"`, `<x-surface level="surface-container">`, `gap="space200"`), one of the fixed text and interaction classes (`md-type-*`, `md-ink-*`, `md-state-layer`, `md-focus-ring`, `md-touch-target`, `md-link`), or a token the application's own CSS reads with `var()`. The tokens are CSS custom properties (`--md-sys-color-*`, `--md-sys-typescale-*`, `--md-sys-shape-*`, `--md-sys-elevation-*`, `--md-sys-motion-*`, `--md-sys-state-*`, `--md-sys-measurement-*`, `--md-ref-typeface-*`), so a stylesheet names a token and never a value.
|
||||||
|
|
||||||
|
## Colour
|
||||||
|
|
||||||
|
A colour scheme is generated from one seed by Google's colour science (`php artisan material:scheme`); every role below is a slot in that scheme, light and dark, at three contrast levels. A view names a role and nothing else — never a hex, a palette tone or an opacity — because only a role follows the theme, the contrast level and a colour profile.
|
||||||
|
|
||||||
|
### Roles
|
||||||
|
|
||||||
|
| Role | Purpose | Its `on-` pair | In this library |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| primary | High-emphasis fills, text and icons: the key action on a screen | on-primary | `var(--md-sys-color-primary)`, `md-ink-primary`, `<x-button variant="filled">` |
|
||||||
|
| primary-container | A standout fill for key components (FAB, an emphasised panel) | on-primary-container | `var(--md-sys-color-primary-container)` |
|
||||||
|
| primary-dim | A darker primary for a pressed or contrasting fill (2025 spec) | on-primary | `var(--md-sys-color-primary-dim)` |
|
||||||
|
| secondary | Less prominent fills, text and icons | on-secondary | `var(--md-sys-color-secondary)` |
|
||||||
|
| secondary-container | The recessive fill: tonal buttons, selected navigation, selected chips | on-secondary-container | `var(--md-sys-color-secondary-container)`, `<x-button variant="tonal">` |
|
||||||
|
| tertiary | A complementary accent, used sparingly for contrast | on-tertiary | `var(--md-sys-color-tertiary)`, `color="tertiary"` |
|
||||||
|
| tertiary-container | The complementary fill | on-tertiary-container | `var(--md-sys-color-tertiary-container)` |
|
||||||
|
| error | Urgency and errors; static, does not follow dynamic colour | on-error | `md-ink-error`, `var(--md-sys-color-error)`, `color="error"` |
|
||||||
|
| error-container | An error panel | on-error-container | `var(--md-sys-color-error-container)`, `<x-alert color="error">` |
|
||||||
|
| success, warning, info | This library's custom state colours, built like error on the 2025 spec, with `-container` and `on-` pairs | on-success … | `md-ink-success`, `var(--md-sys-color-warning-container)`, `color="info"` |
|
||||||
|
| surface | The page background | on-surface | the page itself (the foundation paints it), `<x-surface level="surface">` |
|
||||||
|
| on-surface-variant | Lower-emphasis text and icons on any surface | — | `md-ink-variant` |
|
||||||
|
| outline | A boundary that must be read: a text field, a target's edge (3:1 against surface) | — | `md-ink-quiet`, `var(--md-sys-color-outline)` |
|
||||||
|
| outline-variant | Decorative lines: dividers, card edges | — | `<x-divider>`, `<x-surface outlined>`, `var(--md-sys-color-outline-variant)` |
|
||||||
|
| inverse-surface | A surface that contrasts with its surroundings (the snackbar) | inverse-on-surface | `var(--md-sys-color-inverse-surface)` with `md-ink-inverse` |
|
||||||
|
| inverse-primary | An action on an inverse surface (the snackbar's action) | — | `var(--md-sys-color-inverse-primary)` |
|
||||||
|
| scrim | Behind a modal, at 32% | — | `color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent)` |
|
||||||
|
| shadow | The shadow colour, inside every `--md-sys-elevation-*` | — | — |
|
||||||
|
| surface-dim, surface-bright | Add-on surfaces that keep their relative brightness in both themes | on-surface | `<x-surface level="surface-dim">`, `<x-surface level="surface-bright">` |
|
||||||
|
| primary-fixed, primary-fixed-dim, on-primary-fixed, on-primary-fixed-variant (and secondary, tertiary) | Add-on roles with the same tone in light and dark; for a colour that must not change with the theme; never where contrast matters | — | `var(--md-sys-color-primary-fixed)` with `var(--md-sys-color-on-primary-fixed)` |
|
||||||
|
|
||||||
|
Pairing: a role's `on-` pair is the only combination whose contrast is guaranteed at every contrast level. A `primary` fill under `on-primary` text and a `secondary-container` fill under `on-secondary-container` are right; `primary-container` under `on-surface`, or `secondary-container` under `primary`, are not, and break as the contrast level rises. A component sets its own pair; the application's CSS writes both halves (`background-color: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary)`). Google: "Pair and layer color roles only as intended … Don't mix roles improperly."
|
||||||
|
|
||||||
|
### Surface containers
|
||||||
|
|
||||||
|
A hierarchy of emphasis, not of height: the tone separates panels before any shadow does, and a region keeps its role at every breakpoint (body always `surface`, navigation always `surface-container`).
|
||||||
|
|
||||||
|
| Role | Use | In this library |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| surface | The page | the page itself, `<x-surface level="surface">` |
|
||||||
|
| surface-container-lowest | The most recessed panel; an elevated card's body in dark themes | `<x-surface level="surface-container-lowest">` |
|
||||||
|
| surface-container-low | An elevated card, a modal bottom or side sheet, the full-screen search view | `<x-surface level="surface-container-low">` |
|
||||||
|
| surface-container | Navigation bar and rail, docked and floating toolbars, menus, the segmented list | `<x-surface>` (the default level) |
|
||||||
|
| surface-container-high | Dialogs, the search bar, date and time pickers, a rich tooltip | `<x-surface level="surface-container-high">` |
|
||||||
|
| surface-container-highest | A filled card, a filled text field, a filled chip's selected state | `<x-surface level="surface-container-highest">` |
|
||||||
|
|
||||||
|
In the application's CSS each is `var(--md-sys-color-surface-container-low)` and so on; the ink on every one of them is `on-surface`.
|
||||||
|
|
||||||
|
### Emphasis and lines
|
||||||
|
|
||||||
|
- Default ink is `on-surface` (`md-ink`); lower emphasis is `on-surface-variant` (`md-ink-variant`); decoration is `outline` (`md-ink-quiet`). Emphasis is never an opacity: M3 reserves 38% (`--md-sys-state-disabled-content-opacity`) for disabled content and 12% (`--md-sys-state-disabled-container-opacity`) for a disabled container.
|
||||||
|
- `outline` for a boundary that has to be perceived (a text field's edge, a target's edge — 3:1 against the surface); `outline-variant` for dividers and the edge of a card or any component holding several elements. Google: "Don't use the outline color for dividers … use outline variant instead." `outline-variant` may edge a chip or a button only because the content inside already carries the contrast.
|
||||||
|
- A hyperlink in running text is `primary` (or `tertiary` for a quieter link) **and** underlined: `md-link` with `md-ink-primary`.
|
||||||
|
|
||||||
|
### Contrast
|
||||||
|
|
||||||
|
| Level | Target | How |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Standard | Hierarchy from high- and low-contrast elements together; text 4.5:1, large text and icons 3:1, grouped non-text controls 3:1 | the default scheme |
|
||||||
|
| Medium | 3:1 minimum everywhere, without halation | `<html data-contrast="medium">` |
|
||||||
|
| High | 7:1 | `<html data-contrast="high">`, or the visitor's OS setting (`theme.contrast.default` = `system`) |
|
||||||
|
|
||||||
|
Every role changes with the level automatically; a component built from roles needs nothing else. Disabled states are exempt from contrast. A colour outside the roles (a hex, white, black) does not change and is the one thing that breaks a contrast level.
|
||||||
|
|
||||||
|
Sources: https://m3.material.io/styles/color/roles · https://m3.material.io/styles/color/system/how-the-system-works · https://m3.material.io/styles/color/advanced/apply-colors · https://m3.material.io/foundations/designing/color-contrast
|
||||||
|
|
||||||
|
## Surfaces and elevation
|
||||||
|
|
||||||
|
M3 separates surfaces by tone first; a shadow says that something floats over the content or is being interacted with. "When it comes to applying shadows, less is more."
|
||||||
|
|
||||||
|
| Level | Shadow | Rests here | In this library |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 0 | none | The page, cards (filled, outlined), buttons (filled, tonal, outlined), button groups, icon buttons, lists, chips, tabs, sliders, the rail, a docked side sheet, a carousel, a full-screen dialog, a FAB inside the rail, an app bar at rest | — |
|
||||||
|
| 1 | 1dp | Elevated cards, elevated buttons and chips, modal bottom and side sheets, a banner | `box-shadow: var(--md-sys-elevation-1)` |
|
||||||
|
| 2 | 3dp | Menus, the navigation bar, a scrolled app bar, toolbars, rich tooltips | `var(--md-sys-elevation-2)` |
|
||||||
|
| 3 | 6dp | FAB and extended FAB, the FAB menu's close button, dialogs, date and time pickers, the search bar | `var(--md-sys-elevation-3)` |
|
||||||
|
| 4 | 8dp | Interaction only: a level-3 element on hover or while dragged | `var(--md-sys-elevation-4)` |
|
||||||
|
| 5 | 12dp | Interaction only | `var(--md-sys-elevation-5)` |
|
||||||
|
|
||||||
|
- Hover lifts an element one level (a FAB 3 → 4, an elevated card 1 → 2); focus and selection may too; a raised element lowers when something higher appears.
|
||||||
|
- Overlapping panels take different surface-container roles to show separation; the roles are not tied to the levels.
|
||||||
|
- A scrim (`scrim` at 32%) brings focus to a modal over a large surface; it is never a substitute for a shadow on a small floating element.
|
||||||
|
- On a dark surface a shadow is nearly invisible, so the tone does the work there.
|
||||||
|
|
||||||
|
Sources: https://m3.material.io/styles/elevation/overview · https://m3.material.io/styles/elevation/applying-elevation · https://m3.material.io/styles/elevation/tokens
|
||||||
|
|
||||||
|
## Shape
|
||||||
|
|
||||||
|
### The corner scale
|
||||||
|
|
||||||
|
| Style | Value | In this library |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| None | 0 | `var(--md-sys-shape-corner-none)`, `corner="none"` |
|
||||||
|
| Extra small | 4px | `var(--md-sys-shape-corner-xs)`, `corner="xs"` |
|
||||||
|
| Small | 8px | `var(--md-sys-shape-corner-sm)`, `corner="sm"` |
|
||||||
|
| Medium | 12px | `var(--md-sys-shape-corner-md)`, `corner="md"` |
|
||||||
|
| Large | 16px | `var(--md-sys-shape-corner-lg)`, `corner="lg"` |
|
||||||
|
| Large increased | 20px | `var(--md-sys-shape-corner-lg-increased)`, `corner="lg-increased"` |
|
||||||
|
| Extra large | 28px | `var(--md-sys-shape-corner-xl)`, `corner="xl"` |
|
||||||
|
| Extra large increased | 32px | `var(--md-sys-shape-corner-xl-increased)`, `corner="xl-increased"` |
|
||||||
|
| Extra extra large | 48px | `var(--md-sys-shape-corner-xxl)`, `corner="xxl"` |
|
||||||
|
| Full | a stadium or circle | `var(--md-sys-shape-corner-full)`, `corner="full"` |
|
||||||
|
|
||||||
|
`corner` is `<x-surface>`'s prop. In the application's CSS a corner is `border-radius` on a token, and one side at a time a logical longhand (`border-start-start-radius` and `border-start-end-radius` for a bottom sheet's top); a length of your own is off the scale.
|
||||||
|
|
||||||
|
### Corner by component
|
||||||
|
|
||||||
|
| Component | Corner | Note |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Buttons, icon buttons, split button (outer), FAB menu items | full | a press morphs to `md` (xs/sm sizes), `lg` (md), `xl` (lg/xl); a selected toggle swaps round ↔ square |
|
||||||
|
| Connected button group | full outside, `sm` between segments | segments press to `xs` |
|
||||||
|
| FAB | `lg` 16 (baseline 56px), `lg-increased` 20 (medium 80px), `xl` 28 (large 96px) | extended FAB `lg` |
|
||||||
|
| Chips | `sm` 8 | an avatar in a chip `md` 12 |
|
||||||
|
| Cards | `md` 12 | no change on hover |
|
||||||
|
| Text fields | `xs` 4 (outlined: all corners; filled: top corners only) | |
|
||||||
|
| Menus, snackbar, plain tooltip | `xs` 4 | the Expressive vertical menu rounds the focused item |
|
||||||
|
| Rich tooltip | `md` 12 | |
|
||||||
|
| Dialogs | `xl` 28 | full-screen dialog `none` |
|
||||||
|
| Bottom sheet | `xl` 28 on top | |
|
||||||
|
| Side sheet | `lg` 16 on the inner side | |
|
||||||
|
| Search bar | full | search view `xl` 28 when docked, `none` full-screen |
|
||||||
|
| Date and time pickers | `xl` 28 | date cells full |
|
||||||
|
| Carousel items | `xl` 28 | |
|
||||||
|
| Navigation indicator, badges, switch, slider handle, checkbox state layer | full | checkbox box 2px, tab indicator 3px on top |
|
||||||
|
| Navigation bar, app bar, docked toolbar, tabs | none | floating toolbar full |
|
||||||
|
| Segmented list rows | `xs` inner, `lg` outer; a selected row `lg` | |
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- Optical roundness: a shape nested in a rounded container takes inner radius = outer radius − padding (48 − 14 = 34), never the container's own radius.
|
||||||
|
- Large and full corners do not belong on information-dense containers (cards, tables, text fields).
|
||||||
|
- A press squares a round shape and rounds a square one (the components carry the morph on the fast spatial spring); nothing morphs on hover.
|
||||||
|
- The 35 Expressive shapes (`<x-shape name="cookie-9">`, also the loading indicator and the standard button group's press shape) are decoration for emphasis and delight — never a carrier of meaning, never behind text-heavy content, and used sparingly.
|
||||||
|
|
||||||
|
Sources: https://m3.material.io/styles/shape/corner-radius-scale · https://m3.material.io/styles/shape/shape-morph · https://m3.material.io/styles/shape/overview-principles
|
||||||
|
|
||||||
|
## Type
|
||||||
|
|
||||||
|
The typeface is Google Sans Flex for brand and plain styles (`--md-ref-typeface-brand`, `--md-ref-typeface-plain`); an application may replace it after importing the stylesheet. Each style is one class that sets size, line height, weight, family and tracking together — or, in the application's CSS, `font: var(--md-sys-typescale-body-md)` with `letter-spacing: var(--md-sys-typescale-body-md-tracking)`. A size, weight, line height or letter spacing of your own is off the scale.
|
||||||
|
|
||||||
|
| Role | Style | Size / line | Weight | In this library | Use for |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| Display | large / medium / small | 57/64 · 45/52 · 36/44 | 400 | `md-type-display-lg` … | hero figures, one short marketing line; never running text |
|
||||||
|
| Headline | large / medium / small | 32/40 · 28/36 · 24/32 | 400 | `md-type-headline-lg` … | page titles, section titles, a dialog's headline (`headline-sm`) |
|
||||||
|
| Title | large / medium / small | 22/28 · 16/24 · 14/20 | 400 / 500 / 500 | `md-type-title-lg` … | app bar title (`lg`), card and list-section titles (`md`), dense headers (`sm`) |
|
||||||
|
| Body | large / medium / small | 16/24 · 14/20 · 12/16 | 400 | `md-type-body-lg` … | paragraphs (`lg` for reading, `md` in components), supporting text (`sm`) |
|
||||||
|
| Label | large / medium / small | 14/20 · 12/16 · 11/16 | 500 | `md-type-label-lg` … | buttons and tabs (`lg`), chips and navigation (`md`), captions and badges (`sm`) |
|
||||||
|
|
||||||
|
- `md-type-emphasized-*` (`--md-sys-typescale-emphasized-*`) is the same size and line height one weight step heavier (400 → 500, 500 → 700), fully rounded in Google Sans Flex, with its own tracking. M3 uses it deliberately, never by default: a selected list or menu item, a button's label on a primary action, an extended FAB, a badge, a headline given editorial weight.
|
||||||
|
- Tracking follows Compose's `TypeScaleTokens`: display-large −0.2, title-medium 0.2, title-small 0.1, body-large 0.5, body-medium 0.2, body-small 0.4, label-large 0.1, label-medium and small 0.5 (sp; rem = sp/16); the emphasized set tightens a few (display-large 0, title-medium 0.15, body-large 0.15, body-medium 0.25).
|
||||||
|
- Line length 40–60 characters (`max-inline-size: 60ch` in the application's CSS). Figures that change take `md-tabular`.
|
||||||
|
- Text must scale to 200%: containers grow, side-by-side controls stack, padding stays; components without text (progress, checkboxes) do not scale. Truncate to an ellipsis (`md-truncate`) only when the full text is one tooltip or link away.
|
||||||
|
- When customising, change the typeface or tracking, never the sizes: component layout depends on them.
|
||||||
|
|
||||||
|
Sources: https://m3.material.io/styles/typography/type-scale-tokens · https://m3.material.io/styles/typography/applying-type · https://m3.material.io/styles/typography/fonts · https://m3.material.io/foundations/writing/text-resizing · https://m3.material.io/foundations/writing/text-truncation
|
||||||
|
|
||||||
|
## Motion
|
||||||
|
|
||||||
|
M3 Expressive moves on physics: every transition is a spring, and the library samples each spring into a CSS `linear()` easing paired with a duration. Use the pair together, or the curve is stretched over the wrong time.
|
||||||
|
|
||||||
|
| Spring | Damping / stiffness | Duration | In this library | For |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Spatial fast | 0.6 / 800 | 350ms | `var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast)` | small elements: a button's press morph, a switch, a chip |
|
||||||
|
| Spatial default | 0.8 / 380 | 500ms | `var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default)` | most position, size and shape changes |
|
||||||
|
| Spatial slow | 0.8 / 200 | 650ms | `var(--md-sys-motion-spatial-slow-duration) var(--md-sys-motion-spatial-slow)` | large surfaces: a sheet, a pane, a full-screen transition |
|
||||||
|
| Effects fast | 1.0 / 3800 | 150ms | `var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast)` | state layers, small fades |
|
||||||
|
| Effects default | 1.0 / 1600 | 200ms | `var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default)` | most colour and opacity changes |
|
||||||
|
| Effects slow | 1.0 / 800 | 300ms | `var(--md-sys-motion-effects-slow-duration) var(--md-sys-motion-effects-slow)` | large fades, a scrim |
|
||||||
|
|
||||||
|
A transition names the property, then the pair: `transition: transform var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default), opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast)`.
|
||||||
|
|
||||||
|
- Spatial springs are underdamped and overshoot — that bounce is what reads as Expressive — so they carry only position, size and shape. Effects springs are critically damped and carry colour and opacity, which must never overshoot. A transition on `all` mixes the two and is wrong.
|
||||||
|
- The Standard motion scheme (`<html data-motion="standard">`, config `motion.scheme`) swaps the spatial springs for stiffer ones with almost no bounce (0.9 / 1400, 700, 300; 350, 500, 750ms) for utilitarian products; effects are shared.
|
||||||
|
- Direction: something entering decelerates (`--md-sys-motion-easing-emphasized-decelerate`, or a spatial spring from off-screen), a permanent exit accelerates (`--md-sys-motion-easing-emphasized-accelerate`), a temporary exit that can be recalled (a drawer, a sheet) takes `--md-sys-motion-easing-emphasized`; exits are shorter than entrances, and larger areas move longer.
|
||||||
|
- The cubic-bezier set (`--md-sys-motion-easing-standard`, `-emphasized`, `-emphasized-decelerate`, `-emphasized-accelerate`, with `--md-sys-motion-duration-short|medium|long`) is for the few transitions whose duration is fixed from outside: a view transition, an animated scroll.
|
||||||
|
- Reduced motion zeroes every duration token, so anything animated through them turns instant; a literal `300ms`, or a keyframe animation with its own timing, ignores the visitor's setting and is a bug. Container transforms, parallax and expansions are removed, not slowed.
|
||||||
|
|
||||||
|
Sources: https://m3.material.io/styles/motion/overview · https://m3.material.io/styles/motion/overview/specs · https://m3.material.io/styles/motion/easing-and-duration/tokens-specs · https://m3.material.io/styles/motion/transitions/transition-patterns
|
||||||
|
|
||||||
|
## States and targets
|
||||||
|
|
||||||
|
| State | Layer | Class or hook | Also |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Enabled | none | — | |
|
||||||
|
| Hover | 8% of the content colour | `md-state-layer` (pointer devices only) | one level of elevation on floating elements |
|
||||||
|
| Focused | 10% | `md-state-layer md-focus-ring` (keyboard focus: a 3px `secondary` ring, 2px out) | only one focused element at a time |
|
||||||
|
| Pressed | 10% | `md-state-layer` (`:active`) | the shape morph on buttons |
|
||||||
|
| Dragged | 16% | `md-state-layer` with `data-md-dragged` | one level of elevation |
|
||||||
|
| Disabled | content 38%, container 12%, no state layer, not focusable | `color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent)`, and the container likewise with `--md-sys-state-disabled-container-opacity` | exempt from contrast; a FAB is hidden rather than disabled |
|
||||||
|
| Selected | the `secondary-container` pair, a filled icon, the emphasized style | component props (`selected`, `aria-selected`, `aria-pressed`) | combines with hover, focus and press |
|
||||||
|
|
||||||
|
- The state layer takes the content's `on-` colour (on `secondary-container` it is `on-secondary-container`), is 40px on a 48px target, and only one shows at a time. `md-state-layer` draws it in `currentColor` as a `::before`, so the element becomes `position: relative`.
|
||||||
|
- Every state shows two indicators, so a colour change alone is never a state: add a shape, an outline, an icon, a weight or a word (`aria-selected` plus the container, an error colour plus an icon and a message).
|
||||||
|
- Targets: 48×48px minimum, 8px between targets, on every device; `md-touch-target` extends a smaller drawing to 48px. Density is an opt-in prop (`dense`) that steps padding by 4px and never applies to menus, snackbars, dialogs or settings controls, and never takes a target below 48px.
|
||||||
|
- Keyboard: Tab and Shift+Tab between components in DOM order, arrows within a component (menu, tabs, grid, radio group), Enter and Space activate, Escape dismisses; a dialog moves focus in on open and back to its opener on close.
|
||||||
|
|
||||||
|
Sources: https://m3.material.io/foundations/interaction/states/state-layers · https://m3.material.io/foundations/interaction/states/applying-states · https://m3.material.io/foundations/designing/structure · https://m3.material.io/foundations/layout/grids-spacing/density
|
||||||
|
|
||||||
|
## Layout and breakpoints
|
||||||
|
|
||||||
|
Layout keys on the width of the window, in M3's five window size classes and only those. A layout component names the class in a prop (`hide-below`, `hide-from`, `stack-below`, `<x-grid>`'s `columns` map); the application's CSS writes the width as a range media query; a script asks `resources/js/breakpoints.js` (`from('expanded')`, `upTo('medium')`) for the same numbers.
|
||||||
|
|
||||||
|
| Class | Width | Prop value · CSS | Navigation | Panes | Dialogs and choices | Margins |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| Compact | below 600px | the default; `hide-from="medium"` for "only here" · `@media (width < 600px)` | navigation bar; the rail opens as a modal | 1 | full-screen or basic dialog; a bottom sheet for choices | 16px |
|
||||||
|
| Medium | 600–839px | `medium` · `@media (width >= 600px)` | collapsed rail (96px) | 1, or 2 for low-density content at 50% each | basic dialog; a menu for choices | 24px |
|
||||||
|
| Expanded | 840–1199px | `expanded` · `@media (width >= 840px)` | rail, collapsed or expanded, collapsible | 2 recommended; a fixed pane 360px | basic dialog; menu | 24px |
|
||||||
|
| Large | 1200–1599px | `large` · `@media (width >= 1200px)` | rail expanded | 2; a fixed pane 412px | basic dialog; menu | 24px |
|
||||||
|
| Extra-large | 1600px and up | `extra-large` · `@media (width >= 1600px)` | rail expanded | 2, or 3 with a standard side sheet (at most 400px) | basic dialog; menu | 24px |
|
||||||
|
|
||||||
|
- `<x-scaffold>` implements the navigation column; `<x-pane>` is a content region with the margins above; `<x-list-detail>` is the second pane of a list-detail layout from expanded, `<x-supporting-pane>` puts a supporting pane (360px, beside the focus pane) from expanded and below it before that. Moving up a class, ask what to reveal, divide into panes, resize, reposition or swap — never swap a component for one that does not do the same job.
|
||||||
|
- Scaffold: bars (app bar at the top, navigation bar at the bottom: 3–5 destinations), rails (the navigation rail, toolbars, the FAB, on the leading edge), panes (all content), around a safety region that stays clear of the device's own chrome (`--material-safe-top|bottom|left|right`).
|
||||||
|
- Canonical layouts: feed (`<x-feed>`, a grid of cards that gains columns as the room grows), list-detail (one pane on compact, two from expanded; a back button only in single-pane mode, a selected row only in two-pane mode), supporting pane (two thirds focus, one third support).
|
||||||
|
- Bidirectionality: write logical properties (`padding-inline-start`, `margin-inline-end`, `inset-inline-start`, `border-inline-start`, `md-text-start`); `<x-row>` runs in the inline direction and mirrors by itself; leading and trailing icons swap, directional icons (back, send) mirror, the rail moves to the right; charts, media controls, clocks and Hebrew progress bars stay left-to-right.
|
||||||
|
|
||||||
|
Sources: https://m3.material.io/foundations/layout/breakpoints/overview · https://m3.material.io/foundations/layout/breakpoints/compact (medium, expanded, large-extra-large) · https://m3.material.io/foundations/layout/scaffold/overview · https://m3.material.io/foundations/layout/canonical-examples/overview · https://m3.material.io/foundations/layout/bidirectionality-rtl
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
M3's spacing tokens are multiples of an 8px base on a 4px grid. A layout component takes the token's name (`gap="space200"`, `<x-surface padding="space300">`); the application's CSS reads it (`var(--md-sys-measurement-space200)`).
|
||||||
|
|
||||||
|
| Token | Value | In this library |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| space25 | 2px | `space25` |
|
||||||
|
| space50 | 4px | `space50` |
|
||||||
|
| space75 | 6px | `space75` |
|
||||||
|
| space100 | 8px (the base) | `space100` |
|
||||||
|
| space125 | 10px | `space125` |
|
||||||
|
| space200 | 16px | `space200` — a component's padding, compact margins |
|
||||||
|
| space300 | 24px | `space300` — a dialog's padding, margins from medium |
|
||||||
|
| space400 | 32px | `space400` |
|
||||||
|
| space500 | 40px | `space500` |
|
||||||
|
| space600 | 48px | `space600` — a target |
|
||||||
|
| space700 | 56px | `space700` |
|
||||||
|
| space800 | 64px | `space800` |
|
||||||
|
| space900 | 72px | `space900` |
|
||||||
|
|
||||||
|
- Padding and gaps live on the parent (`<x-surface padding="space200">` around `<x-stack gap="space100">`), never as margins on children; a margin is for space beyond a container's padding or between layout regions.
|
||||||
|
- Spacing does not scale with text: at 200% text size the same padding and gaps stay.
|
||||||
|
- Name a gap by what it separates when a component has several (icon–label 8px, label–supporting text 4px).
|
||||||
|
|
||||||
|
Sources: https://m3.material.io/styles/spacing/overview · https://m3.material.io/styles/spacing/tokens · https://m3.material.io/styles/spacing/applying-spacing
|
||||||
|
|
||||||
|
## Icons
|
||||||
|
|
||||||
|
`<x-icon name="lock">` draws a Material Symbol Rounded (weight 400, grade 0), outlined or `filled`, at optical size 24 or 20.
|
||||||
|
|
||||||
|
| Axis | Values | In this library |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Fill | 0 outlined, 1 filled | `filled` — active, selected or on state (a selected navigation item, a FAB's icon, a checked filter chip) |
|
||||||
|
| Weight | 100–700; never below 200 at 24px | 400 for every icon; one weight per group |
|
||||||
|
| Grade | −25 on dark backgrounds, 0 otherwise, positive for emphasis | 0 |
|
||||||
|
| Optical size | 20 dense, 24 standard, 40–48 with display type | `size="20"` and below pick the 20 cut (small buttons, chips, dense lists); `optical="20"` for an icon sized by the application's own CSS |
|
||||||
|
|
||||||
|
- An icon beside text takes the text's size and colour (`size="20"` beside `md-type-label-lg`, 24 beside body) and the same optical weight; its baseline sits about 11.5% of the text size below the text's.
|
||||||
|
- Icons stay flat and forward-facing, on the pixel grid, inside their 20px live area of the 24px canvas.
|
||||||
|
- An icon-only control has an accessible name (`aria-label`, or a tooltip that names it); a decorative icon is `aria-hidden`; a complex icon drawn below 20px needs a label beside it.
|
||||||
|
|
||||||
|
Sources: https://m3.material.io/styles/icons/overview · https://m3.material.io/styles/icons/designing-icons · https://m3.material.io/styles/icons/applying-icons
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
The guideline's own Accessibility line has the rule; beyond it: every repeated landmark —
|
||||||
|
`search`, `complementary`, `form`, `region`, not just `nav` — is labelled the same way; an
|
||||||
|
ambiguous button ("Save", "Learn more") needs a name that says what it does, not just what kind
|
||||||
|
of control it is; DOM order is reading order, a dialog returns focus to its opener, and a group of
|
||||||
|
related controls is one Tab stop with the arrows moving inside it; an invalid field also carries
|
||||||
|
`aria-invalid`, and a loading state has a name too.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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-*`
|
||||||
|
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
|
||||||
|
sections above have it.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
The rules, tables and wording here are Google's, condensed from the Material Design 3 documentation at https://m3.material.io (Foundations, Styles and Components), which Google publishes under the Creative Commons Attribution 4.0 License except as otherwise noted; the numeric token values are from the Android Open Source Project's Material 3 token files in androidx Compose (Apache License 2.0). Copyright Google LLC; Copyright The Android Open Source Project. The library's `NOTICE` records the same. Dates and page names are those of the site as read on 2026-09-13; the full extracted references, with every source page, are kept in the package repository under `docs/reference/m3/`.
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
---
|
|
||||||
name: tailwindcss-development
|
|
||||||
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
|
|
||||||
license: MIT
|
|
||||||
metadata:
|
|
||||||
author: laravel
|
|
||||||
---
|
|
||||||
|
|
||||||
# Tailwind CSS Development
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
|
|
||||||
|
|
||||||
## Basic Usage
|
|
||||||
|
|
||||||
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
|
||||||
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
|
||||||
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
|
||||||
|
|
||||||
## Tailwind CSS v4 Specifics
|
|
||||||
|
|
||||||
- Always use Tailwind CSS v4 and avoid deprecated utilities.
|
|
||||||
- `corePlugins` is not supported in Tailwind v4.
|
|
||||||
|
|
||||||
### CSS-First Configuration
|
|
||||||
|
|
||||||
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
|
|
||||||
|
|
||||||
<!-- CSS-First Config -->
|
|
||||||
```css
|
|
||||||
@theme {
|
|
||||||
--color-brand: oklch(0.72 0.11 178);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Import Syntax
|
|
||||||
|
|
||||||
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
|
|
||||||
|
|
||||||
<!-- v4 Import Syntax -->
|
|
||||||
```diff
|
|
||||||
- @tailwind base;
|
|
||||||
- @tailwind components;
|
|
||||||
- @tailwind utilities;
|
|
||||||
+ @import "tailwindcss";
|
|
||||||
```
|
|
||||||
|
|
||||||
### Replaced Utilities
|
|
||||||
|
|
||||||
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
|
|
||||||
|
|
||||||
| Deprecated | Replacement |
|
|
||||||
|------------|-------------|
|
|
||||||
| bg-opacity-* | bg-black/* |
|
|
||||||
| text-opacity-* | text-black/* |
|
|
||||||
| border-opacity-* | border-black/* |
|
|
||||||
| divide-opacity-* | divide-black/* |
|
|
||||||
| ring-opacity-* | ring-black/* |
|
|
||||||
| placeholder-opacity-* | placeholder-black/* |
|
|
||||||
| flex-shrink-* | shrink-* |
|
|
||||||
| flex-grow-* | grow-* |
|
|
||||||
| overflow-ellipsis | text-ellipsis |
|
|
||||||
| decoration-slice | box-decoration-slice |
|
|
||||||
| decoration-clone | box-decoration-clone |
|
|
||||||
|
|
||||||
## Spacing
|
|
||||||
|
|
||||||
Use `gap` utilities instead of margins for spacing between siblings:
|
|
||||||
|
|
||||||
<!-- Gap Utilities -->
|
|
||||||
```html
|
|
||||||
<div class="flex gap-8">
|
|
||||||
<div>Item 1</div>
|
|
||||||
<div>Item 2</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dark Mode
|
|
||||||
|
|
||||||
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
|
|
||||||
|
|
||||||
<!-- Dark Mode -->
|
|
||||||
```html
|
|
||||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
|
||||||
Content adapts to color scheme
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
|
|
||||||
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
|
||||||
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
|
||||||
- Using margins for spacing between siblings instead of gap utilities
|
|
||||||
- Forgetting to add dark mode variants when the project uses dark mode
|
|
||||||
+16
-1
@@ -69,5 +69,20 @@ VITE_APP_NAME="${APP_NAME}"
|
|||||||
# OCTANE_HTTPS=false
|
# OCTANE_HTTPS=false
|
||||||
# OCTANE_MAX_EXECUTION_TIME=300
|
# OCTANE_MAX_EXECUTION_TIME=300
|
||||||
|
|
||||||
# Docker (used only when deploying with docker-compose.yml)
|
# Uploads: each encrypted chunk the browser sends, in MB
|
||||||
|
# UPLOAD_CHUNK_SIZE_MB=16
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -67,12 +67,14 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
# The runner is an arm64 server, so the amd64 image is emulated. On its 6.8 kernel, recent
|
# The runner is an arm64 server, so the amd64 image's final stage is emulated. QEMU 8.x
|
||||||
# QEMU builds segfault compiling PHP extensions (docker/buildx#3170); QEMU 8 is pinned.
|
# crashes running x86_64 programs on an arm64 host (QEMU issue 2168, "QEMU internal
|
||||||
|
# SIGSEGV {code=MAPERR, addr=0x20}") and 10.2 segfaults on this runner too; 9.2.2 was
|
||||||
|
# checked on the runner's host: node, composer and install-php-extensions all run.
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
with:
|
with:
|
||||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
image: tonistiigi/binfmt:qemu-v9.2.2
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|||||||
@@ -27,3 +27,6 @@ frankenphp
|
|||||||
frankenphp-worker.php
|
frankenphp-worker.php
|
||||||
|
|
||||||
/tests/Browser/Screenshots
|
/tests/Browser/Screenshots
|
||||||
|
|
||||||
|
# Planning notes stay local
|
||||||
|
/docs/plans
|
||||||
|
|||||||
+61
-1
@@ -7,6 +7,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- The admin dashboard shows the installed SealShare version, with links to its release notes, the SealShare website and noNameWEB.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Docker installs updated from 2.0 answered every upload with "409 Conflict". The example `docker-compose.yml` mounted the SQLite volume over all of `/app/database`, which hid the image's new migration, so it never ran. The container now adds the migrations the volume is missing before migrating, so existing compose files keep working.
|
||||||
|
- A share with several files and a download limit was deleted as soon as one file was downloaded: every single 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. To fix 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.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `docker-compose.example.yml` mounts `sealshare_database` at `/app/database/sqlite` and sets `DB_DATABASE` to the file in it. To switch an existing install, mount the same volume there and set `DB_DATABASE: /app/database/sqlite/database.sqlite` in both services; the database is kept.
|
||||||
|
- A share that reached 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 still 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", marks shares at their limit as "Download limit reached" and no longer counts them as active.
|
||||||
|
- The sort dropdown on the admin dashboard spans the full width of the shares card.
|
||||||
|
- Development: `docker-compose.dev.yml` now extends `docker-compose.yml`, so the dev stack runs the scheduler and the production image's PHP extensions, and takes its settings from `.env` (which selects the file through `COMPOSE_FILE`). A Vite dev server with hot reload runs beside the app. No ports are published unless `docker-compose.ports.yml` is added; with OrbStack the app is at `https://app.sealshare.orb.local`. `docker/dev.Dockerfile` became the `dev` stage of the `Dockerfile`.
|
||||||
|
- The Docker image's PHP limits (`PHP_UPLOAD_MAX_FILESIZE`, `PHP_POST_MAX_SIZE`, `PHP_MAX_EXECUTION_TIME`, `PHP_MAX_INPUT_TIME`, `PHP_MEMORY_LIMIT`) are read by PHP itself from the environment; the entrypoint no longer writes an ini file on start. The variables and their defaults are unchanged.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Email verification (`/email/verify`), which was never enforced: SealShare has a single admin account and no registration.
|
||||||
|
- The `composer dev` script: development runs in Docker (`docker-compose.dev.yml`).
|
||||||
|
|
||||||
|
## [2.1.0] - 2026-09-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A password generator for share passwords, with a copy button. The password is shown once more beside the new link. Admins can turn it off, or switch between random characters and a passphrase, in Admin settings.
|
||||||
|
- `AUTO_HTTPS` for the Docker image: set it to `"true"` with `SERVER_NAME` to get a Let's Encrypt certificate and serve HTTPS. Without it the container serves plain HTTP on port 80, as before.
|
||||||
|
- `UPLOAD_CHUNK_SIZE_MB` sets the size of each upload chunk (default 16).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Breaking: uploads need HTTPS.** Files are now encrypted in the browser and uploaded in chunks, which browsers only allow over HTTPS or on `localhost`. Over plain HTTP downloads still work, but uploads don't. Use `AUTO_HTTPS` or a reverse proxy that terminates TLS.
|
||||||
|
- Large uploads are much faster: each chunk is written to disk once, already encrypted, and a failed chunk is retried.
|
||||||
|
- Each share has its own random key; with a share password it is protected with Argon2id instead of PBKDF2. Existing shares keep working.
|
||||||
|
- PHP's upload limits no longer cap the share file size. `PHP_UPLOAD_MAX_FILESIZE` and `PHP_POST_MAX_SIZE` default to `64M`, and `LIVEWIRE_MAX_UPLOAD_TIME` is no longer needed.
|
||||||
|
- Unfinished uploads count towards the storage quota and are deleted after 4 hours.
|
||||||
|
- The interface moves to [Livewire Material](https://gitea.nonameweb.ch/noNameWEB/livewire-material) 2.1.0 and no longer ships Tailwind CSS. Every page uses the same single-column layout of cards, and the admin dashboard lists shares instead of a table. Colour profiles and light/dark choices carry over.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- "Download all" works for large shares: the ZIP is streamed instead of being built in memory and written unencrypted to a temporary file.
|
||||||
|
- Unencrypted copies of uploads no longer stay behind in Livewire's temporary folder; the hourly cleanup removes old ones.
|
||||||
|
- Removed the unused `docker/Caddyfile`.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- An encrypted file with missing or reordered chunks now fails to decrypt.
|
||||||
|
|
||||||
|
## [2.0.1] - 2026-09-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The 2.0.0 Docker image did not start: the entrypoint's `php artisan view:cache` failed with "Unable to locate a class or view for component [showcase::example]", because Livewire Material only registered its showcase components while the showcase was enabled, which it is not in production. Livewire Material 1.1.1 registers them always, and a test now caches every view as the entrypoint does.
|
||||||
|
|
||||||
## [2.0.0] - 2026-09-13
|
## [2.0.0] - 2026-09-13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -107,5 +166,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Dark themed UI built with Livewire, Alpine.js, Tailwind CSS and DaisyUI.
|
- 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.
|
||||||
|
|
||||||
[Unreleased]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.0...main
|
[2.1.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.1...v2.1.0
|
||||||
|
[2.0.1]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.0...v2.0.1
|
||||||
[2.0.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/releases/tag/v2.0.0
|
[2.0.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/releases/tag/v2.0.0
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
|||||||
## Project Rules
|
## Project Rules
|
||||||
|
|
||||||
- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it.
|
- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it.
|
||||||
- Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo.
|
- Record a rule with `record-rule` only when the user explicitly asks for one. Instructions for the work at hand are not rules, no matter how emphatic: "remove this typo", "use X here" are work to do, not rules to record. Never record a rule on your own initiative, as a byproduct of a change, or to summarize what you just did. When the user does ask, pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Use `record-rule` rather than your native memory or notes tool, because native memory is personal and session-scoped, while only `.ai/rules` is shared with the team and persists in the repo.
|
||||||
|
|
||||||
## Artisan
|
## Artisan
|
||||||
|
|
||||||
@@ -109,8 +109,9 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
|||||||
|
|
||||||
# Test Enforcement
|
# Test Enforcement
|
||||||
|
|
||||||
- Test every code change by adding or updating a test.
|
- Add or update tests for behavior and logic changes when a test provides meaningful regression coverage.
|
||||||
- Run the affected tests and ensure they pass.
|
- Pure copy, styling, and layout-only changes do not require new or updated tests.
|
||||||
|
- When test coverage applies, run the affected tests and ensure they pass.
|
||||||
- Test the changed behavior and its important failure modes, but do not add tests beyond them.
|
- Test the changed behavior and its important failure modes, but do not add tests beyond them.
|
||||||
- Read the `testing-best-practices` skill before writing tests.
|
- Read the `testing-best-practices` skill before writing tests.
|
||||||
|
|
||||||
@@ -191,12 +192,86 @@ 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, built on Tailwind CSS. It replaces UI kits such as maryUI, daisyUI and Flux 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.
|
||||||
- Never write maryUI tags (`<x-mary-*>`) or daisyUI classes (`btn`, `card`, `badge`, `bg-base-200`, `text-base-content`…). They compile to nothing and fail silently.
|
- 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`.
|
||||||
- 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`.
|
||||||
|
|
||||||
|
=== nonameweb/livewire-material/material-3 rules ===
|
||||||
|
|
||||||
|
## Material 3
|
||||||
|
|
||||||
|
Every view in this application is Material 3 Expressive (m3.material.io), through `nonameweb/livewire-material`. These rules decide what to write; the `material-3-design` skill carries the tables, the numbers and Google's source pages behind each one — activate it before designing a screen.
|
||||||
|
|
||||||
|
The library is plain CSS on M3's tokens. 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.
|
||||||
|
- A fixed set of classes for text and interaction on plain elements: `md-type-*`, `md-ink-*`, `md-text-*`, `md-truncate`, `md-tabular`, `md-visually-hidden`, `md-state-layer`, `md-focus-ring`, `md-touch-target` and `md-link`.
|
||||||
|
- The application's own CSS, named by the application, whose values are `--md-sys-*` custom properties.
|
||||||
|
|
||||||
|
### Colour
|
||||||
|
|
||||||
|
- A colour is always a role: `md-ink-variant` on text, `var(--md-sys-color-outline-variant)` in the application's CSS, `color="error"` on a component. Never a hex, a palette tone or an opacity.
|
||||||
|
- Pair a role only with its `on-` partner: a `primary` fill takes `on-primary` text, a `secondary-container` fill takes `on-secondary-container`. That pair is the one whose contrast is guaranteed at every contrast level; mixing pairs (`primary-container` under `on-surface`) is not.
|
||||||
|
- `primary` is the one key action on a screen (a filled button; the FAB in `primary-container`). `secondary-container` is the quiet fill (tonal buttons, selected navigation, selected chips). `tertiary` is a contrasting accent, used rarely. `error`, `success`, `warning`, `info` mean state and nothing else: the `-container` for a tinted panel, the role itself for its text and icon.
|
||||||
|
- Ink is `on-surface` (`md-ink`); lower emphasis is `on-surface-variant` (`md-ink-variant`); decoration is `outline` (`md-ink-quiet`). Never dim ink with an opacity: 38% means disabled.
|
||||||
|
- `outline` is a boundary that must be read (a text field, the edge of a target). `outline-variant` is a divider or a card edge (`<x-divider>`, `<x-surface outlined>`). Never `outline` on a divider.
|
||||||
|
- Fixed and dim roles (`primary-fixed`, `surface-dim`, …) are for a colour that must not change with the theme; if unsure, don't. Inverse roles only on an inverse surface (the snackbar).
|
||||||
|
- A link in running text is underlined (`md-link`, with `md-ink-primary`); colour alone signals nothing.
|
||||||
|
- Contrast: 4.5:1 for text, 3:1 for large text, icons and grouped controls; disabled is exempt. Three contrast levels exist (`<html data-contrast>`: standard, medium, high) and every role changes with them — which is why only roles are allowed.
|
||||||
|
|
||||||
|
### Surfaces and elevation
|
||||||
|
|
||||||
|
- The page is `surface`. Panels separate by tone first: `surface-container-lowest` … `surface-container-highest` is a hierarchy of emphasis, not of height (`<x-surface level="surface-container-high">`). Navigation chrome is `surface-container`; a dialog, a menu, the search bar are `surface-container-high`; a modal sheet is `surface-container-low`; a filled card is `surface-container-highest`. A region keeps its role at every width.
|
||||||
|
- Shadows (`var(--md-sys-elevation-1)` … `-5`) are for what floats or lifts: 1 for elevated cards, buttons and modal sheets; 2 for menus, the navigation bar, a scrolled app bar; 3 for the FAB, dialogs, pickers and search; one level more on hover; nothing rests above 3. Fewer shadows carry more meaning.
|
||||||
|
- A scrim is `scrim` at 32%: `color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent)`.
|
||||||
|
|
||||||
|
### Shape
|
||||||
|
|
||||||
|
- Corners come from the scale `var(--md-sys-shape-corner-{none|xs|sm|md|lg|lg-increased|xl|xl-increased|xxl|full})`, or `<x-surface corner="md">`; never a length of your own.
|
||||||
|
- By family: `full` buttons, icon buttons, chips' avatars, badges, switches, sliders, the search bar, navigation indicators; `xs` text fields, menus, snackbars, plain tooltips; `sm` chips; `md` cards, rich tooltips; `lg` the FAB and a side sheet's inner corners; `xl` dialogs, bottom sheets, the search view, pickers, carousel items; `xxl` large hero containers.
|
||||||
|
- Nested shapes: inner radius = outer radius − padding; never the same radius inside and out.
|
||||||
|
- A press squares a round shape (the components do it; nothing morphs on hover). The 35 `<x-shape>`s are decoration, never meaning, used sparingly.
|
||||||
|
|
||||||
|
### Type
|
||||||
|
|
||||||
|
- Every text element carries one `md-type-*` class: `display` for hero figures and short marketing lines; `headline` for page and section titles; `title` for card, dialog and list-section titles; `body` for paragraphs (`md-type-body-lg` for reading); `label` inside components (buttons, chips, tabs, captions). In the application's CSS a style is `font: var(--md-sys-typescale-body-md)` with its `-tracking`; never a size, weight, line height or letter spacing of your own.
|
||||||
|
- `md-type-emphasized-*` is opt-in: a selected item, a primary action, a headline, a badge — not decoration.
|
||||||
|
- 40–60 characters per line; `md-tabular` on figures that change; text must scale to 200% without loss (containers grow, rows wrap, no fixed heights on text, no ellipsis without a way to read the rest).
|
||||||
|
|
||||||
|
### Motion
|
||||||
|
|
||||||
|
- Position, size and shape move on the spatial springs (they overshoot): `transition: transform var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default)` — `fast` for small elements, `slow` for large ones. Colour and opacity move on the effects springs (`--md-sys-motion-effects-*`), which never overshoot. Always pair an easing with its duration.
|
||||||
|
- Entering decelerates, a permanent exit accelerates, a temporary exit (a sheet, a drawer) takes the emphasized curve; exits are shorter than entrances.
|
||||||
|
- Everything that moves goes through these tokens, so reduced motion makes it instant; a literal duration is a bug.
|
||||||
|
|
||||||
|
### States and targets
|
||||||
|
|
||||||
|
- Interactive elements carry `md-state-layer md-focus-ring`: hover 8%, focus 10%, pressed 10%, dragged 16% (`data-md-dragged`) of the content colour. Disabled is content at 38% and a container at 12% of `on-surface` (`--md-sys-state-disabled-content-opacity`, `--md-sys-state-disabled-container-opacity`, through `color-mix()`), with no state layer. Every state shows two indicators: colour plus a shape, an outline, an icon or a word.
|
||||||
|
- Every target is at least 48×48px with 8px between targets (`md-touch-target` on anything drawn smaller); a denser layout is an opt-in prop, never a default.
|
||||||
|
- Keyboard: Tab between components, arrows within one, Enter and Space activate, Escape dismisses; a dialog takes focus and gives it back to what opened it.
|
||||||
|
|
||||||
|
### Layout and breakpoints
|
||||||
|
|
||||||
|
- Widths are M3's window size classes and only those: compact below 600px (the default), medium 600, expanded 840, large 1200, extra-large 1600. A layout component takes them as props (`<x-row stack-below="medium">`, `<x-stack hide-from="expanded">`, `<x-grid :columns="['compact' => 1, 'expanded' => 2]">`); the application's CSS writes `@media (width >= 840px)`; a script asks `from()` and `upTo()` from `resources/js/breakpoints.js`.
|
||||||
|
- What changes per class: compact — navigation bar, one pane, full-screen dialogs, a bottom sheet for choices; medium — collapsed rail, one pane; expanded — rail (collapsible), two panes, menus and basic dialogs; large and extra-large — the rail expanded, two panes, a third only at extra-large as a side sheet. `<x-scaffold>` does this; content lives in panes (`<x-pane>`, `<x-list-detail>` for a list's second pane), never beside the rail by hand.
|
||||||
|
- Margins are 16px below medium and 24px from it (`<x-pane>` draws them); spacing sits on the 4px grid as `space25` … `space900`, as padding and gaps on the parent, with margins only between layout regions. A fixed pane is 360px (expanded) or 412px (large); a side sheet at most 400px.
|
||||||
|
- Write logical properties (`padding-inline-start`, `inset-inline-end`, `md-text-start`); directional icons mirror in RTL; charts and media controls stay LTR. Keep controls inside the safe area (`--material-safe-*`).
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
|
||||||
|
- Native elements first (`<button>`, `<dialog>`, `<input>`), then ARIA. One `main`, one `banner`, one `contentinfo`; every repeated `nav` labelled, without the word "navigation".
|
||||||
|
- Headings in order from a single H1; the level is structure, the `md-type-*` class is appearance.
|
||||||
|
- An icon-only control has an accessible name that does not include its role; decorative icons are hidden; an error is announced and tied to its field (`aria-describedby`); a toast uses a polite live region and never takes focus. A single-key shortcut needs a modifier or a focused component.
|
||||||
|
|
||||||
|
### Icons
|
||||||
|
|
||||||
|
- `<x-icon name="home">` is a Material Symbol Rounded: `filled` means active or selected, `optical="20"` when drawn at 20px or less, one weight per group, the size and colour of the text beside it.
|
||||||
|
|
||||||
|
### Don'ts
|
||||||
|
|
||||||
|
- No icon in a snackbar; no disabled FAB (hide it); no horizontal radio rows; no hover morph on cards; no `outline` on dividers; no hex colours; no utility classes, and no breakpoint, radius, shadow, type size or duration off M3's scales; no segmented buttons, navigation drawer or bottom app bar — use `<x-button-group connected>`, the expanded rail and `<x-toolbar>`.
|
||||||
|
|
||||||
</laravel-boost-guidelines>
|
</laravel-boost-guidelines>
|
||||||
|
|||||||
+50
-23
@@ -1,7 +1,9 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
# Stage 1: Install PHP dependencies
|
# Stage 1: Install PHP dependencies
|
||||||
# ============================================
|
# ============================================
|
||||||
FROM composer:2 AS vendor
|
# Built on the build machine's own platform: vendor/ is plain PHP, the same for every target, so a
|
||||||
|
# multi-arch build runs it once and never under emulation.
|
||||||
|
FROM --platform=$BUILDPLATFORM composer:2 AS vendor
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -21,8 +23,9 @@ RUN composer dump-autoload --optimize --no-dev
|
|||||||
# ============================================
|
# ============================================
|
||||||
# Stage 2: Build frontend assets
|
# Stage 2: Build frontend assets
|
||||||
# ============================================
|
# ============================================
|
||||||
# After Composer: the stylesheet and script import Livewire Material from vendor/.
|
# After Composer: the stylesheet and script import Livewire Material from vendor/. On the build
|
||||||
FROM node:24-alpine AS assets
|
# machine's platform too: the output is CSS and JavaScript, whatever the target.
|
||||||
|
FROM --platform=$BUILDPLATFORM node:24-alpine AS assets
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -36,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" \
|
||||||
@@ -66,14 +97,6 @@ ENV APP_NAME="SealShare" \
|
|||||||
BCRYPT_ROUNDS="12" \
|
BCRYPT_ROUNDS="12" \
|
||||||
OCTANE_SERVER="frankenphp"
|
OCTANE_SERVER="frankenphp"
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy Caddyfile
|
|
||||||
COPY docker/Caddyfile /etc/caddy/Caddyfile
|
|
||||||
|
|
||||||
# Copy PHP ini for upload limits
|
|
||||||
COPY docker/php/uploads.ini /usr/local/etc/php/conf.d/99-uploads.ini
|
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
@@ -84,23 +107,27 @@ 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.1.1 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
|
||||||
|
|
||||||
# Make entrypoint executable
|
# Make entrypoint and healthcheck executable
|
||||||
RUN chmod +x docker/entrypoint.sh
|
RUN chmod +x docker/entrypoint.sh docker/healthcheck.sh
|
||||||
|
|
||||||
EXPOSE 80 443 443/udp
|
EXPOSE 80 443 443/udp
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD curl --silent --fail http://localhost/up || exit 1
|
CMD /app/docker/healthcheck.sh
|
||||||
|
|
||||||
ENTRYPOINT ["docker/entrypoint.sh"]
|
ENTRYPOINT ["docker/entrypoint.sh"]
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ A simple, self-hosted file sharing solution built with Laravel. Upload files, ge
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **File Uploading** — Drag & drop or browse to upload single/multiple files and folders with real-time progress
|
- **File Uploading** — Drag & drop or browse to upload single/multiple files and folders with real-time progress; large files go up in chunks, each retried on its own if the connection drops
|
||||||
- **Shareable Links** — Each upload generates a unique link for recipients, also as a QR code (saved as a PNG) or through the device's share sheet
|
- **Shareable Links** — Each upload generates a unique link for recipients, also as a QR code (saved as a PNG) or through the device's share sheet
|
||||||
- **Encryption at Rest** — Files are encrypted on the server as they arrive, with AES-256-GCM (chunked, streaming); with a share password the key is derived from it and never stored. It is not end-to-end encryption: the server handles the files unencrypted while they are uploaded and downloaded
|
- **Encryption at Rest** — Files are encrypted in the uploader's browser, chunk by chunk with AES-256-GCM, before they are sent, and are stored only in encrypted form; with a share password the share's key is wrapped with a key derived from it (Argon2id) and never stored as it is. It is not end-to-end encryption: the server issues the key, checks each chunk, and decrypts the files for downloads
|
||||||
- **Password Protection** — Optionally protect shares with a password
|
- **Password Protection** — Optionally protect shares with a password, typed or generated (random characters or a passphrase, as the admin configures) and copied on the upload page or next to the new link
|
||||||
- **Expiration** — Shares auto-expire after a configurable duration (1 hour to 30 days)
|
- **Expiration** — Shares auto-expire after a configurable duration (1 hour to 30 days)
|
||||||
- **Download Limits** — Set a maximum number of downloads per share
|
- **Download Limits** — Set a maximum number of downloads per share
|
||||||
- **ZIP Downloads** — Download all files in a share as a single ZIP archive
|
- **ZIP Downloads** — Download all files in a share as a single ZIP archive, streamed as it is built, whatever the files' size
|
||||||
- **Auto-Cleanup** — Expired shares and files are automatically deleted (hourly)
|
- **Auto-Cleanup** — Expired shares and files are automatically deleted (hourly)
|
||||||
- **Admin Dashboard** — View, manage, and delete all shares
|
- **Admin Dashboard** — View, manage, and delete all shares
|
||||||
- **Admin Settings** — Configure upload limits, storage quotas, branding, and more
|
- **Admin Settings** — Configure upload limits, storage quotas, branding, and more
|
||||||
@@ -40,10 +40,10 @@ 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 with PBKDF2-SHA256 key derivation |
|
| **Encryption** | Chunked AES-256-GCM (WebCrypto in the browser), keys wrapped with Argon2id |
|
||||||
| **ZIP Downloads** | Native PHP ZipArchive |
|
| **ZIP Downloads** | [ZipStream-PHP](https://packagist.org/packages/maennchen/zipstream-php) |
|
||||||
| **Testing** | Pest 5 with browser tests (Playwright) |
|
| **Testing** | Pest 5 with browser tests (Playwright) |
|
||||||
| **Code Style** | Laravel Pint |
|
| **Code Style** | Laravel Pint |
|
||||||
| **Build Tool** | Vite |
|
| **Build Tool** | Vite |
|
||||||
@@ -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
|
||||||
@@ -75,7 +95,7 @@ cp docker-compose.example.yml docker-compose.yml
|
|||||||
# Generate an app key and paste it into docker-compose.yml
|
# Generate an app key and paste it into docker-compose.yml
|
||||||
docker run --rm gitea.nonameweb.ch/nonameweb/sealshare:latest php artisan key:generate --show
|
docker run --rm gitea.nonameweb.ch/nonameweb/sealshare:latest php artisan key:generate --show
|
||||||
|
|
||||||
# Edit docker-compose.yml — set APP_KEY, APP_URL, and SERVER_NAME
|
# Edit docker-compose.yml — set APP_KEY and APP_URL, and choose how HTTPS is served (below)
|
||||||
# Then start:
|
# Then start:
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
@@ -88,29 +108,34 @@ Migrations run automatically on startup. Open your configured domain — the Set
|
|||||||
|----------|----------|-------------|
|
|----------|----------|-------------|
|
||||||
| `APP_KEY` | Yes | Laravel encryption key |
|
| `APP_KEY` | Yes | Laravel encryption key |
|
||||||
| `APP_URL` | Yes | Full URL (e.g. `https://share.example.com`) |
|
| `APP_URL` | Yes | Full URL (e.g. `https://share.example.com`) |
|
||||||
| `SERVER_NAME` | Yes | Domain for auto-TLS (e.g. `share.example.com`) |
|
| `AUTO_HTTPS` | No | `true` to fetch a Let's Encrypt certificate for `SERVER_NAME` and serve HTTPS on port 443 (port 80 redirects); default `false`, plain HTTP on port 80 for a reverse proxy |
|
||||||
|
| `SERVER_NAME` | With `AUTO_HTTPS` | The domain to fetch the certificate for (e.g. `share.example.com`) |
|
||||||
|
| `UPLOAD_CHUNK_SIZE_MB` | No | Size of each encrypted chunk the browser sends; default `16` |
|
||||||
|
|
||||||
|
**HTTPS is required for uploads.** Files are encrypted in the uploader's browser with WebCrypto, which browsers only offer over HTTPS or on `localhost`; over plain HTTP the upload page says so and takes no files (downloads keep working). Either set `AUTO_HTTPS: "true"` with `SERVER_NAME` — ports 80 and 443 must be reachable from the internet — or put a reverse proxy that terminates TLS in front of port 80.
|
||||||
|
|
||||||
**Volumes:**
|
**Volumes:**
|
||||||
|
|
||||||
| 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.1.1 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:**
|
||||||
|
|
||||||
Uploads beyond the defaults need these limits raised together:
|
Files go up in chunks of `UPLOAD_CHUNK_SIZE_MB`, one request each, so PHP's upload limits and a proxy's request timeout do not limit a file's size. What does:
|
||||||
|
|
||||||
| Limit | Where | Default |
|
| Limit | Where | Default |
|
||||||
|-------|-------|---------|
|
|-------|-------|---------|
|
||||||
| `PHP_UPLOAD_MAX_FILESIZE` / `PHP_POST_MAX_SIZE` | Environment | `4G` — hard cap per file / per upload batch |
|
|
||||||
| Max file size / Max size per share | Admin → Settings | 100 MB / 2 GB |
|
| Max file size / Max size per share | Admin → Settings | 100 MB / 2 GB |
|
||||||
| `LIVEWIRE_MAX_UPLOAD_TIME` | Environment | 30 minutes per upload |
|
| Storage quota | Admin → Settings | 20 GB — files still uploading count towards it |
|
||||||
| `OCTANE_MAX_EXECUTION_TIME` / `PHP_MAX_EXECUTION_TIME` | Environment | 300 seconds — encrypting a large file takes a while |
|
| `UPLOAD_CHUNK_SIZE_MB` | Environment | `16` |
|
||||||
|
|
||||||
Behind a reverse proxy, raise its request body limit and read timeout as well (nginx: `client_max_body_size`, `proxy_read_timeout`).
|
Behind a reverse proxy, its request body limit must be a little larger than a chunk (nginx: `client_max_body_size 32m;`), and `proxy_request_buffering off;` keeps nginx from writing each chunk to its own temporary files. `PHP_UPLOAD_MAX_FILESIZE` / `PHP_POST_MAX_SIZE` (default `64M`) only apply to the admin's logo upload. An upload no chunk reached for 4 hours is deleted by the hourly cleanup.
|
||||||
|
|
||||||
### Manual (without Docker)
|
### Manual (without Docker)
|
||||||
|
|
||||||
@@ -152,3 +177,5 @@ Add the scheduler to your crontab:
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
This project is open-source software licensed under the [MIT License](LICENSE).
|
This project is open-source software licensed under the [MIT License](LICENSE).
|
||||||
|
|
||||||
|
Generated passphrases draw from the [EFF Large Wordlist](https://www.eff.org/deeplinks/2016/07/new-wordlists-random-passphrases) by the Electronic Frontier Foundation, licensed under [CC BY 3.0 US](https://creativecommons.org/licenses/by/3.0/us/) (`resources/wordlists/eff-large-wordlist.txt`, without its four hyphenated words).
|
||||||
|
|||||||
@@ -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),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,30 +5,84 @@ namespace App\Console\Commands;
|
|||||||
use App\Models\Share;
|
use App\Models\Share;
|
||||||
use App\Services\ShareService;
|
use App\Services\ShareService;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Livewire\Features\SupportFileUploads\FileUploadConfiguration;
|
||||||
|
|
||||||
class CleanupExpiredShares extends Command
|
class CleanupExpiredShares extends Command
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* How long an upload or a temporary upload file may sit untouched before it is deleted.
|
||||||
|
*/
|
||||||
|
private const ABANDONED_AFTER_HOURS = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 and shares that have reached their download limit';
|
protected $description = 'Delete expired shares, shares that have reached their download limit, abandoned uploads and old temporary uploads';
|
||||||
|
|
||||||
public function handle(ShareService $shareService): int
|
public function handle(ShareService $shareService): int
|
||||||
{
|
{
|
||||||
$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();
|
||||||
|
|
||||||
$count = $expiredShares->count();
|
|
||||||
|
|
||||||
foreach ($expiredShares as $share) {
|
foreach ($expiredShares as $share) {
|
||||||
$shareService->deleteShare($share);
|
$shareService->deleteShare($share);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info("Cleaned up {$count} expired share(s).");
|
$this->info("Cleaned up {$expiredShares->count()} expired share(s).");
|
||||||
|
|
||||||
|
// A page that stopped sending chunks: closed, crashed or left behind.
|
||||||
|
$abandonedUploads = Share::query()
|
||||||
|
->whereNull('completed_at')
|
||||||
|
->where('updated_at', '<', now()->subHours(self::ABANDONED_AFTER_HOURS))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($abandonedUploads as $share) {
|
||||||
|
$shareService->deleteShare($share);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Cleaned up {$abandonedUploads->count()} abandoned upload(s).");
|
||||||
|
$this->info('Cleaned up '.$this->deleteOldTemporaryUploads().' temporary upload file(s).');
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Livewire's temporary uploads past the same age: the admin logo's, and the unencrypted
|
||||||
|
* copies uploads left there before files were encrypted in the browser.
|
||||||
|
*/
|
||||||
|
private function deleteOldTemporaryUploads(): int
|
||||||
|
{
|
||||||
|
if (FileUploadConfiguration::isUsingS3()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$storage = FileUploadConfiguration::storage();
|
||||||
|
$cutoff = now()->subHours(self::ABANDONED_AFTER_HOURS)->getTimestamp();
|
||||||
|
$deleted = 0;
|
||||||
|
|
||||||
|
foreach ($storage->allFiles(FileUploadConfiguration::path()) as $path) {
|
||||||
|
if ($storage->exists($path) && $storage->lastModified($path) < $cutoff) {
|
||||||
|
$storage->delete($path);
|
||||||
|
$deleted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $deleted;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ use App\Models\Share;
|
|||||||
use App\Models\ShareFile;
|
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 Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
||||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
use ZipArchive;
|
use ZipStream\CompressionMethod;
|
||||||
|
use ZipStream\ZipStream;
|
||||||
|
|
||||||
class DownloadController extends Controller
|
class DownloadController extends Controller
|
||||||
{
|
{
|
||||||
@@ -20,53 +22,68 @@ class DownloadController extends Controller
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download all files as a ZIP archive.
|
* Download all files as a ZIP archive, streamed file by file as it is decrypted: stored without
|
||||||
|
* compression, with ZIP64 for files over 4 GB, and never held in memory or written to disk.
|
||||||
*/
|
*/
|
||||||
public function download(Share $share): BinaryFileResponse
|
public function download(Request $request, Share $share): StreamedResponse
|
||||||
{
|
{
|
||||||
abort_if($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);
|
||||||
|
|
||||||
$tempPath = tempnam(sys_get_temp_dir(), 'sealshare_');
|
// Counted before the body streams: the session is saved by then.
|
||||||
|
abort_unless($this->shareService->claimDownload($share, $request->session()), 404);
|
||||||
|
|
||||||
$zip = new ZipArchive;
|
return new StreamedResponse(function () use ($share, $key): void {
|
||||||
$zip->open($tempPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
$zip = new ZipStream(
|
||||||
|
defaultCompressionMethod: CompressionMethod::STORE,
|
||||||
|
defaultEnableZeroHeader: true,
|
||||||
|
sendHttpHeaders: false,
|
||||||
|
flushOutput: true,
|
||||||
|
);
|
||||||
|
|
||||||
foreach ($share->files as $file) {
|
foreach ($share->files as $file) {
|
||||||
$encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path));
|
$chunks = $this->encryptionService->decryptedChunks(
|
||||||
$content = $this->encryptionService->decryptFile($encryptedPath, $key);
|
Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path)),
|
||||||
|
$key,
|
||||||
|
);
|
||||||
|
|
||||||
$filename = $file->relative_path ?: $file->original_name;
|
$zip->addFileFromPsr7Stream(fileName: $this->archiveName($file), stream: new PumpStream(function () use ($chunks): string|false {
|
||||||
$filename = str_replace('\\', '/', $filename);
|
while ($chunks->valid() && $chunks->current() === '') {
|
||||||
|
$chunks->next();
|
||||||
|
}
|
||||||
|
|
||||||
if (str_starts_with($filename, '/') || str_contains($filename, '..')) {
|
if (! $chunks->valid()) {
|
||||||
$filename = basename($filename);
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$chunk = $chunks->current();
|
||||||
|
$chunks->next();
|
||||||
|
|
||||||
|
return $chunk;
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
$zip->addFromString($filename, $content);
|
$zip->finish();
|
||||||
}
|
}, 200, [
|
||||||
|
|
||||||
$zip->close();
|
|
||||||
|
|
||||||
$this->shareService->recordDownload($share);
|
|
||||||
|
|
||||||
return response()->download($tempPath, 'share-'.$share->token.'.zip', [
|
|
||||||
'Content-Type' => 'application/zip',
|
'Content-Type' => 'application/zip',
|
||||||
])->deleteFileAfterSend(true);
|
'Content-Disposition' => HeaderUtils::makeDisposition('attachment', 'share-'.$share->token.'.zip'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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->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';
|
||||||
|
|
||||||
@@ -83,13 +100,29 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file's path inside the archive: its folder path when it came from a dropped folder, never
|
||||||
|
* one that could reach outside the archive.
|
||||||
|
*/
|
||||||
|
private function archiveName(ShareFile $file): string
|
||||||
|
{
|
||||||
|
$filename = str_replace('\\', '/', $file->relative_path ?: $file->original_name);
|
||||||
|
|
||||||
|
if (str_starts_with($filename, '/') || str_contains($filename, '..')) {
|
||||||
|
return basename($filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $filename;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the decryption key from session or share.
|
* Resolve the decryption key from session or share.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\ShareFile;
|
||||||
|
use App\Services\ShareService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class UploadChunkController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ShareService $shareService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store one encrypted chunk of a file the uploader's page registered.
|
||||||
|
*
|
||||||
|
* Only the session that started the pending share may add to it. A chunk the server already
|
||||||
|
* has is acknowledged without being written again; one that skips ahead gets a 409 with the
|
||||||
|
* number of chunks stored, so the browser can continue from there.
|
||||||
|
*/
|
||||||
|
public function store(Request $request, ShareFile $shareFile, int $index): JsonResponse
|
||||||
|
{
|
||||||
|
$share = $shareFile->share;
|
||||||
|
|
||||||
|
abort_if($share->isCompleted() || ! in_array($share->token, $request->session()->get('pending_shares', []), true), 404);
|
||||||
|
|
||||||
|
if ($index !== $shareFile->uploaded_chunks) {
|
||||||
|
return response()->json(
|
||||||
|
['uploaded_chunks' => $shareFile->uploaded_chunks],
|
||||||
|
$index < $shareFile->uploaded_chunks ? 200 : 409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$uploadedChunks = $this->shareService->storeChunk($shareFile, $index, $request->getContent());
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
abort(422, 'The chunk is invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['uploaded_chunks' => $uploadedChunks]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,14 +15,20 @@ class AdminDashboard extends Component
|
|||||||
use WithPagination;
|
use WithPagination;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The columns the table can be sorted by.
|
* The orders the shares list offers, each a column and a direction.
|
||||||
*
|
*
|
||||||
* @var list<string>
|
* @var array<string, array{0: string, 1: string}>
|
||||||
*/
|
*/
|
||||||
public const SORTABLE = ['token', 'files_count', 'total_size', 'download_count', 'expires_at', 'created_at'];
|
public const SORTS = [
|
||||||
|
'newest' => ['created_at', 'desc'],
|
||||||
|
'oldest' => ['created_at', 'asc'],
|
||||||
|
'expiring' => ['expires_at', 'asc'],
|
||||||
|
'largest' => ['total_size', 'desc'],
|
||||||
|
'most-downloaded' => ['download_count', 'desc'],
|
||||||
|
'most-files' => ['files_count', 'desc'],
|
||||||
|
];
|
||||||
|
|
||||||
/** @var array{column: string, direction: string} */
|
public string $sort = 'newest';
|
||||||
public array $sortBy = ['column' => 'created_at', 'direction' => 'desc'];
|
|
||||||
|
|
||||||
/** The share the delete dialog is asking about, while it is open. */
|
/** The share the delete dialog is asking about, while it is open. */
|
||||||
public ?int $deletingShareId = null;
|
public ?int $deletingShareId = null;
|
||||||
@@ -35,28 +41,43 @@ class AdminDashboard extends Component
|
|||||||
$this->deletingShareId = null;
|
$this->deletingShareId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new order starts again from the first page.
|
||||||
|
*/
|
||||||
|
public function updatedSort(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
public function render(): mixed
|
public function render(): mixed
|
||||||
{
|
{
|
||||||
$shareService = app(ShareService::class);
|
$shareService = app(ShareService::class);
|
||||||
|
|
||||||
// The sort comes from the browser: only a known column and direction reach the query.
|
// The sort comes from the browser: only a known order reaches the query.
|
||||||
$column = in_array($this->sortBy['column'] ?? null, self::SORTABLE, true) ? $this->sortBy['column'] : 'created_at';
|
[$column, $direction] = self::SORTS[$this->sort] ?? self::SORTS['newest'];
|
||||||
$direction = ($this->sortBy['direction'] ?? null) === 'asc' ? 'asc' : 'desc';
|
|
||||||
|
|
||||||
|
// Shares whose files are still being uploaded are not shares yet; their bytes do count as used space.
|
||||||
$shares = Share::query()
|
$shares = Share::query()
|
||||||
|
->whereNotNull('completed_at')
|
||||||
->withCount('files')
|
->withCount('files')
|
||||||
|
// Shares that never expire come after every share that does, whichever way expiry is sorted.
|
||||||
|
->when($column === 'expires_at', fn ($query) => $query->orderByRaw('expires_at is null'))
|
||||||
->orderBy($column, $direction)
|
->orderBy($column, $direction)
|
||||||
|
->orderByDesc('id')
|
||||||
->paginate(15);
|
->paginate(15);
|
||||||
|
|
||||||
return view('livewire.admin.admin-dashboard', [
|
return view('livewire.admin.admin-dashboard', [
|
||||||
'shares' => $shares,
|
'shares' => $shares,
|
||||||
'totalShares' => Share::query()->count(),
|
'totalShares' => Share::query()->whereNotNull('completed_at')->count(),
|
||||||
'activeShares' => Share::query()->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()->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,8 +3,11 @@
|
|||||||
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 Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
@@ -35,6 +38,23 @@ class AdminSettings extends Component
|
|||||||
|
|
||||||
public bool $allowNeverExpire = false;
|
public bool $allowNeverExpire = false;
|
||||||
|
|
||||||
|
/** How the upload page offers generated share passwords: `off`, `button` or `prefill`. */
|
||||||
|
public string $passwordGeneratorMode = 'button';
|
||||||
|
|
||||||
|
/** `characters` or `passphrase`. */
|
||||||
|
public string $passwordGeneratorType = 'characters';
|
||||||
|
|
||||||
|
public int $passwordLength = 20;
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
public array $passwordCharacterSets = [];
|
||||||
|
|
||||||
|
public bool $passwordAvoidAmbiguous = true;
|
||||||
|
|
||||||
|
public int $passphraseWords = 6;
|
||||||
|
|
||||||
|
public string $passphraseSeparator = 'hyphen';
|
||||||
|
|
||||||
public string $siteTitle = '';
|
public string $siteTitle = '';
|
||||||
|
|
||||||
public string $siteDescription = '';
|
public string $siteDescription = '';
|
||||||
@@ -51,54 +71,39 @@ class AdminSettings extends Component
|
|||||||
{
|
{
|
||||||
$this->colorProfile = Scheme::profile() ?? '';
|
$this->colorProfile = Scheme::profile() ?? '';
|
||||||
$this->defaultExpiration = Setting::get('default_expiration', '') ?? '';
|
$this->defaultExpiration = Setting::get('default_expiration', '') ?? '';
|
||||||
$this->maxFileSize = min(
|
$this->maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024);
|
||||||
(int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024),
|
|
||||||
self::phpMaxUploadMb(),
|
|
||||||
);
|
|
||||||
$this->maxStorageQuota = (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024) / (1024 * 1024 * 1024);
|
$this->maxStorageQuota = (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024) / (1024 * 1024 * 1024);
|
||||||
$this->maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
$this->maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
||||||
$this->maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024) / (1024 * 1024 * 1024);
|
$this->maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024) / (1024 * 1024 * 1024);
|
||||||
$this->allowNeverExpire = (bool) Setting::get('allow_never_expire', false);
|
$this->allowNeverExpire = (bool) Setting::get('allow_never_expire', false);
|
||||||
$this->siteTitle = Setting::get('site_title', '') ?? '';
|
$this->siteTitle = Setting::get('site_title', '') ?? '';
|
||||||
$this->siteDescription = Setting::get('site_description', '') ?? '';
|
$this->siteDescription = Setting::get('site_description', '') ?? '';
|
||||||
}
|
|
||||||
|
|
||||||
public static function phpMaxUploadMb(): int
|
$passwordOptions = app(PasswordGeneratorService::class)->options();
|
||||||
{
|
$this->passwordGeneratorMode = $passwordOptions['mode'];
|
||||||
$parse = function (string $value): int {
|
$this->passwordGeneratorType = $passwordOptions['type'];
|
||||||
$value = trim($value);
|
$this->passwordLength = $passwordOptions['length'];
|
||||||
$last = strtolower($value[strlen($value) - 1]);
|
$this->passwordCharacterSets = $passwordOptions['characterSets'];
|
||||||
$num = (int) $value;
|
$this->passwordAvoidAmbiguous = $passwordOptions['avoidAmbiguous'];
|
||||||
|
$this->passphraseWords = $passwordOptions['words'];
|
||||||
return match ($last) {
|
$this->passphraseSeparator = $passwordOptions['separator'];
|
||||||
'g' => $num * 1024,
|
|
||||||
'm' => $num,
|
|
||||||
'k' => max(1, (int) ($num / 1024)),
|
|
||||||
default => max(1, (int) ($num / (1024 * 1024))),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
$upload = $parse(ini_get('upload_max_filesize') ?: '2M');
|
|
||||||
$post = $parse(ini_get('post_max_size') ?: '8M');
|
|
||||||
|
|
||||||
return min($upload, $post);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveSettings(): void
|
public function saveSettings(): void
|
||||||
{
|
{
|
||||||
$phpMaxMb = self::phpMaxUploadMb();
|
$validated = $this->validate([
|
||||||
|
|
||||||
$this->validate([
|
|
||||||
'colorProfile' => ['required', 'string', Rule::in(array_keys(Scheme::profiles()))],
|
'colorProfile' => ['required', 'string', Rule::in(array_keys(Scheme::profiles()))],
|
||||||
'maxFileSize' => ['required', 'integer', 'min:1', 'max:'.$phpMaxMb],
|
'defaultExpiration' => ['nullable', 'string', Rule::in(array_keys(Share::EXPIRATIONS))],
|
||||||
|
'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'],
|
||||||
'maxSizePerShare' => ['required', 'integer', 'min:1'],
|
'maxSizePerShare' => ['required', 'integer', 'min:1'],
|
||||||
'siteTitle' => ['nullable', 'string', 'max:255'],
|
'siteTitle' => ['nullable', 'string', 'max:255'],
|
||||||
'siteDescription' => ['nullable', 'string', 'max:1000'],
|
'siteDescription' => ['nullable', 'string', 'max:1000'],
|
||||||
'siteLogo' => ['nullable', 'file', 'mimes:png,jpg,jpeg,gif,webp', 'max:2048'],
|
'siteLogo' => ['nullable', 'file', 'mimes:png,jpg,jpeg,gif,webp', 'max:2048'],
|
||||||
|
...$this->passwordGeneratorRules(),
|
||||||
], [
|
], [
|
||||||
'maxFileSize.max' => __('Cannot exceed the PHP limit of :max MB. Increase upload_max_filesize and post_max_size in your PHP configuration.', ['max' => $phpMaxMb]),
|
'passwordCharacterSets.required' => __('Choose at least one kind of character.'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($this->systemPassword) {
|
if ($this->systemPassword) {
|
||||||
@@ -116,6 +121,8 @@ class AdminSettings extends Component
|
|||||||
Setting::set('site_title', $this->siteTitle ?: null);
|
Setting::set('site_title', $this->siteTitle ?: null);
|
||||||
Setting::set('site_description', $this->siteDescription ?: null);
|
Setting::set('site_description', $this->siteDescription ?: null);
|
||||||
|
|
||||||
|
$this->savePasswordGeneratorSettings($validated);
|
||||||
|
|
||||||
if ($this->siteLogo && is_object($this->siteLogo)) {
|
if ($this->siteLogo && is_object($this->siteLogo)) {
|
||||||
$existingLogo = Setting::get('site_logo');
|
$existingLogo = Setting::get('site_logo');
|
||||||
if ($existingLogo) {
|
if ($existingLogo) {
|
||||||
@@ -132,6 +139,77 @@ class AdminSettings extends Component
|
|||||||
$this->success(__('Settings saved successfully.'));
|
$this->success(__('Settings saved successfully.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The generator's rules. A field the chosen mode or type hides is excluded, so it never blocks
|
||||||
|
* saving and keeps the value saved before.
|
||||||
|
*
|
||||||
|
* @return array<string, array<int, mixed>>
|
||||||
|
*/
|
||||||
|
protected function passwordGeneratorRules(): array
|
||||||
|
{
|
||||||
|
$characters = ['exclude_if:passwordGeneratorMode,off', 'exclude_unless:passwordGeneratorType,characters'];
|
||||||
|
$passphrase = ['exclude_if:passwordGeneratorMode,off', 'exclude_unless:passwordGeneratorType,passphrase'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'passwordGeneratorMode' => ['required', 'string', Rule::in(PasswordGeneratorService::MODES)],
|
||||||
|
'passwordGeneratorType' => ['exclude_if:passwordGeneratorMode,off', 'required', 'string', Rule::in(PasswordGeneratorService::TYPES)],
|
||||||
|
'passwordLength' => [...$characters, 'required', 'integer', 'min:'.PasswordGeneratorService::MIN_LENGTH, 'max:'.PasswordGeneratorService::MAX_LENGTH],
|
||||||
|
'passwordCharacterSets' => [...$characters, 'required', 'array'],
|
||||||
|
'passwordCharacterSets.*' => [...$characters, 'string', Rule::in(array_keys(PasswordGeneratorService::CHARACTER_SETS))],
|
||||||
|
'passwordAvoidAmbiguous' => [...$characters, 'boolean'],
|
||||||
|
'passphraseWords' => [...$passphrase, 'required', 'integer', 'min:'.PasswordGeneratorService::MIN_WORDS, 'max:'.PasswordGeneratorService::MAX_WORDS],
|
||||||
|
'passphraseSeparator' => [...$passphrase, 'required', 'string', Rule::in(array_keys(PasswordGeneratorService::SEPARATORS))],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the generator settings that passed validation; excluded ones keep their saved value.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $validated
|
||||||
|
*/
|
||||||
|
protected function savePasswordGeneratorSettings(array $validated): void
|
||||||
|
{
|
||||||
|
Setting::set('password_generator_mode', $validated['passwordGeneratorMode']);
|
||||||
|
|
||||||
|
if (array_key_exists('passwordGeneratorType', $validated)) {
|
||||||
|
Setting::set('password_generator_type', $validated['passwordGeneratorType']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('passwordLength', $validated)) {
|
||||||
|
Setting::set('password_generator_length', $validated['passwordLength']);
|
||||||
|
Setting::set('password_generator_character_sets', implode(',', $validated['passwordCharacterSets']));
|
||||||
|
Setting::set('password_generator_avoid_ambiguous', $validated['passwordAvoidAmbiguous'] ? '1' : '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('passphraseWords', $validated)) {
|
||||||
|
Setting::set('password_generator_words', $validated['passphraseWords']);
|
||||||
|
Setting::set('password_generator_separator', $validated['passphraseSeparator']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form's generator options while they are valid, for the example; `null` otherwise.
|
||||||
|
*
|
||||||
|
* @return array{type: string, length: int, characterSets: list<string>, avoidAmbiguous: bool, words: int, separator: string}|null
|
||||||
|
*/
|
||||||
|
protected function passwordPreviewOptions(): ?array
|
||||||
|
{
|
||||||
|
$values = $this->only(['passwordGeneratorMode', 'passwordGeneratorType', 'passwordLength', 'passwordCharacterSets', 'passwordAvoidAmbiguous', 'passphraseWords', 'passphraseSeparator']);
|
||||||
|
|
||||||
|
if ($this->passwordGeneratorMode === 'off' || Validator::make($values, $this->passwordGeneratorRules())->fails()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => $this->passwordGeneratorType,
|
||||||
|
'length' => $this->passwordLength,
|
||||||
|
'characterSets' => array_values($this->passwordCharacterSets),
|
||||||
|
'avoidAmbiguous' => $this->passwordAvoidAmbiguous,
|
||||||
|
'words' => $this->passphraseWords,
|
||||||
|
'separator' => $this->passphraseSeparator,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function removeLogo(): void
|
public function removeLogo(): void
|
||||||
{
|
{
|
||||||
$existingLogo = Setting::get('site_logo');
|
$existingLogo = Setting::get('site_logo');
|
||||||
@@ -157,10 +235,14 @@ class AdminSettings extends Component
|
|||||||
|
|
||||||
public function render(): mixed
|
public function render(): mixed
|
||||||
{
|
{
|
||||||
|
$passwordGenerator = app(PasswordGeneratorService::class);
|
||||||
|
$passwordPreviewOptions = $this->passwordPreviewOptions();
|
||||||
|
|
||||||
return view('livewire.admin.admin-settings', [
|
return view('livewire.admin.admin-settings', [
|
||||||
'hasSystemPassword' => (bool) Setting::get('system_password'),
|
'hasSystemPassword' => (bool) Setting::get('system_password'),
|
||||||
'currentLogo' => Setting::get('site_logo'),
|
'currentLogo' => Setting::get('site_logo'),
|
||||||
'phpMaxUploadMb' => self::phpMaxUploadMb(),
|
'passwordExample' => $passwordPreviewOptions ? $passwordGenerator->generate($passwordPreviewOptions) : null,
|
||||||
|
'passwordEntropy' => $passwordPreviewOptions ? $passwordGenerator->entropyBits($passwordPreviewOptions) : null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+123
-117
@@ -3,24 +3,29 @@
|
|||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
|
use App\Models\Share;
|
||||||
|
use App\Services\PasswordGeneratorService;
|
||||||
use App\Services\ShareService;
|
use App\Services\ShareService;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Carbon\CarbonInterval;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
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\Component;
|
use Livewire\Component;
|
||||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
|
||||||
use Livewire\WithFileUploads;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The upload page. The browser encrypts each file chunk by chunk and sends the chunks to
|
||||||
|
* UploadChunkController (resources/js/share-uploader.js); this component registers the files
|
||||||
|
* into a pending share, lists them and completes the share with its options.
|
||||||
|
*/
|
||||||
#[Layout('layouts.app')]
|
#[Layout('layouts.app')]
|
||||||
class FileUploader extends Component
|
class FileUploader extends Component
|
||||||
{
|
{
|
||||||
use WithFileUploads;
|
/** The pending share this page uploads into: created with the first file, one per page load. */
|
||||||
|
#[Locked]
|
||||||
/** @var array<int, TemporaryUploadedFile> */
|
public ?string $pendingToken = null;
|
||||||
public array $files = [];
|
|
||||||
|
|
||||||
/** @var array<int, string|null> */
|
|
||||||
public array $relativePaths = [];
|
|
||||||
|
|
||||||
public bool $usePassword = false;
|
public bool $usePassword = false;
|
||||||
|
|
||||||
@@ -38,165 +43,166 @@ class FileUploader extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle an upload the temporary upload endpoint did not accept.
|
* Register the files a visitor chose and hand the browser what it encrypts and sends them
|
||||||
|
* with. A file an admin limit refuses gets `null` in its place and the reason under `files`.
|
||||||
*
|
*
|
||||||
* Validation errors (a 422) mean the whole file reached the server and was
|
* @param array<int, array{name?: mixed, size?: mixed, path?: mixed}> $files
|
||||||
* rejected there, so the real reason is logged for the administrator rather
|
* @return array<int, array{id: int, url: string, key: string, noncePrefix: string, chunkSize: int, chunkCount: int}|null>
|
||||||
* than guessed at in front of the user. Anything else is a transport failure.
|
|
||||||
*/
|
*/
|
||||||
public function _uploadErrored($name, $errorsInJson, $isMultiple): void
|
public function registerFiles(array $files, ShareService $shareService): array
|
||||||
{
|
{
|
||||||
$this->dispatch('upload:errored', name: $name)->self();
|
$this->resetErrorBag('files');
|
||||||
|
|
||||||
$errors = is_null($errorsInJson) ? null : (json_decode($errorsInJson, true)['errors'] ?? null);
|
$targets = [];
|
||||||
|
|
||||||
if ($errors) {
|
foreach ($files as $file) {
|
||||||
Log::warning('File upload rejected by the temporary upload endpoint.', ['errors' => $errors]);
|
try {
|
||||||
|
$shareFile = $shareService->registerFile(
|
||||||
|
$this->pendingShare(),
|
||||||
|
(string) ($file['name'] ?? ''),
|
||||||
|
(int) ($file['size'] ?? -1),
|
||||||
|
isset($file['path']) ? (string) $file['path'] : null,
|
||||||
|
);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
if (! $this->getErrorBag()->has('files')) {
|
||||||
|
$this->addError('files', $e->errors()['files'][0]);
|
||||||
|
}
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
$targets[] = null;
|
||||||
'files' => __('Upload failed: the server could not accept the file. Please try again or contact the administrator.'),
|
|
||||||
]);
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->pendingToken !== $shareFile->share->token) {
|
||||||
|
$this->pendingToken = $shareFile->share->token;
|
||||||
|
session()->push('pending_shares', $this->pendingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
$header = $shareService->readHeader($shareFile);
|
||||||
|
|
||||||
|
$targets[] = [
|
||||||
|
'id' => $shareFile->id,
|
||||||
|
'url' => Str::beforeLast(route('upload.chunk', ['shareFile' => $shareFile, 'index' => 0]), '/'),
|
||||||
|
'key' => $shareFile->share->encryption_key,
|
||||||
|
'noncePrefix' => bin2hex($header['noncePrefix']),
|
||||||
|
'chunkSize' => $header['chunkSize'],
|
||||||
|
'chunkCount' => $header['chunkCount'],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$maxFileSizeMb = (int) ((int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024));
|
return $targets;
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'files' => __('Upload failed: file may be too large (max :max MB) or the connection was interrupted.', ['max' => $maxFileSizeMb]),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a freshly uploaded batch of files.
|
* Take files out of the pending share, whether or not their upload finished.
|
||||||
*
|
*
|
||||||
* Dispatches `files-processed` so the front end can drop its "uploading" state.
|
* @param array<int, mixed> $fileIds
|
||||||
* This runs for every batch, including additional files added to an existing
|
|
||||||
* selection, which a one-off `x-init` on the file list cannot cover.
|
|
||||||
*/
|
*/
|
||||||
public function updatedFiles(): void
|
public function removeFiles(array $fileIds, ShareService $shareService): void
|
||||||
{
|
{
|
||||||
$this->dispatch('files-processed')->self();
|
$files = $this->pendingShare()?->files()->whereIn('id', array_map('intval', $fileIds))->get() ?? [];
|
||||||
|
|
||||||
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
|
foreach ($files as $file) {
|
||||||
$maxFileSizeMb = $maxFileSize / (1024 * 1024);
|
$shareService->removeFile($file);
|
||||||
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
|
||||||
|
|
||||||
$this->resetErrorBag('files');
|
|
||||||
|
|
||||||
if (count($this->files) > $maxFilesPerShare) {
|
|
||||||
$this->addError('files', __('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare]));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($this->files as $file) {
|
$this->resetErrorBag('files');
|
||||||
if ($file->getSize() > $maxFileSize) {
|
}
|
||||||
$this->addError('files', __('":name" is too large (:size MB). Maximum file size is :max MB.', [
|
|
||||||
'name' => $file->getClientOriginalName(),
|
|
||||||
'size' => round($file->getSize() / (1024 * 1024), 1),
|
|
||||||
'max' => (int) $maxFileSizeMb,
|
|
||||||
]));
|
|
||||||
|
|
||||||
return;
|
/**
|
||||||
}
|
* Fill in a generated password as protection is switched on, when the admin chose "Prefilled".
|
||||||
|
* A password already in the field stays.
|
||||||
|
*/
|
||||||
|
public function updatedUsePassword(bool $value): void
|
||||||
|
{
|
||||||
|
$passwordGenerator = app(PasswordGeneratorService::class);
|
||||||
|
|
||||||
|
if ($value && $this->password === '' && $passwordGenerator->mode() === 'prefill') {
|
||||||
|
$this->password = $passwordGenerator->generate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function removeFile(int $index): void
|
public function generatePassword(PasswordGeneratorService $passwordGenerator): void
|
||||||
{
|
{
|
||||||
unset($this->files[$index], $this->relativePaths[$index]);
|
if ($passwordGenerator->mode() === 'off') {
|
||||||
$this->files = array_values($this->files);
|
return;
|
||||||
$this->relativePaths = array_values($this->relativePaths);
|
}
|
||||||
|
|
||||||
|
$this->password = $passwordGenerator->generate();
|
||||||
|
$this->resetErrorBag('password');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createShare(ShareService $shareService): void
|
public function createShare(ShareService $shareService): void
|
||||||
{
|
{
|
||||||
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
$rules = [];
|
||||||
$maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024);
|
|
||||||
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
|
|
||||||
|
|
||||||
$rules = [
|
if (! Setting::get('allow_never_expire', false)) {
|
||||||
'files' => ['required', 'array', 'min:1', 'max:'.$maxFilesPerShare],
|
$rules['expiration'] = ['required', 'string', Rule::in(array_keys(Share::EXPIRATIONS))];
|
||||||
'files.*' => ['required', 'file', 'max:'.($maxFileSize / 1024)],
|
|
||||||
];
|
|
||||||
|
|
||||||
$allowNeverExpire = (bool) Setting::get('allow_never_expire', false);
|
|
||||||
|
|
||||||
if (! $allowNeverExpire) {
|
|
||||||
$rules['expiration'] = ['required', 'string', 'in:1h,24h,48h,7d,14d,30d'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->usePassword) {
|
if ($this->usePassword) {
|
||||||
$rules['password'] = ['required', 'string', 'min:8'];
|
$rules['password'] = ['required', 'string', 'min:8'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->validate($rules, [
|
if ($rules !== []) {
|
||||||
'expiration.required' => __('An expiration time is required.'),
|
$this->validate($rules, [
|
||||||
'files.required' => __('Please select at least one file to upload.'),
|
'expiration.required' => __('An expiration time is required.'),
|
||||||
'files.max' => __('Too many files. Maximum :max files allowed per share.'),
|
]);
|
||||||
'files.*.max' => __('A file exceeds the maximum size of :max KB.'),
|
}
|
||||||
]);
|
|
||||||
|
|
||||||
if ($shareService->isStorageFull()) {
|
$pendingShare = $this->pendingShare();
|
||||||
$this->addError('files', __('Storage is full. Please contact the administrator.'));
|
|
||||||
|
if ($pendingShare === null) {
|
||||||
|
$this->addError('files', __('Please select at least one file to upload.'));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$totalSize = collect($this->files)->sum(fn ($file) => $file->getSize());
|
$share = $shareService->completeShare($pendingShare, [
|
||||||
|
|
||||||
if ($totalSize > $maxSizePerShare) {
|
|
||||||
$this->addError('files', __('Total file size exceeds the maximum allowed per share.'));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$fileData = [];
|
|
||||||
foreach ($this->files as $index => $file) {
|
|
||||||
$relativePath = $this->relativePaths[$index] ?? null;
|
|
||||||
|
|
||||||
if ($relativePath !== null) {
|
|
||||||
$relativePath = str_replace('\\', '/', $relativePath);
|
|
||||||
|
|
||||||
if (str_starts_with($relativePath, '/') || str_contains($relativePath, '..')) {
|
|
||||||
$relativePath = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$fileData[] = [
|
|
||||||
'file' => $file,
|
|
||||||
'relativePath' => $relativePath,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$expiresAt = match ($this->expiration) {
|
|
||||||
'1h' => now()->addHour(),
|
|
||||||
'24h' => now()->addDay(),
|
|
||||||
'48h' => now()->addDays(2),
|
|
||||||
'7d' => now()->addWeek(),
|
|
||||||
'14d' => now()->addDays(14),
|
|
||||||
'30d' => now()->addMonth(),
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
$share = $shareService->createShare($fileData, [
|
|
||||||
'password' => $this->usePassword ? $this->password : null,
|
'password' => $this->usePassword ? $this->password : null,
|
||||||
'expires_at' => $expiresAt,
|
'expires_at' => isset(Share::EXPIRATIONS[$this->expiration])
|
||||||
|
? now()->add(CarbonInterval::make(Share::EXPIRATIONS[$this->expiration]['interval']))
|
||||||
|
: null,
|
||||||
'max_downloads' => $this->maxDownloads ?: null,
|
'max_downloads' => $this->maxDownloads ?: null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
session()->put('pending_shares', array_values(array_diff(session('pending_shares', []), [$share->token])));
|
||||||
|
|
||||||
|
// The page the upload leads to offers the password once more, next to the link; it is
|
||||||
|
// never stored in the clear, so this flash is the only way it gets there.
|
||||||
|
if ($this->usePassword) {
|
||||||
|
session()->flash('share_password', [
|
||||||
|
'token' => $share->token,
|
||||||
|
'password' => Crypt::encryptString($this->password),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$this->redirect(route('share.created', $share), navigate: true);
|
$this->redirect(route('share.created', $share), navigate: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render(): mixed
|
public function render(): mixed
|
||||||
{
|
{
|
||||||
$shareService = app(ShareService::class);
|
$shareService = app(ShareService::class);
|
||||||
|
$pendingFiles = $this->pendingShare()?->files()->orderBy('id')->get() ?? collect();
|
||||||
|
|
||||||
return view('livewire.file-uploader', [
|
return view('livewire.file-uploader', [
|
||||||
|
'pendingFiles' => $pendingFiles,
|
||||||
|
'allFilesUploaded' => $pendingFiles->isNotEmpty() && $pendingFiles->every(fn ($file): bool => $file->completed_at !== null),
|
||||||
'isStorageFull' => $shareService->isStorageFull(),
|
'isStorageFull' => $shareService->isStorageFull(),
|
||||||
'siteTitle' => Setting::get('site_title'),
|
|
||||||
'siteDescription' => Setting::get('site_description'),
|
|
||||||
'siteLogo' => Setting::get('site_logo'),
|
|
||||||
'allowNeverExpire' => (bool) Setting::get('allow_never_expire', false),
|
'allowNeverExpire' => (bool) Setting::get('allow_never_expire', false),
|
||||||
|
'passwordGeneratorMode' => app(PasswordGeneratorService::class)->mode(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This page's pending share, while it is still pending and this session started it.
|
||||||
|
*/
|
||||||
|
private function pendingShare(): ?Share
|
||||||
|
{
|
||||||
|
if ($this->pendingToken === null || ! in_array($this->pendingToken, session('pending_shares', []), true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Share::query()->where('token', $this->pendingToken)->whereNull('completed_at')->first();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -10,7 +9,7 @@ use Livewire\Attributes\Layout;
|
|||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
#[Layout('layouts.auth')]
|
#[Layout('layouts.app')]
|
||||||
class SetupWizard extends Component
|
class SetupWizard extends Component
|
||||||
{
|
{
|
||||||
#[Validate('required|string|max:255')]
|
#[Validate('required|string|max:255')]
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ namespace App\Livewire;
|
|||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\Share;
|
use App\Models\Share;
|
||||||
use App\Services\QrCodeService;
|
use App\Services\QrCodeService;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
#[Layout('layouts.app')]
|
#[Layout('layouts.app')]
|
||||||
@@ -13,9 +15,21 @@ class ShareCreated extends Component
|
|||||||
{
|
{
|
||||||
public Share $share;
|
public Share $share;
|
||||||
|
|
||||||
|
/** The share's password, offered once to the uploader who just set it; `null` on any other visit. */
|
||||||
|
#[Locked]
|
||||||
|
public ?string $password = null;
|
||||||
|
|
||||||
public function mount(Share $share): void
|
public function mount(Share $share): void
|
||||||
{
|
{
|
||||||
|
abort_unless($share->isCompleted(), 404);
|
||||||
|
|
||||||
$this->share = $share;
|
$this->share = $share;
|
||||||
|
|
||||||
|
$flashedPassword = session('share_password');
|
||||||
|
|
||||||
|
if (is_array($flashedPassword) && ($flashedPassword['token'] ?? null) === $share->token) {
|
||||||
|
$this->password = Crypt::decryptString($flashedPassword['password']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render(): mixed
|
public function render(): mixed
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use App\Models\Setting;
|
|
||||||
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;
|
||||||
@@ -20,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->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
|
||||||
@@ -66,10 +62,12 @@ class ShareDownload extends Component
|
|||||||
|
|
||||||
public function render(): mixed
|
public function render(): mixed
|
||||||
{
|
{
|
||||||
|
$shareService = app(ShareService::class);
|
||||||
|
|
||||||
return view('livewire.share-download', [
|
return view('livewire.share-download', [
|
||||||
'siteTitle' => Setting::get('site_title'),
|
'downloadWindowEndsAt' => $shareService->downloadWindowEndsAt($this->share, session()->driver()),
|
||||||
'siteDescription' => Setting::get('site_description'),
|
'remainingDownloads' => $this->share->max_downloads ? max($this->share->max_downloads - $this->share->download_count, 0) : null,
|
||||||
'siteLogo' => Setting::get('site_logo'),
|
'downloadWindow' => CarbonInterval::minutes(ShareService::DOWNLOAD_WINDOW_MINUTES)->cascade()->forHumans(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use Livewire\Attributes\Layout;
|
|||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
#[Layout('layouts.auth')]
|
#[Layout('layouts.app')]
|
||||||
class SystemPasswordPrompt extends Component
|
class SystemPasswordPrompt extends Component
|
||||||
{
|
{
|
||||||
#[Validate('required|string')]
|
#[Validate('required|string')]
|
||||||
|
|||||||
@@ -10,15 +10,32 @@ 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',
|
||||||
'encryption_key',
|
'encryption_key',
|
||||||
'encryption_salt',
|
'encryption_salt',
|
||||||
|
'wrapped_key',
|
||||||
'expires_at',
|
'expires_at',
|
||||||
'max_downloads',
|
'max_downloads',
|
||||||
'download_count',
|
'download_count',
|
||||||
'total_size',
|
'total_size',
|
||||||
|
'completed_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,8 +47,10 @@ 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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +62,15 @@ class Share extends Model
|
|||||||
return $this->hasMany(ShareFile::class);
|
return $this->hasMany(ShareFile::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the share was created: until then its files are still being uploaded and nobody
|
||||||
|
* but the uploader's page may reach it.
|
||||||
|
*/
|
||||||
|
public function isCompleted(): bool
|
||||||
|
{
|
||||||
|
return $this->completed_at !== null;
|
||||||
|
}
|
||||||
|
|
||||||
public function isExpired(): bool
|
public function isExpired(): bool
|
||||||
{
|
{
|
||||||
return $this->expires_at && $this->expires_at->isPast();
|
return $this->expires_at && $this->expires_at->isPast();
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class ShareFile extends Model
|
|||||||
'stored_path',
|
'stored_path',
|
||||||
'file_size',
|
'file_size',
|
||||||
'mime_type',
|
'mime_type',
|
||||||
|
'uploaded_chunks',
|
||||||
|
'completed_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +28,8 @@ class ShareFile extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'file_size' => 'integer',
|
'file_size' => 'integer',
|
||||||
|
'uploaded_chunks' => 'integer',
|
||||||
|
'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'));
|
||||||
|
|||||||
@@ -4,11 +4,30 @@ namespace App\Services;
|
|||||||
|
|
||||||
use Generator;
|
use Generator;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The encrypted file formats and the keys behind them.
|
||||||
|
*
|
||||||
|
* New files are `SEALCHK2`, written chunk by chunk as the uploader's browser sends them:
|
||||||
|
*
|
||||||
|
* [8 bytes: "SEALCHK2" magic]
|
||||||
|
* [4 bytes: chunk size S, uint32 big-endian]
|
||||||
|
* [7 bytes: random nonce prefix]
|
||||||
|
* Per chunk i: [ciphertext (S bytes, fewer on the last chunk)][16 bytes: GCM tag]
|
||||||
|
*
|
||||||
|
* Chunk i's nonce is the prefix, i as uint32 big-endian and a byte that is 1 on the last chunk
|
||||||
|
* and 0 on every other (the STREAM construction), so dropping, reordering or appending chunks
|
||||||
|
* fails authentication. The browser encrypts with the same layout (resources/js/share-uploader.js).
|
||||||
|
*
|
||||||
|
* `SEALCHK1` (a tag before each chunk, the index XORed into a 12-byte nonce, no last-chunk flag)
|
||||||
|
* and the single-block legacy format are still read for shares created before.
|
||||||
|
*/
|
||||||
class FileEncryptionService
|
class FileEncryptionService
|
||||||
{
|
{
|
||||||
|
public const HEADER_LENGTH = 19;
|
||||||
|
|
||||||
|
public const TAG_LENGTH = 16;
|
||||||
|
|
||||||
private const CIPHER = 'aes-256-gcm';
|
private const CIPHER = 'aes-256-gcm';
|
||||||
|
|
||||||
private const PBKDF2_ITERATIONS = 100000;
|
private const PBKDF2_ITERATIONS = 100000;
|
||||||
@@ -17,28 +36,23 @@ class FileEncryptionService
|
|||||||
|
|
||||||
private const NONCE_LENGTH = 12;
|
private const NONCE_LENGTH = 12;
|
||||||
|
|
||||||
private const TAG_LENGTH = 16;
|
private const NONCE_PREFIX_LENGTH = 7;
|
||||||
|
|
||||||
private const MAGIC_HEADER = 'SEALCHK1';
|
private const MAGIC = 'SEALCHK2';
|
||||||
|
|
||||||
private const DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB
|
private const LEGACY_CHUNKED_MAGIC = 'SEALCHK1';
|
||||||
|
|
||||||
|
private const WRAPPED_KEY_ALGORITHM = 'argon2id';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive an encryption key from a password and salt using PBKDF2-SHA256.
|
* Derive a key from a password and salt using PBKDF2-SHA256, as shares created before
|
||||||
|
* envelope encryption were keyed.
|
||||||
*/
|
*/
|
||||||
public function deriveKey(string $password, string $salt): string
|
public function deriveKey(string $password, string $salt): string
|
||||||
{
|
{
|
||||||
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).
|
||||||
*/
|
*/
|
||||||
@@ -48,219 +62,134 @@ class FileEncryptionService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypt a file using chunked AES-256-GCM.
|
* Wrap a share's data key with a key derived from its password (Argon2id).
|
||||||
*
|
*
|
||||||
* Output format:
|
* The result names its algorithm and parameters, so they can be raised later without breaking
|
||||||
* [8 bytes: "SEALCHK1" magic]
|
* shares wrapped before: `argon2id$<opslimit>$<memlimit>$<salt>$<nonce>$<box>`, in hex.
|
||||||
* [4 bytes: chunk size, uint32 big-endian]
|
|
||||||
* [12 bytes: base nonce]
|
|
||||||
* Per chunk:
|
|
||||||
* [16 bytes: GCM auth tag]
|
|
||||||
* [N bytes: ciphertext (up to chunk_size)]
|
|
||||||
*/
|
*/
|
||||||
public function encryptFile(string $sourcePath, string $destPath, string $key): void
|
public function wrapKey(string $dataKeyHex, string $password): string
|
||||||
{
|
{
|
||||||
$source = fopen($sourcePath, 'rb');
|
$salt = random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES);
|
||||||
|
$opslimit = SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE;
|
||||||
|
$memlimit = SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE;
|
||||||
|
|
||||||
if ($source === false) {
|
$wrappingKey = $this->deriveWrappingKey($password, $salt, $opslimit, $memlimit);
|
||||||
throw new RuntimeException("Cannot read source file: {$sourcePath}");
|
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
|
||||||
}
|
$box = sodium_crypto_secretbox(hex2bin($dataKeyHex), $nonce, $wrappingKey);
|
||||||
|
|
||||||
$dest = fopen($destPath, 'wb');
|
sodium_memzero($wrappingKey);
|
||||||
|
|
||||||
if ($dest === false) {
|
return implode('$', [self::WRAPPED_KEY_ALGORITHM, $opslimit, $memlimit, bin2hex($salt), bin2hex($nonce), bin2hex($box)]);
|
||||||
fclose($source);
|
|
||||||
|
|
||||||
throw new RuntimeException("Cannot write encrypted file: {$destPath}");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$binaryKey = $this->normalizeToBinaryKey($key);
|
|
||||||
$baseNonce = random_bytes(self::NONCE_LENGTH);
|
|
||||||
$chunkSize = self::DEFAULT_CHUNK_SIZE;
|
|
||||||
|
|
||||||
// Write header
|
|
||||||
fwrite($dest, self::MAGIC_HEADER);
|
|
||||||
fwrite($dest, pack('N', $chunkSize));
|
|
||||||
fwrite($dest, $baseNonce);
|
|
||||||
|
|
||||||
$chunkIndex = 0;
|
|
||||||
|
|
||||||
while (! feof($source)) {
|
|
||||||
$plaintext = fread($source, $chunkSize);
|
|
||||||
|
|
||||||
if ($plaintext === false || $plaintext === '') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$nonce = $this->deriveChunkNonce($baseNonce, $chunkIndex);
|
|
||||||
$tag = '';
|
|
||||||
|
|
||||||
$ciphertext = openssl_encrypt(
|
|
||||||
$plaintext,
|
|
||||||
self::CIPHER,
|
|
||||||
$binaryKey,
|
|
||||||
OPENSSL_RAW_DATA,
|
|
||||||
$nonce,
|
|
||||||
$tag,
|
|
||||||
'',
|
|
||||||
self::TAG_LENGTH,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($ciphertext === false) {
|
|
||||||
throw new RuntimeException('Encryption failed at chunk '.$chunkIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
fwrite($dest, $tag);
|
|
||||||
fwrite($dest, $ciphertext);
|
|
||||||
$chunkIndex++;
|
|
||||||
}
|
|
||||||
} catch (RuntimeException $e) {
|
|
||||||
fclose($source);
|
|
||||||
fclose($dest);
|
|
||||||
@unlink($destPath);
|
|
||||||
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
|
|
||||||
fclose($source);
|
|
||||||
fclose($dest);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt a file and return the plaintext content.
|
* Unwrap a share's data key with its password; returns the key as hex.
|
||||||
*/
|
*/
|
||||||
public function decryptFile(string $encryptedPath, string $key): string
|
public function unwrapKey(string $wrappedKey, string $password): string
|
||||||
{
|
{
|
||||||
if ($this->isChunkedFormat($encryptedPath)) {
|
$parts = explode('$', $wrappedKey);
|
||||||
$parts = [];
|
|
||||||
|
|
||||||
foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) {
|
if (count($parts) !== 6 || $parts[0] !== self::WRAPPED_KEY_ALGORITHM) {
|
||||||
$parts[] = $chunk;
|
throw new RuntimeException('Unsupported wrapped key');
|
||||||
}
|
|
||||||
|
|
||||||
return implode('', $parts);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->decryptLegacy($encryptedPath, $key);
|
[, $opslimit, $memlimit, $salt, $nonce, $box] = $parts;
|
||||||
|
|
||||||
|
$wrappingKey = $this->deriveWrappingKey($password, hex2bin($salt), (int) $opslimit, (int) $memlimit);
|
||||||
|
$dataKey = sodium_crypto_secretbox_open(hex2bin($box), hex2bin($nonce), $wrappingKey);
|
||||||
|
|
||||||
|
sodium_memzero($wrappingKey);
|
||||||
|
|
||||||
|
if ($dataKey === false) {
|
||||||
|
throw new RuntimeException('Unwrapping failed - wrong password or corrupted key');
|
||||||
|
}
|
||||||
|
|
||||||
|
return bin2hex($dataKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt a file and stream the response.
|
* The header a new encrypted file starts with, with a fresh random nonce prefix.
|
||||||
*/
|
*/
|
||||||
public function decryptFileStream(string $encryptedPath, string $key, string $filename, string $mimeType, ?int $fileSize = null): StreamedResponse
|
public function createHeader(int $chunkSize): string
|
||||||
{
|
{
|
||||||
$headers = [
|
return self::MAGIC.pack('N', $chunkSize).random_bytes(self::NONCE_PREFIX_LENGTH);
|
||||||
'Content-Type' => $mimeType ?: 'application/octet-stream',
|
}
|
||||||
'Content-Disposition' => HeaderUtils::makeDisposition('attachment', $filename, 'download'),
|
|
||||||
|
/**
|
||||||
|
* Read a `SEALCHK2` header.
|
||||||
|
*
|
||||||
|
* @return array{chunkSize: int, noncePrefix: string}
|
||||||
|
*/
|
||||||
|
public function parseHeader(string $header): array
|
||||||
|
{
|
||||||
|
if (strlen($header) !== self::HEADER_LENGTH || ! str_starts_with($header, self::MAGIC)) {
|
||||||
|
throw new RuntimeException('Invalid encrypted file header');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'chunkSize' => unpack('N', substr($header, 8, 4))[1],
|
||||||
|
'noncePrefix' => substr($header, 12, self::NONCE_PREFIX_LENGTH),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($fileSize !== null) {
|
|
||||||
$headers['Content-Length'] = $fileSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->isChunkedFormat($encryptedPath)) {
|
|
||||||
return new StreamedResponse(function () use ($encryptedPath, $key): void {
|
|
||||||
foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) {
|
|
||||||
echo $chunk;
|
|
||||||
flush();
|
|
||||||
}
|
|
||||||
}, 200, $headers);
|
|
||||||
}
|
|
||||||
|
|
||||||
$content = $this->decryptLegacy($encryptedPath, $key);
|
|
||||||
|
|
||||||
if (! isset($headers['Content-Length'])) {
|
|
||||||
$headers['Content-Length'] = strlen($content);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new StreamedResponse(function () use ($content): void {
|
|
||||||
echo $content;
|
|
||||||
}, 200, $headers);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream decrypted file content directly to output (echo).
|
* How many chunks a file of this size is sent in; an empty file is one empty chunk.
|
||||||
* Use this when you need to add post-streaming logic inside a StreamedResponse callback.
|
|
||||||
*/
|
*/
|
||||||
public function streamDecryptedFile(string $encryptedPath, string $key): void
|
public function chunkCount(int $size, int $chunkSize): int
|
||||||
{
|
{
|
||||||
if ($this->isChunkedFormat($encryptedPath)) {
|
return max(1, intdiv($size + $chunkSize - 1, $chunkSize));
|
||||||
foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) {
|
|
||||||
echo $chunk;
|
|
||||||
flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
echo $this->decryptLegacy($encryptedPath, $key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize a hex key to binary.
|
* Where chunk `$index` starts in the encrypted file.
|
||||||
*/
|
*/
|
||||||
private function normalizeToBinaryKey(string $key): string
|
public function chunkOffset(int $index, int $chunkSize): int
|
||||||
{
|
{
|
||||||
return strlen($key) === 64 ? hex2bin($key) : $key;
|
return self::HEADER_LENGTH + $index * ($chunkSize + self::TAG_LENGTH);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive a unique nonce for a chunk by XORing the chunk index into the last 4 bytes.
|
* Encrypt one chunk: its ciphertext followed by its tag, as WebCrypto returns it.
|
||||||
*/
|
*/
|
||||||
private function deriveChunkNonce(string $baseNonce, int $chunkIndex): string
|
public function encryptChunk(string $plaintext, string $key, string $noncePrefix, int $index, bool $isLast): string
|
||||||
{
|
{
|
||||||
$nonce = $baseNonce;
|
$tag = '';
|
||||||
$indexBytes = pack('N', $chunkIndex);
|
|
||||||
|
|
||||||
for ($i = 0; $i < 4; $i++) {
|
$ciphertext = openssl_encrypt(
|
||||||
$nonce[self::NONCE_LENGTH - 4 + $i] = $nonce[self::NONCE_LENGTH - 4 + $i] ^ $indexBytes[$i];
|
$plaintext,
|
||||||
|
self::CIPHER,
|
||||||
|
$this->normalizeToBinaryKey($key),
|
||||||
|
OPENSSL_RAW_DATA,
|
||||||
|
$this->chunkNonce($noncePrefix, $index, $isLast),
|
||||||
|
$tag,
|
||||||
|
'',
|
||||||
|
self::TAG_LENGTH,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($ciphertext === false) {
|
||||||
|
throw new RuntimeException('Encryption failed at chunk '.$index);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $nonce;
|
return $ciphertext.$tag;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a file uses the chunked encryption format.
|
* Decrypt one chunk, which fails unless its index and last-chunk flag are the ones it was
|
||||||
|
* encrypted with.
|
||||||
*/
|
*/
|
||||||
private function isChunkedFormat(string $path): bool
|
public function decryptChunk(string $chunk, string $key, string $noncePrefix, int $index, bool $isLast): string
|
||||||
{
|
{
|
||||||
$handle = fopen($path, 'rb');
|
if (strlen($chunk) < self::TAG_LENGTH) {
|
||||||
|
throw new RuntimeException('Invalid encrypted file: truncated chunk '.$index);
|
||||||
if ($handle === false) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$magic = fread($handle, 8);
|
|
||||||
fclose($handle);
|
|
||||||
|
|
||||||
return $magic === self::MAGIC_HEADER;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt a legacy single-block encrypted file.
|
|
||||||
* Format: [12-byte nonce][16-byte auth tag][ciphertext]
|
|
||||||
*/
|
|
||||||
private function decryptLegacy(string $encryptedPath, string $key): string
|
|
||||||
{
|
|
||||||
$data = file_get_contents($encryptedPath);
|
|
||||||
|
|
||||||
if ($data === false) {
|
|
||||||
throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$binaryKey = $this->normalizeToBinaryKey($key);
|
|
||||||
$nonce = substr($data, 0, self::NONCE_LENGTH);
|
|
||||||
$tag = substr($data, self::NONCE_LENGTH, self::TAG_LENGTH);
|
|
||||||
$ciphertext = substr($data, self::NONCE_LENGTH + self::TAG_LENGTH);
|
|
||||||
|
|
||||||
$plaintext = openssl_decrypt(
|
$plaintext = openssl_decrypt(
|
||||||
$ciphertext,
|
substr($chunk, 0, -self::TAG_LENGTH),
|
||||||
self::CIPHER,
|
self::CIPHER,
|
||||||
$binaryKey,
|
$this->normalizeToBinaryKey($key),
|
||||||
OPENSSL_RAW_DATA,
|
OPENSSL_RAW_DATA,
|
||||||
$nonce,
|
$this->chunkNonce($noncePrefix, $index, $isLast),
|
||||||
$tag,
|
substr($chunk, -self::TAG_LENGTH),
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($plaintext === false) {
|
if ($plaintext === false) {
|
||||||
@@ -271,7 +200,54 @@ class FileEncryptionService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generator that yields decrypted plaintext chunks from a chunked encrypted file.
|
* The decrypted content of a file in any of the three formats, chunk by chunk.
|
||||||
|
*
|
||||||
|
* @return Generator<int, string>
|
||||||
|
*/
|
||||||
|
public function decryptedChunks(string $encryptedPath, string $key): Generator
|
||||||
|
{
|
||||||
|
$magic = (string) file_get_contents($encryptedPath, false, null, 0, 8);
|
||||||
|
|
||||||
|
if ($magic === self::MAGIC) {
|
||||||
|
yield from $this->decryptChunks($encryptedPath, $key);
|
||||||
|
} elseif ($magic === self::LEGACY_CHUNKED_MAGIC) {
|
||||||
|
yield from $this->decryptLegacyChunks($encryptedPath, $key);
|
||||||
|
} else {
|
||||||
|
yield $this->decryptLegacy($encryptedPath, $key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a hex key to binary.
|
||||||
|
*/
|
||||||
|
private function normalizeToBinaryKey(string $key): string
|
||||||
|
{
|
||||||
|
return strlen($key) === 64 ? hex2bin($key) : $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deriveWrappingKey(string $password, string $salt, int $opslimit, int $memlimit): string
|
||||||
|
{
|
||||||
|
return sodium_crypto_pwhash(
|
||||||
|
SODIUM_CRYPTO_SECRETBOX_KEYBYTES,
|
||||||
|
$password,
|
||||||
|
$salt,
|
||||||
|
$opslimit,
|
||||||
|
$memlimit,
|
||||||
|
SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `SEALCHK2` chunk's nonce: the file's prefix, the chunk index and the last-chunk flag.
|
||||||
|
*/
|
||||||
|
private function chunkNonce(string $noncePrefix, int $index, bool $isLast): string
|
||||||
|
{
|
||||||
|
return $noncePrefix.pack('N', $index).($isLast ? "\x01" : "\x00");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a `SEALCHK2` file; the chunk count comes from the file's length, so a file cut short
|
||||||
|
* at a chunk boundary fails on its new last chunk.
|
||||||
*
|
*
|
||||||
* @return Generator<int, string>
|
* @return Generator<int, string>
|
||||||
*/
|
*/
|
||||||
@@ -284,17 +260,44 @@ class FileEncryptionService
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Read header
|
['chunkSize' => $chunkSize, 'noncePrefix' => $noncePrefix] = $this->parseHeader((string) fread($handle, self::HEADER_LENGTH));
|
||||||
$magic = fread($handle, 8);
|
|
||||||
|
|
||||||
if ($magic !== self::MAGIC_HEADER) {
|
$storedChunkSize = $chunkSize + self::TAG_LENGTH;
|
||||||
throw new RuntimeException('Invalid chunked file format');
|
$payloadLength = (int) filesize($encryptedPath) - self::HEADER_LENGTH;
|
||||||
|
$chunkCount = intdiv($payloadLength + $storedChunkSize - 1, $storedChunkSize);
|
||||||
|
|
||||||
|
if ($chunkCount === 0) {
|
||||||
|
throw new RuntimeException('Invalid encrypted file: no chunks');
|
||||||
}
|
}
|
||||||
|
|
||||||
$chunkSizeData = fread($handle, 4);
|
for ($index = 0; $index < $chunkCount; $index++) {
|
||||||
$chunkSize = unpack('N', $chunkSizeData)[1];
|
$chunk = (string) fread($handle, $storedChunkSize);
|
||||||
|
|
||||||
$baseNonce = fread($handle, self::NONCE_LENGTH);
|
yield $this->decryptChunk($chunk, $key, $noncePrefix, $index, $index === $chunkCount - 1);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a `SEALCHK1` file.
|
||||||
|
*
|
||||||
|
* @return Generator<int, string>
|
||||||
|
*/
|
||||||
|
private function decryptLegacyChunks(string $encryptedPath, string $key): Generator
|
||||||
|
{
|
||||||
|
$handle = fopen($encryptedPath, 'rb');
|
||||||
|
|
||||||
|
if ($handle === false) {
|
||||||
|
throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fread($handle, 8);
|
||||||
|
|
||||||
|
$chunkSize = unpack('N', (string) fread($handle, 4))[1];
|
||||||
|
$baseNonce = (string) fread($handle, self::NONCE_LENGTH);
|
||||||
|
|
||||||
if (strlen($baseNonce) !== self::NONCE_LENGTH) {
|
if (strlen($baseNonce) !== self::NONCE_LENGTH) {
|
||||||
throw new RuntimeException('Invalid chunked file: truncated header');
|
throw new RuntimeException('Invalid chunked file: truncated header');
|
||||||
@@ -320,14 +323,12 @@ class FileEncryptionService
|
|||||||
throw new RuntimeException('Invalid chunked file: missing ciphertext at chunk '.$chunkIndex);
|
throw new RuntimeException('Invalid chunked file: missing ciphertext at chunk '.$chunkIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
$nonce = $this->deriveChunkNonce($baseNonce, $chunkIndex);
|
|
||||||
|
|
||||||
$plaintext = openssl_decrypt(
|
$plaintext = openssl_decrypt(
|
||||||
$ciphertext,
|
$ciphertext,
|
||||||
self::CIPHER,
|
self::CIPHER,
|
||||||
$binaryKey,
|
$binaryKey,
|
||||||
OPENSSL_RAW_DATA,
|
OPENSSL_RAW_DATA,
|
||||||
$nonce,
|
$this->legacyChunkNonce($baseNonce, $chunkIndex),
|
||||||
$tag,
|
$tag,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -342,4 +343,47 @@ class FileEncryptionService
|
|||||||
fclose($handle);
|
fclose($handle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `SEALCHK1` chunk's nonce: the chunk index XORed into the last 4 bytes of the base nonce.
|
||||||
|
*/
|
||||||
|
private function legacyChunkNonce(string $baseNonce, int $chunkIndex): string
|
||||||
|
{
|
||||||
|
$nonce = $baseNonce;
|
||||||
|
$indexBytes = pack('N', $chunkIndex);
|
||||||
|
|
||||||
|
for ($i = 0; $i < 4; $i++) {
|
||||||
|
$nonce[self::NONCE_LENGTH - 4 + $i] = $nonce[self::NONCE_LENGTH - 4 + $i] ^ $indexBytes[$i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a legacy single-block encrypted file.
|
||||||
|
* Format: [12-byte nonce][16-byte auth tag][ciphertext]
|
||||||
|
*/
|
||||||
|
private function decryptLegacy(string $encryptedPath, string $key): string
|
||||||
|
{
|
||||||
|
$data = file_get_contents($encryptedPath);
|
||||||
|
|
||||||
|
if ($data === false) {
|
||||||
|
throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$plaintext = openssl_decrypt(
|
||||||
|
substr($data, self::NONCE_LENGTH + self::TAG_LENGTH),
|
||||||
|
self::CIPHER,
|
||||||
|
$this->normalizeToBinaryKey($key),
|
||||||
|
OPENSSL_RAW_DATA,
|
||||||
|
substr($data, 0, self::NONCE_LENGTH),
|
||||||
|
substr($data, self::NONCE_LENGTH, self::TAG_LENGTH),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($plaintext === false) {
|
||||||
|
throw new RuntimeException('Decryption failed - wrong key or corrupted data');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $plaintext;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Random\Randomizer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Random share passwords, drawn the way Admin settings say.
|
||||||
|
*
|
||||||
|
* Every draw comes from `Random\Randomizer`'s default engine, which is the operating system's
|
||||||
|
* CSPRNG. Passphrases come from EFF's large word list (CC BY 3.0 US), without its four hyphenated
|
||||||
|
* words so a separator always splits a passphrase into its words.
|
||||||
|
*/
|
||||||
|
class PasswordGeneratorService
|
||||||
|
{
|
||||||
|
/** Off: uploaders type their own. Button: a Generate button fills one in. Prefill: filled in as protection is switched on. */
|
||||||
|
public const MODES = ['off', 'button', 'prefill'];
|
||||||
|
|
||||||
|
public const TYPES = ['characters', 'passphrase'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The characters each set draws from. The symbols leave out what chat apps turn into formatting
|
||||||
|
* (`* _ ~ \``) and what breaks once pasted into quotes or markup (`' " \ < >`).
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
public const CHARACTER_SETS = [
|
||||||
|
'uppercase' => 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||||
|
'lowercase' => 'abcdefghijklmnopqrstuvwxyz',
|
||||||
|
'numbers' => '0123456789',
|
||||||
|
'symbols' => '!#$%&()+,-./:;=?@[]{}',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Characters that read alike in many typefaces. */
|
||||||
|
public const AMBIGUOUS_CHARACTERS = '0O1lI';
|
||||||
|
|
||||||
|
/** @var array<string, string> */
|
||||||
|
public const SEPARATORS = [
|
||||||
|
'hyphen' => '-',
|
||||||
|
'dot' => '.',
|
||||||
|
'underscore' => '_',
|
||||||
|
'space' => ' ',
|
||||||
|
];
|
||||||
|
|
||||||
|
public const MIN_LENGTH = 12;
|
||||||
|
|
||||||
|
public const MAX_LENGTH = 64;
|
||||||
|
|
||||||
|
public const MIN_WORDS = 4;
|
||||||
|
|
||||||
|
public const MAX_WORDS = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array{mode: string, type: string, length: int, characterSets: list<string>, avoidAmbiguous: bool, words: int, separator: string}
|
||||||
|
*/
|
||||||
|
public const DEFAULTS = [
|
||||||
|
'mode' => 'button',
|
||||||
|
'type' => 'characters',
|
||||||
|
'length' => 20,
|
||||||
|
'characterSets' => ['uppercase', 'lowercase', 'numbers'],
|
||||||
|
'avoidAmbiguous' => true,
|
||||||
|
'words' => 6,
|
||||||
|
'separator' => 'hyphen',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var list<string>|null */
|
||||||
|
private ?array $wordList = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the upload page offers generated passwords.
|
||||||
|
*/
|
||||||
|
public function mode(): string
|
||||||
|
{
|
||||||
|
$mode = Setting::get('password_generator_mode');
|
||||||
|
|
||||||
|
return in_array($mode, self::MODES, true) ? $mode : self::DEFAULTS['mode'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The saved generator settings, with the default for anything missing or no longer allowed.
|
||||||
|
*
|
||||||
|
* @return array{mode: string, type: string, length: int, characterSets: list<string>, avoidAmbiguous: bool, words: int, separator: string}
|
||||||
|
*/
|
||||||
|
public function options(): array
|
||||||
|
{
|
||||||
|
$type = Setting::get('password_generator_type');
|
||||||
|
$length = (int) Setting::get('password_generator_length', self::DEFAULTS['length']);
|
||||||
|
$words = (int) Setting::get('password_generator_words', self::DEFAULTS['words']);
|
||||||
|
$separator = Setting::get('password_generator_separator');
|
||||||
|
$characterSets = array_values(array_intersect(
|
||||||
|
array_keys(self::CHARACTER_SETS),
|
||||||
|
explode(',', (string) Setting::get('password_generator_character_sets')),
|
||||||
|
));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'mode' => $this->mode(),
|
||||||
|
'type' => in_array($type, self::TYPES, true) ? $type : self::DEFAULTS['type'],
|
||||||
|
'length' => $length >= self::MIN_LENGTH && $length <= self::MAX_LENGTH ? $length : self::DEFAULTS['length'],
|
||||||
|
'characterSets' => $characterSets ?: self::DEFAULTS['characterSets'],
|
||||||
|
'avoidAmbiguous' => (bool) Setting::get('password_generator_avoid_ambiguous', self::DEFAULTS['avoidAmbiguous'] ? '1' : '0'),
|
||||||
|
'words' => $words >= self::MIN_WORDS && $words <= self::MAX_WORDS ? $words : self::DEFAULTS['words'],
|
||||||
|
'separator' => is_string($separator) && array_key_exists($separator, self::SEPARATORS) ? $separator : self::DEFAULTS['separator'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a password from the given options, or from the saved settings.
|
||||||
|
*
|
||||||
|
* @param array{type: string, length: int, characterSets: list<string>, avoidAmbiguous: bool, words: int, separator: string}|null $options
|
||||||
|
*/
|
||||||
|
public function generate(?array $options = null): string
|
||||||
|
{
|
||||||
|
$options ??= $this->options();
|
||||||
|
|
||||||
|
return $options['type'] === 'passphrase'
|
||||||
|
? $this->passphrase($options['words'], self::SEPARATORS[$options['separator']])
|
||||||
|
: $this->characters($options['length'], $options['characterSets'], $options['avoidAmbiguous']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw characters uniformly from the chosen sets, drawing again until every set shows up at
|
||||||
|
* least once. Redrawing keeps each valid password equally likely, where placing one character
|
||||||
|
* of each set first would not.
|
||||||
|
*
|
||||||
|
* @param list<string> $characterSets
|
||||||
|
*/
|
||||||
|
public function characters(int $length, array $characterSets, bool $avoidAmbiguous): string
|
||||||
|
{
|
||||||
|
$alphabets = $this->alphabets($characterSets, $avoidAmbiguous);
|
||||||
|
|
||||||
|
if ($alphabets === [] || $length < count($alphabets)) {
|
||||||
|
throw new InvalidArgumentException('A password needs at least one character set and room for each of them.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$randomizer = new Randomizer;
|
||||||
|
|
||||||
|
do {
|
||||||
|
$password = $randomizer->getBytesFromString(implode('', $alphabets), $length);
|
||||||
|
} while (array_filter($alphabets, fn (string $alphabet): bool => strpbrk($password, $alphabet) === false) !== []);
|
||||||
|
|
||||||
|
return $password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw words from the word list, each independently of the others.
|
||||||
|
*/
|
||||||
|
public function passphrase(int $words, string $separator): string
|
||||||
|
{
|
||||||
|
$wordList = $this->wordList();
|
||||||
|
$randomizer = new Randomizer;
|
||||||
|
|
||||||
|
return implode($separator, array_map(
|
||||||
|
fn (): string => $wordList[$randomizer->getInt(0, count($wordList) - 1)],
|
||||||
|
range(1, max(1, $words)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roughly how many bits of entropy a password from these options carries.
|
||||||
|
*
|
||||||
|
* @param array{type: string, length: int, characterSets: list<string>, avoidAmbiguous: bool, words: int} $options
|
||||||
|
*/
|
||||||
|
public function entropyBits(array $options): int
|
||||||
|
{
|
||||||
|
if ($options['type'] === 'passphrase') {
|
||||||
|
return (int) floor($options['words'] * log(count($this->wordList()), 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
$alphabetSize = strlen(implode('', $this->alphabets($options['characterSets'], $options['avoidAmbiguous'])));
|
||||||
|
|
||||||
|
return $alphabetSize > 0 ? (int) floor($options['length'] * log($alphabetSize, 2)) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function wordList(): array
|
||||||
|
{
|
||||||
|
return $this->wordList ??= file(resource_path('wordlists/eff-large-wordlist.txt'), FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The characters of each chosen set, without the look-alikes when asked.
|
||||||
|
*
|
||||||
|
* @param list<string> $characterSets
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function alphabets(array $characterSets, bool $avoidAmbiguous): array
|
||||||
|
{
|
||||||
|
return collect(self::CHARACTER_SETS)
|
||||||
|
->only($characterSets)
|
||||||
|
->map(fn (string $alphabet): string => $avoidAmbiguous ? str_replace(str_split(self::AMBIGUOUS_CHARACTERS), '', $alphabet) : $alphabet)
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
+329
-53
@@ -5,80 +5,265 @@ 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\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;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use League\MimeTypeDetection\FinfoMimeTypeDetector;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A share's life: files registered into a pending share, their encrypted chunks stored as the
|
||||||
|
* uploader's browser sends them, and the share completed with its options.
|
||||||
|
*/
|
||||||
class ShareService
|
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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new share with encrypted files.
|
* Register a file the uploader's browser is about to send, in the given pending share or in a
|
||||||
|
* new one, and write its encrypted file's header.
|
||||||
*
|
*
|
||||||
* @param array<int, array{file: UploadedFile, relativePath: string|null}> $files
|
* @throws ValidationException when the file breaks an admin limit
|
||||||
* @param array{password?: string|null, expires_at?: string|null, max_downloads?: int|null} $options
|
|
||||||
*/
|
*/
|
||||||
public function createShare(array $files, array $options = []): Share
|
public function registerFile(?Share $pendingShare, string $name, int $size, ?string $relativePath): ShareFile
|
||||||
{
|
{
|
||||||
$token = $this->generateUniqueToken();
|
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
|
||||||
$salt = $this->encryptionService->generateSalt();
|
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
||||||
$password = $options['password'] ?? null;
|
$maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024);
|
||||||
|
|
||||||
if ($password) {
|
if ($name === '' || mb_strlen($name) > 255 || $size < 0) {
|
||||||
$encryptionKey = $this->encryptionService->deriveKey($password, $salt);
|
$this->rejectFile(__('The file could not be added.'));
|
||||||
$encryptionKeyHex = bin2hex($encryptionKey);
|
|
||||||
$storedEncryptionKey = null;
|
|
||||||
} else {
|
|
||||||
$encryptionKeyHex = $this->encryptionService->generateRandomKey();
|
|
||||||
$storedEncryptionKey = $encryptionKeyHex;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$share = Share::query()->create([
|
if ($size > $maxFileSize) {
|
||||||
'token' => $token,
|
$this->rejectFile(__('":name" is too large (:size MB). Maximum file size is :max MB.', [
|
||||||
'password' => $password ? Hash::make($password) : null,
|
'name' => $name,
|
||||||
'encryption_key' => $storedEncryptionKey,
|
'size' => round($size / (1024 * 1024), 1),
|
||||||
'encryption_salt' => $salt,
|
'max' => intdiv($maxFileSize, 1024 * 1024),
|
||||||
'expires_at' => $options['expires_at'] ?? null,
|
]));
|
||||||
'max_downloads' => $options['max_downloads'] ?? null,
|
}
|
||||||
|
|
||||||
|
if ($pendingShare && $pendingShare->files()->count() >= $maxFilesPerShare) {
|
||||||
|
$this->rejectFile(__('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($pendingShare?->total_size ?? 0) + $size > $maxSizePerShare) {
|
||||||
|
$this->rejectFile(__('Total file size exceeds the maximum allowed per share.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->getTotalUsedSpace() + $size > $this->getMaxStorageQuota()) {
|
||||||
|
$this->rejectFile(__('Storage is full. Please contact the administrator.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$share = $pendingShare ?? Share::query()->create([
|
||||||
|
'token' => $this->generateUniqueToken(),
|
||||||
|
'encryption_key' => $this->encryptionService->generateRandomKey(),
|
||||||
'total_size' => 0,
|
'total_size' => 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$totalSize = 0;
|
$storedName = Str::uuid().'.enc';
|
||||||
|
|
||||||
foreach ($files as $fileData) {
|
Storage::disk('shares')->makeDirectory($share->token);
|
||||||
/** @var UploadedFile $file */
|
Storage::disk('shares')->put($share->token.'/'.$storedName, $this->encryptionService->createHeader((int) config('uploads.chunk_size')));
|
||||||
$file = $fileData['file'];
|
|
||||||
$relativePath = $fileData['relativePath'] ?? null;
|
|
||||||
$storedName = Str::uuid().'.enc';
|
|
||||||
$storedPath = 'shares/'.$share->token.'/'.$storedName;
|
|
||||||
|
|
||||||
$tempPath = $file->getRealPath();
|
$file = $share->files()->create([
|
||||||
$destPath = Storage::disk('shares')->path($share->token.'/'.$storedName);
|
'original_name' => $name,
|
||||||
|
'relative_path' => $this->sanitizeRelativePath($relativePath),
|
||||||
|
'stored_path' => 'shares/'.$share->token.'/'.$storedName,
|
||||||
|
'file_size' => $size,
|
||||||
|
]);
|
||||||
|
|
||||||
Storage::disk('shares')->makeDirectory($share->token);
|
$share->increment('total_size', $size);
|
||||||
|
|
||||||
$this->encryptionService->encryptFile($tempPath, $destPath, $encryptionKeyHex);
|
return $file;
|
||||||
|
}
|
||||||
|
|
||||||
ShareFile::query()->create([
|
/**
|
||||||
'share_id' => $share->id,
|
* Verify the encrypted chunk that comes next for a file and write it into place; returns how
|
||||||
'original_name' => $file->getClientOriginalName(),
|
* many of the file's chunks are stored. The plaintext only exists in memory, to be checked.
|
||||||
'relative_path' => $relativePath,
|
*
|
||||||
'stored_path' => $storedPath,
|
* @throws InvalidArgumentException when the chunk has the wrong length or fails authentication
|
||||||
'file_size' => $file->getSize(),
|
* @throws ModelNotFoundException when the file was removed meanwhile
|
||||||
'mime_type' => $file->getMimeType(),
|
*/
|
||||||
]);
|
public function storeChunk(ShareFile $file, int $index, string $chunk): int
|
||||||
|
{
|
||||||
|
$share = $file->share;
|
||||||
|
$handle = @fopen($this->storedFilePath($file), 'r+b');
|
||||||
|
|
||||||
$totalSize += $file->getSize();
|
if ($handle === false) {
|
||||||
|
throw (new ModelNotFoundException)->setModel(ShareFile::class, [$file->id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$share->update(['total_size' => $totalSize]);
|
try {
|
||||||
|
['chunkSize' => $chunkSize, 'noncePrefix' => $noncePrefix] = $this->encryptionService->parseHeader(
|
||||||
|
(string) fread($handle, FileEncryptionService::HEADER_LENGTH),
|
||||||
|
);
|
||||||
|
|
||||||
return $share->fresh();
|
$chunkCount = $this->encryptionService->chunkCount($file->file_size, $chunkSize);
|
||||||
|
$isLast = $index === $chunkCount - 1;
|
||||||
|
|
||||||
|
if ($index >= $chunkCount) {
|
||||||
|
throw new InvalidArgumentException('Chunk '.$index.' is beyond the end of the file');
|
||||||
|
}
|
||||||
|
|
||||||
|
$plaintextLength = $isLast ? $file->file_size - $index * $chunkSize : $chunkSize;
|
||||||
|
|
||||||
|
if (strlen($chunk) !== $plaintextLength + FileEncryptionService::TAG_LENGTH) {
|
||||||
|
throw new InvalidArgumentException('Chunk '.$index.' has the wrong length');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$plaintext = $this->encryptionService->decryptChunk($chunk, $share->encryption_key, $noncePrefix, $index, $isLast);
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
throw new InvalidArgumentException('Chunk '.$index.' failed authentication');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mimeType = $index === 0
|
||||||
|
? ((new FinfoMimeTypeDetector)->detectMimeType($file->original_name, $plaintext) ?? 'application/octet-stream')
|
||||||
|
: $file->mime_type;
|
||||||
|
|
||||||
|
unset($plaintext);
|
||||||
|
|
||||||
|
if (fseek($handle, $this->encryptionService->chunkOffset($index, $chunkSize)) !== 0
|
||||||
|
|| fwrite($handle, $chunk) !== strlen($chunk)
|
||||||
|
|| ! fflush($handle)) {
|
||||||
|
throw new RuntimeException('Cannot write chunk '.$index.' of file '.$file->id);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counted only once, even when a retry of the same chunk raced this request.
|
||||||
|
$stored = ShareFile::query()
|
||||||
|
->whereKey($file->id)
|
||||||
|
->where('uploaded_chunks', $index)
|
||||||
|
->update([
|
||||||
|
'uploaded_chunks' => $index + 1,
|
||||||
|
'mime_type' => $mimeType,
|
||||||
|
'completed_at' => $isLast ? now() : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($stored === 0) {
|
||||||
|
return ShareFile::query()->findOrFail($file->id)->uploaded_chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
$share->touch();
|
||||||
|
|
||||||
|
return $index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a file from a pending share, whether or not its upload finished.
|
||||||
|
*/
|
||||||
|
public function removeFile(ShareFile $file): void
|
||||||
|
{
|
||||||
|
Storage::disk('shares')->delete($file->share->token.'/'.basename($file->stored_path));
|
||||||
|
|
||||||
|
$file->share->decrement('total_size', $file->file_size);
|
||||||
|
$file->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a pending share once every file has arrived: with a password the data key is
|
||||||
|
* wrapped and no longer stored as it is.
|
||||||
|
*
|
||||||
|
* @param array{password?: string|null, expires_at?: mixed, max_downloads?: int|null} $options
|
||||||
|
*
|
||||||
|
* @throws ValidationException when files are missing, unfinished or break an admin limit
|
||||||
|
*/
|
||||||
|
public function completeShare(Share $share, array $options = []): Share
|
||||||
|
{
|
||||||
|
$files = $share->files()->get();
|
||||||
|
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
||||||
|
$maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024);
|
||||||
|
|
||||||
|
if ($files->isEmpty()) {
|
||||||
|
$this->rejectFile(__('Please select at least one file to upload.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($files->contains(fn (ShareFile $file): bool => $file->completed_at === null)) {
|
||||||
|
$this->rejectFile(__('Wait until every file has finished uploading, or remove the ones that failed.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($files->count() > $maxFilesPerShare) {
|
||||||
|
$this->rejectFile(__('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($files->sum('file_size') > $maxSizePerShare) {
|
||||||
|
$this->rejectFile(__('Total file size exceeds the maximum allowed per share.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$password = $options['password'] ?? null;
|
||||||
|
|
||||||
|
$share->update([
|
||||||
|
'password' => $password ? Hash::make($password) : null,
|
||||||
|
'wrapped_key' => $password ? $this->encryptionService->wrapKey($share->encryption_key, $password) : null,
|
||||||
|
'encryption_key' => $password ? null : $share->encryption_key,
|
||||||
|
'expires_at' => $options['expires_at'] ?? null,
|
||||||
|
'max_downloads' => $options['max_downloads'] ?? null,
|
||||||
|
'total_size' => $files->sum('file_size'),
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $share;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a share from files already on the server, through the same steps an upload from the
|
||||||
|
* browser takes. Used by tests and demo data.
|
||||||
|
*
|
||||||
|
* @param array<int, array{file: UploadedFile, relativePath: string|null}> $files
|
||||||
|
* @param array{password?: string|null, expires_at?: mixed, max_downloads?: int|null} $options
|
||||||
|
*/
|
||||||
|
public function createShare(array $files, array $options = []): Share
|
||||||
|
{
|
||||||
|
$share = null;
|
||||||
|
|
||||||
|
foreach ($files as $fileData) {
|
||||||
|
$file = $fileData['file'];
|
||||||
|
$shareFile = $this->registerFile($share, $file->getClientOriginalName(), $file->getSize(), $fileData['relativePath'] ?? null);
|
||||||
|
$share = $shareFile->share;
|
||||||
|
|
||||||
|
$header = $this->readHeader($shareFile);
|
||||||
|
$source = fopen($file->getRealPath(), 'rb');
|
||||||
|
|
||||||
|
for ($index = 0; $index < $header['chunkCount']; $index++) {
|
||||||
|
$plaintextLength = min($header['chunkSize'], $shareFile->file_size - $index * $header['chunkSize']);
|
||||||
|
|
||||||
|
// A fake upload reports a size its content does not have: zeros make up the rest.
|
||||||
|
$chunk = $this->encryptionService->encryptChunk(
|
||||||
|
str_pad($plaintextLength > 0 ? (string) fread($source, $plaintextLength) : '', $plaintextLength, "\0"),
|
||||||
|
$share->encryption_key,
|
||||||
|
$header['noncePrefix'],
|
||||||
|
$index,
|
||||||
|
$index === $header['chunkCount'] - 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->storeChunk($shareFile->refresh(), $index, $chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($share === null) {
|
||||||
|
$this->rejectFile(__('Please select at least one file to upload.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->completeShare($share, $options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,7 +293,8 @@ class ShareService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the decryption key for a share.
|
* Get the decryption key for a share: unwrapped with the password, derived from it for shares
|
||||||
|
* created before key wrapping, or stored for shares without a password.
|
||||||
*/
|
*/
|
||||||
public function getDecryptionKey(Share $share, ?string $password = null): string
|
public function getDecryptionKey(Share $share, ?string $password = null): string
|
||||||
{
|
{
|
||||||
@@ -117,6 +303,10 @@ class ShareService
|
|||||||
throw new RuntimeException('Password required for this share');
|
throw new RuntimeException('Password required for this share');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($share->wrapped_key !== null) {
|
||||||
|
return $this->encryptionService->unwrapKey($share->wrapped_key, $password);
|
||||||
|
}
|
||||||
|
|
||||||
return bin2hex($this->encryptionService->deriveKey($password, $share->encryption_salt));
|
return bin2hex($this->encryptionService->deriveKey($password, $share->encryption_salt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,19 +326,59 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get total used space in bytes.
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total used space in bytes, files still being uploaded included.
|
||||||
*/
|
*/
|
||||||
public function getTotalUsedSpace(): int
|
public function getTotalUsedSpace(): int
|
||||||
{
|
{
|
||||||
@@ -160,9 +390,7 @@ class ShareService
|
|||||||
*/
|
*/
|
||||||
public function isStorageFull(): bool
|
public function isStorageFull(): bool
|
||||||
{
|
{
|
||||||
$maxQuota = (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024);
|
return $this->getTotalUsedSpace() >= $this->getMaxStorageQuota();
|
||||||
|
|
||||||
return $this->getTotalUsedSpace() >= $maxQuota;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -172,4 +400,52 @@ class ShareService
|
|||||||
{
|
{
|
||||||
return (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024);
|
return (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A registered file's chunk size, nonce prefix and chunk count, from its encrypted file's header.
|
||||||
|
*
|
||||||
|
* @return array{chunkSize: int, noncePrefix: string, chunkCount: int}
|
||||||
|
*/
|
||||||
|
public function readHeader(ShareFile $file): array
|
||||||
|
{
|
||||||
|
$header = $this->encryptionService->parseHeader(
|
||||||
|
(string) file_get_contents($this->storedFilePath($file), false, null, 0, FileEncryptionService::HEADER_LENGTH),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...$header, 'chunkCount' => $this->encryptionService->chunkCount($file->file_size, $header['chunkSize'])];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a file's encrypted content is stored on disk.
|
||||||
|
*/
|
||||||
|
public function storedFilePath(ShareFile $file): string
|
||||||
|
{
|
||||||
|
return Storage::disk('shares')->path($file->share->token.'/'.basename($file->stored_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A relative path from a dropped folder, or null when it could reach outside the share.
|
||||||
|
*/
|
||||||
|
private function sanitizeRelativePath(?string $relativePath): ?string
|
||||||
|
{
|
||||||
|
if ($relativePath === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relativePath = str_replace('\\', '/', $relativePath);
|
||||||
|
|
||||||
|
if (str_starts_with($relativePath, '/') || str_contains($relativePath, '..')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $relativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
private function rejectFile(string $message): never
|
||||||
|
{
|
||||||
|
throw ValidationException::withMessages(['files' => $message]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -17,7 +17,7 @@
|
|||||||
"testing-best-practices",
|
"testing-best-practices",
|
||||||
"octane-development",
|
"octane-development",
|
||||||
"livewire-development",
|
"livewire-development",
|
||||||
"tailwindcss-development",
|
"livewire-material-development",
|
||||||
"livewire-material-development"
|
"material-3-design"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-4
@@ -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();
|
|
||||||
|
|||||||
+3
-8
@@ -16,14 +16,13 @@
|
|||||||
"laravel/octane": "^2.13",
|
"laravel/octane": "^2.13",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"livewire/livewire": "^4.0",
|
"livewire/livewire": "^4.0",
|
||||||
"nonameweb/livewire-material": "^1.0"
|
"maennchen/zipstream-php": "^3.2",
|
||||||
|
"nonameweb/livewire-material": "^2.0"
|
||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -51,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"
|
||||||
],
|
],
|
||||||
@@ -88,7 +83,7 @@
|
|||||||
"screenshots": [
|
"screenshots": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"npm run build",
|
"npm run build",
|
||||||
"@php -d upload_max_filesize=4G -d post_max_size=4G vendor/bin/pest tests/Screenshots"
|
"@php vendor/bin/pest tests/Screenshots"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"extra": {
|
"extra": {
|
||||||
|
|||||||
Generated
+362
-346
File diff suppressed because it is too large
Load Diff
+15
-111
@@ -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.1.0',
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Application Debug Mode
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| When your application is in debug mode, detailed error messages with
|
|
||||||
| stack traces will be shown on every error that occurs within your
|
|
||||||
| application. If disabled, a simple generic error page is shown.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'debug' => (bool) env('APP_DEBUG', false),
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Application URL
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| This URL is used by the console to properly generate URLs when using
|
|
||||||
| the Artisan command line tool. You should set this to the root of
|
|
||||||
| the application so that it's available within Artisan commands.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'url' => env('APP_URL', 'http://localhost'),
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Application Timezone
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Here you may specify the default timezone for your application, which
|
|
||||||
| will be used by the PHP date and date-time functions. The timezone
|
|
||||||
| is set to "UTC" by default as it is suitable for most use cases.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'timezone' => 'UTC',
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Application Locale Configuration
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| The application locale determines the default locale that will be used
|
|
||||||
| by Laravel's translation / localization methods. This option can be
|
|
||||||
| set to any locale for which you plan to have translation strings.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'locale' => env('APP_LOCALE', 'en'),
|
|
||||||
|
|
||||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
|
||||||
|
|
||||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Encryption Key
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| This key is utilized by Laravel's encryption services and should be set
|
|
||||||
| to a random, 32 character string to ensure that all encrypted values
|
|
||||||
| are secure. You should do this prior to deploying the application.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'cipher' => 'AES-256-CBC',
|
|
||||||
|
|
||||||
'key' => env('APP_KEY'),
|
|
||||||
|
|
||||||
'previous_keys' => [
|
|
||||||
...array_filter(
|
|
||||||
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
|
||||||
),
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Maintenance Mode Driver
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| These configuration options determine the driver used to determine and
|
|
||||||
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
|
||||||
| allow maintenance mode to be controlled across multiple machines.
|
|
||||||
|
|
|
||||||
| Supported drivers: "file", "cache"
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'maintenance' => [
|
|
||||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
|
||||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
|
||||||
],
|
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
-117
@@ -1,117 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
|
|
||||||
return [
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Authentication Defaults
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| This option defines the default authentication "guard" and password
|
|
||||||
| reset "broker" for your application. You may change these values
|
|
||||||
| as required, but they're a perfect start for most applications.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'defaults' => [
|
|
||||||
'guard' => env('AUTH_GUARD', 'web'),
|
|
||||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Authentication Guards
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Next, you may define every authentication guard for your application.
|
|
||||||
| Of course, a great default configuration has been defined for you
|
|
||||||
| which utilizes session storage plus the Eloquent user provider.
|
|
||||||
|
|
|
||||||
| All authentication guards have a user provider, which defines how the
|
|
||||||
| users are actually retrieved out of your database or other storage
|
|
||||||
| system used by the application. Typically, Eloquent is utilized.
|
|
||||||
|
|
|
||||||
| Supported: "session"
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'guards' => [
|
|
||||||
'web' => [
|
|
||||||
'driver' => 'session',
|
|
||||||
'provider' => 'users',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| User Providers
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| All authentication guards have a user provider, which defines how the
|
|
||||||
| users are actually retrieved out of your database or other storage
|
|
||||||
| system used by the application. Typically, Eloquent is utilized.
|
|
||||||
|
|
|
||||||
| If you have multiple user tables or models you may configure multiple
|
|
||||||
| providers to represent the model / table. These providers may then
|
|
||||||
| be assigned to any extra authentication guards you have defined.
|
|
||||||
|
|
|
||||||
| Supported: "database", "eloquent"
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'providers' => [
|
|
||||||
'users' => [
|
|
||||||
'driver' => 'eloquent',
|
|
||||||
'model' => env('AUTH_MODEL', User::class),
|
|
||||||
],
|
|
||||||
|
|
||||||
// 'users' => [
|
|
||||||
// 'driver' => 'database',
|
|
||||||
// 'table' => 'users',
|
|
||||||
// ],
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Resetting Passwords
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| These configuration options specify the behavior of Laravel's password
|
|
||||||
| reset functionality, including the table utilized for token storage
|
|
||||||
| and the user provider that is invoked to actually retrieve users.
|
|
||||||
|
|
|
||||||
| The expiry time is the number of minutes that each reset token will be
|
|
||||||
| considered valid. This security feature keeps tokens short-lived so
|
|
||||||
| they have less time to be guessed. You may change this as needed.
|
|
||||||
|
|
|
||||||
| The throttle setting is the number of seconds a user must wait before
|
|
||||||
| generating more password reset tokens. This prevents the user from
|
|
||||||
| quickly generating a very large amount of password reset tokens.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'passwords' => [
|
|
||||||
'users' => [
|
|
||||||
'provider' => 'users',
|
|
||||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
|
||||||
'expire' => 60,
|
|
||||||
'throttle' => 60,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Password Confirmation Timeout
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Here you may define the number of seconds before a password confirmation
|
|
||||||
| window expires and users are asked to re-enter their password via the
|
|
||||||
| confirmation screen. By default, the timeout lasts for three hours.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
|
||||||
|
|
||||||
];
|
|
||||||
+10
-122
@@ -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),
|
|
||||||
],
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
];
|
|
||||||
+10
-71
@@ -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,
|
||||||
|
|||||||
+19
-254
@@ -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'),
|
|
||||||
],
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
];
|
|
||||||
+11
-127
@@ -1,140 +1,24 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Markdown Mail Settings
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Markdown mail wears Livewire Material's theme, coloured from the light
|
||||||
|
| scheme in resources/css/material-scheme.json. Every other key comes from
|
||||||
|
| the framework's defaults.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Default Mailer
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| This option controls the default mailer that is used to send all email
|
|
||||||
| messages unless another mailer is explicitly specified when sending
|
|
||||||
| the message. All additional mailers can be configured within the
|
|
||||||
| "mailers" array. Examples of each type of mailer are provided.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'default' => env('MAIL_MAILER', 'log'),
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Mailer Configurations
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Here you may configure all of the mailers used by your application plus
|
|
||||||
| their respective settings. Several examples have been configured for
|
|
||||||
| you and you are free to add your own as your application requires.
|
|
||||||
|
|
|
||||||
| Laravel supports a variety of mail "transport" drivers that can be used
|
|
||||||
| when delivering an email. You may specify which one you're using for
|
|
||||||
| your mailers below. You may also add additional mailers if needed.
|
|
||||||
|
|
|
||||||
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
|
||||||
| "postmark", "resend", "log", "array",
|
|
||||||
| "failover", "roundrobin"
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'mailers' => [
|
|
||||||
|
|
||||||
'smtp' => [
|
|
||||||
'transport' => 'smtp',
|
|
||||||
'scheme' => env('MAIL_SCHEME'),
|
|
||||||
'url' => env('MAIL_URL'),
|
|
||||||
'host' => env('MAIL_HOST', '127.0.0.1'),
|
|
||||||
'port' => env('MAIL_PORT', 2525),
|
|
||||||
'username' => env('MAIL_USERNAME'),
|
|
||||||
'password' => env('MAIL_PASSWORD'),
|
|
||||||
'timeout' => null,
|
|
||||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
|
||||||
],
|
|
||||||
|
|
||||||
'ses' => [
|
|
||||||
'transport' => 'ses',
|
|
||||||
],
|
|
||||||
|
|
||||||
'postmark' => [
|
|
||||||
'transport' => 'postmark',
|
|
||||||
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
|
||||||
// 'client' => [
|
|
||||||
// 'timeout' => 5,
|
|
||||||
// ],
|
|
||||||
],
|
|
||||||
|
|
||||||
'resend' => [
|
|
||||||
'transport' => 'resend',
|
|
||||||
],
|
|
||||||
|
|
||||||
'sendmail' => [
|
|
||||||
'transport' => 'sendmail',
|
|
||||||
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
|
||||||
],
|
|
||||||
|
|
||||||
'log' => [
|
|
||||||
'transport' => 'log',
|
|
||||||
'channel' => env('MAIL_LOG_CHANNEL'),
|
|
||||||
],
|
|
||||||
|
|
||||||
'array' => [
|
|
||||||
'transport' => 'array',
|
|
||||||
],
|
|
||||||
|
|
||||||
'failover' => [
|
|
||||||
'transport' => 'failover',
|
|
||||||
'mailers' => [
|
|
||||||
'smtp',
|
|
||||||
'log',
|
|
||||||
],
|
|
||||||
'retry_after' => 60,
|
|
||||||
],
|
|
||||||
|
|
||||||
'roundrobin' => [
|
|
||||||
'transport' => 'roundrobin',
|
|
||||||
'mailers' => [
|
|
||||||
'ses',
|
|
||||||
'postmark',
|
|
||||||
],
|
|
||||||
'retry_after' => 60,
|
|
||||||
],
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Global "From" Address
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| You may wish for all emails sent by your application to be sent from
|
|
||||||
| the same address. Here you may specify a name and address that is
|
|
||||||
| used globally for all emails that are sent by your application.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'from' => [
|
|
||||||
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
|
||||||
'name' => env('MAIL_FROM_NAME', 'Example'),
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Markdown Mail Settings
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Markdown mail wears Livewire Material's theme, coloured from the light
|
|
||||||
| scheme in resources/css/material-scheme.json.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'markdown' => [
|
'markdown' => [
|
||||||
'theme' => env('MAIL_MARKDOWN_THEME', 'livewire-material::mail.theme'),
|
'theme' => env('MAIL_MARKDOWN_THEME', 'livewire-material::mail.theme'),
|
||||||
|
|
||||||
'paths' => [
|
'paths' => [
|
||||||
resource_path('views/vendor/mail'),
|
resource_path('views/vendor/mail'),
|
||||||
],
|
],
|
||||||
|
|
||||||
'extensions' => [
|
|
||||||
// \League\CommonMark\Extension\Strikethrough\StrikethroughExtension::class,
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
+11
-206
@@ -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'),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
];
|
|
||||||
+11
-205
@@ -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),
|
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Upload Chunk Size
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The uploader's browser encrypts every file in chunks of this many bytes
|
||||||
|
| and sends each chunk as a request of its own. The size is written into
|
||||||
|
| each file's header, so changing it never affects files already stored.
|
||||||
|
| A reverse proxy in front must accept request bodies a little larger.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'chunk_size' => (int) env('UPLOAD_CHUNK_SIZE_MB', 16) * 1024 * 1024,
|
||||||
|
|
||||||
|
];
|
||||||
@@ -27,9 +27,22 @@ class ShareFactory extends Factory
|
|||||||
'max_downloads' => null,
|
'max_downloads' => null,
|
||||||
'download_count' => 0,
|
'download_count' => 0,
|
||||||
'total_size' => 0,
|
'total_size' => 0,
|
||||||
|
'completed_at' => now(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A share whose files are still being uploaded.
|
||||||
|
*/
|
||||||
|
public function pending(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'encryption_key' => bin2hex(random_bytes(32)),
|
||||||
|
'encryption_salt' => null,
|
||||||
|
'completed_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function withPassword(string $password = 'secret'): static
|
public function withPassword(string $password = 'secret'): static
|
||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
|
|||||||
@@ -25,6 +25,20 @@ class ShareFileFactory extends Factory
|
|||||||
'stored_path' => 'shares/'.fake()->uuid().'.enc',
|
'stored_path' => 'shares/'.fake()->uuid().'.enc',
|
||||||
'file_size' => fake()->numberBetween(1024, 10485760),
|
'file_size' => fake()->numberBetween(1024, 10485760),
|
||||||
'mime_type' => 'text/plain',
|
'mime_type' => 'text/plain',
|
||||||
|
'uploaded_chunks' => 1,
|
||||||
|
'completed_at' => now(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file whose chunks have not all arrived yet.
|
||||||
|
*/
|
||||||
|
public function uploading(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'mime_type' => null,
|
||||||
|
'uploaded_chunks' => 0,
|
||||||
|
'completed_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*
|
||||||
|
* Shares and files that exist already were complete when they were created.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('shares', function (Blueprint $table) {
|
||||||
|
$table->text('wrapped_key')->nullable()->after('encryption_salt');
|
||||||
|
$table->timestamp('completed_at')->nullable()->after('total_size');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('share_files', function (Blueprint $table) {
|
||||||
|
$table->unsignedInteger('uploaded_chunks')->default(0)->after('mime_type');
|
||||||
|
$table->timestamp('completed_at')->nullable()->after('uploaded_chunks');
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('shares')->update(['completed_at' => DB::raw('created_at')]);
|
||||||
|
DB::table('share_files')->update(['completed_at' => DB::raw('created_at')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('shares', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['wrapped_key', 'completed_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('share_files', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['uploaded_chunks', 'completed_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
|||||||
+68
-27
@@ -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: "4G"
|
|
||||||
PHP_POST_MAX_SIZE: "4G"
|
|
||||||
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
|
||||||
|
|||||||
+27
-14
@@ -4,7 +4,7 @@
|
|||||||
#
|
#
|
||||||
# Quick start:
|
# Quick start:
|
||||||
# 1. Copy this file: cp docker-compose.example.yml docker-compose.yml
|
# 1. Copy this file: cp docker-compose.example.yml docker-compose.yml
|
||||||
# 2. Edit the settings below (APP_URL and SERVER_NAME are required)
|
# 2. Edit the settings below (APP_URL is required; uploads need HTTPS, see below)
|
||||||
# 3. Start: docker compose up -d
|
# 3. Start: docker compose up -d
|
||||||
# 4. Open your browser to your configured domain
|
# 4. Open your browser to your configured domain
|
||||||
#
|
#
|
||||||
@@ -22,18 +22,25 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "80:80" # HTTP
|
- "80:80" # HTTP
|
||||||
- "443:443" # HTTPS (auto TLS via Let's Encrypt when SERVER_NAME is a real domain)
|
- "443:443" # HTTPS (a Let's Encrypt certificate with AUTO_HTTPS)
|
||||||
- "443:443/udp" # HTTP/3 (QUIC)
|
- "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
|
||||||
SERVER_NAME: # Your domain for auto-TLS, e.g. share.example.com (use "localhost" for local testing)
|
|
||||||
# APP_KEY: # Auto-generated if not set. Copy from logs to persist across restarts.
|
# 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 ---
|
||||||
|
# Files are encrypted in the uploader's browser, which browsers only allow over HTTPS (or on
|
||||||
|
# localhost). Either let this container fetch a Let's Encrypt certificate (ports 80 and 443
|
||||||
|
# reachable from the internet), or put a reverse proxy that terminates TLS in front of port 80.
|
||||||
|
# AUTO_HTTPS: "true"
|
||||||
|
# SERVER_NAME: share.example.com # The domain to fetch the certificate for (only with AUTO_HTTPS)
|
||||||
|
|
||||||
# --- Optional: Application ---
|
# --- Optional: Application ---
|
||||||
# APP_ENV: production
|
# APP_ENV: production
|
||||||
@@ -45,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
|
||||||
|
|
||||||
@@ -53,15 +60,17 @@ services:
|
|||||||
# OCTANE_HTTPS: "false" # Set to "true" when using HTTPS
|
# OCTANE_HTTPS: "false" # Set to "true" when using HTTPS
|
||||||
# OCTANE_MAX_EXECUTION_TIME: 300 # Max request execution time (seconds)
|
# OCTANE_MAX_EXECUTION_TIME: 300 # Max request execution time (seconds)
|
||||||
|
|
||||||
# --- Optional: PHP upload limits ---
|
# --- Optional: Uploads ---
|
||||||
# PHP_UPLOAD_MAX_FILESIZE: "4G" # Max single file size
|
# UPLOAD_CHUNK_SIZE_MB: "16" # Each encrypted chunk the browser sends; a reverse proxy must accept a little more
|
||||||
# PHP_POST_MAX_SIZE: "4G" # Max total request size
|
|
||||||
# PHP_MAX_EXECUTION_TIME: "300" # Upload timeout in seconds
|
# --- Optional: PHP limits ---
|
||||||
# PHP_MAX_INPUT_TIME: "300" # Input processing timeout
|
# PHP_UPLOAD_MAX_FILESIZE: "64M" # Only for the admin's logo upload: shares upload in chunks
|
||||||
# PHP_MEMORY_LIMIT: "512M" # PHP memory limit
|
# PHP_POST_MAX_SIZE: "64M"
|
||||||
# LIVEWIRE_MAX_UPLOAD_TIME: "30" # Minutes a single upload may take (raise for large files on slow links)
|
# PHP_MAX_EXECUTION_TIME: "300"
|
||||||
|
# PHP_MAX_INPUT_TIME: "300"
|
||||||
|
# PHP_MEMORY_LIMIT: "512M"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "--silent", "--fail", "http://localhost/up"]
|
test: ["CMD", "/app/docker/healthcheck.sh"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
@@ -74,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}"
|
||||||
+27
-31
@@ -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,36 +26,26 @@ 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}
|
AUTO_HTTPS: ${AUTO_HTTPS:-false}
|
||||||
APP_ENV: ${APP_ENV:-production}
|
|
||||||
APP_DEBUG: ${APP_DEBUG:-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}
|
||||||
OCTANE_HTTPS: ${OCTANE_HTTPS:-false}
|
OCTANE_HTTPS: ${OCTANE_HTTPS:-false}
|
||||||
OCTANE_MAX_EXECUTION_TIME: ${OCTANE_MAX_EXECUTION_TIME:-300}
|
OCTANE_MAX_EXECUTION_TIME: ${OCTANE_MAX_EXECUTION_TIME:-300}
|
||||||
PHP_UPLOAD_MAX_FILESIZE: ${PHP_UPLOAD_MAX_FILESIZE:-4G}
|
UPLOAD_CHUNK_SIZE_MB: ${UPLOAD_CHUNK_SIZE_MB:-16}
|
||||||
PHP_POST_MAX_SIZE: ${PHP_POST_MAX_SIZE:-4G}
|
PHP_UPLOAD_MAX_FILESIZE: ${PHP_UPLOAD_MAX_FILESIZE:-64M}
|
||||||
|
PHP_POST_MAX_SIZE: ${PHP_POST_MAX_SIZE:-64M}
|
||||||
PHP_MAX_EXECUTION_TIME: ${PHP_MAX_EXECUTION_TIME:-300}
|
PHP_MAX_EXECUTION_TIME: ${PHP_MAX_EXECUTION_TIME:-300}
|
||||||
PHP_MAX_INPUT_TIME: ${PHP_MAX_INPUT_TIME:-300}
|
PHP_MAX_INPUT_TIME: ${PHP_MAX_INPUT_TIME:-300}
|
||||||
PHP_MEMORY_LIMIT: ${PHP_MEMORY_LIMIT:-512M}
|
PHP_MEMORY_LIMIT: ${PHP_MEMORY_LIMIT:-512M}
|
||||||
LIVEWIRE_MAX_UPLOAD_TIME: ${LIVEWIRE_MAX_UPLOAD_TIME:-30}
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "--silent", "--fail", "http://localhost/up"]
|
test: ["CMD", "/app/docker/healthcheck.sh"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
@@ -53,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
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
frankenphp
|
|
||||||
order php_server before file_server
|
|
||||||
admin off
|
|
||||||
}
|
|
||||||
|
|
||||||
{$SERVER_NAME:localhost} {
|
|
||||||
root * /app/public
|
|
||||||
encode zstd gzip
|
|
||||||
request_body {
|
|
||||||
max_size 4gb
|
|
||||||
}
|
|
||||||
php_server
|
|
||||||
}
|
|
||||||
@@ -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:-4G}
|
|
||||||
post_max_size = ${PHP_POST_MAX_SIZE:-4G}
|
|
||||||
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"]
|
|
||||||
+22
-10
@@ -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:-4G}
|
if [ ! -e "database/migrations/${migration##*/}" ]; then
|
||||||
post_max_size = ${PHP_POST_MAX_SIZE:-4G}
|
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
|
||||||
@@ -33,5 +33,17 @@ php artisan config:cache
|
|||||||
php artisan route:cache
|
php artisan route:cache
|
||||||
php artisan view:cache
|
php artisan view:cache
|
||||||
|
|
||||||
echo "[entrypoint] Starting Octane (FrankenPHP)..."
|
# Uploads are encrypted in the browser, which browsers only allow over HTTPS: either this container
|
||||||
|
# fetches a certificate for SERVER_NAME itself, or a reverse proxy in front terminates TLS.
|
||||||
|
if [ "${AUTO_HTTPS:-false}" = "true" ]; then
|
||||||
|
if [ -z "$SERVER_NAME" ]; then
|
||||||
|
echo "[entrypoint] AUTO_HTTPS=true needs SERVER_NAME, the domain to fetch a certificate for." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[entrypoint] Starting Octane (FrankenPHP) with automatic HTTPS for $SERVER_NAME..."
|
||||||
|
exec php artisan octane:frankenphp --host="$SERVER_NAME" --port=443 --https --http-redirect
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[entrypoint] Starting Octane (FrankenPHP) on HTTP..."
|
||||||
exec php artisan octane:frankenphp --host=0.0.0.0 --port=80
|
exec php artisan octane:frankenphp --host=0.0.0.0 --port=80
|
||||||
|
|||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Healthy when the application answers /up: over HTTP on port 80, or over HTTPS for SERVER_NAME when
|
||||||
|
# AUTO_HTTPS is on (port 80 then only redirects). The certificate is not checked, so a container
|
||||||
|
# still waiting for Let's Encrypt is judged by the application, not by its certificate.
|
||||||
|
if [ "${AUTO_HTTPS:-false}" = "true" ]; then
|
||||||
|
exec curl --silent --fail --insecure --resolve "$SERVER_NAME:443:127.0.0.1" "https://$SERVER_NAME/up"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec curl --silent --fail http://localhost/up
|
||||||
@@ -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 = 4G
|
upload_max_filesize = ${PHP_UPLOAD_MAX_FILESIZE:-64M}
|
||||||
post_max_size = 4G
|
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,210 +0,0 @@
|
|||||||
# Colour profiles
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
An installation of SealShare can wear one of eight colour profiles instead of the single indigo
|
|
||||||
scheme. The admin picks the profile in Admin settings, previews it on the page while choosing, and
|
|
||||||
on Save it applies to everyone: signed-in users, recipients on the upload and download pages, the
|
|
||||||
Markdown mails and the error pages. Livewire Material learns colour profiles in general — any
|
|
||||||
application lists its own in config, the package generates them, switches between them before the
|
|
||||||
first paint and follows the active one everywhere it draws colour — and ships it as 1.1.0 before
|
|
||||||
SealShare tags 2.0.0.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
**Livewire Material 1.0.1** (`../livewire-material`):
|
|
||||||
|
|
||||||
- `php artisan material:scheme {seed} --variant= --contrast= --success= --warning= --info= --output=`
|
|
||||||
(`src/Console/SchemeCommand.php`) runs `resources/node/scheme.mjs` (Google's
|
|
||||||
material-color-utilities, a 93 KB bundle) through Node and writes `resources/css/material-scheme.css`
|
|
||||||
— `:root, [data-theme='light'] { color-scheme: light; --md-sys-color-*: … }` and
|
|
||||||
`[data-theme='dark'] { … }`, about 60 roles each — and `material-scheme.json`
|
|
||||||
(`{seed, variant, spec, contrast, light, dark}`).
|
|
||||||
- Every component and token reads only `--md-sys-color-*` (`resources/css/tokens/theme.css` maps them
|
|
||||||
to Tailwind colours). The package's own default is `resources/css/tokens/scheme.css` and `.json`.
|
|
||||||
- `<x-theme-script>` (in `<head>`, before `@vite`) writes `data-theme`, `data-theme-choice`,
|
|
||||||
`data-theme-key`, `data-rail`, `data-rail-key` on `<html>` before the first paint, and puts them
|
|
||||||
back after a `wire:navigate` swap (`onSwap`). `$store.theme` lives in `resources/js/theme.js`.
|
|
||||||
- `Support\Scheme::load()` / `light()` read `config('livewire-material.scheme')` (the JSON) merged over
|
|
||||||
the package default; the mail theme (`resources/views/mail/theme.blade.php`) and the fallback styles
|
|
||||||
of the error pages (`Support\ErrorPage::fallbackStyles()`) use it.
|
|
||||||
- Tests: `SchemeCommandTest` (runs Node), `TokensTest`, `MailThemeTest`, `ErrorPagesTest`,
|
|
||||||
`ShowcaseTest`, browser tests in three engines; CI on Gitea.
|
|
||||||
|
|
||||||
**SealShare:**
|
|
||||||
|
|
||||||
- One scheme, `#4f46e5` Vibrant (`.ai/rules/css.md` records the exact command).
|
|
||||||
- The production Docker image has no Node, so nothing can be generated at runtime.
|
|
||||||
- Settings → Appearance is the Light/Dark/System picker, stored per browser. All users are admins;
|
|
||||||
recipients are guests.
|
|
||||||
- `App\Livewire\Admin\AdminSettings` holds the settings in `Setting` (key/value) and saves them in
|
|
||||||
`saveSettings()` with one validation call; the form ends in "Save Settings". It uses `Toasts`.
|
|
||||||
- Octane: the application boots once per worker, so anything request-specific must be read per call.
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
- **The admin chooses, nobody else** — one profile for the whole installation; no per-user or
|
|
||||||
per-visitor choice. Light, dark and system stay each visitor's own, as now.
|
|
||||||
- **Ready-made profiles, no free colour** — generated ahead of time with `material:scheme` and shipped
|
|
||||||
in the CSS: correct from the first frame, no generator in the browser, mails and error pages can
|
|
||||||
follow.
|
|
||||||
- **Eight profiles, Vibrant style like today** — `indigo` Indigo `#4f46e5` (the default, today's),
|
|
||||||
`blue` Blue `#0b57d0`, `teal` Teal `#00897b`, `green` Green `#2e7d32`, `amber` Amber `#e8710a`,
|
|
||||||
`rose` Rose `#c2185b`, `violet` Violet `#6750a4`, all `vibrant`; `graphite` Graphite `#5f6368`
|
|
||||||
in the `neutral` style.
|
|
||||||
- **The mechanism is the package's, the profiles are the application's** — Livewire Material gets
|
|
||||||
`profiles` in its config; SealShare lists its eight in its published config. Other applications
|
|
||||||
define their own.
|
|
||||||
- **Swatch picker at the top of Admin settings, previewed live, applied on Save** — a "Colour
|
|
||||||
profile" card with one swatch per profile (primary, secondary and tertiary dots, the name, a check
|
|
||||||
on the chosen one); a click recolours the page at once; "Save Settings" stores it for everyone.
|
|
||||||
Leaving without saving shows the saved profile on the next page.
|
|
||||||
- **Profiles are keyed by `<html data-scheme>`** — the default profile also stands without the
|
|
||||||
attribute, so the stylesheet works before the head script runs and with an unknown name:
|
|
||||||
|
|
||||||
```css
|
|
||||||
:root, [data-theme='light'] { /* default, light */ }
|
|
||||||
[data-theme='dark'] { /* default, dark */ }
|
|
||||||
[data-scheme='teal'], [data-scheme='teal'][data-theme='light'] { /* teal, light */ }
|
|
||||||
[data-scheme='teal'][data-theme='dark'] { /* teal, dark */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
A profile's two-attribute selectors outrank the default's single ones, and its one-attribute
|
|
||||||
selector comes later in the file than `:root`, so the order is part of the format.
|
|
||||||
- **The active profile is resolved on every use, never kept** — the application registers a resolver
|
|
||||||
once (`Scheme::resolveProfileUsing(fn (): ?string => …)`); the head script, the mail theme and the
|
|
||||||
error pages call it each time they draw. A name that is not a generated profile, or no resolver,
|
|
||||||
falls back to the JSON's `default` — the `profile` config (else the first profile) when the scheme
|
|
||||||
was generated. Nothing request-specific is
|
|
||||||
stored on a static, so Octane workers stay clean.
|
|
||||||
- **The JSON keeps its old top-level shape** — `light` and `dark` are still the default profile's
|
|
||||||
roles, beside `default` and `profiles.{name}.{label, seed, variant, spec, contrast, light, dark}`,
|
|
||||||
so a reader of the 1.0 format keeps working.
|
|
||||||
- **`material:scheme` with a seed is unchanged** — one scheme, as in 1.0. Without a seed it generates
|
|
||||||
every configured profile; without either it fails with a message naming both ways.
|
|
||||||
- **A `<x-scheme-picker>` component in the package** — native radios in a `radiogroup`, bound with
|
|
||||||
`wire:model` (or `x-model`), each labelled with the profile's name and its three colours from the
|
|
||||||
JSON; choosing one sets `<html data-scheme>` immediately (the preview). Errors for the bound property
|
|
||||||
show under it.
|
|
||||||
- **The showcase can preview every profile** — a profile menu in its app bar when profiles are
|
|
||||||
configured, recolouring the showcase without storing anything.
|
|
||||||
- **Release** — Livewire Material 1.1.0 (a feature), then SealShare's lock, all before 2.0.0.
|
|
||||||
SealShare's changelog lists it under 2.0.0 "Added".
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- A colour picker for any colour, extracting a colour from the logo, or per-profile contrast levels.
|
|
||||||
- Per-user or per-visitor profiles, or a profile switch outside Admin settings.
|
|
||||||
- Changing the website's colours (it stays indigo) or adding profile screenshots.
|
|
||||||
- New success/warning/info sources per profile — they stay the package defaults.
|
|
||||||
|
|
||||||
## Implementation steps
|
|
||||||
|
|
||||||
### Livewire Material 1.1.0 (`../livewire-material`)
|
|
||||||
|
|
||||||
1. **Config.** `config/livewire-material.php`: `'profiles' => []` (name ⇒ `label`, `seed`,
|
|
||||||
`variant`, optional `contrast`) and `'profile' => null` (the fallback name), documented in the
|
|
||||||
config comment beside `scheme`.
|
|
||||||
2. **Generator.** `SchemeCommand`: `seed` becomes optional. Without it, read `profiles`; for each
|
|
||||||
run `scheme.mjs` as today (validating seed, variant and contrast through the generator's own
|
|
||||||
errors), then write the stylesheet in the format under Decisions — the default profile (the
|
|
||||||
`profile` config, else the first) as the plain blocks, then every profile's blocks in config
|
|
||||||
order — and the JSON with `default`, `profiles` and the default's top-level `light`/`dark`. The
|
|
||||||
header comment names the command and says the profiles come from config. With neither a seed nor
|
|
||||||
profiles, fail naming both.
|
|
||||||
3. **Scheme.** `Support\Scheme`: `resolveProfileUsing(?Closure $resolver): void`,
|
|
||||||
`profiles(?string $path = null): array` (name ⇒ label and light/dark roles, from the JSON),
|
|
||||||
`profile(?string $path = null): ?string` (the resolver's answer if it names a profile in the
|
|
||||||
JSON, else `default` from the JSON, else null), and `load(?string $path = null, ?string $profile = null)`
|
|
||||||
returning that profile's roles merged over the package default (the active profile when
|
|
||||||
`$profile` is null; the top-level roles for a 1.0 file). `light()` follows, so the mail theme and
|
|
||||||
`ErrorPage::fallbackStyles()` draw the active profile without further change: the fallback's plain
|
|
||||||
`:root`/`[data-theme]` blocks carry that profile's roles, which is all a page without its build
|
|
||||||
needs. Every method reads the JSON on each call, as `load()` does today.
|
|
||||||
4. **Head script.** `<x-theme-script>`: when the JSON has profiles, write
|
|
||||||
`data-scheme="{active profile}"` on `<html>` with the others, and keep it through `onSwap`.
|
|
||||||
`$store.theme` gains `scheme` (read from the attribute) and `previewScheme(name)` (sets the
|
|
||||||
attribute, stores nothing).
|
|
||||||
5. **Picker.** `resources/views/components/scheme-picker.blade.php` as under Decisions: props
|
|
||||||
`label`, `hint`, `profiles` (default `Scheme::profiles()`), `name`; labels through `__()`. Each
|
|
||||||
swatch is a label around a visually hidden native radio, drawn with Tailwind utilities (a
|
|
||||||
`surface-container` tile, `outline` when checked, a check icon); its three dots are the only inline
|
|
||||||
styles — `background-color` from that profile's light roles, which `Scheme` has already checked
|
|
||||||
are `#rrggbb` — because they show another profile's colours than the page's. `x-on:change` calls
|
|
||||||
`$store.theme.previewScheme($event.target.value)`. With no profiles it renders nothing.
|
|
||||||
6. **Showcase.** A profile menu in `resources/views/showcase/layout.blade.php`'s app bar when profiles
|
|
||||||
exist, calling `previewScheme`; the colour section already reads the variables, so it follows.
|
|
||||||
`src/Showcase/Sections.php` gains the picker as an example (and the search index with it).
|
|
||||||
7. **Docs.** `resources/boost/skills/livewire-material-development/SKILL.md` (Colour scheme: profiles,
|
|
||||||
resolver, picker; the new component in Components), `resources/boost/guidelines/core.blade.php`
|
|
||||||
(one line), `README.md` (Colour scheme and Configuration).
|
|
||||||
8. **Release.** Verify in `.verify` (Feature + Browser in chrome, firefox, safari), push, watch CI,
|
|
||||||
tag `1.1.0`.
|
|
||||||
|
|
||||||
### SealShare
|
|
||||||
|
|
||||||
9. **Package.** `composer update nonameweb/livewire-material` to 1.1.0.
|
|
||||||
10. **Profiles.** `config/livewire-material.php`: the eight profiles under Decisions and
|
|
||||||
`'profile' => 'indigo'`. Run `php artisan material:scheme` to regenerate
|
|
||||||
`resources/css/material-scheme.css` and `.json`; `npm run build`. Update `.ai/rules/css.md`: the
|
|
||||||
scheme is regenerated with `php artisan material:scheme` from the profiles in config, never
|
|
||||||
hand-edited.
|
|
||||||
11. **Resolver.** `AppServiceProvider::boot()`: `Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'))`.
|
|
||||||
12. **Admin settings.** `AdminSettings`: `public string $colorProfile`, mounted from
|
|
||||||
`Scheme::profile()`; validated with `Rule::in(array_keys(Scheme::profiles()))` in
|
|
||||||
`saveSettings()` — the profiles actually generated into the stylesheet, not merely listed in
|
|
||||||
config; saved with `Setting::set('color_profile', $this->colorProfile)`.
|
|
||||||
`admin-settings.blade.php`: a "Colour profile" card first in the form with
|
|
||||||
`<x-scheme-picker wire:model="colorProfile" :label="__('Colour profile')" />` and a hint that
|
|
||||||
the choice applies to every page, mail and error page after saving.
|
|
||||||
13. **Docs.** README features: "Colour Profiles — eight colour profiles, chosen by the admin".
|
|
||||||
CHANGELOG `2.0.0` "Added". `composer screenshots` again (the admin settings shot shows the new
|
|
||||||
card); the website's feature list gains the same line.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
**Package**
|
|
||||||
|
|
||||||
- `SchemeCommandTest`: with profiles configured and no seed, the JSON has `default` and every profile
|
|
||||||
with light and dark roles, top-level `light`/`dark` equal the default's; the stylesheet has the
|
|
||||||
default's plain blocks first and each profile's `[data-scheme='…']` blocks after, with its hexes;
|
|
||||||
a seed still writes the 1.0 format; neither fails with the message.
|
|
||||||
- `SchemeTest` (new, Feature): `profile()` follows a resolver naming a profile, falls back on an
|
|
||||||
unknown name, on no resolver and on a 1.0 file; `load()` returns the chosen profile's roles.
|
|
||||||
- `MailThemeTest`: the mail's primary is the resolved profile's. `ErrorPagesTest`: the fallback
|
|
||||||
styles carry the resolved profile's roles.
|
|
||||||
- Components: `<x-theme-script>` renders `data-scheme` for the resolved profile and none without
|
|
||||||
profiles; `<x-scheme-picker>` renders a radio per profile, checked from `wire:model`, with the
|
|
||||||
labels.
|
|
||||||
- Browser (three engines): `--md-sys-color-primary` on `<html>` is the profile's in light and in
|
|
||||||
dark, and the default's without the attribute; choosing a swatch changes it at once; the attribute
|
|
||||||
survives `wire:navigate`; the showcase menu previews a profile.
|
|
||||||
|
|
||||||
**SealShare**
|
|
||||||
|
|
||||||
- `AdminSettingsTest`: a valid profile is saved and a toast dispatched; an unknown one fails
|
|
||||||
validation and saves nothing.
|
|
||||||
- `ColourProfileTest` (new, Feature): a guest's upload page renders `data-scheme` from the saved
|
|
||||||
setting and the default without one; the reset-password mail uses the profile's primary.
|
|
||||||
- `tests/Browser/SealShareTest.php`: in Admin settings a swatch recolours the page before saving;
|
|
||||||
after Save and a reload, and on a guest's download page, the profile stays.
|
|
||||||
- `DesignLanguageTest` and `WebsiteTest` keep passing.
|
|
||||||
|
|
||||||
## Risks and open questions
|
|
||||||
|
|
||||||
- **Stylesheet size.** Eight profiles × two themes × ~60 roles is about 60 KB before compression
|
|
||||||
(a few KB gzipped); acceptable, and the CSS stays cacheable.
|
|
||||||
- **A query per page for the setting.** `Setting::get('color_profile')` runs when the head script
|
|
||||||
renders, like the site title already does; cache it later if it ever shows.
|
|
||||||
- **Swatch colours are inline styles.** The design guard does not look at `style` attributes, so
|
|
||||||
nothing stops them spreading; they stay inside `<x-scheme-picker>` and come only from `Scheme`'s
|
|
||||||
checked hexes, which the component test asserts.
|
|
||||||
- **A profile removed from config** while saved leaves the setting pointing nowhere; the resolver's
|
|
||||||
fallback to the default covers it, and Admin settings shows the default as chosen.
|
|
||||||
- **Config and stylesheet out of step.** A profile added to config but not generated is not offered:
|
|
||||||
the picker, the resolver and the validation all read the generated JSON. `.ai/rules/css.md` says to
|
|
||||||
regenerate after changing profiles.
|
|
||||||
- **Open tabs** keep the profile they loaded (or previewed) until their next full load;
|
|
||||||
`wire:navigate` carries the page's current attribute forward.
|
|
||||||
- **Error pages without a build** use the fallback styles, which draw the active profile directly;
|
|
||||||
covered by `ErrorPagesTest`.
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
# SealShare on Livewire Material (2.0.0)
|
|
||||||
|
|
||||||
> The package itself — its decisions, the wave plan (Phases 1–10) and its tests — moved to
|
|
||||||
> the package repo on 2026-09-13: [noNameWEB/livewire-material · docs/plans/livewire-material.md](https://gitea.nonameweb.ch/noNameWEB/livewire-material/src/branch/main/docs/plans/livewire-material.md).
|
|
||||||
> This file keeps what SealShare does once the package reaches `1.0.0`.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
SealShare's UI is maryUI 2.9 on daisyUI 5 — a generic web-page look. After this change it runs
|
|
||||||
on **`nonameweb/livewire-material` `^1.0`**: a clean, calm indigo Material 3 Expressive app with
|
|
||||||
a top app bar, light / dark / system theme, and two Expressive moments — the upload drop zone
|
|
||||||
and "link ready" — shipped as SealShare 2.0.0.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
**Stacks.** SealShare: Laravel 13.31, Livewire 4.4, maryUI 2.9.10 (no prefix), daisyUI 5.7,
|
|
||||||
Tailwind 4.3, Pest 5.1, Octane on FrankenPHP, PHP 8.5; public on GitHub under MIT, image
|
|
||||||
published to `ghcr.io/surtic86/sealshare`. ReStride: same Laravel / Livewire / Tailwind / Pest,
|
|
||||||
private on `gitea.nonameweb.ch`, CI through Gitea act_runner.
|
|
||||||
|
|
||||||
**SealShare's UI surface** (inventory, 2026-09-13):
|
|
||||||
|
|
||||||
- maryUI tags: `button` 30, `input` 18, `password` 16, `icon` 14, `card` 9 (6 `actions`
|
|
||||||
slots), `menu`/`menu-item` 1/4 (settings nav), `theme-toggle` 3, `toggle` 2, `select` 2,
|
|
||||||
`modal` 2, `table` 1 (`:headers :rows :sort-by with-pagination`, `@scope`), `textarea` 1,
|
|
||||||
`toast` 1 (never triggered).
|
|
||||||
- Raw daisyUI: `btn` (+ `-primary/-ghost/-sm/-xs/-error/-outline/-disabled`), `alert` ×6,
|
|
||||||
`card`/`card-body` (4 admin stat tiles), `join` (2 copy fields), `progress` ×2,
|
|
||||||
`loading` ×2, `badge-success/-error`, `divider`, `link link-primary` ×5, `label`,
|
|
||||||
`file-input`, `checkbox`; tokens `bg-base-*`, `border-base-300`, `text-error/success`,
|
|
||||||
`border-primary(/50)`, `bg-primary/5`; raw `text-green-600`, `bg-white` (QR code).
|
|
||||||
Secondary text is `opacity-50/60/70`.
|
|
||||||
- 20 Heroicons (outline), through `blade-heroicons` pulled in transitively by maryUI.
|
|
||||||
- No `Mary\` PHP coupling. Admin settings flashes `session('message')` into an alert
|
|
||||||
(`AdminSettings.php:116,128,135`). 3 `wire:confirm`.
|
|
||||||
- Layouts: `layouts/app` → `app/sidebar` (centered `max-w-5xl` + footer nav), used by the
|
|
||||||
Livewire pages and all settings SFCs (`config/livewire.php:47`); `layouts/auth` →
|
|
||||||
`auth/simple`. The theme script sits *outside* `<head>` and hard-codes dark, while maryUI's
|
|
||||||
toggle defaults from the OS. `partials/head` loads Instrument Sans from fonts.bunny.net.
|
|
||||||
- Dead: `/dashboard` (starter placeholder, and Fortify's `home`), `welcome`,
|
|
||||||
`pages/auth/register` (still referenced by `Fortify::registerView`,
|
|
||||||
`FortifyServiceProvider.php:52`), `layouts/app/header`, `layouts/auth/{card,split}`,
|
|
||||||
`components/app-logo`, `components/desktop-user-menu`, `components/placeholder-pattern`;
|
|
||||||
the `alpinejs` npm dependency; the Flux credentials step in `tests.yml` and `docker.yml`.
|
|
||||||
- Settings `profile` and `password` show "Saved." through `components/action-message`,
|
|
||||||
listening for `profile-updated` / `password-updated`; `partials/settings-heading` uses a
|
|
||||||
daisyUI `divider`. The 3 `wire:confirm` are admin settings (remove logo, clear system
|
|
||||||
password) and admin dashboard (delete share). `AdminDashboard::headers()` exists only for
|
|
||||||
maryUI's table.
|
|
||||||
- Tests assert text only, never markup; no browser tests.
|
|
||||||
- Docker: the image's caches run in `docker/entrypoint.sh` (`config:cache`, `route:cache`,
|
|
||||||
`view:cache`); `docker/dev-entrypoint.sh` runs `npm run build` against the host's mounted
|
|
||||||
`vendor/` without a `composer install`. The Flux credentials step is in `tests.yml`,
|
|
||||||
`docker.yml` **and** `lint.yml`.
|
|
||||||
- Screens: setup, system password, upload, share created, share download, admin dashboard,
|
|
||||||
admin settings, settings (profile, password, appearance, two-factor), Fortify pages (login,
|
|
||||||
forgot, reset, 2FA challenge, confirm, verify email). Stock Laravel error pages and mails.
|
|
||||||
|
|
||||||
**Constraints found.**
|
|
||||||
|
|
||||||
- SealShare's `Dockerfile` builds assets (stage 1) **before** `composer install` (stage 2);
|
|
||||||
CSS imported from `vendor/` needs the order swapped.
|
|
||||||
- Laravel **replaces** the `errors` view namespace at render time with
|
|
||||||
`config('view.paths')` + `/errors` and the framework's own
|
|
||||||
(`Illuminate/Foundation/Exceptions/RegisterErrorViewPaths.php`), so error views a package
|
|
||||||
adds with `addNamespace('errors', …)` are wiped; only a path in `view.paths` survives.
|
|
||||||
- The package lives at `https://gitea.nonameweb.ch/noNameWEB/livewire-material.git` (public,
|
|
||||||
anonymous reads verified 2026-09-13).
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
The package's decisions are in its own plan. SealShare's:
|
|
||||||
|
|
||||||
- **Converts after `1.0.0`, in one pass, by hand** (~150 tags; no codemod), on branch
|
|
||||||
`material`, released as **2.0.0**.
|
|
||||||
- **Moving SealShare to Gitea is a separate plan** — this plan works wherever it is hosted.
|
|
||||||
- **Seed `#4f46e5` (the favicon's indigo), Vibrant** — chosen after comparing it with Tonal Spot
|
|
||||||
on the upload page in both themes (2026-09-13): Tonal Spot read grey-lavender on this seed.
|
|
||||||
- **Theme default `system`**, storage key `sealshare-theme`, legacy `mary-theme` adopted once.
|
|
||||||
Appearance is a Light / Dark / System connected button group.
|
|
||||||
- **One top app bar everywhere** — logo and site title; a theme toggle for guests, an avatar
|
|
||||||
account menu (Upload, Admin dashboard, Admin settings, Settings, theme, Log out) for users;
|
|
||||||
centered content; Admin and Settings sub-pages as secondary tabs (menu picker on a phone);
|
|
||||||
auth pages a centered card under the same bar. No rail, no bottom bar.
|
|
||||||
- **Expressive components plus two hero moments** — an Expressive shape behind the upload icon
|
|
||||||
that morphs while files are dragged over, the wavy progress indicator for uploads, a
|
|
||||||
shape-backed check when the link is ready; admin stats count up once. Instant under
|
|
||||||
`prefers-reduced-motion`.
|
|
||||||
- **The public download page uses no anchored components** (no menus, no tooltips) — it must
|
|
||||||
work for recipients on iOS below 18.4.
|
|
||||||
- **Starter-kit cleanup during the conversion** — delete the placeholder `/dashboard`, point
|
|
||||||
Fortify `home` at the admin dashboard, delete the unused views and the `registerView`
|
|
||||||
binding, drop `alpinejs` from npm and the Flux step from CI.
|
|
||||||
- **Confirmations become M3 basic dialogs** (the 3 `wire:confirm`) — the browser's native
|
|
||||||
confirm cannot be themed and reads as a different app. *(Not asked in the interview; object
|
|
||||||
in review if you prefer the native confirm.)*
|
|
||||||
- **Save feedback becomes a snackbar** through the package's `Toasts` concern — admin
|
|
||||||
settings' flashed `session('message')` alert and settings' "Saved." `action-message` alike.
|
|
||||||
*(Follows from the snackbar; not asked separately.)*
|
|
||||||
- **The font is self-hosted** — the fonts.bunny.net request goes, which also suits a
|
|
||||||
privacy-minded self-hosted app.
|
|
||||||
- **Tests: updated feature tests, the package's guard as `DesignLanguageTest`, Livewire tests
|
|
||||||
for changed behaviour, and four browser tests** with `pestphp/pest-plugin-browser` (new dev
|
|
||||||
dependency, approved).
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- ReStride adopting the package — its own plan, after `1.0.0`.
|
|
||||||
- Moving SealShare's repository, CI and image registry to Gitea — its own plan.
|
|
||||||
- Everything the package plan puts out of scope.
|
|
||||||
- Changes to SealShare's features, routes or information architecture beyond the cleanup above.
|
|
||||||
|
|
||||||
## Implementation steps
|
|
||||||
|
|
||||||
Step numbers continue the original plan's, so references elsewhere stay valid.
|
|
||||||
|
|
||||||
### Phase 11 — SealShare 2.0.0 (after `1.0.0`)
|
|
||||||
|
|
||||||
34. **Branch** `material` from `main`; open the PR so CI runs.
|
|
||||||
35. **Dependencies.** Add the `vcs` repository and `composer require nonameweb/livewire-material:^1.0`;
|
|
||||||
`composer remove robsontenorio/mary` (drops `blade-heroicons` with it);
|
|
||||||
`npm remove daisyui alpinejs`; `composer require --dev pestphp/pest-plugin-browser`.
|
|
||||||
maryUI goes **first** because its class components would shadow the package's same-named
|
|
||||||
anonymous ones; the branch is therefore red from here until step 44, which is accepted —
|
|
||||||
it merges once, green (Decisions: one pass).
|
|
||||||
36. **CI and Docker.** Remove the Flux credentials step from `.github/workflows/tests.yml`,
|
|
||||||
`docker.yml` and `lint.yml`; install Playwright browsers in `tests.yml`. `Dockerfile`: run
|
|
||||||
the Composer stage first and `COPY --from=vendor /app/vendor ./vendor` into the Node stage
|
|
||||||
before `npm run build`. `docker/dev-entrypoint.sh`: run `composer install` when `vendor/` is
|
|
||||||
missing, before `npm run build`. No `icons:cache` anywhere: the package draws its symbols
|
|
||||||
without blade-icons.
|
|
||||||
37. **Styles and scheme.** `resources/css/app.css`: `@import 'tailwindcss'`, the package entry
|
|
||||||
from `vendor/`, `./material-scheme.css`, `@source '../views'` and the package's views; drop
|
|
||||||
the daisyUI plugin, maryUI and pagination `@source`s and the swap safelist.
|
|
||||||
`resources/js/app.js` imports the package JS. Run
|
|
||||||
`php artisan material:scheme "#4f46e5" --variant=vibrant` (chosen over Tonal Spot after
|
|
||||||
comparing both on the upload page in both themes).
|
|
||||||
38. **Head and theme.** `partials/head`: remove fonts.bunny.net; include `<x-theme-script />`
|
|
||||||
before `@vite` (it currently sits outside `<head>`). Publish the config with
|
|
||||||
`theme.default = system`, `storage_key = sealshare-theme`, `legacy_keys = ['mary-theme']`.
|
|
||||||
39. **Layouts.** Rebuild `layouts/app.blade.php` (absorbing `app/sidebar`): `<x-app-bar>` with
|
|
||||||
`app-logo-icon` / branding logo and site title, `<x-theme-toggle>` for guests or
|
|
||||||
`<x-account-menu>` for users (Upload, Admin dashboard, Admin settings, Settings, theme, Log
|
|
||||||
out through `App\Livewire\Actions\Logout`), centered content, `<x-toast>`.
|
|
||||||
`layouts/auth.blade.php` (absorbing `auth/simple`): the same bar and a centered card.
|
|
||||||
40. **Cleanup.** Delete the `/dashboard` route, `dashboard.blade.php`, `placeholder-pattern`,
|
|
||||||
`welcome`, `pages/auth/register` and its `Fortify::registerView` line,
|
|
||||||
`layouts/app/{header,sidebar}`, `layouts/auth/{card,split,simple}`, `app-logo`,
|
|
||||||
`desktop-user-menu`. Fortify `home` → `/admin/dashboard`. Update `AuthenticationTest:22`
|
|
||||||
and `EmailVerificationTest:32,63` to the new redirect; `DashboardTest` is rewritten to
|
|
||||||
assert that a signed-in admin lands on the admin dashboard and `/dashboard` is gone
|
|
||||||
(replacing its placeholder tests, approved in the interview). `RegistrationTest` stays.
|
|
||||||
41. **Public pages.** `livewire/file-uploader`: drop zone with `<x-shape>` behind the upload
|
|
||||||
icon morphing while `dragging`, existing Alpine folder walking and `livewire-upload-*`
|
|
||||||
wiring kept, wavy `<x-progress>`, `<x-loading>` for processing, selected files as
|
|
||||||
`<x-list>`, Share Options `<x-card>` (`<x-toggle>`, `<x-select>`, number `<x-input>`s),
|
|
||||||
`<x-alert>` for storage full, filled primary "Create Share Link".
|
|
||||||
`share-created`: shape-backed check, `<x-input copyable>` for the link, four `<x-stat>`,
|
|
||||||
info `<x-alert>`, "Upload More". `share-download`: password `<x-card>` with
|
|
||||||
`<x-password>`, files as `<x-list>` with download icon buttons, "Download All" — no menus
|
|
||||||
or tooltips. `system-password-prompt`, `setup-wizard` onto fields and buttons.
|
|
||||||
42. **Auth pages** (`login`, `forgot-password`, `reset-password`, `two-factor-challenge`,
|
|
||||||
`confirm-password`, `verify-email`): fields, `<x-checkbox>` for remember me, `link` utility
|
|
||||||
for text links, `auth-session-status` onto `<x-alert>` (drops `text-green-600`).
|
|
||||||
43. **Settings.** `pages/settings/layout` → `<x-section-nav>`; `partials/settings-heading`
|
|
||||||
drops the daisyUI divider for `<x-divider>`; `profile` and `password` show "Saved." as a
|
|
||||||
snackbar through `Toasts` (the `profile-updated` / `password-updated` dispatches stay for
|
|
||||||
any listener) and `components/action-message` is deleted;
|
|
||||||
`appearance` → Light / Dark / System `<x-group>` on `$store.theme` (the only toggle on the
|
|
||||||
page); `two-factor` → `<x-badge>` status, `<x-modal fullscreen>` setup with the QR on a
|
|
||||||
white token surface, `<x-input copyable>` key, recovery codes; `delete-user-form` →
|
|
||||||
`<x-modal>` with a `danger` action.
|
|
||||||
44. **Admin.** `admin-dashboard`: four `<x-stat>` (counting up once), disk usage
|
|
||||||
`<x-progress>`, hand-written `<x-table>` with `<x-sort-header>` and pagination (the
|
|
||||||
`@scope` cells become plain Blade and `AdminDashboard::headers()` goes), view and delete
|
|
||||||
icon buttons, delete confirmation in a basic `<x-modal>` instead of `wire:confirm`.
|
|
||||||
`admin-settings`: cards, `<x-textarea>`, `<x-file>` for the logo with preview,
|
|
||||||
`<x-toggle>`, `<x-select>`, `<x-input suffix>`; "Remove the logo?" and "Remove the system
|
|
||||||
password?" become basic dialogs instead of `wire:confirm`; `AdminSettings` uses `Toasts`
|
|
||||||
instead of `session()->flash('message')` (3 places) and the alert block goes. Keep every
|
|
||||||
existing `data-test` attribute on the element that now plays its role.
|
|
||||||
45. **Error pages and mail.** Confirm the package's error views render in SealShare's theme;
|
|
||||||
set `config/mail.php` `markdown.theme` to `livewire-material::mail.theme`; check the
|
|
||||||
password-reset and verify-email mails.
|
|
||||||
46. **Guards.** `tests/Feature/DesignLanguageTest.php` using `DesignGuard` over
|
|
||||||
`resources/views` and `app/` — no maryUI, no daisyUI, only declared colours, only existing
|
|
||||||
icons. A grep for `base-content|bg-base|btn|mary` returns nothing.
|
|
||||||
47. **Rules and AI.** `php artisan boost:update --discover` to install the package guideline and
|
|
||||||
skill; `record-rule` for SealShare: the scheme is regenerated with `material:scheme`,
|
|
||||||
never hand-edited; the download page stays free of anchored components; the theme key.
|
|
||||||
48. **Docs.** README tech stack and the "Dark Mode" feature line; CHANGELOG `2.0.0`.
|
|
||||||
49. **Ship.** Full suite green on the PR; merge; tag `v2.0.0` (publishes the image through
|
|
||||||
`docker.yml`).
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- Feature tests updated where redirects or text change: `AuthenticationTest`,
|
|
||||||
`EmailVerificationTest`, `DashboardTest` (rewritten), `AdminSettingsTest` (asserts the
|
|
||||||
toast is dispatched instead of the flash), `TwoFactorAuthenticationTest`,
|
|
||||||
`AdminDashboardTest`, `ShareDownloadTest`.
|
|
||||||
- `DesignLanguageTest` through the package guard.
|
|
||||||
- Livewire tests: admin settings save/remove-logo/clear-password dispatch toasts; profile and
|
|
||||||
password updates dispatch the "Saved." toast (`ProfileUpdateTest`, `PasswordUpdateTest`);
|
|
||||||
delete share, remove logo and clear system password go through their dialogs' confirm
|
|
||||||
actions.
|
|
||||||
- Browser tests (`tests/Browser`): upload by drop and by Browse → progress → share created →
|
|
||||||
copy link; the password-protected download page at 393px; admin table sort and delete
|
|
||||||
dialog; a first visit follows the OS theme and Appearance switches it.
|
|
||||||
- Narrow runs per step; the full suite on the PR's CI.
|
|
||||||
|
|
||||||
## Risks and open questions
|
|
||||||
|
|
||||||
- **Scope and time.** The whole catalogue (~45 components plus extras) comes before SealShare
|
|
||||||
changes at all, so its starter-kit bugs (the placeholder `/dashboard`) stay until then.
|
|
||||||
Mitigation: waves tagged `0.x`, each reviewed in the showcase; SealShare keeps working
|
|
||||||
meanwhile.
|
|
||||||
- **Gitea becomes a build dependency.** Every SealShare CI run and Docker build fetches the
|
|
||||||
package from `gitea.nonameweb.ch`; an outage or a sign-in setting reverting breaks builds.
|
|
||||||
Mitigation: dist archives cached by Composer in CI; revisit Packagist if it bites.
|
|
||||||
- **iOS / Safari below 18.4.** Anchored menus and tooltips do not position there. Mitigation:
|
|
||||||
SealShare's download page uses none; native `<select>` stays the fallback everywhere.
|
|
||||||
- **Scheme and spring values are tuned by eye**; Tonal Spot may read washed out on indigo —
|
|
||||||
the Vibrant comparison in step 37 is the check.
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
# Screenshots and the SealShare website
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Two things that feed each other. First, one command — `composer screenshots` — produces every
|
|
||||||
screenshot of SealShare from fixed demo data, desktop and phone, light and dark, ready for the web.
|
|
||||||
Second, a static website at **sealshare.nonameweb.ch**, made the way mailifysms.nonameweb.ch is:
|
|
||||||
hand-written HTML and CSS in `website/`, uploaded by hand. The site presents SealShare as what it
|
|
||||||
is — software a company installs to run **its own upload platform**, so it exchanges files with
|
|
||||||
customers securely without relying on an outside service — shows the screenshots, compares
|
|
||||||
SealShare with hosted transfer services and with other self-hosted tools, and tells how to install
|
|
||||||
it. It goes live with 2.0.0. The README gets a few of the same screenshots, and its encryption
|
|
||||||
wording is corrected.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
**MailifySMS, the model** (`../MailifySMS`):
|
|
||||||
|
|
||||||
- `website/` holds `index.html`, `privacy_policy.html`, `terms_and_conditions.html`,
|
|
||||||
`css/theme.css` (a palette sampled from the app's screenshots), `css/device-frame.css` (a phone
|
|
||||||
bezel shared with the store canvases), self-hosted Poppins (`fonts/`, OFL) and `img/`
|
|
||||||
(`icon.png`, `hero.jpg`, `screenshots/{light,dark}/NN-name.png` at 540px).
|
|
||||||
- `index.html`: sticky nav with a phone toggle, hero, "How it works", "Key features", a screenshot
|
|
||||||
gallery with a Light/Dark switch (`data-light`/`data-dark` on each `<img>`), FAQ accordion,
|
|
||||||
contact card (`surtic86@gmail.com`), footer (quick links, legal). Plausible:
|
|
||||||
`<script defer data-domain="mailifysms.nonameweb.ch" src="https://plausible.io/js/script.js">`.
|
|
||||||
The page's JS is one inline `<script>` at the end.
|
|
||||||
- `CLAUDE.md` records that `website/` "is a faithful copy of what is deployed, images included, so
|
|
||||||
it can be uploaded wholesale". There is no deploy automation.
|
|
||||||
- `tools/screenshots.sh` (macOS only) drives an emulator and headless Chrome; documented in
|
|
||||||
`CLAUDE.md` § Screenshots, with the reasons behind each quirk.
|
|
||||||
|
|
||||||
**Hosting.** `*.nonameweb.ch` is a wildcard DNS record to `80.74.140.2` (METANET shared hosting,
|
|
||||||
nginx), the same as mailifysms. `sealshare.nonameweb.ch` resolves already; HTTP serves the host's
|
|
||||||
placeholder, HTTPS has no certificate. Creating the site and its Let's Encrypt certificate is done
|
|
||||||
in the hosting panel.
|
|
||||||
|
|
||||||
**SealShare.**
|
|
||||||
|
|
||||||
- Laravel 13.31, Livewire 4.4, Livewire Material 1.0.1, Pest 5.1 with `pestphp/pest-plugin-browser`
|
|
||||||
(Playwright 1.63). The browser tests run the app in-process, so factories, `Storage::fake()` and
|
|
||||||
`travelTo()` shape what the browser sees. `tests/Pest.php` applies `Tests\TestCase` and
|
|
||||||
`RefreshDatabase` to `Feature` and `Browser`, and creates an admin in `beforeEach` (the setup
|
|
||||||
gate).
|
|
||||||
- Device presets: `visit()->on()->macbook14()` is 1512×982 at 2× (a 3024×1964 capture);
|
|
||||||
`on()->iPhone15Pro()` is 393×852 at 3× (1179×2556). `inLightMode()` / `inDarkMode()`,
|
|
||||||
`screenshot(fullPage, filename)`. Screenshots are written to `tests/Browser/Screenshots/<name>.png`;
|
|
||||||
the directory is created, subdirectories in the name are not — names must be flat. Pest empties
|
|
||||||
that directory when a browser run starts, and has no reduced-motion emulation.
|
|
||||||
- Pest's in-process server does not store a multipart upload, so a browser test cannot select files
|
|
||||||
through the file input.
|
|
||||||
- PHP here has GD with WebP and PNG support; the production image does not need it (this is a
|
|
||||||
development tool).
|
|
||||||
- Colours: `resources/css/material-scheme.json` (seed `#4f46e5`, Vibrant) holds the light and dark
|
|
||||||
roles as hexes. Font: Google Sans Flex, `vendor/nonameweb/livewire-material/resources/fonts/google-sans-flex/GoogleSansFlex-Latin.woff2`
|
|
||||||
with its `OFL.txt`. Logo: `resources/views/components/app-logo-icon.blade.php` (SVG).
|
|
||||||
- **Encryption, as the code does it:** `ShareService::createShare()` encrypts each uploaded file on
|
|
||||||
the server with AES-256-GCM (chunked) through `FileEncryptionService`. Without a share password
|
|
||||||
the key is stored in `shares.encryption_key`; with one, the key is derived with PBKDF2-SHA256 and
|
|
||||||
never stored. The server sees the plaintext while uploading and downloading. The README calls
|
|
||||||
this "End-to-End Encryption", which it is not.
|
|
||||||
- The upload page is public, optionally behind the system password (`SystemPasswordGate`); a
|
|
||||||
customer given that password can upload and send the link back.
|
|
||||||
- Install today (README): `ghcr.io/surtic86/sealshare`, clone from GitHub. Gitea
|
|
||||||
(`gitea.nonameweb.ch/noNameWEB/SealShare`) is now the public repository; the image registry for
|
|
||||||
2.0.0 is settled separately.
|
|
||||||
- `.gitignore` does not ignore `tests/Browser/Screenshots`; `.dockerignore` excludes `tests` and
|
|
||||||
`*.md` but would copy a `website/` directory into the image.
|
|
||||||
|
|
||||||
**Peers.** Pingvin Share has been archived since June 2025 (its README points to forks such as
|
|
||||||
Pingvin Share X). PsiTransfer, Gokapi and Erugo are single-purpose self-hosted share tools; Gokapi
|
|
||||||
advertises end-to-end encryption.
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
- **Positioning: software you host, not a service** — the site says plainly that SealShare is not
|
|
||||||
hosted by anyone but the company that installs it: its own upload platform for exchanging files
|
|
||||||
with customers, data on its own server, no dependence on an external service.
|
|
||||||
- **Pages: `index.html` and `privacy.html`** — one landing page, and a short privacy page because
|
|
||||||
the site uses Plausible. No terms page: the software is MIT-licensed and no service is offered.
|
|
||||||
- **A comparison with hosted transfer services and with self-hosted share tools** — two tables on
|
|
||||||
the landing page. Cloud suites (Nextcloud-style) are left out.
|
|
||||||
- **Hand-written HTML and CSS, like MailifySMS** — no build step; `website/` is uploaded as it is.
|
|
||||||
- **Colours copied from `material-scheme.json`, not sampled** — `website/css/theme.css` lists the
|
|
||||||
roles it uses with the scheme's hexes, light by default and dark under
|
|
||||||
`@media (prefers-color-scheme: dark)`; it names the seed and variant it was copied from, so a
|
|
||||||
regenerated scheme is copied again. The site follows the visitor's system theme and has no
|
|
||||||
toggle of its own.
|
|
||||||
- **Google Sans Flex, self-hosted** — the app's font, copied with its `OFL.txt` into
|
|
||||||
`website/fonts/`; nothing is loaded from Google.
|
|
||||||
- **Uploaded by hand, like MailifySMS** — `website/` is a faithful copy of what is live. You create
|
|
||||||
the subdomain and certificate once in the hosting panel and upload the folder when it changes.
|
|
||||||
No hosting credentials anywhere in the repository or CI.
|
|
||||||
- **Plausible** — `data-domain="sealshare.nonameweb.ch"`, the same script as MailifySMS; the site
|
|
||||||
must be added in the Plausible account.
|
|
||||||
- **English only.**
|
|
||||||
- **Screenshots: desktop and phone, each in light and dark (20 images)** —
|
|
||||||
desktop (MacBook 14, 2×): `01-upload` (files selected, options filled), `02-share-created`,
|
|
||||||
`03-qr-code` (the dialog), `04-download` (the recipient's file list), `05-admin-dashboard`,
|
|
||||||
`06-admin-settings`; phone (iPhone 15 Pro, 3×): `01-upload`, `02-password` (the recipient's
|
|
||||||
password prompt), `03-download`, `04-qr-code`.
|
|
||||||
- **Screenshots run as Pest browser tests in `tests/Screenshots/`, started by `composer screenshots`**
|
|
||||||
— the directory is not one of phpunit.xml's test suites, so `php artisan test`, the Browser
|
|
||||||
suite and CI never run it. It reuses Playwright and the in-process server.
|
|
||||||
- **Fixed demo data** — factories and `ShareService` with fixed names, sizes and tokens, time
|
|
||||||
frozen with `travelTo()`, the site title and branding at their defaults, one admin
|
|
||||||
("Alex Morgan"). Every run produces the same images unless the UI changed.
|
|
||||||
- **Images published as WebP by the test run itself** — after each capture a small helper resizes
|
|
||||||
it with GD into `website/img/screenshots/{desktop,phone}/{light,dark}/NN-name-<width>.webp` at two widths
|
|
||||||
(desktop 1600 and 800 px, phone 1080 and 540 px) for `srcset`. Raw PNGs stay in
|
|
||||||
`tests/Browser/Screenshots/`, which is gitignored; only the WebP files are committed.
|
|
||||||
- **Device frames in CSS** — `website/css/device-frame.css` draws a laptop and a phone around the
|
|
||||||
screenshots; the hero shows the desktop upload and the phone download screenshots framed, in
|
|
||||||
the visitor's theme. No generated hero image.
|
|
||||||
- **README shows three screenshots** — desktop upload, desktop share created, phone download (light),
|
|
||||||
referenced from `website/img/screenshots/…`, so each image exists once in the repository.
|
|
||||||
- **Encryption is described accurately, on the site and in the README** — "encrypted at rest with
|
|
||||||
AES-256-GCM; with a share password the key is never stored". The comparison marks end-to-end
|
|
||||||
encryption "no" for SealShare. The README's "End-to-End Encryption" line is corrected.
|
|
||||||
- **Built on `material`, live with 2.0.0** — screenshots show the 2.0.0 interface; the site links
|
|
||||||
the Gitea repository, and its install commands are the README's at release, whatever registry
|
|
||||||
2.0.0 ships with.
|
|
||||||
- **Comparison facts are researched, dated and sourced** — from each product's own site,
|
|
||||||
documentation or repository; the tables say "as of <month year>" and link every source. Hosted
|
|
||||||
services: WeTransfer, SwissTransfer, Dropbox Transfer, Google Drive links. Self-hosted tools:
|
|
||||||
open source, single-purpose, installable with Docker, with a release in the 12 months before the
|
|
||||||
research — expected Pingvin Share X, PsiTransfer, Gokapi and Erugo; any that fails the rule is
|
|
||||||
dropped and named in the commit message. Criteria (rows): where files are stored, who operates
|
|
||||||
it, recipient needs an account, password protection, expiry, download limit, encryption at rest,
|
|
||||||
end-to-end encryption, folder upload, custom branding, maximum file size, licence and cost. A
|
|
||||||
value that cannot be sourced is "—", never guessed.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- A terms page, a documentation section, a German version.
|
|
||||||
- Deploy automation (Gitea Actions, SFTP scripts), and creating the subdomain, certificate or
|
|
||||||
Plausible site — those are yours in the hosting panel and the Plausible account.
|
|
||||||
- Comparing with Nextcloud, ownCloud or other cloud suites.
|
|
||||||
- Store-style canvases with captions, a generated hero image, video or animated screenshots.
|
|
||||||
- Running the screenshots in CI, or checking them against earlier runs (visual regression).
|
|
||||||
- The image registry move and the install commands' final form (settled with 2.0.0).
|
|
||||||
- Implementing end-to-end encryption.
|
|
||||||
|
|
||||||
## Implementation steps
|
|
||||||
|
|
||||||
1. **Housekeeping.** `.gitignore`: `/tests/Browser/Screenshots`. `.dockerignore`: `website`.
|
|
||||||
2. **Screenshot helper.** `tests/Screenshots/Publisher.php` (`Tests\Screenshots\Publisher`):
|
|
||||||
`publish(string $capture, string $device, string $theme, string $name, array $widths): void`
|
|
||||||
reads `tests/Browser/Screenshots/<capture>.png` with GD, and for each width writes
|
|
||||||
`website/img/screenshots/<device>/<theme>/<name>-<width>.webp` (quality 82, aspect kept,
|
|
||||||
directories created). It throws when the capture is missing, so a failed shot fails the run.
|
|
||||||
3. **Demo data.** `tests/Screenshots/DemoData.php`: `admin()`, `shares()` (eight shares with fixed
|
|
||||||
tokens, file names such as `Q3-report.pdf`, `Contract 2026.pdf`, `Product photos/…`, sizes,
|
|
||||||
download counts and expiries, one password-protected, one expired), created through factories
|
|
||||||
and `ShareService` with `Storage::fake('shares')` so the files exist encrypted.
|
|
||||||
4. **Screenshot tests.** `tests/Screenshots/ScreenshotsTest.php`, with `tests/Pest.php` extended to
|
|
||||||
`->in('Feature', 'Browser', 'Screenshots')`:
|
|
||||||
- `beforeEach`: `config(['session.driver' => 'file'])`, `travelTo('2026-10-01 09:30')`, demo
|
|
||||||
data; a `ready()` wait as in `SealShareTest`.
|
|
||||||
- One test per device and theme (four tests), each visiting the pages in turn, waiting for
|
|
||||||
`networkidle` and fonts (`document.fonts.ready`), hiding the text caret, capturing
|
|
||||||
viewport-sized (not full-page) shots, and calling `Publisher::publish()` right after each.
|
|
||||||
- The upload shot with files selected: create Livewire temporary uploads on the fake
|
|
||||||
`livewire-tmp` disk and set the uploader's property through `$wire.$set` with
|
|
||||||
`livewire-file:` references, then fill the options. If Livewire refuses that, the shot shows
|
|
||||||
the drop zone with the options filled instead, and the plan's risk note is updated.
|
|
||||||
- QR dialog: `click('[data-test="show-qr-code"]')`; password prompt: the protected share on the
|
|
||||||
phone; admin pages as the admin.
|
|
||||||
5. **Command.** `composer.json` script `"screenshots"`: `Composer\\Config::disableProcessTimeout`,
|
|
||||||
`npm run build`, `@php vendor/bin/pest tests/Screenshots` — the build first, so the shots show
|
|
||||||
the current assets. Playwright's Chromium must be installed (`npx playwright install chromium`),
|
|
||||||
as for the browser tests.
|
|
||||||
6. **Website scaffold.** `website/`:
|
|
||||||
- `css/theme.css` — tokens copied from `material-scheme.json` (with seed and variant noted),
|
|
||||||
Google Sans Flex `@font-face`, layout, nav, hero, sections, cards, tables (scrolling
|
|
||||||
sideways on a phone), FAQ (`<details>`), footer; light and dark through
|
|
||||||
`prefers-color-scheme`.
|
|
||||||
- `css/device-frame.css` — laptop and phone frames.
|
|
||||||
- `fonts/GoogleSansFlex-Latin.woff2`, `fonts/OFL.txt`; `img/logo.svg` (from `app-logo-icon`),
|
|
||||||
`img/icon.png` (favicon, from `public/`).
|
|
||||||
7. **Landing page.** `website/index.html` (Plausible in `<head>`, one inline script at the end):
|
|
||||||
- nav: Why, Features, Screenshots, Compare, Install, FAQ, Gitea;
|
|
||||||
- hero: "Your own secure upload platform" — self-hosted file exchange with customers, no
|
|
||||||
outside service; buttons "Install" (to #install) and "Source on Gitea"; framed desktop and
|
|
||||||
phone screenshots as `<picture>` elements whose `<source media="(prefers-color-scheme: dark)">`
|
|
||||||
picks the dark captures;
|
|
||||||
- "Why run your own": your server, your domain and branding, customers upload and download
|
|
||||||
without accounts, encrypted at rest, no per-seat pricing;
|
|
||||||
- "How it works": upload → link or QR code → the customer downloads, with expiry, download
|
|
||||||
limit and password;
|
|
||||||
- features (from the README, accurate encryption wording);
|
|
||||||
- screenshots: Desktop/Phone and Light/Dark switches over one gallery (Light/Dark starting on
|
|
||||||
the visitor's system theme), `srcset` for both widths, `loading="lazy"`, descriptive `alt`;
|
|
||||||
- compare: the two dated tables with sources (step 8);
|
|
||||||
- install: the README's Docker quick start and a link to the full instructions on Gitea;
|
|
||||||
- FAQ: "Is it end-to-end encrypted?" (no — at rest, and what a password adds), "Can customers
|
|
||||||
send files to us?" (yes, through the upload page, optionally behind the system password),
|
|
||||||
"How big can files be?" (the README's large-file limits), "What does it cost?" (MIT, your
|
|
||||||
hosting), "Who runs it?" (you);
|
|
||||||
- contact (`surtic86@gmail.com`, as MailifySMS) and footer (Gitea, licence, privacy, noNameWEB).
|
|
||||||
8. **Comparison research.** For each product, record every criterion with its source URL and the
|
|
||||||
date checked; apply the self-hosted selection rule; fill the tables. Keep the notes in the
|
|
||||||
commit message, not in the repository.
|
|
||||||
9. **Privacy page.** `website/privacy.html`: who runs the site (contact), the host (METANET, server
|
|
||||||
logs), Plausible (cookieless, no personal data, EU-hosted, link to its data policy), no other
|
|
||||||
third parties, fonts served locally, contact for questions; dated.
|
|
||||||
10. **README and changelog.** Correct the encryption lines (the intro sentence stays accurate;
|
|
||||||
"End-to-End Encryption" becomes "Encryption at Rest", described as in Decisions), add a
|
|
||||||
Screenshots section with the three images, add the website link. CHANGELOG `2.0.0` "Fixed":
|
|
||||||
the README no longer calls the encryption end-to-end. The website and the screenshot tooling
|
|
||||||
get no changelog entry — they do not change the application.
|
|
||||||
11. **Project notes.** `record-rule` for `website/**`: `website/` is a faithful copy of what is live,
|
|
||||||
uploaded by hand; its colours are copied from `material-scheme.json` and must be copied again
|
|
||||||
when the scheme is regenerated; the comparison is dated and every value sourced. And for
|
|
||||||
`tests/Screenshots/**`: run with `composer screenshots` whenever the interface changes, before
|
|
||||||
a release; the demo data is fixed so runs are reproducible.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- `tests/Unit/ScreenshotPublisherTest.php`: a generated PNG is written as WebP at each requested
|
|
||||||
width with the aspect ratio kept, into the device/theme directory; a missing capture throws.
|
|
||||||
- `tests/Feature/WebsiteTest.php` guards `website/` without a browser:
|
|
||||||
- every local `src`, `href`, `srcset` entry and CSS `url()` resolves to a file in `website/`;
|
|
||||||
- every screenshot the gallery or README references exists for both widths and both themes;
|
|
||||||
- no request goes to a host other than `plausible.io` (no Google Fonts, no CDN);
|
|
||||||
- `index.html` and `privacy.html` have a `<title>`, `lang="en"` and a meta description;
|
|
||||||
- the README has no "End-to-End Encryption" line, the site's features section does not say
|
|
||||||
"end-to-end", and SealShare's end-to-end cell in the comparison (marked
|
|
||||||
`data-compare="sealshare-e2e"`) reads "No" — the FAQ may still ask the question.
|
|
||||||
- The screenshot run itself is the test of step 4: it fails when a page, selector or capture
|
|
||||||
breaks. It is run by hand before a release, not in CI.
|
|
||||||
- The site is looked at in Chrome, Firefox and Safari, light and dark, at phone width, before
|
|
||||||
uploading.
|
|
||||||
|
|
||||||
## Risks and open questions
|
|
||||||
|
|
||||||
- **Selecting files in the upload shot** works: the files are stored with Livewire's own
|
|
||||||
`FileUploadConfiguration::storeTemporaryFile()` and handed to `_finishUpload` by their signed
|
|
||||||
names; Livewire's temporary-upload cleanup is turned off for the run, because under the frozen
|
|
||||||
clock it deletes them.
|
|
||||||
- **Comparison accuracy and fairness.** Other products change; the tables are dated and sourced,
|
|
||||||
and re-checked when the site is updated. Swiss unfair-competition law expects comparisons to be
|
|
||||||
accurate and not misleading — values that cannot be sourced stay "—".
|
|
||||||
- **Install commands depend on the registry move.** Until it is settled, the install section copies
|
|
||||||
the current README; it is updated before the site goes live with 2.0.0.
|
|
||||||
- **Screenshot determinism.** Relative dates ("in 3 days") depend on `travelTo()`; animations
|
|
||||||
(the share-created shape, counting stats, dialog entry) are waited out — Pest has no
|
|
||||||
reduced-motion emulation — by waiting on `document.getAnimations().length === 0` before each
|
|
||||||
capture.
|
|
||||||
- **The host's name in the privacy page** (METANET) is inferred from the server's reverse DNS
|
|
||||||
(`urbanus.ch-meta.net`); confirm it before the page goes live.
|
|
||||||
- **The contact address** is the one MailifySMS publishes (`surtic86@gmail.com`); change it in
|
|
||||||
step 7 if SealShare should have its own.
|
|
||||||
- **Image weight.** Twenty screenshots at two widths as WebP should stay under ~4 MB in total; if
|
|
||||||
not, lower the quality or drop the larger phone width.
|
|
||||||
- **Colours drift** when the scheme is regenerated; the rule in step 11 and the note in
|
|
||||||
`theme.css` are the guard.
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
# Share by QR code and share sheet
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
After an upload, the share created page offers two more ways to hand a share over besides
|
|
||||||
copying the link: a QR code, in a dialog, that another device scans (and that downloads as a
|
|
||||||
PNG for chats and mails), and, where the browser has one, the device's native share sheet. Both
|
|
||||||
carry only the share's link — never a password.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
- Laravel 13.31, Livewire 4.4, Livewire Material 1.0.x, Pest 5 with browser tests, Octane
|
|
||||||
(FrankenPHP). Production image `dunglas/frankenphp:php8.5-alpine` with `intl`, `pcntl`, `zip`
|
|
||||||
added; it has `xmlwriter` and `iconv`, and neither `gd` nor `imagick`.
|
|
||||||
- `bacon/bacon-qr-code` v3.1.1 is installed through `laravel/fortify` (`^3.0`), which draws the
|
|
||||||
two-factor setup QR with `Writer` + `ImageRenderer` + `SvgImageBackEnd` and strips the XML
|
|
||||||
declaration (`TwoFactorAuthenticatable::twoFactorQrCodeSvg()`). SVG needs no image extension;
|
|
||||||
a server-side PNG would.
|
|
||||||
- `app/Livewire/ShareCreated.php` (`#[Layout('layouts.app')]`, `public Share $share`) renders
|
|
||||||
`resources/views/livewire/share-created.blade.php`: the link as
|
|
||||||
`<x-input :value="route('share.download', $share)" readonly copyable data-test="share-link">`,
|
|
||||||
four `<x-stat>`, an info alert for password-protected shares, and "Upload More". The route
|
|
||||||
`share/{share:token}/created` sits behind `system.password`, like the upload page.
|
|
||||||
- The two-factor dialog (`pages/settings/⚡two-factor`) is the in-app pattern: the SVG inline
|
|
||||||
on a `bg-white` panel inside `<x-modal fullscreen>`, so it stays scannable in dark mode.
|
|
||||||
- `<x-modal>` without `wire:model` opens from `open` in the surrounding Alpine scope and gives
|
|
||||||
`close()`; `materialToast()` is global; `resources/js/app.js` imports only the package JS.
|
|
||||||
- Services live in `app/Services` (`ShareService`, `FileEncryptionService`). Share passwords
|
|
||||||
are hashed and turned into a key; the plain password is never stored.
|
|
||||||
- No feature test covers `ShareCreated` yet; `tests/Browser/SealShareTest.php` copies the link
|
|
||||||
on that page.
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
- **Only on the share created page** — that is where a share is handed over; the admin
|
|
||||||
dashboard and the download page stay as they are.
|
|
||||||
- **A "Show QR code" button opens a dialog** — the page stays as calm as now; the dialog is
|
|
||||||
`<x-modal fullscreen>` (the whole screen on a phone, to hold up to another camera) with the
|
|
||||||
QR on a white panel.
|
|
||||||
- **Download is a PNG made in the browser** — the dialog's SVG is drawn onto a canvas and saved
|
|
||||||
as `share-<token>.png`; no server route and no `gd`/`imagick` in the Docker images.
|
|
||||||
- **Require `bacon/bacon-qr-code:^3.0` directly** — the version already installed through
|
|
||||||
Fortify, declared so SealShare does not depend on Fortify keeping it.
|
|
||||||
- **Password-protected shares get a note in the dialog** — "Recipients also need the password."
|
|
||||||
The QR holds the link only.
|
|
||||||
- **A "Share…" button opens the native share sheet** — shown only where `navigator.share` exists
|
|
||||||
(mostly phones and Safari), sharing `{ title: <site title>, url: <share link> }`.
|
|
||||||
Cancelling the sheet (`AbortError`) does nothing; any other failure shows an error snackbar.
|
|
||||||
- **In 2.0.0, on the `material` branch** — 2.0.0 is not released and the page was just rebuilt
|
|
||||||
there; one PR, one changelog entry.
|
|
||||||
- **Black modules on white, a four-module quiet zone, error correction M** — the most reliable
|
|
||||||
to scan from a screen or a print; the site's theme does not tint it.
|
|
||||||
- **The SVG is drawn at 1024 × 1024** — CSS scales it down in the dialog, and the canvas draws it
|
|
||||||
at its own size, so the PNG is sharp in every browser (Safari rasterises an SVG at its
|
|
||||||
intrinsic size).
|
|
||||||
- **Generated server-side with the page, opened client-side** — the SVG is a few kilobytes and
|
|
||||||
the dialog needs no round trip; the dialog's `open` is Alpine state, not a Livewire property.
|
|
||||||
- **`App\Services\QrCodeService::svg(string $contents): string`** — one place that knows Bacon's
|
|
||||||
API; `ShareCreated::render()` passes `shareUrl`, `qrCodeSvg` and `siteTitle` to the view (as
|
|
||||||
`FileUploader::render()` passes `siteTitle`), so the URL is built once.
|
|
||||||
- **The share and download behaviour lives in `resources/js/share-created.js`** — an
|
|
||||||
`Alpine.data('shareActions', …)` with `canShare`, `share()` and `downloadQrCode()`, imported
|
|
||||||
by `app.js`, instead of long inline Alpine in the view.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- QR codes on the admin dashboard or the download page.
|
|
||||||
- Sharing the QR image itself through the share sheet (`navigator.share({ files })`).
|
|
||||||
- An SVG download, a server-rendered PNG, or a print layout.
|
|
||||||
- A logo in the middle of the QR, or colours from the theme.
|
|
||||||
- Putting the password (or any secret beyond the link's token) into the QR or the share sheet.
|
|
||||||
|
|
||||||
## Implementation steps
|
|
||||||
|
|
||||||
1. **Dependency.** `composer require bacon/bacon-qr-code:^3.0` (stays at v3.1.1).
|
|
||||||
2. **Service.** `php artisan make:class Services/QrCodeService`: `svg(string $contents): string`
|
|
||||||
renders with `new Writer(new ImageRenderer(new RendererStyle(1024, 4, null, null,
|
|
||||||
Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(0, 0, 0))), new SvgImageBackEnd))`,
|
|
||||||
`writeString($contents, Encoder::DEFAULT_BYTE_MODE_ENCODING, ErrorCorrectionLevel::M())`, and
|
|
||||||
drops the XML declaration as Fortify does.
|
|
||||||
3. **Component.** `ShareCreated::render()` builds `$shareUrl = route('share.download',
|
|
||||||
$this->share)` and passes `shareUrl`, `qrCodeSvg` (from the service) and `siteTitle`
|
|
||||||
(`Setting::get('site_title') ?: config('app.name')`) to the view; the link field uses
|
|
||||||
`$shareUrl`.
|
|
||||||
4. **JavaScript.** `resources/js/share-created.js` registers on `alpine:init`
|
|
||||||
`Alpine.data('shareActions', ({ url, title, filename, messages }) => …)`, `messages` holding
|
|
||||||
the translated `shareFailed` and `downloadFailed`:
|
|
||||||
- `open: false` for the dialog;
|
|
||||||
- `canShare`: `typeof navigator.share === 'function'`, read once at init;
|
|
||||||
- `share()`: `navigator.share({ title, url })`, ignoring `AbortError`, otherwise
|
|
||||||
`materialToast(messages.shareFailed, { type: 'error' })`;
|
|
||||||
- `downloadQrCode(svg)`: takes the `<svg>` element (the button passes
|
|
||||||
`$el.closest('dialog').querySelector('[data-qr-code] svg')` — the dialog has its own Alpine
|
|
||||||
scope, so `$refs` from the outer one would not reach it), serialises it, loads it into an
|
|
||||||
`Image` from a Blob URL, draws it on a 1024 × 1024 canvas with a white fill and
|
|
||||||
`imageSmoothingEnabled = false`, `toBlob('image/png')`, clicks a temporary `<a download>`
|
|
||||||
named `filename`, and revokes both object URLs; a failed load or an empty blob shows
|
|
||||||
`materialToast(messages.downloadFailed, { type: 'error' })`.
|
|
||||||
`resources/js/app.js` imports it after the package.
|
|
||||||
5. **View.** In `share-created.blade.php`, wrap the link and actions in
|
|
||||||
`<div x-data="shareActions({ url: @js($shareUrl), title: @js($siteTitle), filename: @js('share-'.$share->token.'.png'), messages: @js(['shareFailed' => __('The share sheet could not open.'), 'downloadFailed' => __('The QR code could not be saved.')]) })">`
|
|
||||||
(a plain element, so `@js` compiles there):
|
|
||||||
- under the link field, a row with `<x-button :label="__('Show QR code')" icon="qr_code_2"
|
|
||||||
variant="tonal" x-on:click="open = true" data-test="show-qr-code" />` and, in a
|
|
||||||
`<span x-show="canShare" x-cloak>` wrapper, `<x-button :label="__('Share…')" icon="share"
|
|
||||||
variant="tonal" x-on:click="share()" data-test="share-sheet" />`;
|
|
||||||
- `<x-modal fullscreen :title="__('Scan to open the share')">` holding
|
|
||||||
`<div data-qr-code class="mx-auto aspect-square w-full max-w-80 rounded-corner-lg bg-white p-2 [&>svg]:size-full">{!! $qrCodeSvg !!}</div>`
|
|
||||||
(the SVG is generated from the app's own URL — no user input), then, for a
|
|
||||||
password-protected share, `<x-alert color="info" icon="lock" :title="__('Recipients also need the password.')" />`,
|
|
||||||
and actions `<x-button :label="__('Download')" icon="download" x-on:click="downloadQrCode($el.closest('dialog').querySelector('[data-qr-code] svg'))" data-test="download-qr-code" />`
|
|
||||||
and `<x-button :label="__('Close')" x-on:click="close()" />`.
|
|
||||||
"Upload More" and the stats stay where they are.
|
|
||||||
6. **Docs.** README: the "Shareable Links" feature line mentions the QR code and share sheet.
|
|
||||||
CHANGELOG `2.0.0`: an "Added" section (before "Changed", as Keep a Changelog orders them)
|
|
||||||
with an entry for both.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- `tests/Unit/QrCodeServiceTest.php` (the service needs no application):
|
|
||||||
`svg()` returns markup starting with `<svg`, without an XML declaration, 1024 wide, and the
|
|
||||||
same markup for the same contents and different markup for different contents.
|
|
||||||
- `tests/Feature/ShareCreatedTest.php` (new): the page shows the link, and its HTML contains
|
|
||||||
exactly `QrCodeService::svg(route('share.download', $share))` inside the dialog, the
|
|
||||||
"Show QR code" and "Share…" buttons, and the download filename `share-<token>.png`; the
|
|
||||||
password note appears for a protected share and not for an open one.
|
|
||||||
- `tests/Browser/SealShareTest.php`:
|
|
||||||
- "Show QR code" opens the dialog with the QR on a white panel; Download produces an
|
|
||||||
`image/png` blob named `share-<token>.png` (recorded by stubbing
|
|
||||||
`HTMLAnchorElement.prototype.click` through `window.eval`), with no JavaScript errors;
|
|
||||||
- the Share button is hidden where `navigator.share` is missing, and `share()` passes the
|
|
||||||
link to a stubbed `navigator.share`, stays quiet on `AbortError` and shows the error snackbar
|
|
||||||
on any other rejection.
|
|
||||||
- `DesignLanguageTest` keeps passing (`qr_code_2`, `share`, `download` are Material Symbols;
|
|
||||||
`bg-white` is a token).
|
|
||||||
- Narrow runs per step, then the full suite.
|
|
||||||
|
|
||||||
## Risks and open questions
|
|
||||||
|
|
||||||
- **Scanning reliability** is not proven by the tests (no decoder in the stack): the feature
|
|
||||||
test pins the SVG to Bacon's output for the exact URL, and a manual scan with a phone during
|
|
||||||
review is the check.
|
|
||||||
- **The QR is only as right as the link.** Behind a reverse proxy with a wrong `APP_URL` or
|
|
||||||
trusted-proxy setting, both point at the wrong host — unchanged from today.
|
|
||||||
- **Safari and canvas.** Drawing an SVG from a Blob URL onto a canvas works in current
|
|
||||||
Chrome, Firefox and Safari without tainting the canvas; the browser test runs in Chromium
|
|
||||||
locally and in CI, and the three-engine check is manual.
|
|
||||||
- **Share sheet on desktop** exists in Safari and Chromium on some platforms and not in
|
|
||||||
Firefox; the button's absence there is by design.
|
|
||||||
Generated
+124
-1166
File diff suppressed because it is too large
Load Diff
+2
-9
@@ -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,20 +8,12 @@
|
|||||||
"dev": "vite"
|
"dev": "vite"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
|
||||||
"autoprefixer": "^10.5.5",
|
|
||||||
"concurrently": "^10.0.5",
|
|
||||||
"laravel-vite-plugin": "^3.2.0",
|
"laravel-vite-plugin": "^3.2.0",
|
||||||
"tailwindcss": "^4.3.3",
|
"vite": "^8.3.0"
|
||||||
"vite": "^8.2.2"
|
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@tailwindcss/oxide-linux-x64-gnu": "^4.0.1",
|
|
||||||
"lightningcss-linux-x64-gnu": "^1.29.1"
|
"lightningcss-linux-x64-gnu": "^1.29.1"
|
||||||
},
|
},
|
||||||
"overrides": {
|
|
||||||
"shell-quote": "^1.9.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"playwright": "^1.63.0"
|
"playwright": "^1.63.0"
|
||||||
|
|||||||
+13
-13
@@ -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>
|
||||||
|
|||||||
+301
-8
@@ -1,22 +1,315 @@
|
|||||||
@import 'tailwindcss';
|
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||||
@import '../../vendor/nonameweb/livewire-material/resources/css/material.css';
|
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/foundation.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/grid.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/pane.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/row.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/stack.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/surface.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/account-menu.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/alert.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/badge.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/button.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/card.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/checkbox.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/divider.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/empty-state.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/file.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/form.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/group.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/icon.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/input.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/list-item.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/list.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/loading.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/menu-item.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/modal.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/pagination.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/password.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/progress.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/scheme-picker.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/section-nav.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/select.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/shape.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/stat.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/textarea.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/theme-toggle.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/toast.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/toggle.css';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/toolbar.css';
|
||||||
@import './material-scheme.css';
|
@import './material-scheme.css';
|
||||||
|
|
||||||
@source '../views';
|
/*
|
||||||
@source '../../vendor/nonameweb/livewire-material/resources/views';
|
* SealShare's own rules, unlayered so they outrank every package rule: one section per view, in
|
||||||
@source '../../vendor/nonameweb/livewire-material/src';
|
* the order a visitor meets them — the layout and the page template, the share flow (upload,
|
||||||
|
* share created, download), the settings pages in their navigation's order, then admin.
|
||||||
|
*/
|
||||||
|
|
||||||
/* share-created: the check on its shape settles in once the link is ready. */
|
/*
|
||||||
|
* resources/views/layouts/app.blade.php: every page's main region.
|
||||||
|
*
|
||||||
|
* `<x-pane as="main">` gives the region its horizontal M3 margin (16px below `medium`, 24px from
|
||||||
|
* it); the page inside (components/page.blade.php) sets its own width and centres itself. The
|
||||||
|
* vertical rhythm is the app's own. The bottom padding clears the floating toolbar in
|
||||||
|
* partials/toolbar.blade.php by what the toolbar publishes as `--material-bottom-toolbar` (its top
|
||||||
|
* edge's distance from the window's bottom, safe area included), plus 16px. Never set
|
||||||
|
* `--material-bottom-bar` here: the toolbar reads it to place itself.
|
||||||
|
*/
|
||||||
|
.app-main {
|
||||||
|
padding-block-start: var(--md-sys-measurement-space400);
|
||||||
|
padding-block-end: calc(var(--material-bottom-toolbar, 0px) + var(--md-sys-measurement-space200));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >= 600px) {
|
||||||
|
.app-main {
|
||||||
|
padding-block-start: var(--md-sys-measurement-space600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* resources/views/components/page.blade.php: the site's own logo above the title on a `brand` page, at 1.x's 5rem-tall size, its width following the image. */
|
||||||
|
.page-logo {
|
||||||
|
block-size: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* resources/views/livewire/file-uploader.blade.php: the drop zone's dashed outline and its
|
||||||
|
* primary tint while dragging. `data-dragging` is Alpine's, not the package's, since no
|
||||||
|
* component tracks a native drag over an arbitrary drop target; disabled where uploads cannot run
|
||||||
|
* (no secure context) blocks pointer events and dims to M3's disabled-content opacity, as a code dims elsewhere while
|
||||||
|
* busy (.settings-recovery-code--loading).
|
||||||
|
*/
|
||||||
|
.upload-drop-zone {
|
||||||
|
padding: var(--md-sys-measurement-space400);
|
||||||
|
border: 2px dashed var(--md-sys-color-outline-variant);
|
||||||
|
border-radius: var(--md-sys-shape-corner-xl);
|
||||||
|
transition: border-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default), background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-drop-zone[data-dragging='true'] {
|
||||||
|
border-color: var(--md-sys-color-primary);
|
||||||
|
background-color: color-mix(in srgb, var(--md-sys-color-primary-container) 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-drop-zone[aria-disabled='true'] {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: var(--md-sys-state-disabled-content-opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* resources/views/livewire/file-uploader.blade.php: the drop zone's shape morphs into a burst
|
||||||
|
* while files are dragged over it — SealShare's signature, kept from 1.x (docs/reference/m3/styles.md
|
||||||
|
* § Shape: "Shape morph should respond to user interaction"). Two `<x-shape>`s sit
|
||||||
|
* stacked (`inset: 0` on an absolutely positioned element sizes it to the box, no width/height
|
||||||
|
* class needed) and cross-fade/scale on the spatial-slow spring the shape's size warrants
|
||||||
|
* (docs/reference/m3/styles.md § Motion: "larger elements may use slow"); opacity rides the
|
||||||
|
* effects-slow spring beside it, since a colour or fade must never overshoot. Reduced motion needs
|
||||||
|
* no local override: the tokens themselves zero out under it (tokens/motion.css).
|
||||||
|
*/
|
||||||
|
.upload-drop-shapes {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
inline-size: 7rem;
|
||||||
|
block-size: 7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-drop-shape {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
transition: scale var(--md-sys-motion-spatial-slow-duration) var(--md-sys-motion-spatial-slow), rotate var(--md-sys-motion-spatial-slow-duration) var(--md-sys-motion-spatial-slow), opacity var(--md-sys-motion-effects-slow-duration) var(--md-sys-motion-effects-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-drop-shape--idle {
|
||||||
|
scale: 1;
|
||||||
|
rotate: 0deg;
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--md-sys-color-secondary-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-drop-zone[data-dragging='true'] .upload-drop-shape--idle {
|
||||||
|
scale: 0.5;
|
||||||
|
rotate: 45deg;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-drop-shape--burst {
|
||||||
|
scale: 0.5;
|
||||||
|
rotate: -45deg;
|
||||||
|
opacity: 0;
|
||||||
|
color: var(--md-sys-color-primary-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-drop-zone[data-dragging='true'] .upload-drop-shape--burst {
|
||||||
|
scale: 1.1;
|
||||||
|
rotate: 0deg;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-drop-icon {
|
||||||
|
position: relative;
|
||||||
|
color: var(--md-sys-color-on-secondary-container);
|
||||||
|
transition: color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-drop-zone[data-dragging='true'] .upload-drop-icon {
|
||||||
|
color: var(--md-sys-color-on-primary-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* resources/views/livewire/file-uploader.blade.php: the selected-files list scrolls on its own past 1.x's cap instead of pushing the options and the submit button down the page. */
|
||||||
|
.upload-file-list {
|
||||||
|
max-block-size: 18rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* resources/views/livewire/share-created.blade.php: the check that settles onto its Expressive
|
||||||
|
* shape once the link is ready (the `share-ready`/`share-ready-fade` keyframes after it). The shape
|
||||||
|
* sits at the box's edges (`inset: 0` on an absolutely positioned element sizes it, no width/height
|
||||||
|
* class needed); both colours are container roles `md-ink-*` has no class for, so they are the
|
||||||
|
* application's own CSS rather than a component prop.
|
||||||
|
*/
|
||||||
|
.share-check {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
inline-size: 6rem;
|
||||||
|
block-size: 6rem;
|
||||||
|
animation:
|
||||||
|
share-ready var(--md-sys-motion-spatial-slow-duration) var(--md-sys-motion-spatial-slow) both,
|
||||||
|
share-ready-fade var(--md-sys-motion-effects-slow-duration) var(--md-sys-motion-effects-slow) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-check-shape {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
color: var(--md-sys-color-primary-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-check-icon {
|
||||||
|
/* Without this the icon, though later in the DOM, is a non-positioned in-flow child: it paints
|
||||||
|
before the absolutely positioned shape beside it (CSS's stacking order for z-index:auto) and
|
||||||
|
sits hidden underneath it, as .upload-drop-icon's own position: relative is there to avoid. */
|
||||||
|
position: relative;
|
||||||
|
color: var(--md-sys-color-on-primary-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* resources/views/livewire/share-created.blade.php: the check settling onto its shape, run by
|
||||||
|
* .share-check — rotate and scale on the spatial spring (shape motion), opacity on effects beside
|
||||||
|
* it, since M3 never lets a colour or fade overshoot; reduced motion needs no local override, the
|
||||||
|
* duration tokens themselves zero out under it.
|
||||||
|
*/
|
||||||
@keyframes share-ready {
|
@keyframes share-ready {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
|
||||||
rotate: -90deg;
|
rotate: -90deg;
|
||||||
scale: 0.4;
|
scale: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
|
||||||
rotate: 0deg;
|
rotate: 0deg;
|
||||||
scale: 1;
|
scale: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes share-ready-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* resources/views/livewire/share-created.blade.php: the QR code dialog. `App\Services\QrCodeService`
|
||||||
|
* already draws its SVG black on white with a four-module quiet zone, so the container adds no
|
||||||
|
* colour of its own — no colour class or literal colour could give it one that also holds in dark
|
||||||
|
* mode. The corner only rounds the container that clips it, exactly as .settings-two-factor-qr's does.
|
||||||
|
*/
|
||||||
|
.share-qr {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
inline-size: 100%;
|
||||||
|
max-inline-size: 20rem;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
margin-inline: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--md-sys-shape-corner-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-qr svg {
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* resources/views/pages/settings/two-factor.blade.php: the setup QR code. Fortify's own
|
||||||
|
* twoFactorQrCodeSvg() draws no quiet zone, so the SVG comes from App\Services\QrCodeService
|
||||||
|
* against the same otpauth URL instead, which bakes in its own white field and four-module quiet
|
||||||
|
* zone — the only way to guarantee one in dark mode, since no colour class or literal colour can
|
||||||
|
* paint it onto 2.0.0's foundation. Sized at 1.x's 16rem square, corners rounded and clipped to
|
||||||
|
* match the settings surfaces around it.
|
||||||
|
*/
|
||||||
|
.settings-two-factor-qr {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
inline-size: 16rem;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--md-sys-shape-corner-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-two-factor-qr svg {
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* resources/views/pages/settings/two-factor/recovery-codes.blade.php: a code dims to M3's disabled
|
||||||
|
* content opacity while regenerateRecoveryCodes() is in flight, and back, on the effects spring
|
||||||
|
* 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
|
||||||
|
* duration is 0ms.
|
||||||
|
*/
|
||||||
|
.settings-recovery-code {
|
||||||
|
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-recovery-code--loading {
|
||||||
|
opacity: var(--md-sys-state-disabled-content-opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* resources/views/pages/settings/appearance.blade.php: the theme picker stays a comfortable
|
||||||
|
* width instead of stretching across the settings card. No `<x-group>` width prop caps it, and
|
||||||
|
* 24rem matches no `<x-pane>` preset.
|
||||||
|
*/
|
||||||
|
.settings-appearance-picker {
|
||||||
|
max-inline-size: 24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* resources/views/livewire/admin/admin-dashboard.blade.php: a share's details are two lines of
|
||||||
|
* their own (its files, size and downloads; its expiry), and they wrap rather than clip. The
|
||||||
|
* package clamps a list item's description at two lines with an ellipsis, which on a phone hid
|
||||||
|
* the expiry with no way to read it.
|
||||||
|
*/
|
||||||
|
.admin-shares [data-md-list-item-description] {
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-share-detail {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* resources/views/livewire/admin/admin-settings.blade.php: the current and previewed site logo,
|
||||||
|
* at 1.x's 4rem height with its width following the image's own ratio. M3 keeps no size scale for
|
||||||
|
* a plain <img>.
|
||||||
|
*/
|
||||||
|
.admin-settings-logo {
|
||||||
|
block-size: 4rem;
|
||||||
|
border-radius: var(--md-sys-shape-corner-sm);
|
||||||
|
}
|
||||||
|
|||||||
+2837
-263
File diff suppressed because it is too large
Load Diff
+2745
-270
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
// Livewire Material. Alpine is bundled and started by Livewire 4: never import it here as well.
|
// Livewire Material. Alpine is bundled and started by Livewire 4: never import it here as well.
|
||||||
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
|
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
|
||||||
import './share-created.js'
|
import './share-created.js'
|
||||||
|
import './share-uploader.js'
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
/**
|
||||||
|
* `shareUploader`: the upload page's queue. Files are registered with the Livewire component in one
|
||||||
|
* batch per selection, then sent one at a time: each chunk is sliced from the file, encrypted here
|
||||||
|
* with AES-256-GCM and PUT on its own, so the server writes it once, already encrypted.
|
||||||
|
*
|
||||||
|
* The encrypted format is App\Services\FileEncryptionService's SEALCHK2: chunk i's nonce is the
|
||||||
|
* file's 7-byte prefix, i as a big-endian uint32 and a byte that is 1 on the last chunk; WebCrypto
|
||||||
|
* appends the 16-byte tag to the ciphertext, which is how the server stores it.
|
||||||
|
*
|
||||||
|
* A failed request is retried after 1, 2, 4, 8 and 16 seconds; after that the file waits for its
|
||||||
|
* Retry button, which picks up from the chunk the server last confirmed. The server answers 409
|
||||||
|
* with its own count when a chunk skips ahead, and acknowledges a chunk it already has.
|
||||||
|
*/
|
||||||
|
const RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||||
|
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
window.Alpine.data('shareUploader', ({ csrfToken, messages }) => ({
|
||||||
|
secure: window.isSecureContext && Boolean(window.crypto?.subtle),
|
||||||
|
|
||||||
|
dragging: false,
|
||||||
|
|
||||||
|
busy: false,
|
||||||
|
|
||||||
|
/** Files waiting to be sent, in order: { id, file, target, nextIndex }. */
|
||||||
|
queue: [],
|
||||||
|
|
||||||
|
/** Every file this page registered, by id: { state: 'queued'|'uploading'|'uploaded'|'failed', sent, size }. */
|
||||||
|
uploads: {},
|
||||||
|
|
||||||
|
/** The files that failed, by id, kept for their Retry button. */
|
||||||
|
failed: {},
|
||||||
|
|
||||||
|
/** The request on its way, so Cancel and Remove can abort it. */
|
||||||
|
request: null,
|
||||||
|
|
||||||
|
get progress() {
|
||||||
|
const unfinished = Object.values(this.uploads).filter((upload) => upload.state !== 'failed')
|
||||||
|
const size = unfinished.reduce((total, upload) => total + upload.size, 0)
|
||||||
|
|
||||||
|
return size === 0 ? 0 : (unfinished.reduce((total, upload) => total + upload.sent, 0) / size) * 100
|
||||||
|
},
|
||||||
|
|
||||||
|
choose(event) {
|
||||||
|
this.add([...event.target.files].map((file) => ({ file, path: null })))
|
||||||
|
|
||||||
|
event.target.value = ''
|
||||||
|
},
|
||||||
|
|
||||||
|
handleDrop(event) {
|
||||||
|
this.dragging = false
|
||||||
|
|
||||||
|
if (! this.secure) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = event.dataTransfer.items
|
||||||
|
const files = []
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const entry = items[i].webkitGetAsEntry?.()
|
||||||
|
|
||||||
|
if (entry) {
|
||||||
|
this.traverseEntry(entry, '', files)
|
||||||
|
} else if (items[i].kind === 'file') {
|
||||||
|
files.push({ file: items[i].getAsFile(), path: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory entries are read asynchronously.
|
||||||
|
setTimeout(() => this.add(files), 500)
|
||||||
|
},
|
||||||
|
|
||||||
|
traverseEntry(entry, path, files) {
|
||||||
|
if (entry.isFile) {
|
||||||
|
entry.file((file) => files.push({ file, path: path ? `${path}/${file.name}` : null }))
|
||||||
|
} else if (entry.isDirectory) {
|
||||||
|
entry.createReader().readEntries((entries) => {
|
||||||
|
entries.forEach((child) => this.traverseEntry(child, path ? `${path}/${entry.name}` : entry.name, files))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async add(selection) {
|
||||||
|
if (! this.secure || selection.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = await this.$wire.registerFiles(selection.map(({ file, path }) => ({ name: file.name, size: file.size, path })))
|
||||||
|
|
||||||
|
;(targets ?? []).forEach((target, position) => {
|
||||||
|
if (! target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.uploads[target.id] = { state: 'queued', sent: 0, size: selection[position].file.size }
|
||||||
|
this.queue.push({ id: target.id, file: selection[position].file, target, nextIndex: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
this.run()
|
||||||
|
},
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
if (this.busy) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.busy = true
|
||||||
|
|
||||||
|
while (this.queue.length > 0) {
|
||||||
|
const item = this.queue[0]
|
||||||
|
const uploaded = await this.upload(item)
|
||||||
|
|
||||||
|
if (this.queue[0] === item) {
|
||||||
|
this.queue.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploaded) {
|
||||||
|
await this.$wire.$refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.busy = false
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send one file's remaining chunks; true once the server has them all.
|
||||||
|
*/
|
||||||
|
async upload(item) {
|
||||||
|
const { id, file, target } = item
|
||||||
|
const upload = this.uploads[id]
|
||||||
|
|
||||||
|
if (! upload) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
upload.state = 'uploading'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key = await crypto.subtle.importKey('raw', bytesFromHex(target.key), 'AES-GCM', false, ['encrypt'])
|
||||||
|
const noncePrefix = bytesFromHex(target.noncePrefix)
|
||||||
|
|
||||||
|
while (item.nextIndex < target.chunkCount) {
|
||||||
|
const index = item.nextIndex
|
||||||
|
const start = index * target.chunkSize
|
||||||
|
const plaintext = await file.slice(start, start + target.chunkSize).arrayBuffer()
|
||||||
|
const ciphertext = await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv: chunkNonce(noncePrefix, index, index === target.chunkCount - 1), tagLength: 128 },
|
||||||
|
key,
|
||||||
|
plaintext,
|
||||||
|
)
|
||||||
|
|
||||||
|
item.nextIndex = await this.send(upload, `${target.url}/${index}`, ciphertext, start, plaintext.byteLength)
|
||||||
|
upload.sent = Math.min(item.nextIndex * target.chunkSize, upload.size)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
upload.state = 'failed'
|
||||||
|
this.failed[id] = item
|
||||||
|
|
||||||
|
if (error?.status === 419) {
|
||||||
|
window.materialToast(messages.sessionExpired, { type: 'error' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
upload.state = 'uploaded'
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT one encrypted chunk, retrying transient failures; resolves with the number of chunks
|
||||||
|
* the server holds for the file.
|
||||||
|
*/
|
||||||
|
async send(upload, url, body, offset, plaintextLength) {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
const response = await this.put(url, body, (loaded) => {
|
||||||
|
upload.sent = Math.min(offset + (loaded / body.byteLength) * plaintextLength, upload.size)
|
||||||
|
})
|
||||||
|
|
||||||
|
if ((response.status === 200 || response.status === 409) && Number.isInteger(response.uploadedChunks)) {
|
||||||
|
return response.uploadedChunks
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 404 || response.status === 419 || attempt === RETRY_DELAYS.length) {
|
||||||
|
throw Object.assign(new Error('Upload failed'), { status: response.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAYS[attempt]))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
put(url, body, onProgress) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest()
|
||||||
|
|
||||||
|
xhr.open('PUT', url)
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/octet-stream')
|
||||||
|
xhr.setRequestHeader('Accept', 'application/json')
|
||||||
|
xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken)
|
||||||
|
xhr.responseType = 'json'
|
||||||
|
xhr.upload.onprogress = (event) => onProgress(event.loaded)
|
||||||
|
xhr.onload = () => {
|
||||||
|
this.request = null
|
||||||
|
resolve({ status: xhr.status, uploadedChunks: xhr.response?.uploaded_chunks })
|
||||||
|
}
|
||||||
|
xhr.onerror = () => {
|
||||||
|
this.request = null
|
||||||
|
resolve({ status: 0 })
|
||||||
|
}
|
||||||
|
xhr.onabort = () => {
|
||||||
|
this.request = null
|
||||||
|
reject(new DOMException('Upload cancelled', 'AbortError'))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.request = xhr
|
||||||
|
// Chromium sends a Blob body about eight times faster than the same ArrayBuffer.
|
||||||
|
xhr.send(new Blob([body]))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
retry(id) {
|
||||||
|
const item = this.failed[id]
|
||||||
|
|
||||||
|
if (! item) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
delete this.failed[id]
|
||||||
|
this.uploads[id].state = 'queued'
|
||||||
|
this.queue.push(item)
|
||||||
|
this.run()
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(id) {
|
||||||
|
this.forget([id])
|
||||||
|
this.$wire.removeFiles([id])
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop everything still to send and take those files out of the share.
|
||||||
|
*/
|
||||||
|
cancel() {
|
||||||
|
const unfinished = Object.entries(this.uploads)
|
||||||
|
.filter(([, upload]) => upload.state !== 'uploaded')
|
||||||
|
.map(([id]) => Number(id))
|
||||||
|
|
||||||
|
this.forget(unfinished)
|
||||||
|
this.$wire.removeFiles(unfinished)
|
||||||
|
},
|
||||||
|
|
||||||
|
forget(ids) {
|
||||||
|
const current = this.queue[0]
|
||||||
|
|
||||||
|
this.queue = this.queue.filter((item) => ! ids.includes(item.id))
|
||||||
|
|
||||||
|
ids.forEach((id) => {
|
||||||
|
delete this.uploads[id]
|
||||||
|
delete this.failed[id]
|
||||||
|
})
|
||||||
|
|
||||||
|
if (current && ids.includes(current.id)) {
|
||||||
|
this.request?.abort()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
statusOf(id, uploaded) {
|
||||||
|
const upload = this.uploads[id]
|
||||||
|
|
||||||
|
if (uploaded || upload?.state === 'uploaded') {
|
||||||
|
return messages.uploaded
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upload?.state === 'uploading') {
|
||||||
|
return `${Math.round((upload.sent / Math.max(upload.size, 1)) * 100)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
return upload?.state === 'failed' ? messages.failed : messages.queued
|
||||||
|
},
|
||||||
|
|
||||||
|
warnBeforeLeaving(event) {
|
||||||
|
if (this.busy) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.returnValue = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
function bytesFromHex(hex) {
|
||||||
|
return Uint8Array.from(hex.match(/.{2}/g), (pair) => parseInt(pair, 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunk `index`'s 12-byte nonce: the file's prefix, the index as a big-endian uint32 and the
|
||||||
|
* last-chunk flag.
|
||||||
|
*/
|
||||||
|
function chunkNonce(prefix, index, isLast) {
|
||||||
|
const nonce = new Uint8Array(12)
|
||||||
|
|
||||||
|
nonce.set(prefix, 0)
|
||||||
|
new DataView(nonce.buffer).setUint32(7, index)
|
||||||
|
nonce[11] = isLast ? 1 : 0
|
||||||
|
|
||||||
|
return nonce
|
||||||
|
}
|
||||||
@@ -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 |
@@ -1,9 +0,0 @@
|
|||||||
@props([
|
|
||||||
'title',
|
|
||||||
'description',
|
|
||||||
])
|
|
||||||
|
|
||||||
<div class="flex w-full flex-col text-center">
|
|
||||||
<h1 class="type-headline-sm">{{ $title }}</h1>
|
|
||||||
<p class="mt-1 type-body-md text-on-surface-variant">{{ $description }}</p>
|
|
||||||
</div>
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{{-- Every page in SealShare: a centred header over one centred column of cards, the share pages'
|
||||||
|
shape carried to every other page, sign-in included.
|
||||||
|
|
||||||
|
<x-page :title="__('Settings')" :description="__('…')">
|
||||||
|
<x-slot:navigation><x-section-nav :items="$items" /></x-slot:navigation>
|
||||||
|
<x-card variant="outlined" heading="h2" …>…</x-card>
|
||||||
|
</x-page>
|
||||||
|
|
||||||
|
`brand` heads the page with the site's own logo, title and description from Admin settings
|
||||||
|
instead of `title` and `description`, falling back to the app's name and SealShare's line.
|
||||||
|
`mark` is a visual above the title (share created's check). Every page is the same 40rem
|
||||||
|
column (`<x-pane width="narrow">`, M3's cap on a text field), so there is no width prop: content
|
||||||
|
that needs more room is rearranged to fit, as the admin dashboard's shares became a list. No
|
||||||
|
page sets a width or a heading of its own. The page's content stacks 24px apart under the
|
||||||
|
header and the optional `navigation`. --}}
|
||||||
|
|
||||||
|
@props([
|
||||||
|
'title' => null,
|
||||||
|
'description' => null,
|
||||||
|
'brand' => false,
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$logo = null;
|
||||||
|
|
||||||
|
if ($brand) {
|
||||||
|
$title = \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare');
|
||||||
|
$description = \App\Models\Setting::get('site_description') ?: __('Share your files safely and securely');
|
||||||
|
$logo = \App\Models\Setting::get('site_logo');
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<x-pane width="narrow" data-test="page" {{ $attributes }}>
|
||||||
|
<x-stack gap="space400">
|
||||||
|
<x-stack as="header" align="center" gap="space200">
|
||||||
|
@if ($logo)
|
||||||
|
<img src="{{ Storage::disk('public')->url($logo) }}" alt="{{ $title }}" class="page-logo" data-test="page-logo" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{ $mark ?? '' }}
|
||||||
|
|
||||||
|
<x-stack align="center" gap="space100">
|
||||||
|
<h1 class="md-type-headline-lg md-text-center">{{ $title }}</h1>
|
||||||
|
|
||||||
|
@if (filled($description))
|
||||||
|
<p class="md-type-body-lg md-ink-variant md-text-center">{{ $description }}</p>
|
||||||
|
@endif
|
||||||
|
</x-stack>
|
||||||
|
</x-stack>
|
||||||
|
|
||||||
|
{{ $navigation ?? '' }}
|
||||||
|
|
||||||
|
<x-stack gap="space300">
|
||||||
|
{{ $slot }}
|
||||||
|
</x-stack>
|
||||||
|
</x-stack>
|
||||||
|
</x-pane>
|
||||||
@@ -3,10 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
@include('partials.head')
|
@include('partials.head')
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-dvh bg-surface font-sans text-on-surface antialiased [--material-bottom-bar:calc(5rem+env(safe-area-inset-bottom))]">
|
<body>
|
||||||
<main class="mx-auto w-full max-w-5xl px-4 pt-8 pb-32 sm:px-6 sm:pt-12">
|
<x-pane as="main" class="app-main">
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</main>
|
</x-pane>
|
||||||
|
|
||||||
@include('partials.toolbar')
|
@include('partials.toolbar')
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
|
||||||
<head>
|
|
||||||
@include('partials.head')
|
|
||||||
</head>
|
|
||||||
<body class="flex min-h-dvh flex-col bg-surface font-sans text-on-surface antialiased [--material-bottom-bar:calc(5rem+env(safe-area-inset-bottom))]">
|
|
||||||
<main class="flex flex-1 items-start justify-center px-4 pt-8 pb-32 sm:items-center">
|
|
||||||
<div class="w-full max-w-md rounded-corner-xl bg-surface-container-low p-6 sm:p-8">
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
{{ $slot }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
@include('partials.toolbar')
|
|
||||||
|
|
||||||
<x-toast />
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,62 +1,76 @@
|
|||||||
<div>
|
<x-page :title="__('Admin Dashboard')" :description="__('Shares, files and storage at a glance')">
|
||||||
<h1 class="mb-6 type-headline-md">{{ __('Admin Dashboard') }}</h1>
|
<x-grid :columns="2" gap="space200">
|
||||||
|
|
||||||
<div class="mb-6 grid grid-cols-2 gap-3 md:grid-cols-4">
|
|
||||||
<x-stat :title="__('Total Shares')" :value="$totalShares" icon="link" />
|
<x-stat :title="__('Total Shares')" :value="$totalShares" icon="link" />
|
||||||
<x-stat :title="__('Active Shares')" :value="$activeShares" icon="schedule" />
|
<x-stat :title="__('Active Shares')" :value="$activeShares" icon="schedule" />
|
||||||
<x-stat :title="__('Total Files')" :value="$totalFiles" icon="description" />
|
<x-stat :title="__('Total Files')" :value="$totalFiles" icon="description" />
|
||||||
<x-stat :title="__('Disk Usage')" :value="Number::fileSize($usedSpace)" icon="hard_drive" :description="Number::fileSize($usedSpace).' / '.Number::fileSize($maxQuota)">
|
<x-stat :title="__('Disk Usage')" :value="Number::fileSize($usedSpace)" icon="hard_drive" :description="Number::fileSize($usedSpace).' / '.Number::fileSize($maxQuota)">
|
||||||
<x-progress :value="$maxQuota > 0 ? min(100, ($usedSpace / $maxQuota) * 100) : 0" class="mt-2" :label="__('Disk Usage')" />
|
<x-progress :value="$maxQuota > 0 ? min(100, ($usedSpace / $maxQuota) * 100) : 0" :label="__('Disk Usage')" />
|
||||||
</x-stat>
|
</x-stat>
|
||||||
</div>
|
</x-grid>
|
||||||
|
|
||||||
<x-card :title="__('All Shares')" variant="outlined">
|
{{-- The shares as a list, not a table: a table's columns need more than the page's 40rem, and
|
||||||
{{-- Outside the table, so it stays centred on a phone instead of scrolling with the columns. --}}
|
every page keeps that one width. The sort is a full-width select above the list instead of column headers. --}}
|
||||||
@if ($shares->total() === 0)
|
<x-card :title="__('All Shares')" heading="h2" variant="outlined">
|
||||||
<x-empty-state icon="link_off" :title="__('No shares yet')" :description="__('Shares appear here once someone uploads files.')" />
|
<x-stack gap="space200">
|
||||||
@else
|
@if ($shares->total() === 0)
|
||||||
<div class="-mx-4 overflow-x-auto">
|
<x-empty-state icon="link_off" :title="__('No shares yet')" :description="__('Shares appear here once someone uploads files.')" />
|
||||||
<x-table>
|
@else
|
||||||
<thead>
|
<x-select
|
||||||
<tr>
|
wire:model.live="sort"
|
||||||
<x-sort-header column="token" :sort-by="$sortBy">{{ __('Token') }}</x-sort-header>
|
:label="__('Sort by')"
|
||||||
<x-sort-header column="files_count" :sort-by="$sortBy" class="text-end">{{ __('Files') }}</x-sort-header>
|
:options="[
|
||||||
<x-sort-header column="total_size" :sort-by="$sortBy" class="text-end">{{ __('Size') }}</x-sort-header>
|
['id' => 'newest', 'name' => __('Newest first')],
|
||||||
<x-sort-header column="download_count" :sort-by="$sortBy" class="text-end">{{ __('Downloads') }}</x-sort-header>
|
['id' => 'oldest', 'name' => __('Oldest first')],
|
||||||
<x-sort-header column="expires_at" :sort-by="$sortBy">{{ __('Expires') }}</x-sort-header>
|
['id' => 'expiring', 'name' => __('Expiring soonest')],
|
||||||
<x-sort-header column="created_at" :sort-by="$sortBy">{{ __('Created') }}</x-sort-header>
|
['id' => 'largest', 'name' => __('Largest')],
|
||||||
<th><span class="sr-only">{{ __('Actions') }}</span></th>
|
['id' => 'most-downloaded', 'name' => __('Most downloads')],
|
||||||
</tr>
|
['id' => 'most-files', 'name' => __('Most files')],
|
||||||
</thead>
|
]"
|
||||||
<tbody>
|
data-test="shares-sort"
|
||||||
@foreach ($shares as $share)
|
/>
|
||||||
<tr wire:key="share-{{ $share->id }}">
|
|
||||||
<td class="font-mono">{{ $share->token }}</td>
|
|
||||||
<td class="text-end tabular-nums">{{ $share->files_count }}</td>
|
|
||||||
<td class="text-end tabular-nums whitespace-nowrap">{{ Number::fileSize($share->total_size) }}</td>
|
|
||||||
<td class="text-end tabular-nums">{{ $share->download_count }}</td>
|
|
||||||
<td class="whitespace-nowrap">
|
|
||||||
@if ($share->expires_at)
|
|
||||||
<span @class(['text-error' => $share->isExpired()])>{{ $share->expires_at->diffForHumans() }}</span>
|
|
||||||
@else
|
|
||||||
<span class="text-on-surface-variant">{{ __('Never') }}</span>
|
|
||||||
@endif
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap">{{ $share->created_at->diffForHumans() }}</td>
|
|
||||||
<td class="text-end whitespace-nowrap">
|
|
||||||
<x-button icon="open_in_new" :tooltip="__('Open')" :link="route('share.download', $share)" external />
|
|
||||||
<x-button icon="delete" :tooltip="__('Delete')" color="error" wire:click="$set('deletingShareId', {{ $share->id }})" data-test="delete-share-{{ $share->id }}" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@endforeach
|
|
||||||
</tbody>
|
|
||||||
</x-table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">{{ $shares->links() }}</div>
|
{{-- Each share fits the column on a phone: the token opens it, so delete is the one button;
|
||||||
@endif
|
the details are two short lines that never clip (admin-shares, app.css). --}}
|
||||||
|
<x-list dividers :label="__('All Shares')" class="admin-shares">
|
||||||
|
@foreach ($shares as $share)
|
||||||
|
<x-list-item :overline="__('Created :time', ['time' => $share->created_at->diffForHumans()])" wire:key="share-{{ $share->id }}" data-test="share-row">
|
||||||
|
<a href="{{ route('share.download', $share) }}" target="_blank" rel="noopener" class="md-link"><code>{{ $share->token }}</code></a>
|
||||||
|
|
||||||
|
<x-slot:description>
|
||||||
|
<span class="admin-share-detail md-tabular">{{ trans_choice(':count file|:count files', $share->files_count) }} · {{ Number::fileSize($share->total_size) }} · {{ $share->max_downloads ? trans_choice(':count of :max download|:count of :max downloads', $share->max_downloads, ['count' => $share->download_count, 'max' => $share->max_downloads]) : trans_choice(':count download|:count downloads', $share->download_count) }}</span>
|
||||||
|
|
||||||
|
{{-- A share at its limit is closed; the cleanup deletes it a day after its last download. --}}
|
||||||
|
@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>
|
||||||
|
@elseif ($share->isExpired())
|
||||||
|
<span class="admin-share-detail md-ink-error">{{ __('Expired :time', ['time' => $share->expires_at->diffForHumans()]) }}</span>
|
||||||
|
@else
|
||||||
|
<span class="admin-share-detail">{{ __('Expires :time', ['time' => $share->expires_at->diffForHumans()]) }}</span>
|
||||||
|
@endif
|
||||||
|
</x-slot:description>
|
||||||
|
|
||||||
|
<x-slot:end>
|
||||||
|
<x-button icon="delete" :tooltip="__('Delete')" color="error" wire:click="$set('deletingShareId', {{ $share->id }})" data-test="delete-share-{{ $share->id }}" />
|
||||||
|
</x-slot:end>
|
||||||
|
</x-list-item>
|
||||||
|
@endforeach
|
||||||
|
</x-list>
|
||||||
|
|
||||||
|
{{ $shares->links() }}
|
||||||
|
@endif
|
||||||
|
</x-stack>
|
||||||
</x-card>
|
</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?') }}
|
||||||
|
|
||||||
@@ -65,4 +79,4 @@
|
|||||||
<x-button :label="__('Delete')" danger x-on:click="$wire.deleteShare($wire.deletingShareId)" data-test="confirm-delete-share" />
|
<x-button :label="__('Delete')" danger x-on:click="$wire.deleteShare($wire.deletingShareId)" data-test="confirm-delete-share" />
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
</x-modal>
|
</x-modal>
|
||||||
</div>
|
</x-page>
|
||||||
|
|||||||
@@ -1,60 +1,155 @@
|
|||||||
<div class="mx-auto max-w-2xl">
|
<x-page :title="__('System Settings')" :description="__('How the site looks and what uploaders may do')">
|
||||||
<h1 class="mb-6 type-headline-md">{{ __('System Settings') }}</h1>
|
<x-form wire:submit="saveSettings">
|
||||||
|
<x-card :title="__('Colour profile')" heading="h2" variant="outlined">
|
||||||
<form wire:submit="saveSettings" class="grid gap-6">
|
<x-stack gap="space200">
|
||||||
<x-card :title="__('Colour profile')" variant="outlined">
|
<x-scheme-picker wire:model="colorProfile" :hint="__('Choosing one previews it here. After saving, every page, mail and error page uses it.')" data-test="color-profile" />
|
||||||
<x-scheme-picker wire:model="colorProfile" :hint="__('Choosing one previews it here. After saving, every page, mail and error page uses it.')" data-test="color-profile" />
|
</x-stack>
|
||||||
</x-card>
|
</x-card>
|
||||||
|
|
||||||
<x-card :title="__('Branding')" variant="outlined">
|
<x-card :title="__('Branding')" heading="h2" variant="outlined">
|
||||||
<div class="grid gap-5">
|
<x-stack gap="space200">
|
||||||
<x-input wire:model="siteTitle" :label="__('Site Title')" :hint="__('Displayed as the heading on the upload page.')" />
|
<x-input full wire:model="siteTitle" :label="__('Site Title')" :hint="__('Displayed as the heading on the upload, download and sign-in pages.')" />
|
||||||
|
|
||||||
<x-textarea wire:model="siteDescription" :label="__('Site Description')" :hint="__('Displayed below the title on the upload page.')" rows="3" />
|
<x-textarea full wire:model="siteDescription" :label="__('Site Description')" :hint="__('Displayed below the title on the upload, download and sign-in pages.')" rows="3" />
|
||||||
|
|
||||||
<div class="grid gap-3">
|
<x-stack gap="space200">
|
||||||
@if ($currentLogo)
|
@if ($currentLogo)
|
||||||
<div class="flex flex-wrap items-center gap-4">
|
<x-row gap="space200" wrap>
|
||||||
<img src="{{ Storage::disk('public')->url($currentLogo) }}" alt="{{ __('Site Logo') }}" class="h-16 w-auto rounded-corner-sm" />
|
<img src="{{ Storage::disk('public')->url($currentLogo) }}" alt="{{ __('Site Logo') }}" class="admin-settings-logo" />
|
||||||
<x-button :label="__('Remove Logo')" icon="delete" color="error" wire:click="$set('confirmingLogoRemoval', true)" data-test="remove-logo" />
|
<x-button :label="__('Remove Logo')" icon="delete" color="error" wire:click="$set('confirmingLogoRemoval', true)" data-test="remove-logo" />
|
||||||
</div>
|
</x-row>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<x-file wire:model="siteLogo" :label="__('Logo')" accept="image/*,.svg,.svgz" :hint="__('Max 2MB. Recommended: PNG or SVG.')" />
|
<x-file full wire:model="siteLogo" :label="__('Logo')" accept="image/*,.svg,.svgz" :hint="__('Max 2MB. Recommended: PNG or SVG.')" />
|
||||||
|
|
||||||
@if ($siteLogo && is_object($siteLogo))
|
@if ($siteLogo && is_object($siteLogo))
|
||||||
@if (str_contains($siteLogo->getMimeType(), 'svg'))
|
@if (str_contains($siteLogo->getMimeType(), 'svg'))
|
||||||
<p class="type-body-md text-on-surface-variant">{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}</p>
|
<p class="md-type-body-md md-ink-variant">{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}</p>
|
||||||
@else
|
@else
|
||||||
<div>
|
<x-stack gap="space50">
|
||||||
<p class="type-label-lg text-on-surface-variant">{{ __('Preview:') }}</p>
|
<p class="md-type-label-lg md-ink-variant">{{ __('Preview:') }}</p>
|
||||||
<img src="{{ $siteLogo->temporaryUrl() }}" alt="{{ __('Logo preview') }}" class="mt-1 h-16 w-auto rounded-corner-sm" />
|
<img src="{{ $siteLogo->temporaryUrl() }}" alt="{{ __('Logo preview') }}" class="admin-settings-logo" />
|
||||||
</div>
|
</x-stack>
|
||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</x-stack>
|
||||||
</div>
|
</x-stack>
|
||||||
</x-card>
|
</x-card>
|
||||||
|
|
||||||
<x-card :title="__('Upload Protection')" variant="outlined">
|
<x-card :title="__('Upload Protection')" heading="h2" variant="outlined">
|
||||||
<div class="grid gap-3">
|
<x-stack gap="space200">
|
||||||
<x-password
|
<x-stack gap="space100">
|
||||||
wire:model="systemPassword"
|
<x-password full
|
||||||
:label="__('System Upload Password')"
|
wire:model="systemPassword"
|
||||||
:hint="__('Leave blank to keep current. Set a password to require it before uploading.')"
|
:label="__('System Upload Password')"
|
||||||
autocomplete="new-password"
|
:hint="__('Leave blank to keep current. Set a password to require it before uploading.')"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if ($hasSystemPassword)
|
||||||
|
<x-button :label="__('Clear System Password')" icon="lock_reset" color="error" wire:click="$set('confirmingPasswordRemoval', true)" data-test="clear-system-password" />
|
||||||
|
@endif
|
||||||
|
</x-stack>
|
||||||
|
</x-stack>
|
||||||
|
</x-card>
|
||||||
|
|
||||||
|
{{-- How the upload page offers random share passwords (App\Services\PasswordGeneratorService).
|
||||||
|
The example is drawn from the form as it stands, before saving. --}}
|
||||||
|
<x-card :title="__('Share Passwords')" heading="h2" variant="outlined">
|
||||||
|
<x-stack gap="space200">
|
||||||
|
<x-group
|
||||||
|
wire:model.live="passwordGeneratorMode"
|
||||||
|
:label="__('Password generator')"
|
||||||
|
:hint="match ($passwordGeneratorMode) {
|
||||||
|
'off' => __('Uploaders type a password themselves.'),
|
||||||
|
'prefill' => __('A random password is filled in as soon as Password protect is switched on. Generate draws a new one.'),
|
||||||
|
default => __('A Generate button under the password field fills in a random password.'),
|
||||||
|
}"
|
||||||
|
:options="[
|
||||||
|
['id' => 'off', 'name' => __('Off')],
|
||||||
|
['id' => 'button', 'name' => __('On request')],
|
||||||
|
['id' => 'prefill', 'name' => __('Prefilled')],
|
||||||
|
]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@if ($hasSystemPassword)
|
@if ($passwordGeneratorMode !== 'off')
|
||||||
<div>
|
<x-group
|
||||||
<x-button :label="__('Clear System Password')" icon="lock_reset" color="error" wire:click="$set('confirmingPasswordRemoval', true)" data-test="clear-system-password" />
|
wire:model.live="passwordGeneratorType"
|
||||||
</div>
|
:label="__('Kind')"
|
||||||
|
:hint="$passwordGeneratorType === 'passphrase' ? __('Random words, easy to read out or type on a phone.') : __('Random characters, the most secure for their length.')"
|
||||||
|
:options="[
|
||||||
|
['id' => 'characters', 'name' => __('Characters')],
|
||||||
|
['id' => 'passphrase', 'name' => __('Passphrase')],
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if ($passwordGeneratorType === 'passphrase')
|
||||||
|
<x-input full
|
||||||
|
wire:model.live.blur="passphraseWords"
|
||||||
|
:label="__('Words')"
|
||||||
|
type="number"
|
||||||
|
:min="\App\Services\PasswordGeneratorService::MIN_WORDS"
|
||||||
|
:max="\App\Services\PasswordGeneratorService::MAX_WORDS"
|
||||||
|
:hint="__('Between :min and :max.', ['min' => \App\Services\PasswordGeneratorService::MIN_WORDS, 'max' => \App\Services\PasswordGeneratorService::MAX_WORDS])"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-select full
|
||||||
|
wire:model.live="passphraseSeparator"
|
||||||
|
:label="__('Separator')"
|
||||||
|
:options="[
|
||||||
|
['id' => 'hyphen', 'name' => __('Hyphen (-)')],
|
||||||
|
['id' => 'dot', 'name' => __('Dot (.)')],
|
||||||
|
['id' => 'underscore', 'name' => __('Underscore (_)')],
|
||||||
|
['id' => 'space', 'name' => __('Space')],
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
@else
|
||||||
|
<x-input full
|
||||||
|
wire:model.live.blur="passwordLength"
|
||||||
|
:label="__('Length')"
|
||||||
|
type="number"
|
||||||
|
:min="\App\Services\PasswordGeneratorService::MIN_LENGTH"
|
||||||
|
:max="\App\Services\PasswordGeneratorService::MAX_LENGTH"
|
||||||
|
:suffix="__('characters')"
|
||||||
|
:hint="__('Between :min and :max.', ['min' => \App\Services\PasswordGeneratorService::MIN_LENGTH, 'max' => \App\Services\PasswordGeneratorService::MAX_LENGTH])"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-group
|
||||||
|
multiple
|
||||||
|
wire:model.live="passwordCharacterSets"
|
||||||
|
:label="__('Include')"
|
||||||
|
:hint="__('Uppercase letters, lowercase letters, numbers and symbols.')"
|
||||||
|
:options="[
|
||||||
|
['id' => 'uppercase', 'name' => 'A–Z'],
|
||||||
|
['id' => 'lowercase', 'name' => 'a–z'],
|
||||||
|
['id' => 'numbers', 'name' => '0–9'],
|
||||||
|
['id' => 'symbols', 'name' => '#$%'],
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-checkbox
|
||||||
|
wire:model.live="passwordAvoidAmbiguous"
|
||||||
|
:label="__('Avoid look-alike characters')"
|
||||||
|
:hint="__('Leaves out 0, O, 1, l and I.')"
|
||||||
|
/>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($passwordExample)
|
||||||
|
<x-input full
|
||||||
|
:label="__('Example')"
|
||||||
|
:value="$passwordExample"
|
||||||
|
:hint="__('About :bits bits of entropy.', ['bits' => $passwordEntropy])"
|
||||||
|
readonly
|
||||||
|
mono
|
||||||
|
data-test="password-example"
|
||||||
|
/>
|
||||||
|
@endif
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</x-stack>
|
||||||
</x-card>
|
</x-card>
|
||||||
|
|
||||||
<x-card :title="__('Upload Limits')" variant="outlined">
|
<x-card :title="__('Upload Limits')" heading="h2" variant="outlined">
|
||||||
<div class="grid gap-5">
|
<x-stack gap="space200">
|
||||||
<x-toggle
|
<x-toggle
|
||||||
wire:model.live="allowNeverExpire"
|
wire:model.live="allowNeverExpire"
|
||||||
:label="__('Allow shares to never expire')"
|
:label="__('Allow shares to never expire')"
|
||||||
@@ -62,52 +157,47 @@
|
|||||||
right
|
right
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-select
|
<x-select full
|
||||||
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
|
<x-input full
|
||||||
wire:model="maxFileSize"
|
wire:model="maxFileSize"
|
||||||
:label="__('Max file size (MB)')"
|
:label="__('Max file size (MB)')"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
:max="$phpMaxUploadMb"
|
|
||||||
suffix="MB"
|
suffix="MB"
|
||||||
:hint="__('PHP limit: :max MB (upload_max_filesize / post_max_size)', ['max' => $phpMaxUploadMb])"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-input wire:model="maxFilesPerShare" :label="__('Max files per share')" type="number" min="1" />
|
<x-input full wire:model="maxFilesPerShare" :label="__('Max files per share')" type="number" min="1" />
|
||||||
|
|
||||||
<x-input wire:model="maxSizePerShare" :label="__('Max total size per share (GB)')" type="number" min="1" suffix="GB" />
|
<x-input full wire:model="maxSizePerShare" :label="__('Max total size per share (GB)')" type="number" min="1" suffix="GB" />
|
||||||
</div>
|
</x-stack>
|
||||||
</x-card>
|
</x-card>
|
||||||
|
|
||||||
<x-card :title="__('Storage')" variant="outlined">
|
<x-card :title="__('Storage')" heading="h2" variant="outlined">
|
||||||
<x-input
|
<x-stack gap="space200">
|
||||||
wire:model="maxStorageQuota"
|
<x-input full
|
||||||
:label="__('Max storage quota (GB)')"
|
wire:model="maxStorageQuota"
|
||||||
type="number"
|
:label="__('Max storage quota (GB)')"
|
||||||
min="1"
|
type="number"
|
||||||
suffix="GB"
|
min="1"
|
||||||
:hint="__('When reached, new uploads are blocked.')"
|
suffix="GB"
|
||||||
/>
|
:hint="__('When reached, new uploads are blocked.')"
|
||||||
|
/>
|
||||||
|
</x-stack>
|
||||||
</x-card>
|
</x-card>
|
||||||
|
|
||||||
<x-button type="submit" :label="__('Save Settings')" variant="filled" icon="check" spinner="saveSettings" class="w-full" data-test="save-settings" />
|
<x-slot:actions>
|
||||||
</form>
|
<x-button type="submit" :label="__('Save Settings')" variant="filled" icon="check" spinner="saveSettings" data-test="save-settings" />
|
||||||
|
</x-slot:actions>
|
||||||
|
</x-form>
|
||||||
|
|
||||||
<x-modal wire:model="confirmingLogoRemoval" :title="__('Remove the logo?')" icon="delete">
|
<x-modal wire:model="confirmingLogoRemoval" :title="__('Remove the logo?')" icon="delete">
|
||||||
{{ __('The upload and download pages show the default mark again.') }}
|
{{ __('The upload, download and sign-in pages show only the site title.') }}
|
||||||
|
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
<x-button :label="__('Cancel')" x-on:click="close()" />
|
<x-button :label="__('Cancel')" x-on:click="close()" />
|
||||||
@@ -123,4 +213,4 @@
|
|||||||
<x-button :label="__('Remove')" danger wire:click="clearSystemPassword" data-test="confirm-clear-system-password" />
|
<x-button :label="__('Remove')" danger wire:click="clearSystemPassword" data-test="confirm-clear-system-password" />
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
</x-modal>
|
</x-modal>
|
||||||
</div>
|
</x-page>
|
||||||
|
|||||||
@@ -1,212 +1,157 @@
|
|||||||
<div class="mx-auto max-w-3xl">
|
<x-page brand>
|
||||||
<div class="mb-8 text-center">
|
{{-- Files this page already uploaded count towards the quota: they can still become a share. --}}
|
||||||
@if ($siteLogo)
|
@if ($isStorageFull && $pendingFiles->isEmpty())
|
||||||
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="mx-auto mb-4 h-20 w-auto" />
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<h1 class="type-headline-lg">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
|
|
||||||
|
|
||||||
<p class="mt-2 type-body-lg text-on-surface-variant">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($isStorageFull)
|
|
||||||
<x-alert color="warning" :title="__('Storage is full. Uploads are temporarily disabled.')" />
|
<x-alert color="warning" :title="__('Storage is full. Uploads are temporarily disabled.')" />
|
||||||
@else
|
@else
|
||||||
<form
|
<x-form
|
||||||
wire:submit="createShare"
|
wire:submit="createShare"
|
||||||
x-data="{
|
x-data="shareUploader({
|
||||||
uploading: false,
|
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
|
||||||
progress: 0,
|
messages: {{ \Illuminate\Support\Js::from([
|
||||||
dragging: false,
|
'queued' => __('Waiting'),
|
||||||
handleDrop(e) {
|
'uploaded' => __('Uploaded'),
|
||||||
this.dragging = false;
|
'failed' => __('Upload failed'),
|
||||||
const items = e.dataTransfer.items;
|
'sessionExpired' => __('Your session expired. Reload the page to upload again.'),
|
||||||
const files = [];
|
]) }},
|
||||||
|
})"
|
||||||
for (let i = 0; i < items.length; i++) {
|
x-on:beforeunload.window="warnBeforeLeaving($event)"
|
||||||
const entry = items[i].webkitGetAsEntry?.();
|
|
||||||
if (entry) {
|
|
||||||
this.traverseEntry(entry, '', files);
|
|
||||||
} else if (items[i].kind === 'file') {
|
|
||||||
files.push({ file: items[i].getAsFile(), path: null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (! files.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dt = new DataTransfer();
|
|
||||||
const paths = [];
|
|
||||||
files.forEach(f => {
|
|
||||||
dt.items.add(f.file);
|
|
||||||
paths.push(f.path);
|
|
||||||
});
|
|
||||||
|
|
||||||
$wire.relativePaths = [...($wire.relativePaths ?? []), ...paths];
|
|
||||||
|
|
||||||
this.uploading = true;
|
|
||||||
this.progress = 0;
|
|
||||||
|
|
||||||
$wire.uploadMultiple(
|
|
||||||
'files',
|
|
||||||
dt.files,
|
|
||||||
() => this.progress = 100,
|
|
||||||
() => this.resetUpload(),
|
|
||||||
(event) => this.progress = event.detail.progress,
|
|
||||||
() => this.resetUpload(),
|
|
||||||
);
|
|
||||||
}, 500);
|
|
||||||
},
|
|
||||||
resetUpload() {
|
|
||||||
this.uploading = false;
|
|
||||||
this.progress = 0;
|
|
||||||
},
|
|
||||||
traverseEntry(entry, path, files) {
|
|
||||||
if (entry.isFile) {
|
|
||||||
entry.file(file => {
|
|
||||||
files.push({ file, path: path ? path + '/' + file.name : null });
|
|
||||||
});
|
|
||||||
} else if (entry.isDirectory) {
|
|
||||||
const reader = entry.createReader();
|
|
||||||
reader.readEntries(entries => {
|
|
||||||
entries.forEach(e => this.traverseEntry(e, path ? path + '/' + entry.name : entry.name, files));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
x-init="$wire.$on('files-processed', () => resetUpload())"
|
|
||||||
x-on:livewire-upload-start="uploading = true; progress = 0"
|
|
||||||
x-on:livewire-upload-finish="progress = 100"
|
|
||||||
x-on:livewire-upload-cancel="resetUpload()"
|
|
||||||
x-on:livewire-upload-error="resetUpload()"
|
|
||||||
x-on:livewire-upload-progress="progress = $event.detail.progress"
|
|
||||||
>
|
>
|
||||||
|
{{-- WebCrypto, which encrypts the files in the browser, only exists on HTTPS (or localhost). --}}
|
||||||
|
<div x-show="! secure" x-cloak data-test="insecure-context">
|
||||||
|
<x-alert color="warning" :title="__('Uploads need a secure connection (HTTPS).')" :description="__('Ask the administrator to serve this site over HTTPS.')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{{-- Drop zone: the shape behind the icon turns into a burst while files are over it. --}}
|
{{-- Drop zone: the shape behind the icon turns into a burst while files are over it. --}}
|
||||||
<div
|
<div
|
||||||
class="mb-6 rounded-corner-xl border-2 border-dashed p-8 text-center transition-colors duration-(--md-sys-motion-effects-default-duration) ease-effects-default"
|
class="upload-drop-zone"
|
||||||
x-bind:class="{
|
x-bind:data-dragging="dragging ? 'true' : 'false'"
|
||||||
'border-primary bg-primary-container/40': dragging,
|
x-bind:aria-disabled="secure ? 'false' : 'true'"
|
||||||
'border-outline-variant': ! dragging,
|
|
||||||
'pointer-events-none opacity-60': uploading,
|
|
||||||
}"
|
|
||||||
x-on:dragover.prevent="dragging = true"
|
x-on:dragover.prevent="dragging = true"
|
||||||
x-on:dragleave.prevent="dragging = false"
|
x-on:dragleave.prevent="dragging = false"
|
||||||
x-on:drop.prevent="handleDrop($event)"
|
x-on:drop.prevent="handleDrop($event)"
|
||||||
data-test="drop-zone"
|
data-test="drop-zone"
|
||||||
>
|
>
|
||||||
<div class="relative mx-auto mb-4 grid size-28 place-items-center">
|
<x-stack align="center" gap="space200">
|
||||||
<span
|
<div class="upload-drop-shapes">
|
||||||
class="absolute inset-0 transition-[scale,rotate,opacity] duration-(--md-sys-motion-spatial-slow-duration) ease-spatial-slow motion-reduce:transition-none"
|
<x-shape name="cookie-9" class="upload-drop-shape upload-drop-shape--idle" />
|
||||||
x-bind:class="dragging ? 'scale-50 rotate-45 opacity-0' : 'scale-100 rotate-0 opacity-100'"
|
<x-shape name="soft-burst" class="upload-drop-shape upload-drop-shape--burst" data-test="drop-zone-burst" />
|
||||||
><x-shape name="cookie-9" class="size-full text-secondary-container" /></span>
|
<x-icon name="upload" size="48" class="upload-drop-icon" />
|
||||||
<span
|
</div>
|
||||||
class="absolute inset-0 transition-[scale,rotate,opacity] duration-(--md-sys-motion-spatial-slow-duration) ease-spatial-slow motion-reduce:transition-none"
|
|
||||||
x-bind:class="dragging ? 'scale-110 rotate-0 opacity-100' : 'scale-50 -rotate-45 opacity-0'"
|
|
||||||
><x-shape name="soft-burst" class="size-full text-primary-container" /></span>
|
|
||||||
<x-icon name="upload" class="relative size-12 text-on-secondary-container" x-bind:class="dragging && 'text-on-primary-container'" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="type-title-md">{{ __('Drag & drop files or folders here') }}</p>
|
<x-stack align="center" gap="space50">
|
||||||
<p class="mt-1 type-body-md text-on-surface-variant">{{ __('or click to browse') }}</p>
|
<p class="md-type-title-md md-text-center">{{ __('Drag & drop files or folders here') }}</p>
|
||||||
|
<p class="md-type-body-md md-ink-variant md-text-center">{{ __('or click to browse') }}</p>
|
||||||
|
</x-stack>
|
||||||
|
|
||||||
<label
|
{{-- The button is the tab stop and opens the browser's own picker; the input only carries the selection. --}}
|
||||||
class="state-layer focus-ring mt-4 inline-flex h-10 cursor-pointer items-center gap-2 rounded-corner-full border border-outline-variant px-4 type-label-lg text-primary has-focus-visible:outline-3 has-focus-visible:outline-secondary"
|
<x-button :label="__('Browse Files')" icon="folder_open" variant="outlined" x-on:click="$refs.picker.click()" x-bind:disabled="! secure" />
|
||||||
x-bind:class="uploading && 'pointer-events-none opacity-38'"
|
<input type="file" multiple hidden x-ref="picker" x-on:change="choose($event)" x-bind:disabled="! secure" data-test="file-input" />
|
||||||
>
|
</x-stack>
|
||||||
<x-icon name="folder_open" class="size-5" />
|
|
||||||
{{ __('Browse Files') }}
|
|
||||||
<input type="file" wire:model="files" multiple class="sr-only" x-bind:disabled="uploading" />
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Upload progress --}}
|
{{-- Upload progress, over every file still to send --}}
|
||||||
<div x-show="uploading" x-cloak class="mb-6" data-test="upload-progress">
|
<div x-show="busy" x-cloak data-test="upload-progress">
|
||||||
<div x-show="progress < 100">
|
<x-stack gap="space100">
|
||||||
<div class="mb-2 flex items-center justify-between">
|
<x-row justify="between">
|
||||||
<span class="type-label-lg">{{ __('Uploading...') }} <span x-text="Math.round(progress)"></span>%</span>
|
<span class="md-type-label-lg">{{ __('Uploading...') }} <span x-text="Math.round(progress)"></span>%</span>
|
||||||
<x-button :label="__('Cancel')" size="xs" x-on:click="$wire.cancelUpload('files')" />
|
<x-button :label="__('Cancel')" size="xs" x-on:click="cancel()" />
|
||||||
</div>
|
</x-row>
|
||||||
<x-progress bind="progress" wavy :label="__('Uploading')" />
|
<x-progress bind="progress" wavy :label="__('Uploading')" />
|
||||||
</div>
|
</x-stack>
|
||||||
<div x-show="progress >= 100" class="flex items-center gap-3 type-label-lg">
|
|
||||||
<x-loading class="size-8" :label="false" />
|
|
||||||
{{ __('Processing files...') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@error('files')
|
@error('files')
|
||||||
<x-alert color="error" class="mb-4">{{ $message }}</x-alert>
|
<x-alert color="error">{{ $message }}</x-alert>
|
||||||
@enderror
|
@enderror
|
||||||
|
|
||||||
{{-- Selected files --}}
|
{{-- Selected files --}}
|
||||||
@if (count($files))
|
@if ($pendingFiles->isNotEmpty())
|
||||||
<div class="mb-6">
|
<x-stack gap="space100">
|
||||||
<h2 class="mb-2 type-title-md">{{ __('Selected Files') }} ({{ count($files) }})</h2>
|
<h2 class="md-type-title-lg">{{ __('Selected Files') }} ({{ $pendingFiles->count() }})</h2>
|
||||||
<div class="max-h-72 overflow-y-auto">
|
|
||||||
|
<div class="upload-file-list">
|
||||||
<x-list segmented :label="__('Selected Files')">
|
<x-list segmented :label="__('Selected Files')">
|
||||||
@foreach ($files as $index => $file)
|
@foreach ($pendingFiles as $file)
|
||||||
<x-list-item
|
<x-list-item
|
||||||
:title="$relativePaths[$index] ?? $file->getClientOriginalName()"
|
:title="$file->relative_path ?? $file->original_name"
|
||||||
:description="Number::fileSize($file->getSize())"
|
|
||||||
icon="description"
|
icon="description"
|
||||||
wire:key="selected-file-{{ $index }}"
|
wire:key="selected-file-{{ $file->id }}"
|
||||||
|
data-test="selected-file"
|
||||||
>
|
>
|
||||||
|
<x-slot:description>
|
||||||
|
<span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span>
|
||||||
|
· <span class="md-tabular" x-text="statusOf({{ $file->id }}, {{ $file->completed_at ? 'true' : 'false' }})" data-test="file-status"></span>
|
||||||
|
</x-slot:description>
|
||||||
<x-slot:end>
|
<x-slot:end>
|
||||||
<x-button icon="close" :aria-label="__('Remove')" wire:click="removeFile({{ $index }})" />
|
<span x-show="uploads[{{ $file->id }}]?.state === 'failed'" x-cloak>
|
||||||
|
<x-button icon="refresh" :aria-label="__('Retry')" x-on:click="retry({{ $file->id }})" />
|
||||||
|
</span>
|
||||||
|
<x-button icon="close" :aria-label="__('Remove')" x-on:click="remove({{ $file->id }})" />
|
||||||
</x-slot:end>
|
</x-slot:end>
|
||||||
</x-list-item>
|
</x-list-item>
|
||||||
@endforeach
|
@endforeach
|
||||||
</x-list>
|
</x-list>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</x-stack>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- Options --}}
|
{{-- Options --}}
|
||||||
<x-card :title="__('Share Options')" variant="outlined" class="mb-6">
|
<x-card :title="__('Share Options')" heading="h2" variant="outlined">
|
||||||
<div class="grid gap-5">
|
<x-stack gap="space200">
|
||||||
<x-toggle wire:model.live="usePassword" :label="__('Password protect')" right />
|
<x-toggle wire:model.live="usePassword" :label="__('Password protect')" right />
|
||||||
|
|
||||||
@if ($usePassword)
|
@if ($usePassword)
|
||||||
<x-password wire:model="password" :label="__('Password')" autocomplete="new-password" />
|
<x-stack gap="space100">
|
||||||
|
<x-password full wire:model="password" :label="__('Password')" autocomplete="new-password" />
|
||||||
|
|
||||||
|
{{-- Generate draws one as Admin settings say (App\Services\PasswordGeneratorService); Copy takes
|
||||||
|
whatever is in the field, typed or generated, with the snackbar a copyable field shows. --}}
|
||||||
|
<x-row gap="space100" wrap>
|
||||||
|
@if ($passwordGeneratorMode !== 'off')
|
||||||
|
<x-button :label="__('Generate')" icon="password" variant="tonal" wire:click="generatePassword" spinner="generatePassword" data-test="generate-password" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<x-button
|
||||||
|
:label="__('Copy')"
|
||||||
|
icon="content_copy"
|
||||||
|
variant="tonal"
|
||||||
|
x-on:click="navigator.clipboard.writeText($wire.password).then(() => window.materialToast({{ \Illuminate\Support\Js::from(__('Copied to the clipboard')) }}, { type: 'success' }))"
|
||||||
|
x-bind:disabled="! $wire.password"
|
||||||
|
data-test="copy-password"
|
||||||
|
/>
|
||||||
|
</x-row>
|
||||||
|
</x-stack>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<x-select
|
<x-select full
|
||||||
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
|
<x-input full
|
||||||
wire:model="maxDownloads"
|
wire:model="maxDownloads"
|
||||||
:label="__('Max downloads')"
|
:label="__('Max downloads')"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
:placeholder="__('Unlimited')"
|
:placeholder="__('Unlimited')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</x-stack>
|
||||||
</x-card>
|
</x-card>
|
||||||
|
|
||||||
<x-button
|
<x-slot:actions>
|
||||||
type="submit"
|
<x-button
|
||||||
:label="__('Create Share Link')"
|
type="submit"
|
||||||
variant="filled"
|
:label="__('Create Share Link')"
|
||||||
size="md"
|
variant="filled"
|
||||||
class="w-full"
|
size="md"
|
||||||
icon="link"
|
icon="link"
|
||||||
spinner="createShare"
|
spinner="createShare"
|
||||||
x-bind:disabled="uploading || {{ count($files) === 0 ? 'true' : 'false' }}"
|
x-bind:disabled="busy || {{ $allFilesUploaded ? 'false' : 'true' }}"
|
||||||
data-test="create-share"
|
data-test="create-share"
|
||||||
/>
|
/>
|
||||||
</form>
|
</x-slot:actions>
|
||||||
|
</x-form>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</x-page>
|
||||||
|
|||||||
@@ -1,40 +1,42 @@
|
|||||||
<div class="flex flex-col gap-6">
|
<x-page brand>
|
||||||
<x-auth-header :title="__('Setup SealShare')" :description="__('Create your admin account to get started')" />
|
<x-card :title="__('Set up SealShare')" :subtitle="__('Create your admin account to get started')" heading="h2" variant="outlined">
|
||||||
|
<x-form wire:submit="createAdmin">
|
||||||
|
<x-input
|
||||||
|
wire:model="name"
|
||||||
|
:label="__('Name')"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
:placeholder="__('Admin name')"
|
||||||
|
icon="person"
|
||||||
|
/>
|
||||||
|
|
||||||
<form wire:submit="createAdmin" class="flex flex-col gap-6">
|
<x-input
|
||||||
<x-input
|
wire:model="email"
|
||||||
wire:model="name"
|
:label="__('Email address')"
|
||||||
:label="__('Name')"
|
type="email"
|
||||||
type="text"
|
required
|
||||||
required
|
placeholder="admin@example.com"
|
||||||
autofocus
|
icon="mail"
|
||||||
:placeholder="__('Admin name')"
|
/>
|
||||||
icon="person"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<x-input
|
<x-password
|
||||||
wire:model="email"
|
wire:model="password"
|
||||||
:label="__('Email address')"
|
:label="__('Password')"
|
||||||
type="email"
|
required
|
||||||
required
|
:placeholder="__('Password')"
|
||||||
placeholder="admin@example.com"
|
/>
|
||||||
icon="mail"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<x-password
|
<x-password
|
||||||
wire:model="password"
|
wire:model="password_confirmation"
|
||||||
:label="__('Password')"
|
:label="__('Confirm password')"
|
||||||
required
|
required
|
||||||
:placeholder="__('Password')"
|
:placeholder="__('Confirm password')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-password
|
<x-slot:actions>
|
||||||
wire:model="password_confirmation"
|
<x-button type="submit" :label="__('Create Admin Account')" variant="filled" spinner="createAdmin" />
|
||||||
:label="__('Confirm password')"
|
</x-slot:actions>
|
||||||
required
|
</x-form>
|
||||||
:placeholder="__('Confirm password')"
|
</x-card>
|
||||||
/>
|
</x-page>
|
||||||
|
|
||||||
<x-button type="submit" :label="__('Create Admin Account')" variant="filled" class="w-full" spinner="createAdmin" />
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,26 +1,23 @@
|
|||||||
<div class="mx-auto max-w-lg">
|
<x-page :title="__('Share Created!')" :description="__('Your files are ready to share')">
|
||||||
<div class="mb-8 text-center">
|
{{-- The link is ready: a check on an Expressive shape that settles in (share-ready, app.css). --}}
|
||||||
{{-- The link is ready: a check on an Expressive shape that settles in. --}}
|
<x-slot:mark>
|
||||||
<div class="relative mx-auto mb-4 grid size-24 place-items-center motion-safe:animate-[share-ready_var(--md-sys-motion-spatial-slow-duration)_var(--md-sys-motion-spatial-slow)_both]">
|
<div class="share-check">
|
||||||
<x-shape name="soft-burst" class="absolute inset-0 size-full text-primary-container" />
|
<x-shape name="soft-burst" class="share-check-shape" />
|
||||||
<x-icon name="check" class="relative size-12 text-on-primary-container" />
|
<x-icon name="check" size="48" class="share-check-icon" />
|
||||||
</div>
|
</div>
|
||||||
|
</x-slot:mark>
|
||||||
|
|
||||||
<h1 class="type-headline-md">{{ __('Share Created!') }}</h1>
|
<x-stack gap="space200">
|
||||||
<p class="mt-1 type-body-lg text-on-surface-variant">{{ __('Your files are ready to share') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4">
|
|
||||||
{{-- Besides the link: a QR code in a dialog, saved as a PNG in the browser, and the device's
|
{{-- Besides the link: a QR code in a dialog, saved as a PNG in the browser, and the device's
|
||||||
share sheet where there is one (resources/js/share-created.js). Both carry the link only. --}}
|
share sheet where there is one (resources/js/share-created.js). Both carry the link only. --}}
|
||||||
<div
|
<x-stack
|
||||||
|
gap="space100"
|
||||||
x-data="shareActions({
|
x-data="shareActions({
|
||||||
url: @js($shareUrl),
|
url: {{ \Illuminate\Support\Js::from($shareUrl) }},
|
||||||
title: @js($siteTitle),
|
title: {{ \Illuminate\Support\Js::from($siteTitle) }},
|
||||||
filename: @js('share-'.$share->token.'.png'),
|
filename: {{ \Illuminate\Support\Js::from('share-'.$share->token.'.png') }},
|
||||||
messages: @js(['shareFailed' => __('The share sheet could not open.'), 'downloadFailed' => __('The QR code could not be saved.')]),
|
messages: {{ \Illuminate\Support\Js::from(['shareFailed' => __('The share sheet could not open.'), 'downloadFailed' => __('The QR code could not be saved.')]) }},
|
||||||
})"
|
})"
|
||||||
class="grid gap-3"
|
|
||||||
data-test="share-actions"
|
data-test="share-actions"
|
||||||
>
|
>
|
||||||
<x-input
|
<x-input
|
||||||
@@ -28,48 +25,66 @@
|
|||||||
:value="$shareUrl"
|
:value="$shareUrl"
|
||||||
readonly
|
readonly
|
||||||
copyable
|
copyable
|
||||||
|
mono
|
||||||
icon="link"
|
icon="link"
|
||||||
data-test="share-link"
|
data-test="share-link"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
{{-- Only on the visit the upload redirects to: the password is flashed once (FileUploader::createShare).
|
||||||
|
Masked, with the link's copy button at its end, so it is copied without reaching the screen. --}}
|
||||||
|
@if ($password)
|
||||||
|
<x-input
|
||||||
|
type="password"
|
||||||
|
:label="__('Password')"
|
||||||
|
:value="$password"
|
||||||
|
:hint="__('Available only this once. Send it separately from the link.')"
|
||||||
|
readonly
|
||||||
|
copyable
|
||||||
|
icon="key"
|
||||||
|
autocomplete="off"
|
||||||
|
data-test="share-password"
|
||||||
|
/>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<x-row gap="space100" wrap>
|
||||||
<x-button :label="__('Show QR code')" icon="qr_code_2" variant="tonal" x-on:click="open = true" data-test="show-qr-code" />
|
<x-button :label="__('Show QR code')" icon="qr_code_2" variant="tonal" x-on:click="open = true" data-test="show-qr-code" />
|
||||||
|
|
||||||
<span x-show="canShare" x-cloak class="inline-flex">
|
<span x-show="canShare" x-cloak>
|
||||||
<x-button :label="__('Share…')" icon="share" variant="tonal" x-on:click="share()" data-test="share-sheet" />
|
<x-button :label="__('Share…')" icon="share" variant="tonal" x-on:click="share()" data-test="share-sheet" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</x-row>
|
||||||
|
|
||||||
<x-modal fullscreen :title="__('Scan to open the share')" data-test="qr-code-dialog">
|
<x-modal fullscreen :title="__('Scan to open the share')" data-test="qr-code-dialog">
|
||||||
{{-- White in either theme: a scanner needs the contrast. The SVG is drawn from the app's own URL. --}}
|
<x-stack gap="space200">
|
||||||
<div data-qr-code class="mx-auto aspect-square w-full max-w-80 rounded-corner-lg bg-white p-2 [&>svg]:size-full">{!! $qrCodeSvg !!}</div>
|
{{-- The quiet zone is baked into the SVG (App\Services\QrCodeService), white in
|
||||||
|
either theme so a scanner keeps its contrast; the container adds no colour. --}}
|
||||||
|
<div data-qr-code class="share-qr">{!! $qrCodeSvg !!}</div>
|
||||||
|
|
||||||
@if ($share->isPasswordProtected())
|
@if ($share->isPasswordProtected())
|
||||||
<div class="mt-4">
|
|
||||||
<x-alert color="info" icon="lock" :title="__('Recipients also need the password.')" />
|
<x-alert color="info" icon="lock" :title="__('Recipients also need the password.')" />
|
||||||
</div>
|
@endif
|
||||||
@endif
|
</x-stack>
|
||||||
|
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
<x-button :label="__('Close')" x-on:click="close()" />
|
<x-button :label="__('Close')" x-on:click="close()" />
|
||||||
<x-button :label="__('Download')" icon="download" variant="tonal" x-on:click="downloadQrCode($el.closest('dialog').querySelector('[data-qr-code] svg'))" data-test="download-qr-code" />
|
<x-button :label="__('Download')" icon="download" variant="tonal" x-on:click="downloadQrCode($el.closest('dialog').querySelector('[data-qr-code] svg'))" data-test="download-qr-code" />
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
</x-modal>
|
</x-modal>
|
||||||
</div>
|
</x-stack>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<x-grid :columns="2" gap="space200">
|
||||||
<x-stat :title="__('Files')" :value="$share->files->count()" icon="description" />
|
<x-stat :title="__('Files')" :value="$share->files->count()" icon="description" />
|
||||||
<x-stat :title="__('Total Size')" :value="Number::fileSize($share->total_size)" icon="hard_drive" />
|
<x-stat :title="__('Total Size')" :value="Number::fileSize($share->total_size)" icon="hard_drive" />
|
||||||
<x-stat :title="__('Expires')" :value="$share->expires_at ? $share->expires_at->diffForHumans() : __('Never')" icon="schedule" />
|
<x-stat :title="__('Expires')" :value="$share->expires_at ? $share->expires_at->diffForHumans() : __('Never')" icon="schedule" />
|
||||||
<x-stat :title="__('Max Downloads')" :value="$share->max_downloads ?? __('Unlimited')" icon="download" />
|
<x-stat :title="__('Max Downloads')" :value="$share->max_downloads ?? __('Unlimited')" icon="download" />
|
||||||
</div>
|
</x-grid>
|
||||||
|
|
||||||
@if ($share->isPasswordProtected())
|
@if ($share->isPasswordProtected())
|
||||||
<x-alert color="info" icon="lock" :title="__('This share is password protected')" />
|
<x-alert color="info" icon="lock" :title="__('This share is password protected')" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<x-row justify="end">
|
||||||
<x-button :label="__('Upload More')" :link="route('upload')" icon="add" variant="tonal" />
|
<x-button :label="__('Upload More')" :link="route('upload')" icon="add" variant="tonal" />
|
||||||
</div>
|
</x-row>
|
||||||
</div>
|
</x-stack>
|
||||||
</div>
|
</x-page>
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
{{-- The page a recipient opens. No anchored components (menus, tooltips) on it: it has to work on
|
{{-- The page a recipient opens. No anchored components (menus, tooltips) on it: it has to work on
|
||||||
iOS before Safari 18.4, which cannot position them. --}}
|
iOS before Safari 18.4, which cannot position them. --}}
|
||||||
|
|
||||||
<div class="mx-auto w-full max-w-lg">
|
<x-page brand>
|
||||||
<div class="mb-8 text-center">
|
{{-- Each state is one card under the page's h1: the card holds everything the recipient acts
|
||||||
@if ($siteLogo)
|
on, and it is the shape SealShare has always shown them. --}}
|
||||||
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="mx-auto mb-4 h-20 w-auto" />
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<h1 class="type-headline-lg">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
|
|
||||||
|
|
||||||
<p class="mt-2 type-body-lg text-on-surface-variant">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (! $authenticated)
|
@if (! $authenticated)
|
||||||
<form wire:submit="verifyPassword">
|
<x-card :title="__('Password Required')" :subtitle="__('Enter the password to access these files')" heading="h2" variant="outlined">
|
||||||
<x-card :title="__('Password Required')" :subtitle="__('Enter the password to access these files')" variant="outlined">
|
<x-form wire:submit="verifyPassword">
|
||||||
<x-password
|
<x-password
|
||||||
|
full
|
||||||
wire:model="password"
|
wire:model="password"
|
||||||
:label="__('Password')"
|
:label="__('Password')"
|
||||||
required
|
required
|
||||||
@@ -24,40 +17,53 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
<x-button type="submit" :label="__('Unlock')" variant="filled" icon="lock_open" spinner="verifyPassword" class="w-full" />
|
<x-button type="submit" :label="__('Unlock')" variant="filled" icon="lock_open" spinner="verifyPassword" />
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
</x-card>
|
</x-form>
|
||||||
</form>
|
</x-card>
|
||||||
@else
|
@else
|
||||||
<x-card :title="__('Shared Files')" variant="outlined">
|
<x-card :title="__('Shared Files')" heading="h2" variant="outlined">
|
||||||
<x-list :label="__('Shared Files')">
|
{{-- A download link does not render the page again: the download limit's note switches
|
||||||
@foreach ($share->files as $file)
|
on the first press here, and the server draws the open window on the next visit. --}}
|
||||||
<x-list-item
|
<x-stack gap="space200" x-data="{ downloaded: false }">
|
||||||
:title="$file->relative_path ?: $file->original_name"
|
<x-stack gap="space100">
|
||||||
:description="Number::fileSize($file->file_size)"
|
<x-list :label="__('Shared Files')">
|
||||||
icon="description"
|
@foreach ($share->files as $file)
|
||||||
wire:key="file-{{ $file->id }}"
|
<x-list-item
|
||||||
>
|
:title="$file->relative_path ?: $file->original_name"
|
||||||
<x-slot:end>
|
icon="description"
|
||||||
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" />
|
wire:key="file-{{ $file->id }}"
|
||||||
</x-slot:end>
|
>
|
||||||
</x-list-item>
|
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span></x-slot:description>
|
||||||
@endforeach
|
<x-slot:end>
|
||||||
</x-list>
|
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" x-on:click="downloaded = true" />
|
||||||
|
</x-slot:end>
|
||||||
|
</x-list-item>
|
||||||
|
@endforeach
|
||||||
|
</x-list>
|
||||||
|
|
||||||
@if ($share->expires_at)
|
@if ($share->expires_at)
|
||||||
<p class="mt-2 type-body-sm text-on-surface-variant">
|
<p class="md-type-body-sm md-ink-variant">{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}</p>
|
||||||
{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}
|
@endif
|
||||||
</p>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<x-slot:actions>
|
@if ($share->max_downloads)
|
||||||
@if ($share->files->count() > 1)
|
@if ($downloadWindowEndsAt)
|
||||||
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate class="w-full" />
|
<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>
|
||||||
@else
|
@elseif ($remainingDownloads > 0)
|
||||||
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $share->files->first()])" no-wire-navigate class="w-full" />
|
<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>
|
||||||
@endif
|
<p class="md-type-body-sm md-ink-variant" x-show="downloaded" x-cloak>{{ __('You have :window to download the files.', ['window' => $downloadWindow]) }}</p>
|
||||||
</x-slot:actions>
|
@endif
|
||||||
|
@endif
|
||||||
|
</x-stack>
|
||||||
|
|
||||||
|
<x-row justify="end">
|
||||||
|
@if ($share->files->count() > 1)
|
||||||
|
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate x-on:click="downloaded = true" />
|
||||||
|
@else
|
||||||
|
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $share->files->first()])" no-wire-navigate x-on:click="downloaded = true" />
|
||||||
|
@endif
|
||||||
|
</x-row>
|
||||||
|
</x-stack>
|
||||||
</x-card>
|
</x-card>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</x-page>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<div class="flex flex-col gap-6">
|
<x-page brand>
|
||||||
<x-auth-header :title="__('System Password Required')" :description="__('Enter the system password to access the upload page')" />
|
<x-card :title="__('System password required')" :subtitle="__('Enter the system password to access the upload page')" heading="h2" variant="outlined">
|
||||||
|
<x-form wire:submit="verify">
|
||||||
|
<x-password
|
||||||
|
wire:model="password"
|
||||||
|
:label="__('Password')"
|
||||||
|
required
|
||||||
|
:placeholder="__('System password')"
|
||||||
|
/>
|
||||||
|
|
||||||
<form wire:submit="verify" class="flex flex-col gap-6">
|
<x-slot:actions>
|
||||||
<x-password
|
<x-button type="submit" :label="__('Continue')" variant="filled" spinner="verify" />
|
||||||
wire:model="password"
|
</x-slot:actions>
|
||||||
:label="__('Password')"
|
</x-form>
|
||||||
required
|
</x-card>
|
||||||
:placeholder="__('System password')"
|
</x-page>
|
||||||
/>
|
|
||||||
|
|
||||||
<x-button type="submit" :label="__('Continue')" variant="filled" class="w-full" spinner="verify" />
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
<x-layouts::auth :title="__('Confirm password')">
|
<x-layouts::app :title="__('Confirm password')">
|
||||||
<x-auth-header
|
<x-page brand>
|
||||||
:title="__('Confirm password')"
|
<x-card
|
||||||
:description="__('This is a secure area of the application. Please confirm your password before continuing.')"
|
:title="__('Confirm password')"
|
||||||
/>
|
:subtitle="__('This is a secure area of the application. Please confirm your password before continuing.')"
|
||||||
|
heading="h2"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
<x-stack gap="space300">
|
||||||
|
<x-auth-session-status :status="session('status')" />
|
||||||
|
|
||||||
<x-auth-session-status :status="session('status')" />
|
<x-form method="POST" action="{{ route('password.confirm.store') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
<form method="POST" action="{{ route('password.confirm.store') }}" class="flex flex-col gap-5">
|
<x-password
|
||||||
@csrf
|
name="password"
|
||||||
|
:label="__('Password')"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
|
||||||
<x-password
|
<x-slot:actions>
|
||||||
name="password"
|
<x-button type="submit" :label="__('Confirm')" variant="filled" data-test="confirm-password-button" />
|
||||||
:label="__('Password')"
|
</x-slot:actions>
|
||||||
required
|
</x-form>
|
||||||
autofocus
|
</x-stack>
|
||||||
autocomplete="current-password"
|
</x-card>
|
||||||
/>
|
</x-page>
|
||||||
|
</x-layouts::app>
|
||||||
<x-button type="submit" :label="__('Confirm')" variant="filled" class="w-full" data-test="confirm-password-button" />
|
|
||||||
</form>
|
|
||||||
</x-layouts::auth>
|
|
||||||
|
|||||||
@@ -1,27 +1,33 @@
|
|||||||
<x-layouts::auth :title="__('Forgot password')">
|
<x-layouts::app :title="__('Forgot password')">
|
||||||
<x-auth-header :title="__('Forgot password')" :description="__('Enter your email to receive a password reset link')" />
|
<x-page brand>
|
||||||
|
<x-card :title="__('Forgot password')" :subtitle="__('Enter your email to receive a password reset link')" heading="h2" variant="outlined">
|
||||||
|
<x-stack gap="space300">
|
||||||
|
<x-auth-session-status :status="session('status')" />
|
||||||
|
|
||||||
<x-auth-session-status :status="session('status')" />
|
<x-form method="POST" action="{{ route('password.email') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
<form method="POST" action="{{ route('password.email') }}" class="flex flex-col gap-5">
|
<x-input
|
||||||
@csrf
|
name="email"
|
||||||
|
:label="__('Email Address')"
|
||||||
|
:value="old('email')"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
placeholder="email@example.com"
|
||||||
|
icon="mail"
|
||||||
|
/>
|
||||||
|
|
||||||
<x-input
|
<x-slot:actions>
|
||||||
name="email"
|
<x-button type="submit" :label="__('Email password reset link')" variant="filled" data-test="email-password-reset-link-button" />
|
||||||
:label="__('Email Address')"
|
</x-slot:actions>
|
||||||
:value="old('email')"
|
</x-form>
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
placeholder="email@example.com"
|
|
||||||
icon="mail"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<x-button type="submit" :label="__('Email password reset link')" variant="filled" class="w-full" data-test="email-password-reset-link-button" />
|
<p class="md-type-body-md md-ink-variant md-text-center">
|
||||||
</form>
|
{{ __('Or, return to') }}
|
||||||
|
<a href="{{ route('login') }}" class="md-link" wire:navigate>{{ __('log in') }}</a>
|
||||||
<p class="text-center type-body-md text-on-surface-variant">
|
</p>
|
||||||
{{ __('Or, return to') }}
|
</x-stack>
|
||||||
<a href="{{ route('login') }}" class="link" wire:navigate>{{ __('log in') }}</a>
|
</x-card>
|
||||||
</p>
|
</x-page>
|
||||||
</x-layouts::auth>
|
</x-layouts::app>
|
||||||
|
|||||||
@@ -1,40 +1,48 @@
|
|||||||
<x-layouts::auth :title="__('Log in')">
|
<x-layouts::app :title="__('Log in')">
|
||||||
<x-auth-header :title="__('Log in to your account')" :description="__('Enter your email and password below to log in')" />
|
<x-page brand>
|
||||||
|
<x-card :title="__('Log in')" :subtitle="__('Enter your email and password below to log in')" heading="h2" variant="outlined">
|
||||||
|
<x-stack gap="space300">
|
||||||
|
<x-auth-session-status :status="session('status')" />
|
||||||
|
|
||||||
<x-auth-session-status :status="session('status')" />
|
<x-form method="POST" action="{{ route('login.store') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
<form method="POST" action="{{ route('login.store') }}" class="flex flex-col gap-5">
|
<x-input
|
||||||
@csrf
|
name="email"
|
||||||
|
:label="__('Email address')"
|
||||||
|
:value="old('email')"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
icon="mail"
|
||||||
|
/>
|
||||||
|
|
||||||
<x-input
|
<x-stack gap="space50">
|
||||||
name="email"
|
<x-password
|
||||||
:label="__('Email address')"
|
name="password"
|
||||||
:value="old('email')"
|
:label="__('Password')"
|
||||||
type="email"
|
required
|
||||||
required
|
autocomplete="current-password"
|
||||||
autofocus
|
/>
|
||||||
autocomplete="email"
|
|
||||||
placeholder="email@example.com"
|
|
||||||
icon="mail"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="grid gap-1">
|
@if (Route::has('password.request'))
|
||||||
<x-password
|
<x-row justify="end">
|
||||||
name="password"
|
<a class="md-link md-type-label-lg" href="{{ route('password.request') }}" wire:navigate>
|
||||||
:label="__('Password')"
|
{{ __('Forgot your password?') }}
|
||||||
required
|
</a>
|
||||||
autocomplete="current-password"
|
</x-row>
|
||||||
/>
|
@endif
|
||||||
|
</x-stack>
|
||||||
|
|
||||||
@if (Route::has('password.request'))
|
<x-checkbox name="remember" :label="__('Remember me')" :checked="(bool) old('remember')" />
|
||||||
<a class="link w-fit justify-self-end type-label-lg" href="{{ route('password.request') }}" wire:navigate>
|
|
||||||
{{ __('Forgot your password?') }}
|
|
||||||
</a>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-checkbox name="remember" :label="__('Remember me')" :checked="(bool) old('remember')" />
|
<x-slot:actions>
|
||||||
|
<x-button type="submit" :label="__('Log in')" variant="filled" data-test="login-button" />
|
||||||
<x-button type="submit" :label="__('Log in')" variant="filled" class="w-full" data-test="login-button" />
|
</x-slot:actions>
|
||||||
</form>
|
</x-form>
|
||||||
</x-layouts::auth>
|
</x-stack>
|
||||||
|
</x-card>
|
||||||
|
</x-page>
|
||||||
|
</x-layouts::app>
|
||||||
|
|||||||
@@ -1,36 +1,42 @@
|
|||||||
<x-layouts::auth :title="__('Reset password')">
|
<x-layouts::app :title="__('Reset password')">
|
||||||
<x-auth-header :title="__('Reset password')" :description="__('Please enter your new password below')" />
|
<x-page brand>
|
||||||
|
<x-card :title="__('Reset password')" :subtitle="__('Please enter your new password below')" heading="h2" variant="outlined">
|
||||||
|
<x-stack gap="space300">
|
||||||
|
<x-auth-session-status :status="session('status')" />
|
||||||
|
|
||||||
<x-auth-session-status :status="session('status')" />
|
<x-form method="POST" action="{{ route('password.update') }}">
|
||||||
|
@csrf
|
||||||
|
<input type="hidden" name="token" value="{{ request()->route('token') }}">
|
||||||
|
|
||||||
<form method="POST" action="{{ route('password.update') }}" class="flex flex-col gap-5">
|
<x-input
|
||||||
@csrf
|
name="email"
|
||||||
<input type="hidden" name="token" value="{{ request()->route('token') }}">
|
:value="old('email', request('email'))"
|
||||||
|
:label="__('Email')"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
icon="mail"
|
||||||
|
/>
|
||||||
|
|
||||||
<x-input
|
<x-password
|
||||||
name="email"
|
name="password"
|
||||||
:value="old('email', request('email'))"
|
:label="__('Password')"
|
||||||
:label="__('Email')"
|
required
|
||||||
type="email"
|
autocomplete="new-password"
|
||||||
required
|
/>
|
||||||
autocomplete="email"
|
|
||||||
icon="mail"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<x-password
|
<x-password
|
||||||
name="password"
|
name="password_confirmation"
|
||||||
:label="__('Password')"
|
:label="__('Confirm password')"
|
||||||
required
|
required
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-password
|
<x-slot:actions>
|
||||||
name="password_confirmation"
|
<x-button type="submit" :label="__('Reset password')" variant="filled" data-test="reset-password-button" />
|
||||||
:label="__('Confirm password')"
|
</x-slot:actions>
|
||||||
required
|
</x-form>
|
||||||
autocomplete="new-password"
|
</x-stack>
|
||||||
/>
|
</x-card>
|
||||||
|
</x-page>
|
||||||
<x-button type="submit" :label="__('Reset password')" variant="filled" class="w-full" data-test="reset-password-button" />
|
</x-layouts::app>
|
||||||
</form>
|
|
||||||
</x-layouts::auth>
|
|
||||||
|
|||||||
@@ -1,65 +1,65 @@
|
|||||||
<x-layouts::auth :title="__('Two-factor authentication')">
|
<x-layouts::app :title="__('Two-factor authentication')">
|
||||||
<div
|
<x-page brand>
|
||||||
class="flex flex-col gap-6"
|
<x-card :title="__('Two-factor authentication')" heading="h2" variant="outlined">
|
||||||
x-data="{
|
<x-stack
|
||||||
showRecoveryInput: @js($errors->has('recovery_code')),
|
gap="space300"
|
||||||
toggleInput() {
|
x-data="{
|
||||||
this.showRecoveryInput = ! this.showRecoveryInput;
|
showRecoveryInput: {{ \Illuminate\Support\Js::from($errors->has('recovery_code')) }},
|
||||||
$nextTick(() => {
|
toggleInput() {
|
||||||
requestAnimationFrame(() => {
|
this.showRecoveryInput = ! this.showRecoveryInput;
|
||||||
(this.showRecoveryInput ? $refs.recovery : $refs.code)?.querySelector('input')?.focus();
|
$nextTick(() => {
|
||||||
});
|
requestAnimationFrame(() => {
|
||||||
});
|
(this.showRecoveryInput ? $refs.recovery : $refs.code)?.querySelector('input')?.focus();
|
||||||
},
|
});
|
||||||
}"
|
});
|
||||||
>
|
},
|
||||||
<div x-show="! showRecoveryInput">
|
}"
|
||||||
<x-auth-header
|
>
|
||||||
:title="__('Authentication Code')"
|
<p class="md-type-body-md md-ink-variant" x-show="! showRecoveryInput">
|
||||||
:description="__('Enter the authentication code provided by your authenticator application.')"
|
{{ __('Enter the authentication code provided by your authenticator application.') }}
|
||||||
/>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div x-show="showRecoveryInput" x-cloak>
|
<p class="md-type-body-md md-ink-variant" x-show="showRecoveryInput" x-cloak>
|
||||||
<x-auth-header
|
{{ __('Please confirm access to your account by entering one of your emergency recovery codes.') }}
|
||||||
:title="__('Recovery Code')"
|
</p>
|
||||||
:description="__('Please confirm access to your account by entering one of your emergency recovery codes.')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="POST" action="{{ route('two-factor.login.store') }}" class="flex flex-col gap-5">
|
<x-form method="POST" action="{{ route('two-factor.login.store') }}">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<div x-ref="code" x-show="! showRecoveryInput">
|
<div x-ref="code" x-show="! showRecoveryInput">
|
||||||
<x-input
|
<x-input
|
||||||
name="code"
|
name="code"
|
||||||
:label="__('Code')"
|
:label="__('Code')"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
autocomplete="one-time-code"
|
autocomplete="one-time-code"
|
||||||
maxlength="6"
|
maxlength="6"
|
||||||
mono
|
mono
|
||||||
autofocus
|
autofocus
|
||||||
x-bind:disabled="showRecoveryInput"
|
x-bind:disabled="showRecoveryInput"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-ref="recovery" x-show="showRecoveryInput" x-cloak>
|
<div x-ref="recovery" x-show="showRecoveryInput" x-cloak>
|
||||||
<x-input
|
<x-input
|
||||||
name="recovery_code"
|
name="recovery_code"
|
||||||
:label="__('Recovery code')"
|
:label="__('Recovery code')"
|
||||||
autocomplete="one-time-code"
|
autocomplete="one-time-code"
|
||||||
mono
|
mono
|
||||||
x-bind:disabled="! showRecoveryInput"
|
x-bind:disabled="! showRecoveryInput"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-button type="submit" :label="__('Continue')" variant="filled" class="w-full" />
|
<x-slot:actions>
|
||||||
</form>
|
<x-button type="submit" :label="__('Continue')" variant="filled" />
|
||||||
|
</x-slot:actions>
|
||||||
|
</x-form>
|
||||||
|
|
||||||
<p class="text-center type-body-md text-on-surface-variant">
|
<p class="md-type-body-md md-ink-variant md-text-center">
|
||||||
{{ __('or you can') }}
|
{{ __('or you can') }}
|
||||||
<button type="button" class="link" x-show="! showRecoveryInput" x-on:click="toggleInput()">{{ __('login using a recovery code') }}</button>
|
<button type="button" class="md-link" x-show="! showRecoveryInput" x-on:click="toggleInput()">{{ __('login using a recovery code') }}</button>
|
||||||
<button type="button" class="link" x-show="showRecoveryInput" x-cloak x-on:click="toggleInput()">{{ __('login using an authentication code') }}</button>
|
<button type="button" class="md-link" x-show="showRecoveryInput" x-cloak x-on:click="toggleInput()">{{ __('login using an authentication code') }}</button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</x-stack>
|
||||||
</x-layouts::auth>
|
</x-card>
|
||||||
|
</x-page>
|
||||||
|
</x-layouts::app>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user